Exclusivebeta

Pea — <mv-pea>

Makes a tiny issue buried deep inside collapsed layers felt at every level above it. Wrap any nested, collapsible structure (nested <details> or accordions, [aria-expanded] + aria-controls disclosures, a role="tree" file explorer, grouped tables, JSON viewers, wizard sections) and flag items inside with data-pea="error | warning | unread | change" (or any custom kind), aria-invalid="true" or flag(el, kind): every ancestor header gets a compact marker with one shape and count per kind, most severe first, plus a small bump on its bottom edge that sits further in and flattens a little with each layer between the header and the item, so “three levels down” can be felt before anything is opened. Clicking a marker (or Shift+Enter on the header) expands exactly the path down to those items, no sibling, then focuses, scrolls to and briefly rings the first one; F8 / Shift+F8 and a sticky bar (count chips per kind, current item, previous / next) walk through all of them across the structure. Recomputes on DOM and attribute changes through one MutationObserver batched per frame, extends each header’s accessible description (“Contains 2 errors and 1 warning, deepest 3 levels down.”), announces count changes politely and emits mv-pea-change and a cancelable mv-pea-reveal.

CategoryData display
TypeWeb Component (<mv-pea>)
Statusbeta
Keywordsexclusive, 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

Avoid when

Install

node scripts/add.mjs pea --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["pea"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/pea/pea.js, components/pea/pea.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

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

Cultural reference

The Princess and the Pea — Hans Christian Andersen (1835, fable). A single pea hidden under twenty mattresses and twenty feather beds is still felt by the one lying on top. In the UI, a tiny flagged item buried under many collapsed layers is felt on every header above it, with a bump that tells how deep it lies, and one click opens exactly the path down to it.

API

Attributes

NameTypeDefaultDescription
kindsspace-separated listerror warning unread changeKnown 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).
bartop | bottom | nonetopWhere 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-onlybooleanMark 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-invalidbooleanDo not treat aria-invalid="true" as an error: only data-pea flags count.
labelstringFlagged itemsAccessible name of the bar (role="group").
data-peaerror | warning | unread | change | <custom> | falseOn any descendant: flags it. An empty value means change; false, none or off ignores it (and its aria-invalid).
data-pea-labelstringOn 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-headerbooleanOn a child of a role="treeitem": the row that receives the marker (default: its first child that is not the role="group").
data-pea-slotbooleanInside a header: where the marker is placed (default: appended at the end of the header).
data-pea-ignorebooleanOn any descendant: nothing inside it is counted.
data-pea-felt / data-pea-depthkind / numberSet 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-foundkindSet 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

NameTypeDescription
countsRecord<kind, number>Items per kind, every known kind included (zeros too), in severity order (read-only).
totalnumberNumber of flagged items (read-only).
peasArray<{ element, kind, label, depth }>Flagged items in document order; depth = number of collapsible layers above the item (read-only).
stringsPartial<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) => booleanOptional 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 / labelreflectedMirror the attributes.

Methods

NameDescription
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

NameDescription
mv-pea-changeCounts changed (also once on first connection, with initial: true). detail: { counts, total, peas, initial }.
mv-pea-revealCancelable, 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

NameDescription
(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

NameDescription
mv-pea-markerMarker 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-bumpThe 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-barThe 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-ringOverlay 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

NameDefaultDescription
--mv-pea-errorvar(--mv-danger)Tint of errors (diamond).
--mv-pea-warningvar(--mv-warning)Tint of warnings (triangle).
--mv-pea-unreadvar(--mv-accent)Tint of unread items (dot).
--mv-pea-changevar(--mv-info)Tint of changes (square). Custom kinds read --mv-pea-<kind>.
--mv-pea-bump-start0.75remInline offset of the bump for an item one level down: set it per header to where its title starts.
--mv-pea-bump-step0.875remHow 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-offset0pxVertical offset of the bump from the header’s bottom edge.
--mv-pea-gap0.5remSpace 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.