ExklusivBeta
Pea <mv-pea>
Macht ein winziges Problem, das tief in eingeklappten Ebenen vergraben ist, auf jeder Ebene darüber spürbar. Umhülle eine beliebige verschachtelte, einklappbare Struktur (verschachtelte <details> oder Akkordeons, Disclosures mit [aria-expanded] + aria-controls, einen Datei-Explorer mit role="tree", gruppierte Tabellen, JSON-Viewer, Wizard-Abschnitte) und markiere Elemente darin mit data-pea="error | warning | unread | change" (oder einer eigenen Art), aria-invalid="true" oder flag(el, kind): Jeder übergeordnete Header erhält eine kompakte Markierung mit einer Form und einer Anzahl pro Art, die schwerwiegendste zuerst, dazu eine kleine Wölbung an seiner Unterkante, die mit jeder Ebene zwischen Header und Element weiter nach innen rückt und etwas flacher wird, sodass sich „drei Ebenen tiefer“ spüren lässt, bevor irgendetwas geöffnet wird. Ein Klick auf eine Markierung (oder Shift+Enter auf dem Header) klappt genau den Pfad zu diesen Elementen auf, kein Geschwisterelement, fokussiert dann das erste, scrollt zu ihm und lässt es kurz aufleuchten; F8 / Shift+F8 und eine Sticky-Leiste (Zähl-Chips pro Art, aktuelles Element, vorheriges / nächstes) führen durch alle in der gesamten Struktur. Berechnet sich bei DOM- und Attributänderungen neu, über einen einzigen MutationObserver, gebündelt pro Frame, erweitert die zugängliche Beschreibung jedes Headers („Contains 2 errors and 1 warning, deepest 3 levels down.“), sagt Änderungen der Anzahl höflich an und löst mv-pea-change sowie ein abbrechbares mv-pea-reveal aus.
| Kategorie | Datenanzeige |
|---|---|
| Typ | Web Component (<mv-pea>) |
| Status | Beta |
| Kit | Formulare, die niemanden verlieren |
| Keywords | exclusive, culture, tree, tree-view, accordion, details, nested, collapsible, validation, errors, unread, changes, file-explorer, code-review, settings, form, json-viewer, badge-propagation, next-problem, keyboard, aria-describedby |
When to use
- A long settings page or form nests sections several levels deep and a validation error must not hide inside a closed one
- A code review or document tree has unread comments or new changes scattered in collapsed folders
- A JSON, config or permissions viewer must show where invalid or modified values sit without expanding everything
- Save or submit should take the user straight to the first problem, opening only the sections on its way
Avoid when
- The structure is flat or one level deep: a badge on the section header or tab is enough → use Badge instead
- The goal is to warn that the page has unsaved changes before leaving, not to locate them → use Tell-Tale instead
- Collapsed content is not in the DOM (rendered only when opened): the component can only feel what exists
Installation
node scripts/add.mjs pea --out ./src/marvelousKI-Agent mit dem MCP-Server von Marvelous UI: install_components({ slugs: ["pea"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Kopierte Dateien (inklusive Abhängigkeiten): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/pea/pea.js, components/pea/pea.css.
Verwendung
Schnellstart mit dem kleinsten funktionierenden Markup:
<mv-pea>
<details><summary>Settings</summary>
<details><summary>Billing</summary><label>VAT number <input name="vat" aria-invalid="true"></label></details>
</details>
</mv-pea>Referenz-Markup zum Starten und Anpassen mit Attributen, data-* und CSS-Variablen:
<div id="pe-demo" style="width:min(100%,74rem);margin-inline:auto">
<style>
#pe-demo { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1.25rem; align-items:start }
#pe-demo .pe-card { min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:clip }
#pe-demo .pe-head { display:flex; align-items:center; gap:.75rem; padding:.875rem 1rem .875rem 1.125rem; border-bottom:1px solid var(--mv-border) }
#pe-demo .pe-head div { display:grid; gap:.125rem; flex:1; min-width:0 }
#pe-demo .pe-head h3 { margin:0; font-size:.9375rem; letter-spacing:-.01em }
#pe-demo .pe-head p { margin:0; color:var(--mv-fg-muted); font-size:.75rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#pe-demo .pe-foot { display:flex; align-items:center; gap:.5rem .75rem; flex-wrap:wrap; padding:.75rem 1rem .75rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#pe-demo .pe-log { flex:1 1 12rem; min-width:0; margin:0; color:var(--mv-fg-subtle); font:.6875rem/1.4 var(--mv-font-mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#pe-demo .mv-choice { font-size:.75rem }
/* Settings tree: plain nested <details> */
#pe-demo .pe-tree { --mv-pea-bump-step:1.25rem }
#pe-demo .pe-tree details + details > summary, #pe-demo .pe-body > details:first-child > summary { border-top:1px solid var(--mv-border) }
#pe-demo .pe-tree > details:first-child > summary { border-top:0 }
#pe-demo .pe-tree summary { --l:0; --mv-pea-bump-start:calc(2.375rem + var(--l) * 1.25rem); display:flex; align-items:center; gap:.625rem; min-height:2.75rem; padding:.5rem 1rem .5rem calc(1rem + var(--l) * 1.25rem); list-style:none; cursor:pointer; font-size:.8125rem; font-weight:500; outline:none }
#pe-demo .pe-tree summary::-webkit-details-marker { display:none }
#pe-demo .pe-tree summary:hover { background:var(--mv-bg-subtle) }
#pe-demo .pe-tree summary:focus-visible { box-shadow:inset 0 0 0 2px var(--mv-ring) }
#pe-demo .pe-tree summary small { color:var(--mv-fg-muted); font-weight:400; font-size:.75rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0 }
#pe-demo .pe-tree summary .pe-t { flex:none }
#pe-demo .pe-chev { width:.875rem; height:.875rem; flex:none; color:var(--mv-fg-subtle); transition:rotate var(--mv-duration-fast) var(--mv-ease-out) }
#pe-demo details[open] > summary > .pe-chev { rotate:90deg }
#pe-demo .pe-fields { display:grid; gap:.75rem; padding:.75rem 1rem 1rem calc(2.375rem + var(--l,0) * 1.25rem); border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#pe-demo .pe-f { display:grid; gap:.3125rem; font-size:.75rem; font-weight:500 }
#pe-demo .pe-f .mv-input, #pe-demo .pe-f select { max-width:22rem }
#pe-demo .pe-f select { height:var(--mv-control-sm); padding:0 .5rem; border:1px solid var(--mv-input); border-radius:var(--mv-radius-md); background:var(--mv-surface); color:var(--mv-fg); font:inherit; font-weight:400 }
#pe-demo .pe-f small { color:var(--mv-fg-muted); font-weight:400 }
#pe-demo .pe-f .pe-err { color:color-mix(in oklab, var(--mv-danger), var(--mv-fg) 20%) }
#pe-demo .pe-f .pe-err[hidden] { display:none }
#pe-demo .pe-note { margin:0; color:var(--mv-fg-muted); font-size:.75rem }
/* Code review: an ARIA tree of changed files */
#pe-demo [role="tree"], #pe-demo [role="group"] { list-style:none; margin:0; padding:0 }
#pe-demo [role="tree"] { padding:.375rem 0; --mv-pea-bump-step:1rem }
#pe-demo [role="treeitem"] { outline:none }
#pe-demo [role="treeitem"][aria-expanded="false"] > [role="group"] { display:none }
#pe-demo .fx-row { --d:0; --mv-pea-bump-start:calc(2.25rem + var(--d) * 1rem); display:flex; align-items:center; gap:.5rem; min-height:2rem; padding:0 1rem 0 calc(.75rem + var(--d) * 1rem); font-size:.8125rem; cursor:pointer; border-radius:0 }
#pe-demo .fx-row:hover { background:var(--mv-bg-subtle) }
#pe-demo [role="treeitem"]:focus-visible > .fx-row { box-shadow:inset 0 0 0 2px var(--mv-ring) }
#pe-demo [role="treeitem"][aria-selected="true"] > .fx-row { background:var(--mv-accent-subtle) }
#pe-demo .fx-row > svg { width:.9375rem; height:.9375rem; flex:none; color:var(--mv-fg-subtle) }
#pe-demo .fx-row .fx-chev { width:.75rem; height:.75rem; transition:rotate var(--mv-duration-fast) var(--mv-ease-out) }
#pe-demo [aria-expanded="true"] > .fx-row > .fx-chev { rotate:90deg }
#pe-demo .fx-leaf { width:.75rem; flex:none }
#pe-demo .fx-name { font-family:var(--mv-font-mono); font-size:.75rem; white-space:nowrap }
#pe-demo .fx-slot { display:inline-flex; flex:1; min-width:0 }
#pe-demo .fx-diff { flex:none; font:.6875rem var(--mv-font-mono); color:var(--mv-fg-subtle) }
#pe-demo .fx-diff b { font-weight:500; color:color-mix(in oklab, var(--mv-success), var(--mv-fg) 15%) }
#pe-demo .fx-diff i { font-style:normal; color:color-mix(in oklab, var(--mv-danger), var(--mv-fg) 15%) }
#pe-demo .pe-hint { grid-column:1 / -1; margin:0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
#pe-demo kbd { font:.6875rem var(--mv-font-mono) }
@media (max-width:58rem) { #pe-demo { grid-template-columns:minmax(0,1fr) } }
</style>
<!-- 1 · Settings: a validation error four levels deep, felt at the top -->
<section class="pe-card" aria-labelledby="pe-s-title">
<header class="pe-head">
<div>
<h3 id="pe-s-title">Workspace settings</h3>
<p>Acme Logistics · Production</p>
</div>
<button class="mv-button" data-size="sm" type="button" id="pe-save">Save changes</button>
</header>
<mv-pea id="pe-settings" label="Settings issues">
<div class="pe-tree">
<details>
<summary><svg class="pe-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><span class="pe-t">General</span><small>Name, region, language</small></summary>
<div class="pe-fields">
<label class="pe-f">Workspace name<input class="mv-input" data-size="sm" value="Acme Logistics" data-initial="Acme Logistics"></label>
<label class="pe-f">Data region
<select data-initial="us-east"><option value="us-east" selected>US East (N. Virginia)</option><option value="eu-west">EU West (Dublin)</option><option value="ap-south">Asia Pacific (Singapore)</option></select>
</label>
</div>
</details>
<details>
<summary><svg class="pe-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><span class="pe-t">Integrations</span><small>Webhooks, Slack</small></summary>
<div class="pe-body">
<details>
<summary style="--l:1"><svg class="pe-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><span class="pe-t">Webhooks</span><small>2 endpoints</small></summary>
<div class="pe-body">
<details>
<summary style="--l:2"><svg class="pe-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><span class="pe-t">Order events</span><small>orders.created, orders.shipped</small></summary>
<div class="pe-body">
<details>
<summary style="--l:3"><svg class="pe-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><span class="pe-t">Delivery</span><small>URL, timeout</small></summary>
<div class="pe-fields" style="--l:3">
<label class="pe-f">Endpoint URL
<input class="mv-input" data-size="sm" id="pe-url" value="http://hooks.acme-logistics.com/orders" data-initial="http://hooks.acme-logistics.com/orders" aria-invalid="true" aria-describedby="pe-url-err" spellcheck="false">
<small class="pe-err" id="pe-url-err">Use an https:// URL. Plain HTTP endpoints are rejected.</small>
</label>
<label class="pe-f">Timeout (seconds)<input class="mv-input" data-size="sm" type="number" value="10" data-initial="10" min="1" max="30"></label>
</div>
</details>
<details>
<summary style="--l:3"><svg class="pe-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><span class="pe-t">Retries</span><small>Backoff policy</small></summary>
<div class="pe-fields" style="--l:3">
<label class="pe-f">Maximum retries
<select id="pe-retries" data-initial="0" data-pea="warning" data-pea-label="Retries are disabled"><option value="0" selected>None (not recommended)</option><option value="3">3, exponential backoff</option><option value="5">5, exponential backoff</option></select>
</label>
</div>
</details>
</div>
</details>
<details>
<summary style="--l:2"><svg class="pe-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><span class="pe-t">Inventory events</span><small>inventory.low_stock</small></summary>
<div class="pe-fields" style="--l:2"><p class="pe-note">Delivering to https://ops.acme-logistics.com/hooks/stock · last success Sep 22, 2026, 4:12 PM</p></div>
</details>
</div>
</details>
<details>
<summary style="--l:1"><svg class="pe-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><span class="pe-t">Slack</span><small>Alerts channel</small></summary>
<div class="pe-fields" style="--l:1">
<label class="pe-f">Channel<input class="mv-input" data-size="sm" value="#ops-alerts" data-initial="#ops" data-pea="change"></label>
</div>
</details>
</div>
</details>
<details>
<summary><svg class="pe-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><span class="pe-t">Security</span><small>Sessions, API keys</small></summary>
<div class="pe-body">
<details>
<summary style="--l:1"><svg class="pe-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><span class="pe-t">Sessions</span><small>Length, idle timeout</small></summary>
<div class="pe-fields" style="--l:1">
<label class="pe-f">Session length (days)
<input class="mv-input" data-size="sm" id="pe-session" type="number" value="30" data-initial="30" min="1" max="90" data-pea="warning" data-pea-label="Session length above the 14-day policy">
<small>Your security policy recommends 14 days or less.</small>
</label>
</div>
</details>
<details>
<summary style="--l:1"><svg class="pe-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><span class="pe-t">API keys</span><small>3 active</small></summary>
<div class="pe-fields" style="--l:1"><p class="pe-note">Last key created by Priya Raman on Sep 18, 2026.</p></div>
</details>
</div>
</details>
<details>
<summary><svg class="pe-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><span class="pe-t">Notifications</span><small>Email and push</small></summary>
<div class="pe-fields">
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" checked data-initial="false" data-pea="change" data-pea-label="Weekly digest">
<span class="mv-choice-text"><span class="mv-choice-title">Weekly digest</span><span class="mv-choice-description">Every Monday at 9:00 AM</span></span>
</label>
</div>
</details>
<details>
<summary><svg class="pe-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><span class="pe-t">Billing</span><small>Pro plan · $240 / month</small></summary>
<div class="pe-fields"><p class="pe-note">Next invoice on Oct 1, 2026 · Visa ending in 4242</p></div>
</details>
</div>
</mv-pea>
<footer class="pe-foot">
<label class="mv-choice" data-control="start">
<input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="pe-quiet" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Markers on open sections</span></span>
</label>
<button class="mv-button" data-variant="outline" data-size="sm" type="button" id="pe-collapse">Collapse all</button>
<p class="pe-log" id="pe-s-log" aria-hidden="true"></p>
</footer>
</section>
<!-- 2 · Code review: unread comments buried in a collapsed file tree -->
<section class="pe-card" aria-labelledby="pe-r-title">
<header class="pe-head">
<div>
<h3 id="pe-r-title">Add token-bucket rate limiter</h3>
<p>#482 · Kenji Watanabe wants to merge 9 commits into main</p>
</div>
<span class="mv-badge" data-variant="success" data-shape="pill">Open</span>
</header>
<mv-pea id="pe-review" label="Review activity" kinds="unread change">
<ul role="tree" aria-label="Changed files" id="pe-tree">
<li role="treeitem" aria-expanded="true" tabindex="0">
<span class="fx-row"><svg class="fx-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 4.25c0-.55.45-1 1-1h3.5l1.5 1.5h5.5c.55 0 1 .45 1 1v6.5c0 .55-.45 1-1 1h-10.5c-.55 0-1-.45-1-1z"/></svg><span class="fx-name">src</span><span class="fx-slot" data-pea-slot></span></span>
<ul role="group">
<li role="treeitem" aria-expanded="false" tabindex="-1">
<span class="fx-row" style="--d:1"><svg class="fx-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 4.25c0-.55.45-1 1-1h3.5l1.5 1.5h5.5c.55 0 1 .45 1 1v6.5c0 .55-.45 1-1 1h-10.5c-.55 0-1-.45-1-1z"/></svg><span class="fx-name">api</span><span class="fx-slot" data-pea-slot></span></span>
<ul role="group">
<li role="treeitem" aria-expanded="false" tabindex="-1">
<span class="fx-row" style="--d:2"><svg class="fx-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 4.25c0-.55.45-1 1-1h3.5l1.5 1.5h5.5c.55 0 1 .45 1 1v6.5c0 .55-.45 1-1 1h-10.5c-.55 0-1-.45-1-1z"/></svg><span class="fx-name">middleware</span><span class="fx-slot" data-pea-slot></span></span>
<ul role="group">
<li role="treeitem" tabindex="-1" data-pea="unread" data-pea-label="rate-limit.ts · 2 new comments from Amara Okafor"><span class="fx-row" style="--d:3"><span class="fx-leaf"></span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M9.25 1.75h-5.5c-.55 0-1 .45-1 1v10.5c0 .55.45 1 1 1h8.5c.55 0 1-.45 1-1v-7.5z"/><path d="M9.25 1.75v4h4"/></svg><span class="fx-name">rate-limit.ts</span><span class="fx-slot"></span><span class="fx-diff"><b>+128</b> <i>−4</i></span></span></li>
<li role="treeitem" tabindex="-1"><span class="fx-row" style="--d:3"><span class="fx-leaf"></span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M9.25 1.75h-5.5c-.55 0-1 .45-1 1v10.5c0 .55.45 1 1 1h8.5c.55 0 1-.45 1-1v-7.5z"/><path d="M9.25 1.75v4h4"/></svg><span class="fx-name">auth.ts</span><span class="fx-slot"></span><span class="fx-diff"><b>+6</b> <i>−2</i></span></span></li>
</ul>
</li>
<li role="treeitem" tabindex="-1" data-pea="change" data-pea-label="routes.ts · changed since your last review"><span class="fx-row" style="--d:2"><span class="fx-leaf"></span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M9.25 1.75h-5.5c-.55 0-1 .45-1 1v10.5c0 .55.45 1 1 1h8.5c.55 0 1-.45 1-1v-7.5z"/><path d="M9.25 1.75v4h4"/></svg><span class="fx-name">routes.ts</span><span class="fx-slot"></span><span class="fx-diff"><b>+14</b> <i>−1</i></span></span></li>
</ul>
</li>
<li role="treeitem" aria-expanded="false" tabindex="-1">
<span class="fx-row" style="--d:1"><svg class="fx-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 4.25c0-.55.45-1 1-1h3.5l1.5 1.5h5.5c.55 0 1 .45 1 1v6.5c0 .55-.45 1-1 1h-10.5c-.55 0-1-.45-1-1z"/></svg><span class="fx-name">lib</span><span class="fx-slot" data-pea-slot></span></span>
<ul role="group">
<li role="treeitem" tabindex="-1" data-pea="unread" data-pea-label="token-bucket.ts · 1 new comment from Lucía Fernández"><span class="fx-row" style="--d:2"><span class="fx-leaf"></span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M9.25 1.75h-5.5c-.55 0-1 .45-1 1v10.5c0 .55.45 1 1 1h8.5c.55 0 1-.45 1-1v-7.5z"/><path d="M9.25 1.75v4h4"/></svg><span class="fx-name">token-bucket.ts</span><span class="fx-slot"></span><span class="fx-diff"><b>+96</b> <i>−0</i></span></span></li>
<li role="treeitem" tabindex="-1"><span class="fx-row" style="--d:2"><span class="fx-leaf"></span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M9.25 1.75h-5.5c-.55 0-1 .45-1 1v10.5c0 .55.45 1 1 1h8.5c.55 0 1-.45 1-1v-7.5z"/><path d="M9.25 1.75v4h4"/></svg><span class="fx-name">clock.ts</span><span class="fx-slot"></span><span class="fx-diff"><b>+22</b> <i>−0</i></span></span></li>
</ul>
</li>
</ul>
</li>
<li role="treeitem" aria-expanded="false" tabindex="-1">
<span class="fx-row"><svg class="fx-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 4.25c0-.55.45-1 1-1h3.5l1.5 1.5h5.5c.55 0 1 .45 1 1v6.5c0 .55-.45 1-1 1h-10.5c-.55 0-1-.45-1-1z"/></svg><span class="fx-name">tests</span><span class="fx-slot" data-pea-slot></span></span>
<ul role="group">
<li role="treeitem" aria-expanded="false" tabindex="-1">
<span class="fx-row" style="--d:1"><svg class="fx-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 4.25c0-.55.45-1 1-1h3.5l1.5 1.5h5.5c.55 0 1 .45 1 1v6.5c0 .55-.45 1-1 1h-10.5c-.55 0-1-.45-1-1z"/></svg><span class="fx-name">integration</span><span class="fx-slot" data-pea-slot></span></span>
<ul role="group">
<li role="treeitem" tabindex="-1" data-pea="unread" data-pea-label="rate-limit.test.ts · 1 new comment from Amara Okafor"><span class="fx-row" style="--d:2"><span class="fx-leaf"></span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M9.25 1.75h-5.5c-.55 0-1 .45-1 1v10.5c0 .55.45 1 1 1h8.5c.55 0 1-.45 1-1v-7.5z"/><path d="M9.25 1.75v4h4"/></svg><span class="fx-name">rate-limit.test.ts</span><span class="fx-slot"></span><span class="fx-diff"><b>+211</b> <i>−0</i></span></span></li>
</ul>
</li>
<li role="treeitem" tabindex="-1" data-pea="change" data-pea-label="token-bucket.test.ts · changed since your last review"><span class="fx-row" style="--d:1"><span class="fx-leaf"></span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M9.25 1.75h-5.5c-.55 0-1 .45-1 1v10.5c0 .55.45 1 1 1h8.5c.55 0 1-.45 1-1v-7.5z"/><path d="M9.25 1.75v4h4"/></svg><span class="fx-name">token-bucket.test.ts</span><span class="fx-slot"></span><span class="fx-diff"><b>+58</b> <i>−3</i></span></span></li>
</ul>
</li>
<li role="treeitem" aria-expanded="false" tabindex="-1">
<span class="fx-row"><svg class="fx-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 4.25c0-.55.45-1 1-1h3.5l1.5 1.5h5.5c.55 0 1 .45 1 1v6.5c0 .55-.45 1-1 1h-10.5c-.55 0-1-.45-1-1z"/></svg><span class="fx-name">docs</span><span class="fx-slot" data-pea-slot></span></span>
<ul role="group">
<li role="treeitem" tabindex="-1" data-pea="unread" data-pea-label="rate-limits.md · 1 new comment from Priya Raman"><span class="fx-row" style="--d:1"><span class="fx-leaf"></span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M9.25 1.75h-5.5c-.55 0-1 .45-1 1v10.5c0 .55.45 1 1 1h8.5c.55 0 1-.45 1-1v-7.5z"/><path d="M9.25 1.75v4h4"/></svg><span class="fx-name">rate-limits.md</span><span class="fx-slot"></span><span class="fx-diff"><b>+41</b> <i>−0</i></span></span></li>
</ul>
</li>
<li role="treeitem" tabindex="-1"><span class="fx-row"><span class="fx-leaf"></span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M9.25 1.75h-5.5c-.55 0-1 .45-1 1v10.5c0 .55.45 1 1 1h8.5c.55 0 1-.45 1-1v-7.5z"/><path d="M9.25 1.75v4h4"/></svg><span class="fx-name">CHANGELOG.md</span><span class="fx-slot"></span><span class="fx-diff"><b>+3</b> <i>−0</i></span></span></li>
</ul>
</mv-pea>
<footer class="pe-foot">
<button class="mv-button" data-size="sm" type="button" id="pe-comment">Simulate a new comment</button>
<button class="mv-button" data-variant="outline" data-size="sm" type="button" id="pe-read">Mark all as read</button>
<p class="pe-log" id="pe-r-log" aria-hidden="true"></p>
</footer>
</section>
<p class="pe-hint">Click a marker to open exactly the path down to what it felt · <kbd>F8</kbd> / <kbd>Shift+F8</kbd> walk through every flagged item · <kbd>Shift+Enter</kbd> on a header · Open a file to mark it read</p>
<script type="module">
await customElements.whenDefined("mv-pea");
const $ = (id) => document.getElementById(id);
const settings = $("pe-settings");
const review = $("pe-review");
review.strings = { unread: "unread comment|unread comments", change: "update|updates" };
const logTo = (el, pea) => {
const c = Object.entries(pea.counts).filter(([, n]) => n).map(([k, n]) => `${k}: ${n}`).join(", ");
el.textContent = `mv-pea-change → { ${c || "all clear"} }`;
};
settings.addEventListener("mv-pea-change", () => logTo($("pe-s-log"), settings));
review.addEventListener("mv-pea-change", () => logTo($("pe-r-log"), review));
logTo($("pe-s-log"), settings);
logTo($("pe-r-log"), review);
/* Settings: live validation, dirty tracking, save jumps to the first error. */
const fields = [...settings.querySelectorAll("[data-initial]")];
const valueOf = (f) => (f.type === "checkbox" ? String(f.checked) : f.value);
const evaluate = (f) => {
const v = valueOf(f);
if (f.id === "pe-url") {
const bad = !/^https:\/\/[^\s/]+\.[a-z]{2,}(\/\S*)?$/i.test(v.trim());
if (bad) f.setAttribute("aria-invalid", "true");
else f.removeAttribute("aria-invalid");
$("pe-url-err").hidden = !bad;
if (bad) { f.removeAttribute("data-pea"); return; }
}
if (f.id === "pe-retries" && v === "0") { f.dataset.pea = "warning"; return; }
if (f.id === "pe-session" && Number(v) > 14) { f.dataset.pea = "warning"; return; }
if (v !== f.dataset.initial) f.dataset.pea = "change";
else f.removeAttribute("data-pea");
};
for (const f of fields) {
f.addEventListener("input", () => evaluate(f));
f.addEventListener("change", () => evaluate(f));
}
$("pe-save").addEventListener("click", () => {
if (settings.counts.error) { settings.next({ kind: "error" }); return; }
for (const f of fields) { f.dataset.initial = valueOf(f); evaluate(f); }
});
$("pe-collapse").addEventListener("click", () => {
for (const d of settings.querySelectorAll("details[open]")) d.open = false;
});
$("pe-quiet").addEventListener("change", (e) => { settings.collapsedOnly = !e.target.checked; });
/* Review: a minimal ARIA tree (roving tabindex, arrows, Enter). Opening a file marks it read. */
const tree = $("pe-tree");
const items = () => [...tree.querySelectorAll('[role="treeitem"]')].filter((li) => !li.parentElement.closest('[aria-expanded="false"]'));
const focusItem = (li) => {
if (!li) return;
for (const x of tree.querySelectorAll('[role="treeitem"][tabindex="0"]')) x.tabIndex = -1;
li.tabIndex = 0;
li.focus();
};
const open = (li) => {
if (li.hasAttribute("aria-expanded")) { li.setAttribute("aria-expanded", String(li.getAttribute("aria-expanded") !== "true")); return; }
for (const x of tree.querySelectorAll('[aria-selected="true"]')) x.removeAttribute("aria-selected");
li.setAttribute("aria-selected", "true");
if (li.dataset.pea === "unread") li.removeAttribute("data-pea");
};
tree.addEventListener("focusin", (e) => {
const li = e.target.closest('[role="treeitem"]');
if (!li) return;
for (const x of tree.querySelectorAll('[role="treeitem"][tabindex="0"]')) if (x !== li) x.tabIndex = -1;
li.tabIndex = 0;
});
tree.addEventListener("click", (e) => {
const row = e.target.closest(".fx-row");
if (!row) return;
const li = row.parentElement;
focusItem(li);
open(li);
});
tree.addEventListener("keydown", (e) => {
const li = e.target.closest('[role="treeitem"]');
if (!li || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
const list = items();
const i = list.indexOf(li);
const expanded = li.getAttribute("aria-expanded");
const keys = {
ArrowDown: () => focusItem(list[i + 1]),
ArrowUp: () => focusItem(list[i - 1]),
Home: () => focusItem(list[0]),
End: () => focusItem(list[list.length - 1]),
ArrowRight: () => (expanded === "false" ? li.setAttribute("aria-expanded", "true") : expanded === "true" && focusItem(li.querySelector('[role="treeitem"]'))),
ArrowLeft: () => (expanded === "true" ? li.setAttribute("aria-expanded", "false") : focusItem(li.parentElement.closest('[role="treeitem"]'))),
Enter: () => open(li),
" ": () => open(li),
};
if (!keys[e.key]) return;
e.preventDefault();
keys[e.key]();
});
const people = ["Amara Okafor", "Lucía Fernández", "Priya Raman", "Mateus Silva"];
$("pe-comment").addEventListener("click", () => {
const files = [...tree.querySelectorAll('[role="treeitem"]:not([aria-expanded])')].filter((li) => li.dataset.pea !== "unread" && li.parentElement.closest('[aria-expanded="false"]'));
const pool = files.length ? files : [...tree.querySelectorAll('[role="treeitem"]:not([aria-expanded]):not([data-pea="unread"])')];
const li = pool[Math.floor(Math.random() * pool.length)];
if (!li) return;
const who = people[Math.floor(Math.random() * people.length)];
review.flag(li, "unread", { label: `${li.querySelector(".fx-name").textContent} · 1 new comment from ${who}` });
});
$("pe-read").addEventListener("click", () => review.clear("unread"));
</script>
</div>Kulturelle Referenz
Die Prinzessin auf der Erbse, Hans Christian Andersen (1835, Fabel). Eine einzige Erbse unter zwanzig Matratzen und zwanzig Federbetten spürt die, die obenauf liegt, trotzdem. In der UI ist ein winziges markiertes Element unter vielen eingeklappten Ebenen auf jedem Header darüber spürbar, mit einer Wölbung, die verrät, wie tief es liegt, und ein Klick öffnet genau den Pfad zu ihm.
API
Attributes
| Name | Typ | Default | Description |
|---|---|---|---|
kinds | space-separated list | error warning unread change | Known kinds in severity order, most severe first: it orders marker segments, bar chips and which kind a marker click reveals. Kinds found in the DOM but not listed are appended after, with a hollow ring shape and the color --mv-pea-<kind> (fallback --mv-fg-muted). |
bar | top | bottom | none | top | Where the sticky bar goes inside the component: count chips per kind (click = next item of that kind), the current item’s label and position, previous / next buttons. none keeps only markers, keys and methods. |
collapsed-only | boolean | Mark collapsed layers only. By default open layers keep a quiet marker (no fill, no bump) so a scrolled-away header still shows what it contains. | |
ignore-invalid | boolean | Do not treat aria-invalid="true" as an error: only data-pea flags count. | |
label | string | Flagged items | Accessible name of the bar (role="group"). |
data-pea | error | warning | unread | change | <custom> | false | On any descendant: flags it. An empty value means change; false, none or off ignores it (and its aria-invalid). | |
data-pea-label | string | On a flagged item: name used in the bar, events and peas (default: aria-label, aria-labelledby, the field’s <label>, the tree item’s row text, then its text). | |
data-pea-header | boolean | On a child of a role="treeitem": the row that receives the marker (default: its first child that is not the role="group"). | |
data-pea-slot | boolean | Inside a header: where the marker is placed (default: appended at the end of the header). | |
data-pea-ignore | boolean | On any descendant: nothing inside it is counted. | |
data-pea-felt / data-pea-depth | kind / number | Set by the component on every header that feels something: its most severe kind and the deepest level. Style your own headers with them. | |
data-pea-found | kind | Set for 1.8 s on the item a reveal ended on (style it if you like); the visible ring is the component’s own overlay, so host rules removing outlines on rows cannot hide it. |
Properties
| Name | Typ | Description |
|---|---|---|
counts | Record<kind, number> | Items per kind, every known kind included (zeros too), in severity order (read-only). |
total | number | Number of flagged items (read-only). |
peas | Array<{ element, kind, label, depth }> | Flagged items in document order; depth = number of collapsible layers above the item (read-only). |
strings | Partial<Record<string, string>> | Overrides for every text: kind names as "singular|plural" (error, warning, unread, change or a custom kind), describe ({list}, {depth}), levelOne, levels ({n}), deepest ({n}), hint, barLabel, clear, hintKeys, next, prev, position ({i}, {n}), announce ({list}), announceClear. English defaults; lists are joined with Intl.ListFormat in the nearest lang. |
expandLayer | (header, layer, type) => boolean | Optional hook for state-driven trees and accordions: expand the layer your way and return true. Otherwise details are opened, tree items get aria-expanded="true" (and their hidden group is shown), and aria-controls headers are clicked, then forced open on the next frame if the click did not do it. |
kinds / bar / collapsedOnly / ignoreInvalid / label | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
flag(el, kind = "error", { label? }) | Flags an element (sets data-pea and data-pea-label). Returns false if it is not inside the component. |
unflag(el) | Removes an element’s data-pea (aria-invalid stays under your validation’s control). |
clear(kind?) | Removes every data-pea flag, or only those of one kind. Returns how many were cleared. |
reveal(target, { kind?, focus = true }) | Expands exactly the path down and focuses the first item. target = a flagged item, a layer or its header (reveals its items of the most severe kind, or of kind), or any element containing flagged items; element or selector. Returns Promise<Element | null>. |
next({ kind? }) / prev({ kind? }) | Reveals the next / previous flagged item in document order, from the focused element or the last revealed item, wrapping around. Returns Promise<Element | null>. |
refresh() | Recomputes synchronously (normally batched per frame) and returns counts. |
Events
| Name | Description |
|---|---|
mv-pea-change | Counts changed (also once on first connection, with initial: true). detail: { counts, total, peas, initial }. |
mv-pea-reveal | Cancelable, before a reveal expands anything. detail: { target, targets, kind, label, layers (headers about to be expanded, outermost first), source: "marker" | "key" | "bar" | "next" | "api" }. preventDefault() cancels it; open the layers yourself if you need to. |
Content structure
| Name | Description |
|---|---|
(content) | Any nested structure. The component only adds markers and bumps (aria-hidden, data-pea-own) inside headers, ids to their aria-describedby, and data-pea-felt / data-pea-depth / data-pea-found attributes; everything else stays yours. |
CSS classes
| Name | Description |
|---|---|
mv-pea-marker | Marker in a header (aria-hidden, clickable): .mv-pea-seg per kind (data-kind, shape + .mv-pea-count), .mv-pea-tip with the depth in words shown on hover or keyboard focus (data-active). data-state="collapsed | open", data-kind = most severe kind. |
mv-pea-bump | The bump on the header’s bottom edge (collapsed layers only). Moves in by --mv-pea-bump-step and flattens by 14% per extra layer (never below 45%). |
mv-pea-bar | The sticky bar: .mv-pea-chips (.mv-pea-chip per kind, or .mv-pea-clear), .mv-pea-where (current item: -icon, -label, .mv-pea-pos, or the .mv-pea-keys hint), .mv-pea-nav (.mv-pea-nav-btn ×2). data-clear when nothing is flagged. |
mv-pea-ring | Overlay ring (aria-hidden, pointer-events: none) drawn around the revealed item for 1.8 s, following it while it scrolls into view; data-kind, clamped to the component’s width, radius follows the item’s. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-pea-error | var(--mv-danger) | Tint of errors (diamond). |
--mv-pea-warning | var(--mv-warning) | Tint of warnings (triangle). |
--mv-pea-unread | var(--mv-accent) | Tint of unread items (dot). |
--mv-pea-change | var(--mv-info) | Tint of changes (square). Custom kinds read --mv-pea-<kind>. |
--mv-pea-bump-start | 0.75rem | Inline offset of the bump for an item one level down: set it per header to where its title starts. |
--mv-pea-bump-step | 0.875rem | How much further in the bump sits per extra level: set it to your indentation step so it points where the item lies. |
--mv-pea-bump-offset | 0px | Vertical offset of the bump from the header’s bottom edge. |
--mv-pea-gap | 0.5rem | Space before the marker. |
Accessibility
Nothing relies on color: every kind has its own shape (diamond error, triangle warning, dot unread, square change, ring for custom kinds) next to a number, and the depth is spelled out in words on hover or keyboard focus. Markers and bumps are aria-hidden and never become the header’s accessible name; instead each header’s aria-describedby gains a hidden description (“Contains 2 errors and 1 warning, deepest 3 levels down. Shift+Enter reveals it.”) on its focusable element (the <summary>, the aria-controls button, or the treeitem), removed again when the items are gone. No button is nested inside a summary or a button: the marker is a pointer shortcut and Shift+Enter on a focused header is its keyboard equivalent (the header’s own Enter / Space still toggles it). A reveal expands only the layers on the path, moves focus to the item (a temporary tabindex="-1" is added to non-focusable rows and removed on blur), scrolls it into view and rings it for 1.8 s; a focused form field is read with its own label and invalid state. F8 / Shift+F8 anywhere inside the component and the bar’s previous / next buttons (labelled, aria-keyshortcuts) walk through every item; count chips are real buttons named “2 errors”. Count changes are announced in a polite live region, debounced to one message per burst (“1 error and 2 warnings flagged.”, “All clear, nothing flagged.”) and never on first render. Reduced motion (OS or data-motion="reduce"): the ring stays static, the bump appears without rising, scrolling is instant. Forced colors: markers, shapes and bumps use CanvasText, the ring uses Highlight.