Exclusifbêta
Pea <mv-pea>
Fait ressentir à chaque niveau supérieur un petit problème enfoui au fond de couches repliées. Enveloppez n’importe quelle structure imbriquée repliable (<details> imbriqués ou accordéons, boutons de divulgation [aria-expanded] + aria-controls, un explorateur de fichiers role="tree", des tableaux groupés, des visionneuses JSON, des sections d’assistant) et signalez les éléments à l’intérieur avec data-pea="error | warning | unread | change" (ou tout type personnalisé), aria-invalid="true" ou flag(el, kind) : chaque en-tête ancêtre reçoit un repère compact avec une forme et un compteur par type, le plus grave d’abord, plus une petite bosse sur son bord inférieur, plus en retrait et un peu plus aplatie à chaque couche entre l’en-tête et l’élément, si bien que « trois niveaux plus bas » se ressent avant même d’ouvrir quoi que ce soit. Cliquer sur un repère (ou Maj+Entrée sur l’en-tête) déplie exactement le chemin jusqu’à ces éléments, sans aucun voisin, puis met le focus sur le premier, le fait défiler jusqu’à lui et l’entoure brièvement d’un anneau ; F8 / Maj+F8 et une barre collante (pastilles de compteur par type, élément courant, précédent / suivant) les parcourent tous dans la structure. Recalcule aux changements du DOM et des attributs via un seul MutationObserver regroupé par frame, complète la description accessible de chaque en-tête (« Contains 2 errors and 1 warning, deepest 3 levels down. »), annonce sans interrompre les changements de compteur et émet mv-pea-change et un mv-pea-reveal annulable.
| Catégorie | Affichage de données |
|---|---|
| Type | Web Component (<mv-pea>) |
| Statut | bêta |
| Kit | Des formulaires qui ne perdent personne |
| 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/marvelousAgent IA avec le serveur MCP de Marvelous UI : install_components({ slugs: ["pea"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/pea/pea.js, components/pea/pea.css.
Utilisation
Démarrage rapide, le balisage minimal qui fonctionne :
<mv-pea>
<details><summary>Settings</summary>
<details><summary>Billing</summary><label>VAT number <input name="vat" aria-invalid="true"></label></details>
</details>
</mv-pea>Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :
<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>Référence culturelle
La Princesse au petit pois, Hans Christian Andersen (1835, conte). Un seul petit pois caché sous vingt matelas et vingt édredons se fait encore sentir par celle qui dort dessus. Dans l’interface, un minuscule élément signalé, enfoui sous de nombreuses couches repliées, se ressent sur chaque en-tête au-dessus de lui, avec une bosse qui indique sa profondeur, et un clic ouvre exactement le chemin qui y mène.
API
Attributes
| Name | Type | 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 | Type | 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.