Exclusivobeta
Wobble <mv-wobble>
Vista previa del impacto de una eliminación que ajusta la fricción de borrar, archivar, desactivar, revocar o despublicar a lo que realmente depende del elemento. Envuelve cualquier lista, tabla o página de ajustes: al pasar el cursor o enfocar la acción destructiva de un elemento, este se “prueba” con tu probe(id) asíncrono (o un atributo JSON data-wobble-report), con detección de intención al pasar el cursor, deduplicación de peticiones en curso, una ventana de caché y un tiempo de espera, y una tarjeta anclada clasifica la eliminación. Suelto: nada depende de él, se va de inmediato con una barra de deshacer ahí mismo (Ctrl/Cmd+Z; el plazo se pausa al pasar el cursor o con el foco). De carga: hay cosas que se rompen o cambian, listadas por tipo con recuentos y ejemplos (“3 automatizaciones dejan de ejecutarse · 12 facturas pierden su región · 1 filtro de panel se rompe”), el elemento se tambalea sutilmente y al activarlo se abre un alertdialog enfocado que lista cada consecuencia, con una casilla de confirmación opcional por encima de un umbral. Fundamental: la eliminación se bloquea, con el motivo, enlaces a los siguientes pasos y una opción “Reasignar a…” cuyo mv-reassign cancelable (con waitUntil) traslada la carga antes de volver a probar el elemento. Un elemento probado recuerda su nivel (data-wobble-tier, chips [data-wobble-mark] opcionales, la descripción accesible del disparador), y survey prueba de antemano cada elemento visible para que la gente aprenda qué piezas lo sostienen todo. mv-remove es cancelable con waitUntil, y mv-commit avisa cuando una eliminación es definitiva.
| Categoría | Feedback |
|---|---|
| Tipo | Web Component (<mv-wobble>) |
| Estado | beta |
| Kit | Acciones destructivas y secretos seguros |
| También instala | button, checkbox, select |
| Keywords | exclusive, culture, delete, remove, archive, revoke, impact, dependencies, blast-radius, confirm, undo, reassign, destructive-action, safe-delete, admin, settings, alertdialog |
When to use
- Deleting a tag, custom field, role or category may silently break automations, reports or records that use it
- Admins revoke API keys, remove team members or disable feature flags and must see what stops working before they do
- Most deletions in a list are harmless and should stay one click, but a few need a real confirmation or are blocked
- An entity cannot be removed while others depend on it, and people should be offered to reassign them in place
Avoid when
- The change is a value edit whose effects on other values in the page should be previewed, not a removal → use Butterfly instead
- Every removal needs the same confirmation regardless of what depends on it, and there is no dependency data → use Dialog instead
- The action is critical and must be armed by two people or two deliberate keys, not just informed by its impact → use Two-Key instead
Instalación
node scripts/add.mjs wobble --out ./src/marvelousAgente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["wobble"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Archivos copiados (dependencias incluidas): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/position.js, components/wobble/wobble.js, components/wobble/wobble.css, components/button/button.css, components/checkbox/checkbox.css, components/select/select.css.
Uso
Inicio rápido, el marcado mínimo que funciona:
<mv-wobble verb="delete">
<p data-wobble-id="vip" data-wobble-name="VIP" data-wobble-report='{"dependents":[{"count":3,"noun":"automation|automations","effect":"stop running","impact":"breaks"}]}'>VIP <button data-wobble-remove>Delete</button></p>
<p data-wobble-id="old" data-wobble-name="Old leads" data-wobble-report='{"dependents":[]}'>Old leads <button data-wobble-remove>Delete</button></p>
</mv-wobble>Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:
<div id="wb-demo" style="width:min(100%,46rem);margin-inline:auto">
<style>
#wb-demo { display:grid; gap:1.25rem; align-content:start }
#wb-demo .wb-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 }
#wb-demo .wb-head { display:flex; align-items:flex-start; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:1rem 1.125rem .875rem; border-bottom:1px solid var(--mv-border) }
#wb-demo .wb-head h4 { margin:0; font-size:.9375rem; letter-spacing:-.01em }
#wb-demo .wb-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#wb-demo .wb-list { margin:0; padding:.375rem; list-style:none; display:grid; gap:.125rem }
#wb-demo .wb-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto auto; align-items:center; gap:.75rem; padding:.5rem .5rem .5rem .75rem; border-radius:var(--mv-radius-md) }
#wb-demo .wb-row:hover { background-color:var(--mv-bg-subtle) }
#wb-demo .wb-dot { width:.625rem; height:.625rem; border-radius:50%; background:var(--c) }
#wb-demo .wb-key { display:grid; place-items:center; width:2rem; height:2rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-muted); color:var(--mv-fg-muted) }
#wb-demo .wb-key svg { width:1rem; height:1rem }
#wb-demo .wb-name { display:grid; gap:.0625rem; min-width:0 }
#wb-demo .wb-name strong { font-size:.875rem; font-weight:550; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#wb-demo .wb-name span { color:var(--mv-fg-muted); font-size:.75rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#wb-demo .wb-name code { font:.75rem var(--mv-font-mono) }
#wb-demo .wb-foot { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.75rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#wb-demo .wb-controls { display:flex; align-items:center; gap:.5rem 1.25rem; flex-wrap:wrap }
#wb-demo .wb-controls .mv-choice { font-size:.8125rem }
#wb-demo .wb-log { margin:0; min-height:1.1rem; color:var(--mv-fg-subtle); font:.75rem/1.45 var(--mv-font-mono); overflow-wrap:anywhere }
#wb-demo mv-wobble { padding-bottom:.25rem }
@media (max-width:36rem) {
#wb-demo .wb-row { grid-template-columns:auto minmax(0,1fr) auto; }
#wb-demo .wb-row [data-wobble-mark] { grid-column:2; grid-row:2; justify-self:start }
}
</style>
<!-- Main: workspace labels, probed through an async callback; survey tests every visible label up front -->
<section class="wb-card" aria-labelledby="wb-labels-title">
<div class="wb-head">
<div>
<h4 id="wb-labels-title">Labels</h4>
<p>Used on tickets, invoices, automations and reports. Hover or focus “Delete” to see what depends on a label.</p>
</div>
<span class="mv-badge" data-variant="secondary">6 labels</span>
</div>
<mv-wobble id="wb-labels" verb="delete" survey>
<ul class="wb-list">
<li class="wb-row" data-wobble-id="emea" data-wobble-name="Region: EMEA">
<span class="wb-dot" style="--c:oklch(0.62 0.15 250)" aria-hidden="true"></span>
<span class="wb-name"><strong>Region: EMEA</strong><span>Europe, Middle East and Africa accounts</span></span>
<span data-wobble-mark></span>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete Region: EMEA">Delete</button>
</li>
<li class="wb-row" data-wobble-id="legacy" data-wobble-name="Legacy import 2023">
<span class="wb-dot" style="--c:oklch(0.7 0.02 260)" aria-hidden="true"></span>
<span class="wb-name"><strong>Legacy import 2023</strong><span>Created by the CSV migration, Mar 14, 2023</span></span>
<span data-wobble-mark></span>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete Legacy import 2023">Delete</button>
</li>
<li class="wb-row" data-wobble-id="billing" data-wobble-name="Billing contact">
<span class="wb-dot" style="--c:oklch(0.62 0.17 25)" aria-hidden="true"></span>
<span class="wb-name"><strong>Billing contact</strong><span>Marks who receives invoices on each account</span></span>
<span data-wobble-mark></span>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete Billing contact">Delete</button>
</li>
<li class="wb-row" data-wobble-id="vip" data-wobble-name="VIP">
<span class="wb-dot" style="--c:oklch(0.72 0.15 80)" aria-hidden="true"></span>
<span class="wb-name"><strong>VIP</strong><span>Accounts above $50,000 ARR</span></span>
<span data-wobble-mark></span>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete VIP">Delete</button>
</li>
<li class="wb-row" data-wobble-id="webinar" data-wobble-name="Q2 webinar leads">
<span class="wb-dot" style="--c:oklch(0.66 0.14 160)" aria-hidden="true"></span>
<span class="wb-name"><strong>Q2 webinar leads</strong><span>Imported from the June 12 registration list</span></span>
<span data-wobble-mark></span>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete Q2 webinar leads">Delete</button>
</li>
<li class="wb-row" data-wobble-id="churn" data-wobble-name="Churn risk">
<span class="wb-dot" style="--c:oklch(0.6 0.16 320)" aria-hidden="true"></span>
<span class="wb-name"><strong>Churn risk</strong><span>Set by the health score below 40</span></span>
<span data-wobble-mark></span>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Delete Churn risk">Delete</button>
</li>
</ul>
</mv-wobble>
<div class="wb-foot">
<span class="wb-controls">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="wb-ack"> Require acknowledgment above 10 affected</label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="wb-slow"> Slow server</label>
</span>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="wb-reset">Reset demo</button>
<p class="wb-log" id="wb-log" aria-live="polite">Testing every label…</p>
</div>
</section>
<!-- Markup only: API keys, reports in data-wobble-report, verb "revoke"; marks appear as keys get tested -->
<section class="wb-card" aria-labelledby="wb-keys-title">
<div class="wb-head">
<div>
<h4 id="wb-keys-title">API keys</h4>
<p>Reports come from the markup, no script. Each key remembers its tier once tested.</p>
</div>
</div>
<mv-wobble id="wb-keys" verb="revoke">
<ul class="wb-list">
<li class="wb-row" data-wobble-id="key-staging" data-wobble-name="Staging CI"
data-wobble-report='{"dependents":[]}'>
<span class="wb-key" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><path d="m11 12 8-8m-3 3 2 2m-4 0 2 2"/></svg></span>
<span class="wb-name"><strong>Staging CI</strong><span><code>sk_test_…a91f</code> · last used 94 days ago</span></span>
<span data-wobble-mark="icon"></span>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Revoke Staging CI">Revoke</button>
</li>
<li class="wb-row" data-wobble-id="key-zapier" data-wobble-name="Zapier integration"
data-wobble-report='{"dependents":[{"count":4,"noun":"zap|zaps","effect":"stop running","impact":"breaks","items":["New invoice → Slack","Failed payment → Asana","Weekly MRR sheet","Refund → Zendesk"]},{"count":1,"noun":"scheduled export|scheduled exports","effect":"fails on Monday","impact":"breaks","items":["Finance export (Mon 6:00 AM)"]}]}'>
<span class="wb-key" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><path d="m11 12 8-8m-3 3 2 2m-4 0 2 2"/></svg></span>
<span class="wb-name"><strong>Zapier integration</strong><span><code>sk_live_…3c7d</code> · used 2 minutes ago</span></span>
<span data-wobble-mark="icon"></span>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Revoke Zapier integration">Revoke</button>
</li>
<li class="wb-row" data-wobble-id="key-webhook" data-wobble-name="Production webhook signer"
data-wobble-report='{"reason":"Live webhooks sign every event with this key. Move them to another signing key first.","dependents":[{"count":2,"noun":"live webhook|live webhooks","effect":"can’t verify events","impact":"blocks","items":["orders.created → fulfillment","payments.failed → dunning"]}],"next":[{"label":"Create a new signing key","href":"#keys-new"},{"label":"Open webhook settings","href":"#webhooks"}]}'>
<span class="wb-key" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><path d="m11 12 8-8m-3 3 2 2m-4 0 2 2"/></svg></span>
<span class="wb-name"><strong>Production webhook signer</strong><span><code>whsec_…e20b</code> · signs 1,284 events a day</span></span>
<span data-wobble-mark="icon"></span>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" data-wobble-remove aria-label="Revoke Production webhook signer">Revoke</button>
</li>
</ul>
</mv-wobble>
</section>
<script type="module">
const labels = document.getElementById("wb-labels");
const log = document.getElementById("wb-log");
const slow = document.getElementById("wb-slow");
const say = (text) => { log.textContent = text; };
// What the server knows about each label (a real app would query its dependency graph).
const initial = () => ({
emea: { dependents: [
{ kind: "automation", count: 3, noun: "automation|automations", effect: "stop running", impact: "breaks", items: ["EMEA ticket routing", "VAT reminder", "Weekly EMEA digest"], href: "#automations" },
{ kind: "invoice", count: 12, noun: "invoice|invoices", effect: "lose their region", impact: "changes", items: ["INV-2041 · Northwind", "INV-2038 · Contoso"] },
{ kind: "filter", count: 1, noun: "dashboard filter|dashboard filters", effect: "breaks", impact: "breaks", items: ["Revenue by region"], href: "#dashboards" },
] },
legacy: { dependents: [] },
billing: {
reason: "Every active contract needs a label that marks who receives its invoices.",
dependents: [
{ kind: "contract", count: 3, noun: "active contract|active contracts", effect: "require it", impact: "blocks", items: ["Northwind Traders", "Fabrikam", "Tailspin Toys"] },
{ kind: "contact", count: 11, noun: "contact|contacts", effect: "carry it", impact: "changes" },
],
reassign: { label: "Reassign its contracts and contacts to", targets: [{ value: "finance", label: "Finance contact" }, { value: "primary", label: "Primary contact" }] },
},
vip: { dependents: [
{ kind: "contact", count: 48, noun: "account|accounts", effect: "lose the VIP label", impact: "changes", items: ["Adatum Corporation", "Litware", "Woodgrove Bank"] },
{ kind: "view", count: 2, noun: "saved view|saved views", effect: "come back empty", impact: "breaks", items: ["VIP renewals", "High-touch accounts"] },
] },
webinar: { dependents: [] },
churn: { dependents: [
{ kind: "automation", count: 1, noun: "automation|automations", effect: "stops running", impact: "breaks", items: ["Save-desk alert"] },
{ kind: "account", count: 7, noun: "account|accounts", effect: "lose the label", impact: "changes" },
] },
});
let graph = initial();
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
labels.probe = async (id) => {
await wait(slow.checked ? 1600 : 280 + (id.length % 4) * 90);
return structuredClone(graph[id]);
};
let tested = 0;
labels.addEventListener("mv-probe", (e) => {
tested++;
if (tested === 6) say("All 6 labels tested: 2 loose, 3 load-bearing, 1 foundational.");
});
labels.addEventListener("mv-remove", (e) => {
const { id, tier } = e.detail;
say(`mv-remove · ${id} (${tier})`);
// A real app would call its API here: e.detail.waitUntil(fetch(…, { method: "DELETE" }))
if (tier !== "loose") e.detail.waitUntil(wait(slow.checked ? 1200 : 450));
});
labels.addEventListener("mv-undo", (e) => say(`mv-undo · ${e.detail.id} restored`));
labels.addEventListener("mv-commit", (e) => say(`mv-commit · ${e.detail.id} deleted for good`));
labels.addEventListener("mv-reassign", (e) => {
const { id, to, toLabel } = e.detail;
say(`mv-reassign · ${id} → ${to}`);
// Once the contacts are moved and the contracts point to “${toLabel}”, nothing depends on the label anymore.
e.detail.waitUntil(wait(slow.checked ? 1400 : 600).then(() => { graph[id] = { dependents: [] }; }));
});
document.getElementById("wb-ack").addEventListener("change", (e) => {
if (e.target.checked) labels.setAttribute("acknowledge-at", "10");
else labels.removeAttribute("acknowledge-at");
});
document.getElementById("wb-reset").addEventListener("click", () => {
labels.close();
graph = initial();
tested = 0;
labels.invalidate();
for (const item of labels.querySelectorAll("[data-wobble-id]")) {
item.hidden = false;
item.removeAttribute("data-wobble-removed");
item.removeAttribute("data-wobble-tier");
item.querySelector("[data-wobble-mark]").replaceChildren();
}
labels.survey = false;
labels.survey = true;
say("Testing every label…");
});
const keys = document.getElementById("wb-keys");
keys.addEventListener("mv-commit", (e) => {
// Markup demo: keys come back after a while so the example stays usable.
setTimeout(() => { e.detail.item.hidden = false; e.detail.item.removeAttribute("data-wobble-removed"); }, 2500);
});
</script>
</div>Referencia cultural
Jenga, Leslie Scott (1983, juego). Antes de sacar un bloque de la torre, los jugadores lo prueban con un dedo: un bloque suelto sale sin esfuerzo, uno que soporta carga hace tambalear la torre y, a lo largo de la partida, todos aprenden qué piezas lo sostienen todo. En la interfaz, pasar el cursor sobre una acción destructiva prueba el elemento frente a lo que depende de él, y la eliminación se completa con opción de deshacer, pide una confirmación que lista lo que se rompe o queda bloqueada hasta reasignar la carga, mientras cada elemento probado sigue mostrando cuánto sostiene.
API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
trigger | CSS selector | [data-wobble-remove] | Destructive actions inside the element that test and remove an item. Disabled ones (:disabled, aria-disabled="true", inside [inert]) are ignored. Their default action (link, submit) is prevented: the component decides. |
verb | string ("delete" | "archive" | "revoke|revoked"…) | remove | Verb used in every text (“Delete “VIP”?”, “Revoke anyway”, “Staging CI revoked”). The past tense is derived (adds d/ed) or given after a pipe. Per item: data-wobble-verb on the item or the trigger. |
cache | time ("30s", "2m", "0") | 30s | How long a probe result is reused for hovers and clicks. It bounds how stale the preview can be; your server must still refuse removals that became unsafe. |
undo | time | 5s | Undo window of a loose removal before mv-commit. 0 commits at once. A new removal commits the pending one. |
removal | hide | remove | none | hide | What happens to the item once removed: hide collapses it and sets hidden (restored by undo), remove also takes it out of the DOM at commit, none leaves the DOM to your app (frameworks that own the list). |
acknowledge-at | number | When a load-bearing or unverified removal affects at least this many things, the confirm requires checking “I understand N items will be affected” before the destructive button works. | |
probe-delay | number (ms) | 150 | Hover intent before a pointer hover probes an item (fly-overs don’t call your API). Focus probes immediately; moving from one trigger to the next while the card is open switches after 60 ms. |
placement | top | bottom | left | right (-start | -end) | bottom-end | Preferred side of the card relative to the trigger; flips and shifts to stay in the viewport. |
survey | boolean | Tests every trigger as it scrolls into view (IntersectionObserver), so marks and descriptions show the whole structure before anyone hovers. Calls probe once per item: batch on your side if needed. | |
data-wobble-id / data-wobble-name | string | On the item (any ancestor of the trigger): its id passed to probe and events, and the name used in texts. Without an item, data-wobble-remove="id" on the trigger itself works too. | |
data-wobble-report | JSON report | On the item: a report written in the markup (same shape as probe’s result). Takes precedence over probe; changing it invalidates the cache. | |
data-wobble-mark | "" | "icon" | An empty element inside the item that the component fills with a tier chip once the item is tested (icon, name and count; icon only with "icon"). It is aria-hidden: the tier reaches assistive tech through the trigger’s description. | |
data-wobble-tier / data-wobble-active / data-wobble-removed | set by the component | On items: the last known tier (loose | load-bearing | foundational | unknown), the item currently being tested (tinted), and a removed item waiting for its undo window. |
Properties
| Name | Tipo | Description |
|---|---|---|
probe | (id, { item, trigger, signal }) => Report | Promise<Report> | Returns what depends on the item: { dependents: [{ count, noun: "invoice|invoices", effect: "lose their region", impact: "breaks" | "changes" | "blocks", text?, items?: string[], href?, kind? }], tier?, blocked?, reason?, reassign?: { label?, targets: [{ value, label }] }, next?: [{ label, href }] }. The tier is derived when omitted: blocked or any "blocks" impact → foundational, any count → load-bearing, none → loose. A rejection, a timeout (10 s) or an unreadable result gives the unverified tier, which always asks for confirmation. Setting it clears the cache. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (tier names, titles, hints, buttons, undo, errors), with {name}, {Name}, {verb}, {Verb}, {past}, {total}, {things} placeholders. English defaults. |
trigger / verb / cache / undo / removal / acknowledgeAt / probeDelay / placement / survey | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
check(target, { fresh? }) | Tests an item (id, item or trigger) and resolves to its normalized report ({ tier, dependents, total, reason, reassign, next, raw }), or null if not found. fresh bypasses the cache. |
invalidate(id?) | Forgets the cached report of one item, or all of them (after a change made elsewhere). |
preview(target) | Opens the impact card for an item without moving focus (onboarding, a “What uses this?” link). |
requestRemoval(target) | Same as activating the item’s trigger: removes, confirms or blocks according to its tier. |
undoRemoval() | Undoes the pending loose removal. Returns true if an item was restored. |
close() | Closes the card (a pending confirm is cancelled). |
Events
| Name | Description |
|---|---|
mv-probe | A fresh report arrived (not fired for cache hits). detail: { id, item, trigger, tier, report }. |
mv-remove | Cancelable, when the removal is decided: at once for loose items, after the confirm otherwise. detail: { id, item, trigger, tier, report, waitUntil(promise) }. preventDefault() vetoes it; waitUntil shows a busy state and a rejection keeps the item with an error. For loose items, prefer committing on mv-commit (undo stays possible until then). |
mv-reassign | Cancelable, “Reassign” was chosen on a foundational item. detail: { id, item, trigger, to, toLabel, report, waitUntil(promise) }. A promise resolving to a report uses it as the new state; otherwise the item is probed again and the card switches to its new tier. |
mv-undo | A loose removal was undone within its window. detail: { id, item, trigger, report }. |
mv-commit | The removal is final: right after mv-remove for confirmed removals, at the end of the undo window (or on pagehide, or when the element is removed) for loose ones. detail: { id, item, tier, report }. |
mv-error | A probe, removal or reassignment failed. detail: { action: "probe" | "remove" | "reassign", id, error }. |
Content structure
| Name | Description |
|---|---|
(content) | Any list, table or layout of items with their destructive actions. The component appends its card (Popover API top layer), undo bar, hidden descriptions and live regions after your content, and only touches your DOM through data-wobble-* attributes, aria-describedby on triggers, [data-wobble-mark] contents and the hidden state of removed items (unless removal="none"). |
CSS classes
| Name | Description |
|---|---|
mv-wobble-card | The impact card. data-mode="preview | confirm | blocked", data-tier="checking | loose | load-bearing | foundational | unknown". Parts: -head, -tier (-tier-icon, -tier-label), -scale (three rising bars filled up to the tier), -title, -text, -list, -dep (data-impact, -dep-icon, -dep-text, -dep-items, -dep-link), -next, -reassign, -ack, -error, -hint, -actions (-cancel, -confirm). |
mv-wobble-undo | Sticky undo bar after a loose removal: -undo-icon, -undo-text, -undo-btn, -undo-meter (depleting line, data-paused while held). |
mv-wobble-mark-icon / -mark-label / -mark-count | Parts written inside your [data-wobble-mark] elements (data-tier set on the mark). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-wobble-loose | var(--mv-success) | Tone of loose items (card, marks, undo bar). |
--mv-wobble-load-bearing | var(--mv-warning) | Tone of load-bearing items. |
--mv-wobble-foundational | var(--mv-danger) | Tone of foundational items and of consequences that break or block. |
--mv-wobble-amount | 1 | Strength of the item’s motion when tested (0 turns it off, up to 3): the wobble of load-bearing items and the small slide of loose ones. Foundational items never move. |
Accessibility
Triggers stay your own buttons or links with their names; the component adds to each tested trigger an aria-describedby pointing to a hidden text that states the tier and every consequence (“Load-bearing. 16 items depend on “Region: EMEA”. 3 automations stop running; 12 invoices lose their region; 1 dashboard filter breaks.”). When a trigger receives keyboard focus, the result is also announced politely once the probe returns, so nobody has to hover. The preview card is a visual mirror (aria-hidden, nothing focusable) that never takes focus; it stays open while the pointer rests on it and Escape dismisses it (WCAG 1.4.13). Activating a load-bearing, unverified or foundational item opens the card as a labelled, described role="alertdialog" (aria-modal) with focus trapped inside: focus lands on Cancel (the least destructive action), or on the reassignment select when the removal is blocked, and blocked removals are announced assertively. Consequences are a real list; tiers are always stated in words next to an icon (a sliding block, a block under load, a lock), never by color alone, and the rising-bars scale is decorative. Escape or Cancel closes the confirm and returns focus to the trigger; an outside press cancels it. The acknowledgment is a native checkbox with a label; the destructive button is aria-disabled (still focusable) until it is checked and explains why if pressed. Pending actions set aria-busy on the button and the trigger; failures are announced assertively and shown in the card. After a removal, focus moves to the Undo button (loose) or to the next item’s trigger, never to the body; undo returns focus to the restored trigger, Ctrl/Cmd+Z works while the undo bar is shown, and the undo window pauses while the bar is hovered or focused. Reduced motion (OS or data-motion="reduce"): no wobble, no slide, no collapse or spinner animation; the tier text carries everything. Forced colors: marks, tier pill and scale get system-color borders and fills, the tested item gets a Highlight outline.