Exclusivebeta

Context Rings — <mv-context-rings>

Makes visible — and editable — what an AI assistant “knows” for the current task: three concentric rings (“This task” at the center, “Always” for preferences and rules, “Ambient” for streams: email, calendar, chat) plus an “Out of context” tray. Each item is a chip (source icon, size by token cost) spread collision-free around its ring; drag it to another ring with a spring, or move it from the keyboard. A thin gauge around the outer ring compares tokens to the budget: when over, the lowest-priority unpinned ambient items dim with a “will be ignored” label, in eviction order. New ambient items orbit in; a list view takes over on small screens.

CategoryData display
TypeWeb Component (<mv-context-rings>)
Statusbeta
Keywordsexclusive, ai, assistant, agent, context, context-window, memory, tokens, budget, gauge, drag-and-drop, transparency, user-control, rings, orbit

When to use

Avoid when

Install

node scripts/add.mjs context-rings --out ./src/marvelous

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

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, core/pointer.js, core/position.js, components/context-rings/context-rings.js, components/context-rings/context-rings.css.

Usage

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

<div id="cr-demo" style="width:min(100%,70rem);margin-inline:auto">
  <style>
    #cr-demo .cr-bar { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.75rem 1rem; margin:0 0 .75rem }
    #cr-demo .cr-bar p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; max-width:40rem }
    #cr-demo .cr-controls { display:flex; align-items:center; flex-wrap:wrap; gap:.5rem }
    #cr-demo .cr-controls label { display:inline-flex; align-items:center; gap:.5rem; color:var(--mv-fg-muted); font-size:.8125rem }
    #cr-demo .cr-controls .mv-select { width:auto; min-width:7.5rem }
    #cr-demo .cr-log { display:flex; align-items:center; flex-wrap:wrap; gap:.5rem; margin:.75rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; font-variant-numeric:tabular-nums }
    #cr-demo .cr-log code { font:500 .7rem/1 var(--mv-font-mono); padding:.2rem .4rem; border-radius:var(--mv-radius-sm); background:var(--mv-bg-muted); color:var(--mv-fg) }
  </style>

  <div class="cr-bar">
    <p>Drag a chip to another ring, or to “Out of context”. Keyboard: <kbd class="mv-kbd">Enter</kbd> opens the card, <kbd class="mv-kbd">1</kbd> <kbd class="mv-kbd">2</kbd> <kbd class="mv-kbd">3</kbd> move it, <kbd class="mv-kbd">P</kbd> pins it.</p>
    <div class="cr-controls">
      <label>Budget
        <select class="mv-select" id="cr-budget">
          <option value="24000">24k tokens</option>
          <option value="32000" selected>32k tokens</option>
          <option value="64000">64k tokens</option>
        </select>
      </label>
      <button class="mv-button" data-variant="outline" data-size="sm" type="button" id="cr-stream">Stream a new ambient item</button>
    </div>
  </div>

  <mv-context-rings id="cr-rings" budget="32000" task="Reply to Harper &amp; Lane about the late delivery"></mv-context-rings>

  <p class="cr-log"><code>mv-context-change</code><span id="cr-log">Context ready to send.</span></p>

  <script type="module">
    const rings = document.getElementById("cr-rings");
    const min = (m) => Date.now() - m * 60000;

    rings.items = [
      // This task
      { id: "mail-last", ring: "local", label: "Latest email from Dana Harper", source: "email", tokens: 1800, updated: min(38), excerpt: "Hi, we still haven't received the 40 Arlo tables ordered on September 2. Can you give us a firm delivery date? Without an answer by Friday we'll have to cancel." },
      { id: "contract", ring: "local", label: "Contract 2025.pdf", source: "pdf", sourceLabel: "PDF · Drive", tokens: 9600, updated: min(60 * 24 * 41), excerpt: "Section 7 — Lead times: for delays over 15 business days, the customer is entitled to a 5% discount on the affected order." },
      { id: "ticket", ring: "local", label: "Ticket #4821 · late order", source: "ticket", tokens: 2400, updated: min(95), pinned: true, excerpt: "Order HL-2291 held at the Denver warehouse: pallets waiting for the carrier (pickup slot 9/24)." },
      { id: "crm", ring: "local", label: "Account history", source: "crm", tokens: 1600, updated: min(60 * 5), excerpt: "Customer since 2019 · 6 orders · 1 dispute closed in 2023 (partial delivery)." },
      // Always
      { id: "tone", ring: "global", label: "Tone: warm, concise", source: "style", tokens: 120, pinned: true, priority: 2, excerpt: "Short sentences, no internal jargon, always offer a next step." },
      { id: "signature", ring: "global", label: "Signature", source: "profile", tokens: 180, priority: 2, excerpt: "Sam Okafor — Customer Care, Nomad Workshop · +1 (415) 555-0142" },
      { id: "glossary", ring: "global", label: "Product glossary", source: "glossary", tokens: 3200, updated: min(60 * 24 * 6), excerpt: "Arlo: folding oak table · Mist: stackable chair · HL-: prefix for Harper & Lane orders." },
      { id: "refunds", ring: "global", label: "Refund policy", source: "doc", tokens: 1900, updated: min(60 * 24 * 12), excerpt: "Goodwill credit up to 8% without manager approval." },
      // Ambient
      { id: "thread", ring: "ambient", label: "Harper & Lane thread", source: "email", tokens: 5400, priority: 2, pinned: true, updated: min(40), excerpt: "12 messages since September 2, including 3 follow-ups from the customer." },
      { id: "agenda", ring: "ambient", label: "This week's calendar", source: "calendar", tokens: 1200, updated: min(15), excerpt: "Thursday 10:00 AM: logistics sync with the Denver warehouse." },
      { id: "support", ring: "ambient", label: "#support channel", source: "slack", sourceLabel: "Slack · #support", tokens: 4600, priority: 0, updated: min(60 * 3), excerpt: "Leah: “The carrier confirms a Tuesday pickup for HL-2291.”" },
      { id: "recent", ring: "ambient", label: "Recent files", source: "file", tokens: 2600, priority: 0, updated: min(60 * 26), excerpt: "Inventory_Sept.xlsx, Delivery_schedule.pdf, Quote_Mist.docx" },
      { id: "notes", ring: "ambient", label: "Monday meeting notes", source: "note", tokens: 1800, updated: min(60 * 30), excerpt: "Carrier delays rising; warn B2B accounts over $20,000." },
      // Out of context
      { id: "random", ring: "excluded", label: "#random channel", source: "slack", sourceLabel: "Slack · #random", tokens: 4100, priority: 0, updated: min(20) },
      { id: "personal", ring: "excluded", label: "Personal calendar", source: "calendar", tokens: 900, updated: min(60 * 2) },
    ];

    const queue = [
      { id: "reply", label: "Reply from Dana Harper", source: "email", tokens: 900, priority: 2, excerpt: "“Thanks — a Tuesday delivery works for us if you confirm it in writing.”" },
      { id: "invite", label: "Invite: Harper & Lane sync", source: "calendar", tokens: 400, priority: 1, excerpt: "Thursday 2:30 PM · video call · organizer: Leah Martin" },
      { id: "leah", label: "Message from Leah", source: "slack", sourceLabel: "Slack · #support", tokens: 700, priority: 1, excerpt: "“Pickup confirmed Tuesday 8 AM, sending you the bill of lading.”" },
      { id: "invoice", label: "Invoice INV-2291.pdf", source: "pdf", tokens: 1500, priority: 0, excerpt: "Invoice HL-2291 · 40 × Arlo table · $12,960.00" },
    ];
    const stream = document.getElementById("cr-stream");
    stream.addEventListener("click", () => {
      const next = queue.shift();
      if (next) rings.add({ ...next, ring: "ambient", updated: Date.now() });
      if (!queue.length) { stream.disabled = true; stream.textContent = "No more new items"; }
    });

    document.getElementById("cr-budget").addEventListener("change", (e) => { rings.budget = Number(e.target.value); });

    const log = document.getElementById("cr-log");
    const k = (n) => `${(n / 1000).toLocaleString("en-US", { maximumFractionDigits: 1 })}k`;
    rings.addEventListener("mv-context-change", (e) => {
      const { used, budget, evicted } = e.detail;
      log.textContent = `${k(used)} / ${k(budget)} tokens · ${evicted.length ? `${evicted.length} ignored (${evicted.join(", ")})` : "nothing ignored"}`;
    });
  </script>
