# Tool Chips

> Agent run as expandable tool-call rows, then file-diff chips.

- **Slug:** `tool-chips`
- **Kind:** component · **Category:** Agent
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/ToolChips.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/tool-chips
- **All files in one bundle:** https://viewership.ai/ui/components/tool-chips.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/tool-chips.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/tool-chips/`:

   - `ToolChips.astro` (raw: https://viewership.ai/ui/raw/tool-chips/ToolChips.astro.txt)

npm packages: none.

## Usage

```astro
---
import ToolChips from "../components/viewership/tool-chips/ToolChips.astro";
---

<ToolChips />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import ToolChips from "./ToolChips.astro";
import type { ToolDiff, ToolStep } from "./ToolChips.astro";

const steps: ToolStep[] = [
  {
    icon: "think",
    label: "Thinking",
    chip: "Planning the weekly report…",
    detail: [
      { text: "Paid search drove most conversions, so it leads the summary." },
      { text: "Two campaigns are waiting on client approval this week." },
    ],
  },
  {
    icon: "write",
    label: "Write 186 lines",
    chip: "WeeklyReport.astro",
    mono: true,
    detailMono: true,
    detail: [
      { text: "+ const rows = ga4.rows.filter((r) => r.paid)", tone: "add" },
      { text: '+ return report(rows, { client: "northwind" })', tone: "add" },
    ],
  },
  {
    icon: "run",
    label: "Rebuild and verify",
    chip: "npm run check",
    mono: true,
    detailMono: true,
    detail: [{ text: "✓ built in 1.4s" }, { text: "✓ 28 checks passed" }],
  },
  {
    icon: "read",
    label: "Read image",
    chip: "ai-citations.png",
    mono: true,
    detail: [
      { text: "1280 × 720 · bar chart, six prompts." },
      { text: "Perplexity citations are up 2 since August." },
    ],
  },
];

const diffs: ToolDiff[] = [
  {
    file: "report.css",
    add: 11,
    del: 0,
    lines: [
      { text: ".kpi-card {", tone: "ctx" },
      { text: "  gap: 14px;", tone: "del" },
      { text: "  gap: 12px;", tone: "add" },
      { text: "  container-type: inline-size;", tone: "add" },
      { text: "}", tone: "ctx" },
    ],
  },
  {
    file: "WeeklyReport.astro",
    add: 62,
    del: 18,
    lines: [
      { text: "const rows = ga4.rows;", tone: "ctx" },
      { text: "const paid = rows;", tone: "del" },
      { text: "const paid = rows.filter(", tone: "add" },
      { text: "  (r) => r.paid && r.converted,", tone: "add" },
      { text: ");", tone: "add" },
    ],
  },
  {
    file: "clients.ts",
    add: 6,
    del: 2,
    lines: [
      { text: 'export const client = "northwind-old";', tone: "del" },
      { text: 'export const client = "northwind";', tone: "add" },
    ],
  },
];
---

<ToolChips steps={steps} diffs={diffs} header="4 tool calls, 2 messages" more="+2 more" />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `steps` | `{ icon: "think" \| "write" \| "run" \| "read" \| "search"; label: string; chip: string; mono?: boolean; detailMono?: boolean; detail: { text: string; tone?: "add" }[] }[]` |  | Tool calls, in order. Rows expand to detail lines. |
| `diffs` | `{ file: string; add: number; del: number; lines?: { text: string; tone: "add" \| "del" \| "ctx" }[] }[]` | `[]` | Diff chips after the run. Chips with `lines` preview on hover, focus or click. |
| `header` | `string` | `"N tool calls"` | Run header label. |
| `more` | `string` |  | Trailing note, e.g. "+2 more". |
| `open` | `boolean` | `true` | Start expanded. |
| `autoplay` | `boolean` | `true` | Reveal rows 700ms apart in view. When false or under reduced motion, the full run renders. |
| `loop` | `boolean` | `false` | Replay after a hold. Stops once toggled. |

## Source

### `src/components/viewership/tool-chips/ToolChips.astro`

```astro
---
/**
 * ToolChips: an agent run as compact tool-call rows, then file-diff chips.
 *
 * Rows arrive one at a time (STEP_MS apart) and expand to show what the tool
 * did. Hover, focus or click a diff chip to preview its diff in a popover
 * that stays in the viewport (Esc closes it).
 *
 * Events (bubbling, on the root):
 *   vs-tool-chips:toggle-row   detail { label, open }
 *   vs-tool-chips:open-change  detail { open }
 *
 * Adapted from Beautiful UI ToolChips (MIT, © 2026 Shane Levine).
 *
 * @example
 * <ToolChips steps={steps} diffs={diffs} header="3 tool calls" />
 */
import type { HTMLAttributes } from "astro/types";

export type ToolIcon = "think" | "write" | "run" | "read" | "search";
export interface ToolDetailLine {
  text: string;
  tone?: "add";
}
export interface ToolStep {
  icon: ToolIcon;
  label: string;
  /** The inline chip: a file, command or one-line summary. */
  chip: string;
  /** Set the chip in mono. */
  mono?: boolean;
  /** Set the expanded detail in mono. */
  detailMono?: boolean;
  detail: ToolDetailLine[];
}
export interface ToolDiffLine {
  text: string;
  tone: "add" | "del" | "ctx";
}
export interface ToolDiff {
  file: string;
  add: number;
  del: number;
  /** Lines shown in the hover preview. */
  lines?: ToolDiffLine[];
}

type Props = HTMLAttributes<"div"> & {
  steps: ToolStep[];
  diffs?: ToolDiff[];
  /** Run header. Defaults to "N tool calls". */
  header?: string;
  /** Trailing note after the diff chips, e.g. "+2 more". */
  more?: string;
  /** Start with the run expanded. */
  open?: boolean;
  /** Play when scrolled into view. */
  autoplay?: boolean;
  /** Replay after a pause, never while someone is using it. */
  loop?: boolean;
};

const {
  steps = [],
  diffs = [],
  header = `${steps.length} tool call${steps.length === 1 ? "" : "s"}`,
  more,
  open = true,
  autoplay = true,
  loop = false,
  class: className,
  ...rest
} = Astro.props;

const ICONS: Record<ToolIcon, string> = {
  think: '<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" fill="currentColor" stroke="none"/>',
  write: '<path d="M17 3a2.8 2.8 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/>',
  run: '<path d="M4 17l6-5-6-5M12 19h8"/>',
  read: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>',
  search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>',
};

const uid = `vs-tool-${Math.random().toString(36).slice(2, 8)}`;
---

<div
  class:list={["vs-tool-chips", className]}
  data-autoplay={autoplay ? "" : undefined}
  data-loop={loop ? "" : undefined}
  data-open={open ? "" : undefined}
  {...rest}
>
  <button type="button" class="run-header" aria-expanded={open ? "true" : "false"} aria-controls={`${uid}-run`} data-run-toggle>
    <svg class="run-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="M6 9l6 6 6-6"></path>
    </svg>
    <span>{header}</span>
  </button>

  <div class="run" id={`${uid}-run`}>
    <div class="run-clip" data-run-clip>
      <div class="steps">
        {steps.map((step, i) => (
          <div class="step" data-step>
            <button type="button" class="step-row" aria-expanded="false" aria-controls={`${uid}-d${i}`} data-step-toggle={step.label}>
              <span class="step-icon" aria-hidden="true">
                <svg class="glyph" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={ICONS[step.icon] ?? ICONS.run} />
                <svg class="step-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
                  <path d="M6 9l6 6 6-6"></path>
                </svg>
              </span>
              <span class="step-label">{step.label}</span>
              <span class:list={["step-chip", step.mono && "is-mono"]}>{step.chip}</span>
            </button>
            <div class="detail" id={`${uid}-d${i}`}>
              <div class="detail-clip" data-detail-clip>
                <div class:list={["detail-lines", step.detailMono && "is-mono"]}>
                  {step.detail.map((line) => <span class:list={["detail-line", line.tone === "add" && "is-add"]}>{line.text}</span>)}
                </div>
              </div>
            </div>
          </div>
        ))}
      </div>

      {(diffs.length > 0 || more) && (
        <div class="diffs" data-diffs>
          {diffs.map((d, i) => (
            <button
              type="button"
              class="diff-chip"
              style={`--vs-tc-i: ${i}`}
              aria-label={`Show diff for ${d.file}, ${d.add} added${d.del > 0 ? `, ${d.del} removed` : ""}`}
              aria-expanded="false"
              aria-controls={d.lines?.length ? `${uid}-p${i}` : undefined}
              data-diff-chip={d.lines?.length ? `${uid}-p${i}` : undefined}
            >
              <span class="diff-file">{d.file}</span>
              <span class="diff-add">+{d.add}</span>
              {d.del > 0 && <span class="diff-del">−{d.del}</span>}
            </button>
          ))}
          {more && <span class="diff-more" style={`--vs-tc-i: ${diffs.length}`}>{more}</span>}
        </div>
      )}
    </div>
  </div>

  {diffs.map((d, i) =>
    d.lines?.length ? (
      <div class="preview" id={`${uid}-p${i}`} popover="manual" hidden data-preview>
        <div class="preview-head">
          <span class="preview-file">{d.file}</span>
          <span class="preview-counts">
            <span class="diff-add">+{d.add}</span>
            {d.del > 0 && <span class="diff-del"> −{d.del}</span>}
          </span>
        </div>
        <div class="preview-body">
          {d.lines.map((line) => (
            <div class:list={["preview-line", `is-${line.tone}`]}>
              <span class="preview-sign" aria-hidden="true">{line.tone === "add" ? "+" : line.tone === "del" ? "−" : " "}</span>
              <span class="preview-text">{line.text}</span>
            </div>
          ))}
        </div>
      </div>
    ) : null,
  )}
</div>

<style>
  .vs-tool-chips {
    width: 100%;
    max-width: 320px;
    min-height: 220px;
    padding-bottom: 4px;
  }

  button {
    font: inherit;
    color: inherit;
  }
  button:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }

  /* ── run header ── */
  .run-header {
    display: flex;
    width: fit-content;
    align-items: center;
    gap: 6px;
    margin: 0 -6px;
    padding: 4px 6px;
    border: none;
    border-radius: var(--vs-radius-control);
    background: transparent;
    color: var(--vs-ink-2);
    font-size: 12.5px;
    font-variant-numeric: tabular-nums;
    cursor: pointer;
    transition: background-color 100ms ease-out;
  }
  .run-header:hover {
    background: var(--vs-hover-2);
  }
  .run-chevron {
    transform: rotate(-90deg);
    transition: transform 200ms ease-out;
  }
  [data-open] .run-chevron {
    transform: rotate(0deg);
  }

  .run {
    display: grid;
    grid-template-rows: 0fr;
    opacity: 0;
    transition:
      grid-template-rows 300ms ease-out,
      opacity 300ms ease-out;
  }
  [data-open] .run {
    grid-template-rows: 1fr;
    opacity: 1;
  }
  /* the negative margin + padding keeps content aligned while giving row hover pills room */
  .run-clip {
    min-height: 0;
    margin: 0 -4px;
    padding: 0 6px 4px;
    overflow: hidden;
  }

  /* ── tool call rows ── */
  .steps {
    display: flex;
    flex-direction: column;
    gap: 4px;
    margin-top: 6px;
  }
  .step {
    animation: vs-fade-up 300ms var(--vs-ease-out) both;
  }
  .step-row {
    display: flex;
    width: calc(100% + 6px);
    min-width: 0;
    height: 28px;
    align-items: center;
    gap: 8px;
    margin: 0 -3px;
    padding: 0 3px;
    border: none;
    border-radius: var(--vs-radius-control);
    background: transparent;
    text-align: left;
    cursor: pointer;
    transition: background-color 100ms ease-out;
  }
  .step-row:hover {
    background: var(--vs-hover-2);
  }
  .step-icon {
    position: relative;
    display: flex;
    width: 16px;
    height: 16px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    color: var(--vs-ink-3);
  }
  .glyph {
    transition: opacity 100ms ease-out;
  }
  .step-chevron {
    position: absolute;
    opacity: 0;
    transform: rotate(-90deg);
    transition:
      opacity 150ms ease-out,
      transform 150ms ease-out;
  }
  .step-row:hover .glyph,
  .step-row:focus-visible .glyph,
  .step-row[aria-expanded="true"] .glyph {
    opacity: 0;
  }
  .step-row:hover .step-chevron,
  .step-row:focus-visible .step-chevron,
  .step-row[aria-expanded="true"] .step-chevron {
    opacity: 1;
  }
  .step-row[aria-expanded="true"] .step-chevron {
    transform: rotate(0deg);
  }
  .step-label {
    flex-shrink: 0;
    font-size: 12.5px;
    font-weight: 500;
    color: var(--vs-ink);
  }
  .step-chip {
    display: block;
    min-width: 0;
    height: 22px;
    flex: 1;
    box-sizing: border-box;
    padding: 0 6px;
    overflow: hidden;
    border-radius: var(--vs-radius-chip);
    background: var(--vs-field);
    box-shadow: var(--vs-shadow-hairline);
    color: var(--vs-ink-2);
    font-size: 11.5px;
    line-height: 22px;
    text-overflow: ellipsis;
    white-space: nowrap;
    transition: background-color 100ms ease-out;
  }
  .step-chip:hover {
    background: var(--vs-hover-2);
  }
  .is-mono {
    font-family: var(--vs-font-mono);
  }

  .detail {
    display: grid;
    grid-template-rows: 0fr;
    opacity: 0;
    transition:
      grid-template-rows 300ms var(--vs-ease-out),
      opacity 300ms var(--vs-ease-out);
  }
  .step[data-row-open] .detail {
    grid-template-rows: 1fr;
    opacity: 1;
  }
  .detail-clip {
    min-height: 0;
    overflow: hidden;
  }
  .detail-lines {
    display: flex;
    flex-direction: column;
    gap: 2px;
    margin: 2px 0 4px 8px;
    padding: 2px 0 2px 14px;
    border-left: 1px solid var(--vs-line);
  }
  .detail-line {
    overflow: hidden;
    color: var(--vs-ink-2);
    font-size: 11.5px;
    line-height: 1.6;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .detail-line.is-add {
    color: var(--vs-green);
  }

  /* ── file-diff chips ── */
  .diffs {
    display: flex;
    max-width: 100%;
    flex-wrap: wrap;
    gap: 6px;
    margin-top: 10px;
    padding-top: 10px;
    border-top: 1px solid var(--vs-line);
  }
  .diffs[hidden] {
    display: none;
  }
  .diff-chip {
    display: inline-flex;
    max-width: 100%;
    height: 28px;
    align-items: center;
    gap: 8px;
    padding: 0 8px;
    border: none;
    border-radius: var(--vs-radius-chip);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-btn);
    color: var(--vs-ink);
    font-family: var(--vs-font-mono);
    font-size: 11.5px;
    cursor: default;
    transition: background-color 100ms ease-out;
    animation: vs-pop-in 250ms var(--vs-ease-out) calc(var(--vs-tc-i) * 80ms) both;
  }
  .diff-chip[data-diff-chip] {
    cursor: pointer;
  }
  .diff-chip:hover,
  .diff-chip[aria-expanded="true"] {
    background: var(--vs-hover);
  }
  .diff-file {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .diff-add,
  .diff-del {
    flex-shrink: 0;
    font-variant-numeric: tabular-nums;
  }
  .diff-add {
    color: var(--vs-green);
  }
  .diff-del {
    color: var(--vs-red);
  }
  .diff-more {
    display: inline-flex;
    height: 28px;
    align-items: center;
    padding: 0 6px;
    color: var(--vs-ink-3);
    font-family: var(--vs-font-sans);
    font-variant-numeric: tabular-nums;
    font-size: 11.5px;
    animation: vs-fade-in 300ms ease-out calc(var(--vs-tc-i) * 80ms) both;
  }

  /* ── diff preview (top layer via the Popover API; fixed fallback) ── */
  .preview {
    position: fixed;
    inset: auto;
    z-index: 50;
    width: min(288px, calc(100vw - 24px));
    box-sizing: border-box;
    margin: 0;
    padding: 0;
    overflow: hidden;
    border: none;
    border-radius: var(--vs-radius-card);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-overlay);
    color: var(--vs-ink);
    animation: vs-pop-in 160ms var(--vs-ease-out) both;
  }
  .preview-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    padding: 6px 10px;
    border-bottom: 1px solid var(--vs-line);
    font-family: var(--vs-font-mono);
    font-size: 11px;
  }
  .preview-file {
    min-width: 0;
    overflow: hidden;
    color: var(--vs-ink-2);
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .preview-counts {
    flex-shrink: 0;
    font-variant-numeric: tabular-nums;
  }
  .preview-body {
    padding: 4px 0;
    font-family: var(--vs-font-mono);
    font-size: 11px;
    line-height: 1.8;
  }
  .preview-line {
    display: flex;
    gap: 8px;
    padding: 0 10px;
    color: var(--vs-ink-2);
    white-space: pre;
  }
  .preview-line.is-add {
    background: var(--vs-green-tint);
    color: var(--vs-green);
  }
  .preview-line.is-del {
    background: var(--vs-red-tint);
    color: var(--vs-red);
  }
  .preview-sign {
    width: 12px;
    flex-shrink: 0;
    user-select: none;
  }
  .preview-text {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  /* hide the finished run for the instant before the script rewinds it */
  @media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
    .vs-tool-chips[data-autoplay]:not([data-vs-ready]) {
      visibility: hidden;
    }
  }
  /* without script the rows can't toggle, so show what each tool did */
  @media (scripting: none) {
    .run {
      grid-template-rows: 1fr;
      opacity: 1;
    }
    .detail {
      grid-template-rows: 1fr;
      opacity: 1;
    }
    .run-chevron,
    .step-chevron {
      display: none;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    .step,
    .diff-chip,
    .diff-more,
    .preview {
      animation: none;
    }
  }
</style>

<script>
  const STEP_MS = 700;
  const LOOP_HOLD = 4000;
  const GAP = 6;
  const MARGIN = 12;

  /** One pending timeout that can pause and resume where it left off. */
  function createTimer() {
    let id: number | undefined;
    let fn: (() => void) | undefined;
    let left = 0;
    let due = 0;
    let paused = true;
    const arm = () => {
      due = performance.now() + left;
      id = window.setTimeout(() => {
        const run = fn;
        id = fn = undefined;
        run?.();
      }, left);
    };
    return {
      set(ms: number, next: () => void) {
        window.clearTimeout(id);
        id = undefined;
        fn = next;
        left = ms;
        if (!paused) arm();
      },
      pause() {
        if (paused) return;
        paused = true;
        if (id === undefined) return;
        window.clearTimeout(id);
        id = undefined;
        left = Math.max(0, due - performance.now());
      },
      resume() {
        if (!paused) return;
        paused = false;
        if (fn) arm();
      },
      clear() {
        window.clearTimeout(id);
        id = fn = undefined;
      },
    };
  }

  const supportsPopover = typeof HTMLElement !== "undefined" && "showPopover" in HTMLElement.prototype;

  function init(root: HTMLElement) {
    const runToggle = root.querySelector<HTMLButtonElement>("[data-run-toggle]")!;
    const runClip = root.querySelector<HTMLElement>("[data-run-clip]")!;
    const stepEls = [...root.querySelectorAll<HTMLElement>("[data-step]")];
    const diffs = root.querySelector<HTMLElement>("[data-diffs]");
    const chips = [...root.querySelectorAll<HTMLButtonElement>("[data-diff-chip]")];
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const loop = root.hasAttribute("data-loop");
    const timer = createTimer();
    const total = stepEls.length + 1; // rows, then the diff chips
    let step = total;
    let touched = false;

    const emit = (name: string, detail: unknown) =>
      root.dispatchEvent(new CustomEvent(`vs-tool-chips:${name}`, { bubbles: true, detail }));

    /* ── run + rows ── */
    const setRunOpen = (open: boolean) => {
      root.toggleAttribute("data-open", open);
      runToggle.setAttribute("aria-expanded", String(open));
      runClip.inert = !open;
    };
    const setRowOpen = (stepEl: HTMLElement, open: boolean) => {
      stepEl.toggleAttribute("data-row-open", open);
      stepEl.querySelector("[data-step-toggle]")!.setAttribute("aria-expanded", String(open));
      stepEl.querySelector<HTMLElement>("[data-detail-clip]")!.inert = !open;
    };

    runToggle.addEventListener("click", () => {
      const open = !root.hasAttribute("data-open");
      touched = true;
      setRunOpen(open);
      if (!open) closePreview();
      emit("open-change", { open });
    });
    stepEls.forEach((stepEl) => {
      const button = stepEl.querySelector<HTMLButtonElement>("[data-step-toggle]")!;
      setRowOpen(stepEl, false);
      button.addEventListener("click", () => {
        const open = button.getAttribute("aria-expanded") !== "true";
        touched = true;
        setRowOpen(stepEl, open);
        emit("toggle-row", { label: button.dataset.stepToggle, open });
      });
    });
    setRunOpen(root.hasAttribute("data-open"));

    /* ── diff previews ── */
    let current: { chip: HTMLButtonElement; preview: HTMLElement } | null = null;
    let pinned = false; // opened by click/tap: stays until clicked again, Esc, or an outside press
    let closeTimer: number | undefined;

    const place = (chip: HTMLElement, preview: HTMLElement) => {
      const rect = chip.getBoundingClientRect();
      const vw = document.documentElement.clientWidth;
      const vh = window.innerHeight;
      const width = preview.offsetWidth;
      const height = preview.offsetHeight;
      const left = Math.max(MARGIN, Math.min(rect.left, vw - width - MARGIN));
      const fitsBelow = rect.bottom + GAP + height <= vh - MARGIN;
      const fitsAbove = rect.top - GAP - height >= MARGIN;
      const below = fitsBelow || !fitsAbove;
      let top = below ? rect.bottom + GAP : rect.top - GAP - height;
      top = Math.max(MARGIN, Math.min(top, vh - height - MARGIN));
      preview.style.left = `${left}px`;
      preview.style.top = `${top}px`;
      preview.style.transformOrigin = below ? "top left" : "bottom left";
      /* without the top layer, a transformed ancestor can shift `fixed`; correct for it */
      if (!supportsPopover) {
        const actual = preview.getBoundingClientRect();
        preview.style.left = `${left - (actual.left - left)}px`;
        preview.style.top = `${top - (actual.top - top)}px`;
      }
    };

    const openPreview = (chip: HTMLButtonElement) => {
      window.clearTimeout(closeTimer);
      if (current?.chip === chip) return;
      closePreview();
      const preview = root.querySelector<HTMLElement>(`#${CSS.escape(chip.dataset.diffChip!)}`);
      if (!preview) return;
      preview.hidden = false;
      if (supportsPopover) preview.showPopover();
      place(chip, preview);
      chip.setAttribute("aria-expanded", "true");
      current = { chip, preview };
    };

    function closePreview() {
      window.clearTimeout(closeTimer);
      if (!current) return;
      const { chip, preview } = current;
      if (supportsPopover && preview.matches(":popover-open")) preview.hidePopover();
      preview.hidden = true;
      chip.setAttribute("aria-expanded", "false");
      current = null;
      pinned = false;
    }
    /* a short grace period lets the pointer travel from chip to preview */
    const scheduleClose = () => {
      if (pinned) return;
      window.clearTimeout(closeTimer);
      closeTimer = window.setTimeout(closePreview, 120);
    };

    chips.forEach((chip) => {
      chip.addEventListener("pointerenter", (e) => e.pointerType === "mouse" && openPreview(chip));
      chip.addEventListener("pointerleave", (e) => e.pointerType === "mouse" && scheduleClose());
      chip.addEventListener("focus", () => chip.matches(":focus-visible") && openPreview(chip));
      chip.addEventListener("blur", scheduleClose);
      chip.addEventListener("click", () => {
        if (current?.chip === chip && pinned) return closePreview();
        openPreview(chip);
        pinned = true;
      });
    });
    root.querySelectorAll<HTMLElement>("[data-preview]").forEach((preview) => {
      preview.addEventListener("pointerenter", () => window.clearTimeout(closeTimer));
      preview.addEventListener("pointerleave", scheduleClose);
    });
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape" && current) {
        e.preventDefault();
        closePreview();
      }
    };
    const onViewport = () => current && place(current.chip, current.preview);
    const onOutside = (e: PointerEvent) => {
      if (current && !current.chip.contains(e.target as Node) && !current.preview.contains(e.target as Node)) closePreview();
    };
    document.addEventListener("keydown", onKey);
    document.addEventListener("pointerdown", onOutside);
    window.addEventListener("scroll", onViewport, { capture: true, passive: true });
    window.addEventListener("resize", onViewport, { passive: true });

    /* ── sequence ── */
    const render = () => {
      stepEls.forEach((el, i) => (el.hidden = i >= step));
      if (diffs) diffs.hidden = step < total;
    };

    const tick = () => {
      step += 1;
      render();
      if (step < total) timer.set(STEP_MS, tick);
      else if (loop) timer.set(LOOP_HOLD, restart);
    };

    function restart() {
      /* never rewind while someone is reading or using it */
      if (touched || current || root.matches(":hover") || root.contains(document.activeElement)) {
        if (!touched) timer.set(LOOP_HOLD, restart);
        return;
      }
      step = 0;
      stepEls.forEach((el) => setRowOpen(el, false));
      render();
      timer.set(STEP_MS, tick);
    }

    let io: IntersectionObserver | undefined;
    function teardown() {
      timer.clear();
      io?.disconnect();
      closePreview();
      document.removeEventListener("keydown", onKey);
      document.removeEventListener("pointerdown", onOutside);
      window.removeEventListener("scroll", onViewport, { capture: true });
      window.removeEventListener("resize", onViewport);
    }
    document.addEventListener("astro:before-swap", teardown, { once: true });

    if (reduced || !root.hasAttribute("data-autoplay")) {
      render();
      return;
    }

    step = 0;
    render();
    timer.set(STEP_MS, tick);
    io = new IntersectionObserver(([entry]) => {
      if (!root.isConnected) return teardown();
      if (entry?.isIntersecting) timer.resume();
      else timer.pause();
    }, { threshold: 0.2 });
    io.observe(root);
  }

  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-tool-chips:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      init(root);
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>
```
