# Selection Actions

> Select text and a floating toolbar rewrites it in place.

- **Slug:** `selection-actions`
- **Kind:** component · **Category:** Agent
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/SelectionActions.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/selection-actions
- **All files in one bundle:** https://viewership.ai/ui/components/selection-actions.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/selection-actions.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/selection-actions/`:

   - `SelectionActions.astro` (raw: https://viewership.ai/ui/raw/selection-actions/SelectionActions.astro.txt)

3. Copy its registry dependencies, each into its own sibling folder under `src/components/viewership/`:

   - `button`: `Button.astro` (docs: https://viewership.ai/ui/components/button.md)
   - `shimmer`: `Shimmer.astro` (docs: https://viewership.ai/ui/components/shimmer.md)
   - `stream-text`: `StreamText.astro`, `stream-text.ts` (docs: https://viewership.ai/ui/components/stream-text.md)

npm packages: none.

## Usage

```astro
---
import SelectionActions from "../components/viewership/selection-actions/SelectionActions.astro";
---

<SelectionActions />
```

## Example

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

```astro
---
import SelectionActions, { type SelectionSegment } from "./SelectionActions.astro";

const passage: SelectionSegment[] = [
  { text: "The trail pack page gets most AI citations. ", rewrite: "Most AI citations point to the trail pack page. " },
  { text: "Keep it in this week's client report. ", rewrite: "Add it to this week's Northwind report. " },
  {
    text: "Update it first thing Friday so the copy has a full day for review before the weekend launch.",
    rewrites: {
      Improve: "Update the trail pack page Friday morning so the copy gets a full day of review before launch.",
      Shorten: "Update it Friday morning so review finishes before launch.",
      "Change tone": "Let's update it first thing Friday, so the team has a full day to review before launch.",
      "Fix grammar": "Update it first thing on Friday so the copy has a full day for review before the weekend launch.",
      custom: "Update the trail pack page at 9 am Friday. A full review day keeps the launch copy on brand.",
    },
  },
];
---

<SelectionActions passage={passage} selected={2} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `passage` | `{ text, rewrites?, rewrite? }[]` |  | Segments (usually sentences). Selection snaps to them. `rewrites` is keyed by action or "custom"; `rewrite` is the fallback. |
| `selected` | `number \| [number, number]` | `0` | Initial segment or inclusive range. |
| `actions` | `{ primary: SelectionAction[]; more: SelectionAction[] }` | `Explain, Improve · Shorten, Tone, Grammar` | { id, icon?, action?, busyLabel? }. Omit `action` for non-rewrite actions. |
| `labels` | `{ keep?, discard?, placeholder?, toolbar? }` |  | UI and aria text. |
| `autoShow` | `boolean` | `true` | Show the toolbar once in view. |
| `Events` | `CustomEvent` |  | vs-selection-action { action, prompt?, text, rewrite, respondWith(text \| Promise) }, vs-selection-keep { original, text }, vs-selection-discard { original }. |

## Source

### `src/components/viewership/selection-actions/SelectionActions.astro`

```astro
---
/**
 * SelectionActions: select text and a floating toolbar rewrites it in place.
 * Running an action shimmers a busy label, streams the rewrite, then asks
 * to Keep, Discard or Try again.
 *
 * Ported from Beautiful UI `components/primitives/SelectionActions.tsx`
 * (MIT, © 2026 Shane Levine). Icons are Lucide (ISC) equivalents.
 *
 * The passage is a list of segments (usually sentences). Mouse selection,
 * tapping a sentence, or ←/→ (Shift to extend) snaps to whole segments.
 * Tab enters the toolbar; Escape returns to the passage.
 *
 * Rewrites come from each segment's `rewrites` (keyed by action or "custom")
 * or `rewrite`. To produce your own, listen for `vs-selection-action` and
 * call `event.detail.respondWith(stringOrPromise)` synchronously.
 *
 * Events (bubbling, on the root):
 *   vs-selection-action   detail: { action, prompt?, text, rewrite: boolean, respondWith(text | Promise<text>) }
 *   vs-selection-keep     detail: { original, text }
 *   vs-selection-discard  detail: { original }
 *
 * @example
 * <SelectionActions passage={[{ text: "Lead with the offer. " }, { text: "Then add proof.", rewrite: "Follow with one proof point." }]} selected={1} />
 */
import type { HTMLAttributes } from "astro/types";
import Button from "../button/Button.astro";
import Shimmer from "../shimmer/Shimmer.astro";

export interface SelectionSegment {
  text: string;
  /** Rewrite per action name (e.g. "Improve", "Shorten", "Change tone", "Fix grammar", or "custom"). */
  rewrites?: Record<string, string>;
  /** Fallback rewrite for any action. */
  rewrite?: string;
}
export interface SelectionAction {
  /** Visible label. */
  id: string;
  /** Built-in icon (explain, improve, shorten, tone, grammar) or inner SVG markup. */
  icon?: string;
  /** Action name sent with the event and used to pick a rewrite. Omit for actions that don't rewrite (e.g. Explain). */
  action?: string;
  /** Gerund shown while it runs, e.g. "Improving". */
  busyLabel?: string;
}
export interface SelectionActionSet {
  primary: SelectionAction[];
  more: SelectionAction[];
}
export interface SelectionLabels {
  keep: string;
  discard: string;
  placeholder: string;
  toolbar: string;
}

type Props = HTMLAttributes<"div"> & {
  passage: SelectionSegment[];
  /** Initially selected segment index, or an inclusive [start, end] range. */
  selected?: number | [number, number];
  actions?: SelectionActionSet;
  labels?: Partial<SelectionLabels>;
  /** Show the toolbar for the initial selection once in view. */
  autoShow?: boolean;
};

const DEFAULT_ACTIONS: SelectionActionSet = {
  primary: [
    { id: "Explain", icon: "explain" },
    { id: "Improve", icon: "improve", action: "Improve", busyLabel: "Improving" },
  ],
  more: [
    { id: "Shorten", icon: "shorten", action: "Shorten", busyLabel: "Shortening" },
    { id: "Tone", icon: "tone", action: "Change tone", busyLabel: "Changing tone" },
    { id: "Grammar", icon: "grammar", action: "Fix grammar" },
  ],
};

const {
  passage,
  selected = 0,
  actions = DEFAULT_ACTIONS,
  labels = {},
  autoShow = true,
  class: className,
  ...rest
} = Astro.props;

const copy: SelectionLabels = { keep: "Keep", discard: "Discard", placeholder: "Describe edits", toolbar: "Edit selection", ...labels };
const uid = `vs-sa-${Math.random().toString(36).slice(2, 8)}`;
const [selStart, selEnd] = Array.isArray(selected) ? selected : [selected, selected];

/* Lucide (ISC) */
const ICONS: Record<string, string> = {
  explain: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /><path d="M12 17h.01" />',
  improve: '<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" /><path d="M20 3v4" /><path d="M22 5h-4" /><path d="M4 17v2" /><path d="M5 18H3" />',
  shorten: '<circle cx="6" cy="6" r="3" /><path d="M8.12 8.12 12 12" /><path d="M20 4 8.12 15.88" /><circle cx="6" cy="18" r="3" /><path d="M14.8 14.8 20 20" />',
  tone: '<circle cx="12" cy="12" r="10" /><path d="M8 14s1.5 2 4 2 4-2 4-2" /><path d="M9 9h.01" /><path d="M15 9h.01" />',
  grammar: '<path d="m6 16 6-12 6 12" /><path d="M8 12h8" /><path d="m16 20 2 2 4-4" />',
  send: '<path d="m5 12 7-7 7 7" /><path d="M12 19V5" />',
  chevron: '<path d="m9 18 6-6-6-6" />',
  check: '<path d="M20 6 9 17l-5-5" />',
  close: '<path d="M18 6 6 18" /><path d="m6 6 12 12" />',
  retry: '<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" />',
};
const icon = (name: string | undefined, size = 14, sw = 1.8) => {
  const inner = !name ? "" : name.includes("<") ? name : (ICONS[name] ?? "");
  return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
};

const clientConfig = {
  segments: passage.map((s) => ({ text: s.text, rewrites: s.rewrites ?? {}, rewrite: s.rewrite ?? null })),
  selected: [selStart, selEnd],
  busy: Object.fromEntries([...actions.primary, ...actions.more].filter((a) => a.action).map((a) => [a.action, a.busyLabel ?? "Editing"])),
  autoShow,
};
---

<div class:list={["vs-selection-actions", className]} {...rest}>
  <script type="application/json" data-vs-config set:html={JSON.stringify(clientConfig)} />
  <div class="sa-host" data-host>
    <p class="sa-passage" data-passage aria-describedby={`${uid}-hint`}>
      {
        passage.map((seg, i) => {
          if (i === selStart) {
            return (
              <span class="sa-sel" data-sel>{passage.slice(selStart, selEnd + 1).map((s, j) => <span class="sa-seg" data-seg={selStart + j}>{s.text}</span>)}</span>
            );
          }
          if (i > selStart && i <= selEnd) return null;
          return <span class="sa-seg" data-seg={i}>{seg.text}</span>;
        })
      }
    </p>
    <p class="sa-sr" id={`${uid}-hint`}>Select text, or use the arrow keys to choose a sentence (Shift extends), then press Tab to reach the edit toolbar.</p>

    <div class="sa-anchor" data-anchor hidden>
      <div class="sa-bar" role="toolbar" aria-label={copy.toolbar} data-bar data-mode="idle">
        <div class="sa-content" data-content>
          <span class="sa-busy" data-busy hidden>
            <span class="sa-spinner" aria-hidden="true"></span>
            <Shimmer class="sa-busy-label" data-busy-shimmer>Improving…</Shimmer>
            <span class="sa-busy-label" data-busy-plain hidden>Improving…</span>
          </span>

          <span class="sa-result" data-result hidden>
            <button type="button" class="sa-keep" data-keep>
              <Fragment set:html={icon("check")} />
              {copy.keep}
            </button>
            <Button variant="quiet" size="xs" class="sa-btn" data-discard>
              <Fragment set:html={icon("close")} />
              {copy.discard}
            </Button>
            <span class="sa-sep" aria-hidden="true"></span>
            <button type="button" class="sa-round" data-retry aria-label="Try again">
              <Fragment set:html={icon("retry")} />
            </button>
          </span>

          <span class="sa-idle" data-idle>
            <span class="sa-prompt-wrap" data-prompt-wrap>
              <form class="sa-form" data-form>
                <input class="sa-input" data-input aria-label={copy.placeholder} placeholder={copy.placeholder} autocomplete="off" />
              </form>
            </span>
            <span class="sa-actions-wrap" data-actions-wrap>
              <span class="sa-sep sa-sep--strong" data-lead-sep aria-hidden="true"></span>
              {
                actions.primary.map((a) => (
                  <Button variant="quiet" size="xs" class="sa-btn" data-act={a.action ?? ""} data-id={a.id}>
                    <Fragment set:html={icon(a.icon)} />
                    {a.id}
                  </Button>
                ))
              }
              <span class="sa-more" data-more inert>
                {
                  actions.more.map((a) => (
                    <Button variant="quiet" size="xs" class="sa-btn" data-act={a.action ?? ""} data-id={a.id}>
                      <Fragment set:html={icon(a.icon)} />
                      {a.id}
                    </Button>
                  ))
                }
              </span>
              <span class="sa-sep" aria-hidden="true"></span>
              <button type="button" class="sa-round sa-round--ink" data-expand aria-label="Show more actions" aria-expanded="false">
                <span class="sa-chevron" set:html={icon("chevron")} />
              </button>
            </span>
            <span class="sa-send-wrap" data-send-wrap inert>
              <button type="button" class="sa-send" data-send aria-label="Send edit instruction">
                <Fragment set:html={icon("send", 16, 2.4)} />
              </button>
            </span>
          </span>
        </div>
      </div>
    </div>
  </div>
  <p class="sa-sr" aria-live="polite" data-live></p>
</div>

<script>
  import { streamText, type StreamTextHandle } from "../stream-text/stream-text";

  interface Segment {
    text: string;
    rewrites: Record<string, string>;
    rewrite: string | null;
  }
  interface Config {
    segments: Segment[];
    selected: [number, number];
    busy: Record<string, string>;
    autoShow: boolean;
  }
  type Mode = "idle" | "thinking" | "streaming" | "result";

  const EASE = "cubic-bezier(0.23, 1, 0.32, 1)";
  const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

  function init(root: HTMLElement) {
    const cfg = JSON.parse(root.querySelector("script[data-vs-config]")?.textContent || "{}") as Config;
    const segments = cfg.segments.map((s) => ({ ...s }));
    let [start, end] = cfg.selected;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const q = <T extends HTMLElement = HTMLElement>(sel: string) => root.querySelector<T>(sel)!;
    const host = q("[data-host]");
    const passageEl = q("[data-passage]");
    const anchor = q("[data-anchor]");
    const bar = q("[data-bar]");
    const content = q("[data-content]");
    const busyEl = q("[data-busy]");
    const busyShimmer = q("[data-busy-shimmer]");
    const busyPlain = q("[data-busy-plain]");
    const resultEl = q("[data-result]");
    const idleEl = q("[data-idle]");
    const promptWrap = q("[data-prompt-wrap]");
    const form = q<HTMLFormElement>("[data-form]");
    const input = q<HTMLInputElement>("[data-input]");
    const actionsWrap = q("[data-actions-wrap]");
    const leadSep = q("[data-lead-sep]");
    const more = q("[data-more]");
    const expandBtn = q<HTMLButtonElement>("[data-expand]");
    const sendWrap = q("[data-send-wrap]");
    const live = q("[data-live]");

    let mode: Mode = "idle";
    let action = "Improve";
    let pendingText = "";
    let original: Segment[] = [];
    let stream: StreamTextHandle | null = null;
    let thinkTimer: number | undefined;
    let showTimer: number | undefined;
    let frame = 0;
    let widthAnim: Animation | null = null;
    let shown = false;
    let runId = 0;

    passageEl.tabIndex = 0;

    /* ── render the passage with the selection wrapped in one span ── */
    const selEl = () => passageEl.querySelector<HTMLElement>("[data-sel]");
    const renderPassage = () => {
      let html = "";
      segments.forEach((s, i) => {
        const seg = `<span class="sa-seg" data-seg="${i}">${escapeHtml(s.text)}</span>`;
        if (i === start) html += '<span class="sa-sel" data-sel>';
        html += seg;
        if (i === end) html += "</span>";
      });
      passageEl.innerHTML = html;
      place();
    };
    const selectedText = () => segments.slice(start, end + 1).map((s) => s.text).join("");
    const rewriteFor = (name: string) =>
      segments
        .slice(start, end + 1)
        .map((s) => s.rewrites[name] ?? s.rewrite ?? s.text)
        .join("");

    /* ── placement: centred on the selection, under its last line ── */
    function place() {
      cancelAnimationFrame(frame);
      frame = requestAnimationFrame(() => {
        const sel = selEl();
        if (!sel) return;
        const lines = Array.from(sel.getClientRects());
        const last = lines.at(-1);
        const first = lines[0];
        if (!last || !first) return;
        const bounds = sel.getBoundingClientRect();
        const hostRect = host.getBoundingClientRect();
        const w = bar.offsetWidth;
        const h = bar.offsetHeight || 36;
        let x = bounds.left - hostRect.left + bounds.width / 2;
        const minX = 8 - hostRect.left + w / 2;
        const maxX = window.innerWidth - 8 - hostRect.left - w / 2;
        x = minX > maxX ? (minX + maxX) / 2 : Math.min(Math.max(x, minX), maxX);
        let y = last.bottom - hostRect.top + 8;
        if (last.bottom + 8 + h > window.innerHeight - 8 && first.top - 8 - h > 8) y = first.top - hostRect.top - 8 - h;
        anchor.style.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0) translateX(-50%)`;
      });
    }

    /* ── modes + the width morph between them ──────────── */
    const setMode = (next: Mode) => {
      const previous = mode;
      const fromWidth = bar.getBoundingClientRect().width;
      mode = next;
      bar.dataset.mode = next;
      busyEl.hidden = !(next === "thinking" || next === "streaming");
      busyShimmer.hidden = next !== "thinking";
      busyPlain.hidden = next !== "streaming";
      resultEl.hidden = next !== "result";
      idleEl.hidden = next !== "idle";
      const toWidth = Math.ceil(content.getBoundingClientRect().width) + 8;
      if (shown && previous !== next && Math.abs(toWidth - fromWidth) > 1 && !reduce) {
        widthAnim?.cancel();
        widthAnim = bar.animate([{ width: `${fromWidth}px` }, { width: `${toWidth}px` }], { duration: 320, easing: EASE });
      }
      place();
    };

    const setExpanded = (open: boolean) => {
      bar.toggleAttribute("data-expanded", open);
      expandBtn.setAttribute("aria-expanded", String(open));
      expandBtn.setAttribute("aria-label", open ? "Show fewer actions" : "Show more actions");
      more.inert = !open;
      promptWrap.inert = open;
      leadSep.hidden = open;
      place();
    };

    let typingWidth = 0;
    const setTyping = (typing: boolean) => {
      if (typing && !typingWidth) {
        typingWidth = Math.ceil(bar.getBoundingClientRect().width);
        bar.style.setProperty("--sa-typing", `${typingWidth}px`);
        bar.toggleAttribute("data-typing", true);
      } else if (!typing && typingWidth) {
        typingWidth = 0;
        bar.style.removeProperty("--sa-typing");
        bar.toggleAttribute("data-typing", false);
      }
      actionsWrap.inert = typing;
      sendWrap.inert = !typing;
    };

    /* ── running an action ─────────────────────────────── */
    const run = (name: string, prompt?: string) => {
      if (mode === "result") commit(false);
      const id = ++runId;
      action = name;
      setExpanded(false);
      original = segments.slice(start, end + 1).map((s) => ({ ...s }));
      const text = selectedText();
      let response: Promise<string> | null = null;
      root.dispatchEvent(
        new CustomEvent("vs-selection-action", {
          bubbles: true,
          detail: {
            action: name,
            prompt,
            text,
            rewrite: true,
            respondWith: (value: string | Promise<string>) => {
              response = Promise.resolve(value);
            },
          },
        }),
      );
      const label = cfg.busy[name] ?? "Editing";
      busyShimmer.textContent = `${label}…`;
      busyPlain.textContent = `${label}…`;
      live.textContent = `${label}…`;
      setMode("thinking");
      const fallback = rewriteFor(prompt ? "custom" : name);
      const minWait = new Promise<void>((resolve) => (thinkTimer = window.setTimeout(resolve, reduce ? 0 : 700)));
      Promise.all([response ?? Promise.resolve(fallback), minWait])
        .then(([rewrite]) => id === runId && startStream(rewrite))
        .catch(() => {
          if (id !== runId) return;
          live.textContent = "Couldn't rewrite the selection.";
          setMode("idle");
        });
    };

    const startStream = (rewrite: string) => {
      setMode("streaming");
      pendingText = rewrite;
      const sel = selEl();
      if (!sel) return;
      passageEl.setAttribute("aria-busy", "true");
      stream?.stop();
      stream = streamText(sel, rewrite, {
        charsPerTick: 2,
        tickMs: 9,
        onProgress: place,
        onDone: () => {
          passageEl.removeAttribute("aria-busy");
          // Settle into plain text: no caret once the result is offered.
          sel.textContent = rewrite;
          setMode("result");
          live.textContent = `Rewrite ready: ${rewrite} Keep or discard it.`;
        },
      });
    };

    /** Commit (keep) or revert (discard) the streamed rewrite. */
    const commit = (keep: boolean) => {
      runId += 1;
      stream?.stop();
      stream = null;
      window.clearTimeout(thinkTimer);
      passageEl.removeAttribute("aria-busy");
      if (keep && pendingText) {
        segments.splice(start, end - start + 1, { text: pendingText, rewrites: {}, rewrite: null });
        end = start;
        root.dispatchEvent(new CustomEvent("vs-selection-keep", { bubbles: true, detail: { original: original.map((s) => s.text).join(""), text: pendingText } }));
      } else if (original.length) {
        root.dispatchEvent(new CustomEvent("vs-selection-discard", { bubbles: true, detail: { original: original.map((s) => s.text).join("") } }));
      }
      pendingText = "";
      original = [];
      renderPassage();
    };

    const reset = (keep: boolean) => {
      commit(keep);
      input.value = "";
      setTyping(false);
      setExpanded(false);
      setMode("idle");
      live.textContent = keep ? "Rewrite kept." : "Rewrite discarded.";
      focusFirst();
    };

    /* ── wiring ────────────────────────────────────────── */
    root.querySelectorAll<HTMLButtonElement>("[data-act]").forEach((btn) => {
      btn.addEventListener("click", () => {
        const name = btn.dataset.act;
        if (name) run(name);
        else root.dispatchEvent(new CustomEvent("vs-selection-action", { bubbles: true, detail: { action: btn.dataset.id, text: selectedText(), rewrite: false, respondWith: () => {} } }));
      });
    });
    expandBtn.addEventListener("click", () => setExpanded(!bar.hasAttribute("data-expanded")));
    input.addEventListener("input", () => setTyping(input.value.trim().length > 0));
    form.addEventListener("submit", (e) => {
      e.preventDefault();
      const prompt = input.value.trim();
      if (prompt) run(prompt, prompt);
      else run("Improve");
    });
    q("[data-send]").addEventListener("click", () => {
      const prompt = input.value.trim();
      if (prompt) run(prompt, prompt);
    });
    q("[data-keep]").addEventListener("click", () => reset(true));
    q("[data-discard]").addEventListener("click", () => reset(false));
    q("[data-retry]").addEventListener("click", () => {
      const name = action;
      commit(false);
      run(name, cfg.busy[name] ? undefined : name);
    });

    /* toolbar keyboard: ←/→ between controls, Escape back to the passage */
    const controls = () =>
      Array.from(bar.querySelectorAll<HTMLElement>("button, input")).filter(
        (el) => !el.closest("[inert], [hidden]") && el.getClientRects().length > 0,
      );
    function focusFirst() {
      requestAnimationFrame(() => controls()[0]?.focus());
    }
    bar.addEventListener("keydown", (e) => {
      const target = e.target as HTMLElement;
      if (e.key === "Escape") {
        e.preventDefault();
        if (bar.hasAttribute("data-expanded")) setExpanded(false);
        else passageEl.focus();
        return;
      }
      if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
      if (target === input) {
        const atStart = input.selectionStart === 0 && input.selectionEnd === 0;
        const atEnd = input.selectionStart === input.value.length;
        if ((e.key === "ArrowLeft" && !atStart) || (e.key === "ArrowRight" && !atEnd)) return;
      }
      const list = controls();
      const i = list.indexOf(target);
      if (i === -1) return;
      e.preventDefault();
      list[(i + (e.key === "ArrowRight" ? 1 : -1) + list.length) % list.length]?.focus();
    });

    /* ── choosing a selection ──────────────────────────── */
    const select = (a: number, b: number) => {
      if (mode === "thinking" || mode === "streaming") return;
      if (mode === "result") {
        commit(true);
        setMode("idle");
      }
      start = Math.max(0, Math.min(a, b));
      end = Math.min(segments.length - 1, Math.max(a, b));
      renderPassage();
      show();
      live.textContent = `Selected: ${selectedText()}`;
    };
    const segIndex = (node: Node | null) => {
      const el = node instanceof Element ? node : node?.parentElement;
      const seg = el?.closest<HTMLElement>("[data-seg]");
      return seg && passageEl.contains(seg) ? Number(seg.dataset.seg) : -1;
    };
    passageEl.addEventListener("pointerup", (e) => {
      // Let the browser settle the selection first.
      requestAnimationFrame(() => {
        const s = window.getSelection();
        if (s && !s.isCollapsed && s.rangeCount) {
          const range = s.getRangeAt(0);
          if (!passageEl.contains(range.commonAncestorContainer)) return;
          const hits = Array.from(passageEl.querySelectorAll<HTMLElement>("[data-seg]"))
            .filter((seg) => range.intersectsNode(seg) && range.toString().trim().length > 0)
            .map((seg) => Number(seg.dataset.seg));
          // Drop segments the range only grazes at its very edge.
          let a = segIndex(range.startContainer);
          let b = segIndex(range.endContainer);
          if (range.endOffset === 0 && b > a) b -= 1;
          if (a === -1 || b === -1) [a, b] = [Math.min(...hits), Math.max(...hits)];
          s.removeAllRanges();
          if (Number.isFinite(a) && Number.isFinite(b)) select(a, b);
          return;
        }
        const i = segIndex(e.target as Node);
        if (i !== -1) select(i, i);
      });
    });
    passageEl.addEventListener("keydown", (e) => {
      const last = segments.length - 1;
      if (e.key === "ArrowRight" || e.key === "ArrowDown") {
        e.preventDefault();
        if (e.shiftKey) select(start, Math.min(last, end + 1));
        else select(Math.min(last, end + 1), Math.min(last, end + 1));
      } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
        e.preventDefault();
        if (e.shiftKey) select(Math.max(0, start - 1), end > start ? end - 1 : end);
        else select(Math.max(0, start - 1), Math.max(0, start - 1));
      } else if (e.key === "Home") {
        e.preventDefault();
        select(0, 0);
      } else if (e.key === "End") {
        e.preventDefault();
        select(last, last);
      }
    });

    /* ── appearance ────────────────────────────────────── */
    function show() {
      if (shown) return;
      shown = true;
      anchor.hidden = false;
      place();
      requestAnimationFrame(() => {
        anchor.classList.add("is-visible");
        if (!reduce) bar.classList.add("is-popping");
      });
    }
    const io = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting && cfg.autoShow && !shown) {
        showTimer = window.setTimeout(show, reduce ? 0 : 280);
        io.disconnect();
      }
    });
    io.observe(root);

    const ro = new ResizeObserver(place);
    ro.observe(host);
    window.addEventListener("resize", place);
    window.addEventListener("scroll", place, { passive: true, capture: true });

    renderPassage();
    setMode("idle");
    setExpanded(false);
    setTyping(false);

    return () => {
      runId += 1;
      stream?.stop();
      window.clearTimeout(thinkTimer);
      window.clearTimeout(showTimer);
      cancelAnimationFrame(frame);
      widthAnim?.cancel();
      io.disconnect();
      ro.disconnect();
      window.removeEventListener("resize", place);
      window.removeEventListener("scroll", place, { capture: true });
    };
  }

  const teardowns = new Set<() => void>();
  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-selection-actions:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      teardowns.add(init(root));
    });
  boot();
  document.addEventListener("astro:page-load", boot);
  document.addEventListener("astro:before-swap", () => {
    teardowns.forEach((fn) => fn());
    teardowns.clear();
  });
</script>

<style>
  .vs-selection-actions {
    width: 100%;
    max-width: 460px;
    font-family: var(--vs-font-sans);
    text-align: left;
  }
  .sa-host {
    position: relative;
    padding-bottom: 48px;
  }
  .sa-sr {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  .sa-passage {
    margin: 0;
    border-radius: 4px;
    color: var(--vs-ink);
    font-size: 13px;
    line-height: 1.625;
    cursor: text;
    -webkit-user-select: text;
    user-select: text;
  }
  .sa-passage:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }
  .sa-passage :global(.sa-sel) {
    border-radius: 3px;
    background: color-mix(in srgb, var(--vs-accent) 14%, var(--vs-surface));
    color: var(--vs-ink);
    -webkit-box-decoration-break: clone;
    box-decoration-break: clone;
  }
  :global(:root[data-theme="dark"]) .sa-passage :global(.sa-sel) {
    background: var(--vs-accent-tint);
  }
  @media (prefers-color-scheme: dark) {
    :global(:root:not([data-theme="light"])) .sa-passage :global(.sa-sel) {
      background: var(--vs-accent-tint);
    }
  }

  /* ── the floating bar ───────────────────────────────── */
  .sa-anchor {
    position: absolute;
    z-index: 10;
    top: 0;
    left: 0;
    opacity: 0;
    pointer-events: none;
    will-change: transform;
    transition:
      transform 320ms var(--vs-ease-in-out),
      opacity 180ms ease-out;
  }
  .sa-anchor.is-visible {
    opacity: 1;
    pointer-events: auto;
  }
  /* A 36px pill wraps 28px controls at a 4px inset: concentric 14px radii. */
  .sa-bar {
    display: flex;
    width: fit-content;
    max-width: calc(100vw - 48px);
    height: 36px;
    align-items: center;
    justify-content: center;
    gap: 2px;
    padding: 4px;
    overflow: hidden;
    border-radius: var(--vs-radius-pill);
    background: var(--vs-surface);
    color: var(--vs-ink);
    box-shadow: var(--vs-shadow-overlay);
    font-weight: 400;
  }
  .sa-bar.is-popping {
    animation: vs-pop-in 220ms var(--vs-ease-out) both;
  }
  .sa-bar[data-typing] {
    width: var(--sa-typing);
  }
  .sa-content {
    display: flex;
    width: fit-content;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    gap: 2px;
  }
  .sa-bar[data-typing] .sa-content {
    width: calc(var(--sa-typing) - 8px);
  }
  .sa-idle,
  .sa-result {
    display: flex;
    align-items: center;
    gap: 2px;
  }
  .sa-busy {
    display: inline-flex;
    height: 28px;
    align-items: center;
    gap: 6px;
    padding: 0 10px;
    color: var(--vs-ink-2);
    font-size: 12.5px;
    white-space: nowrap;
  }
  .sa-spinner {
    width: 12px;
    height: 12px;
    flex-shrink: 0;
    border: 1.5px solid var(--vs-line-strong);
    border-top-color: var(--vs-ink-2);
    border-radius: 50%;
    animation: vs-spin 700ms linear infinite;
  }
  .sa-busy :global(.sa-busy-label) {
    font-size: 12.5px;
    font-weight: 400;
  }

  button {
    font: inherit;
    border: 0;
    padding: 0;
    cursor: pointer;
  }
  button:focus-visible,
  .sa-input:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }
  .sa-bar :global(.sa-btn) {
    flex-shrink: 0;
  }
  .sa-keep {
    display: inline-flex;
    height: 28px;
    flex-shrink: 0;
    align-items: center;
    gap: 4px;
    padding: 0 10px;
    border-radius: var(--vs-radius-pill);
    background: var(--vs-ink);
    color: var(--vs-canvas);
    box-shadow: var(--vs-shadow-hairline);
    font-size: 12.5px;
    transition: opacity 150ms ease-out, transform 150ms ease-out;
  }
  .sa-keep:hover {
    opacity: 0.9;
  }
  .sa-keep:active {
    transform: scale(0.96);
  }
  .sa-sep {
    width: 1px;
    height: 16px;
    flex-shrink: 0;
    margin: 0 2px;
    background: var(--vs-line);
  }
  .sa-sep--strong {
    margin: 0 4px;
    background: var(--vs-line-strong);
  }
  .sa-round {
    display: flex;
    width: 28px;
    height: 28px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    border-radius: var(--vs-radius-pill);
    background: transparent;
    color: var(--vs-ink-3);
    transition: background-color 150ms ease-out, color 150ms ease-out, transform 150ms ease-out;
  }
  .sa-round:hover {
    background: var(--vs-hover-2);
    color: var(--vs-ink-2);
  }
  .sa-round--ink {
    color: var(--vs-ink);
  }
  .sa-round--ink:hover {
    background: var(--vs-hover);
    color: var(--vs-ink);
  }
  .sa-round:active {
    transform: scale(0.96);
  }
  .sa-chevron {
    display: flex;
    transition: transform 400ms var(--vs-ease-out);
  }
  .sa-bar[data-expanded] .sa-chevron {
    transform: rotate(180deg);
  }

  /* idle: prompt field · actions (+ more) · send */
  .sa-prompt-wrap,
  .sa-actions-wrap,
  .sa-more,
  .sa-send-wrap {
    display: flex;
    min-width: 0;
    align-items: center;
    overflow: hidden;
    transition-duration: 400ms;
    transition-timing-function: var(--vs-ease-out);
  }
  .sa-prompt-wrap {
    max-width: 145px;
    transition-property: max-width, opacity, transform;
  }
  .sa-bar[data-typing] .sa-prompt-wrap {
    max-width: calc(var(--sa-typing) - 40px);
  }
  .sa-bar[data-expanded] .sa-prompt-wrap {
    max-width: 0;
    opacity: 0;
    transform: translateX(-8px);
  }
  .sa-form {
    display: flex;
    width: 145px;
    height: 28px;
    flex-shrink: 0;
    align-items: center;
    margin: 0;
    transition: width 400ms var(--vs-ease-out);
  }
  .sa-bar[data-typing] .sa-form {
    width: calc(var(--sa-typing) - 40px);
  }
  .sa-input {
    width: 100%;
    height: 28px;
    padding: 0 10px 0 12px;
    border: 0;
    border-radius: var(--vs-radius-pill);
    background: transparent;
    color: var(--vs-ink);
    font: inherit;
    font-size: 12.5px;
  }
  .sa-input::placeholder {
    color: var(--vs-ink-3);
  }
  .sa-actions-wrap {
    gap: 2px;
    max-width: 224px;
    transition-property: max-width, opacity, transform;
  }
  .sa-bar[data-expanded] .sa-actions-wrap {
    max-width: 462px;
  }
  .sa-bar[data-typing] .sa-actions-wrap {
    max-width: 0;
    opacity: 0;
    transform: translateX(-8px);
  }
  .sa-more {
    gap: 2px;
    max-width: 0;
    margin-left: 0;
    opacity: 0;
    transition-property: max-width, opacity, margin;
  }
  .sa-bar[data-expanded] .sa-more {
    max-width: 262px;
    margin-left: 2px;
    opacity: 1;
  }
  .sa-send-wrap {
    max-width: 0;
    opacity: 0;
    transform: scale(0.88);
    transition-property: max-width, opacity, transform;
  }
  .sa-bar[data-typing] .sa-send-wrap {
    max-width: 30px;
    opacity: 1;
    transform: scale(1);
  }
  .sa-send {
    display: flex;
    width: 28px;
    height: 28px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    border-radius: var(--vs-radius-pill);
    background: var(--vs-ink);
    color: var(--vs-surface);
    transition: opacity 200ms ease-out, transform 200ms ease-out;
  }
  .sa-send:active {
    transform: scale(0.94);
  }

  @media (prefers-reduced-motion: reduce) {
    .sa-anchor,
    .sa-prompt-wrap,
    .sa-actions-wrap,
    .sa-more,
    .sa-send-wrap,
    .sa-form,
    .sa-chevron {
      transition: none;
    }
    .sa-bar.is-popping {
      animation: none;
    }
    .sa-spinner {
      animation: none;
      border-top-color: var(--vs-line-strong);
    }
  }
  @media (forced-colors: active) {
    .sa-bar {
      outline: 1px solid CanvasText;
    }
    .sa-passage :global(.sa-sel) {
      forced-color-adjust: none;
      background: Highlight;
      color: HighlightText;
    }
  }
</style>
```