</div>

API

Attributes

NameTypeDefaultDescription
budgetnumber (tokens)32000The assistant's token budget. When exceeded, unpinned ambient items are marked “will be ignored” (lowest priority first, then oldest, then heaviest) until the total fits again.
taskstringTask title shown as the heading (“Reply to Harper & Lane about the late delivery”). Also names the region.
viewrings | list(auto)Forces a view. Unset: rings, unless the component is narrower than list-below or too crowded for readable chips (list). The “List view” button writes this attribute.
list-belownumber (px)600Component width under which the automatic view switches to the list (ResizeObserver).
data-view / data-budgetrings | list · ok | near | overReflected by the component (effective view, budget state), usable from CSS.

Properties

NameTypeDescription
items{ id, label, ring, tokens, source, sourceLabel?, pinned?, priority?, excerpt?, updated? }[]The model. ring: "local" | "global" | "ambient" | "excluded". source: email, calendar, chat, slack, file, pdf, profile, style, preference, doc, glossary, ticket, note, crm, history, web, memory (icon and label), or free text. priority: 0 low, 1 normal (default), 2 high. updated: Date, timestamp or ISO string. Reading returns copies. Can be set before the element is defined; a child <script type="application/json"> works too.
usednumberTokens sent (rings, not the tray), before eviction. Read-only.
evictedstring[]Ids that will be ignored, in eviction order. Read-only.

Methods

NameDescription
add(item, { animate? })Adds an item (or updates the one with the same id). An ambient item orbits in from outside the rings with a faint pulse.
move(id, ring)Moves an item to a ring or to "excluded". Returns false if it was already there.
pin(id, pinned?)Pins (or unpins, or toggles): a pinned item is never dropped automatically.
remove(id)Removes an item entirely.

Events

NameDescription
mv-context-changeAfter every change (drag and drop, keyboard, methods, budget). detail: { items, used, budget, evicted } — evicted = ignored ids, in order. Not fired when items is assigned.

Content structure

NameDescription
script[type="application/json"]Optional: array of items read on mount when items was not provided.

CSS classes

NameDescription
mv-context-rings-chip / -chip-bodyChip (button): position on the outer element, visuals and scale on the body. data-ring, data-weight (0–3, cost), data-pinned, data-evicted, data-fresh.
mv-context-rings-band / -tabSVG ellipses of the rings (data-ring) and the name tabs on their top edge.
mv-context-rings-gauge-track / -fill / -over / -tickBudget gauge: track, share within budget, overflow, budget mark.
mv-context-rings-tray“Out of context” tray (drop target).
mv-context-rings-list / -section / -rowList view: four labelled sections, one row per item.
mv-context-rings-popDetail card (role=dialog, top layer): source, excerpt, cost, last update, actions.

CSS variables

NameDefaultDescription
--mv-context-rings-accentvar(--mv-accent)Accent color: center ring, drop targets, gauge.
--mv-context-rings-warningvar(--mv-warning)Over-budget color (always paired with an icon and a label).
--mv-context-rings-local / -global / -ambientFill of each ring.
--mv-context-rings-bgvar(--mv-surface)Component background.

Accessibility

A region named by the task title. Each ring (and the tray) is a role="group" labelled with its item count and cost; chips are real buttons with roving focus (arrows, Home/End; Tab moves between rings), in clockwise order. Full alternative to drag and drop: Enter opens a card (role=dialog, focus trapped, Escape returns focus) with “Move to…”, “Pin” (aria-pressed) and “Exclude”; direct shortcuts 1, 2, 3 (rings), Delete or 0 (exclude), P (pin), exposed through aria-keyshortcuts and an aria-describedby hint. Each chip's accessible name gives source, cost, pinned state and eviction rank. A live region announces moves and budget state changes; the budget is also a role="meter" with aria-valuetext. Ring identity never relies on color: name tabs on the rings, solid vs dashed strokes; the over-budget state pairs an icon with text. “List view” (aria-pressed) presents the same model as four labelled lists, automatically below list-below. Reduced motion (prefers-reduced-motion or data-motion="reduce"): no FLIP, orbit or pulse. No animation loop: everything is event-driven (ResizeObserver, Web Animations).