# Prompt Bar

> Message composer with @ sources, / commands and a model picker.

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

   - `PromptBar.astro` (raw: https://viewership.ai/ui/raw/prompt-bar/PromptBar.astro.txt)

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

   - `glide-menu`: `GlideMenu.astro`, `glide-menu.ts` (docs: https://viewership.ai/ui/components/glide-menu.md)

npm packages: none.

## Usage

```astro
---
import PromptBar from "../components/viewership/prompt-bar/PromptBar.astro";
---

<PromptBar />
```

## Example

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

```astro
---
import PromptBar, { type PromptCommand, type PromptModel, type PromptSource } from "./PromptBar.astro";

interface Props {
  variant?: string;
}
const { variant = "Rounded" } = Astro.props;

const sources: PromptSource[] = [
  { key: "attach", name: "Add photos & files", desc: "Upload from your computer", icon: "clip", attach: true },
  { key: "ga4", name: "GA4 data", desc: "Sessions, conversions, trends", icon: "chart" },
  { key: "campaigns", name: "Ad campaigns", desc: "18 campaigns, spend, ROAS", icon: "layers" },
  { key: "web", name: "Web search", desc: "Real-time news and info", icon: "globe" },
  { key: "brand", name: "Brand voice", desc: "Tone rules and examples", icon: "notes" },
  { key: "team", name: "Team chat", desc: "Read and post in channels", icon: "chat" },
  { key: "inbox", name: "Client inbox", desc: "Read and answer client mail", icon: "mail", connect: true },
];
const commands: PromptCommand[] = [
  { key: "compare", name: "/compare", desc: "This week vs. last week" },
  { key: "report", name: "/report", desc: "Build the weekly report" },
  { key: "citations", name: "/citations", desc: "Check AI search citations" },
  { key: "draft-email", name: "/draft-email", desc: "Write a client update" },
  { key: "summarize", name: "/summarize", desc: "Digest the thread so far" },
];
const models: PromptModel[] = [
  { key: "atlas-5", name: "Atlas 5", tag: "Flagship", flagship: true },
  { key: "atlas-mini", name: "Atlas Mini", tag: "Basic" },
  { key: "atlas-legacy", name: "Atlas 0.4", tag: "Legacy" },
];
---

<div class="stage">
  <PromptBar
    shape={variant === "Pill" ? "pill" : "rounded"}
    sources={sources}
    commands={commands}
    models={models}
    model="atlas-mini"
    placeholder="Ask about this week's campaigns…"
    autoplay
  />
</div>

<style>
  .stage {
    display: flex;
    width: 100%;
    max-width: 420px;
    min-height: 384px;
    flex-direction: column;
    justify-content: flex-end;
    padding-bottom: 32px;
  }
</style>
```

## Variants

Demo variants: `Rounded`, `Pill`.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `shape` | `"rounded" \| "pill"` | `"rounded"` | Card radius, or pill (24px once text wraps). |
| `sources` | `{ key, name, desc, icon?, attach?, connect?, connected? }[]` | `[]` | The @ menu. `attach` opens the file picker; `connect` needs a connection first. Icons: clip, chart, layers, globe, flame, notes, chat, mail, or inner SVG. |
| `commands` | `{ key, name, desc }[]` | `[]` | The / menu. Names include the slash. |
| `models` | `{ key, name, tag?, flagship? }[]` |  | Model options. `flagship` plays a sweep. |
| `model` | `string` |  | Initial model key (default: second). |
| `placeholder` | `string` | `"Write a message…"` | Input placeholder. |
| `tall` | `boolean` | `false` | Multi-line hero size. |
| `autoplay` | `boolean` | `false` | Menu walkthrough in view. Stops on input and under reduced motion. |
| `loop` | `boolean` | `true` | Repeat the walkthrough. |
| `dictationLang` | `string` |  | Dictation language. Defaults to page language. |
| `Events` | `CustomEvent` |  | vs-prompt-submit { text, model, sources, command, attachments, files }, vs-prompt-model { key, name }. |

## Source

### `src/components/viewership/prompt-bar/PromptBar.astro`

```astro
---
/**
 * PromptBar: message composer with @ sources, / commands, model picker,
 * dictation and send.
 *
 * Ported from Beautiful UI `components/primitives/PromptBar.tsx`
 * (MIT, © 2026 Shane Levine). Some icons are Lucide (ISC).
 *
 * - The textarea is an ARIA combobox; the menus are listboxes driven with
 *   aria-activedescendant, so focus stays in the field.
 * - Dictation uses the Web Speech API, only after a click.
 * - `autoplay` runs a walkthrough in view, never under reduced motion.
 *   Any pointer or key input hands control to the user.
 *
 * Events (bubbling, on the root):
 *   vs-prompt-submit  detail: { text, model: { key, name }, sources: string[], command: string | null, attachments: string[], files: File[] }
 *   vs-prompt-model   detail: { key, name }
 *
 * @example
 * <PromptBar shape="pill" placeholder="Ask about this week's campaigns…" />
 */
import type { HTMLAttributes } from "astro/types";
import GlideMenu from "../glide-menu/GlideMenu.astro";

export interface PromptSource {
  key: string;
  name: string;
  desc: string;
  /** Built-in icon (clip, chart, layers, globe, flame, notes, chat, mail) or inner SVG markup. */
  icon?: string;
  /** Picking it opens the file picker instead of inserting a mention. */
  attach?: boolean;
  /** Needs connecting first; shows Connect / Connected. */
  connect?: boolean;
  connected?: boolean;
}
export interface PromptCommand {
  key: string;
  /** Including the slash, e.g. "/weekly-report". */
  name: string;
  desc: string;
}
export interface PromptModel {
  key: string;
  name: string;
  tag?: string;
  /** Selecting it plays the rainbow sweep. */
  flagship?: boolean;
}

type Props = HTMLAttributes<"div"> & {
  /** Corner style: card radius or full pill. */
  shape?: "rounded" | "pill";
  sources?: PromptSource[];
  commands?: PromptCommand[];
  models?: PromptModel[];
  /** Initially selected model key. Defaults to the second model (or the first). */
  model?: string;
  placeholder?: string;
  /** Hero sizing: a multi-line input with the controls on their own row. */
  tall?: boolean;
  /** Self-playing walkthrough of the menus (starts in view). */
  autoplay?: boolean;
  /** Repeat the walkthrough. */
  loop?: boolean;
  /** BCP 47 language for dictation. Defaults to the page language. */
  dictationLang?: string;
};

const {
  shape = "rounded",
  sources = [],
  commands = [],
  models = [
    { key: "flagship", name: "Flagship", tag: "Best", flagship: true },
    { key: "fast", name: "Fast", tag: "Basic" },
  ],
  model,
  placeholder = "Write a message…",
  tall = false,
  autoplay = false,
  loop = true,
  dictationLang,
  class: className,
  ...rest
} = Astro.props;

const uid = `vs-pb-${Math.random().toString(36).slice(2, 8)}`;
const current = models.find((m) => m.key === model) ?? models[1] ?? models[0];

const ICONS: Record<string, string> = {
  // upstream-drawn (MIT)
  clip: '<path d="m21.4 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />',
  chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2" />',
  layers: '<path d="M12 2 2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5M2 12l10 5 10-5" />',
  globe: '<circle cx="12" cy="12" r="10" /><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />',
  // Lucide (ISC)
  flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" />',
  notes: '<rect width="8" height="4" x="8" y="2" rx="1" ry="1" /><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" /><path d="M12 11h4" /><path d="M12 16h4" /><path d="M8 11h.01" /><path d="M8 16h.01" />',
  chat: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />',
  mail: '<rect width="20" height="16" x="2" y="4" rx="2" /><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />',
};
const svg = (inner: string, size = 15, sw = 1.8) =>
  `<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 iconMarkup = (name?: string) => svg(!name ? ICONS.clip : name.includes("<") ? name : (ICONS[name] ?? ICONS.clip));

const clientConfig = {
  uid,
  sources: sources.map((s) => ({ ...s, iconSvg: iconMarkup(s.icon) })),
  commands,
  models,
  model: current?.key ?? "",
  placeholder,
  autoplay,
  loop,
  dictationLang: dictationLang ?? null,
};
---

<div
  class:list={["vs-prompt-bar", `pb--${shape}`, { "pb--tall": tall }, className]}
  {...rest}
>
  <script type="application/json" data-vs-config set:html={JSON.stringify(clientConfig)} />
  <div class="pb-anchor" data-anchor>
    <div class="pb-menu" id={`${uid}-menu`} role="listbox" aria-label="Sources" data-menu hidden>
      <span class="pb-hl" data-hl aria-hidden="true"></span>
      <div class="pb-options" data-options></div>
      <div class="pb-empty" data-empty hidden></div>
      <div class="pb-menu-foot" data-menu-foot aria-hidden="true">Type to search sources &amp; files</div>
    </div>

    <div class="pb-model-menu" data-model-menu hidden>
      <GlideMenu class="pb-model-list" role="menu" aria-label="Model" arrowKeys highlightBg="var(--vs-hover)" highlightRadius="6px">
        {
          models.map((m) => (
            <button type="button" class="pb-model-row" data-menu-row role="menuitemradio" aria-checked={m.key === current?.key ? "true" : "false"} data-model={m.key}>
              <span class="pb-model-name">{m.name}</span>
              {m.tag && <span class="pb-model-tag">{m.tag}</span>}
              <span class="pb-model-check" set:html={svg('<path d="M20 6L9 17l-5-5" />', 13, 2.5)} />
            </button>
          ))
        }
      </GlideMenu>
    </div>

    <div class="pb-composer" data-composer>
      <span class="pb-sweep" aria-hidden="true"><span class="pb-sweep-band" data-sweep></span></span>
      <span class="pb-measure" data-measure aria-hidden="true"></span>
      <div class="pb-attachments" data-attachments hidden></div>
      <div class="pb-controls" data-controls>
        <button type="button" class="pb-icon-btn pb-plus" data-plus aria-label="Add attachments and sources" aria-expanded="false" aria-controls={`${uid}-menu`}>
          <Fragment set:html={svg('<path d="M12 5v14M5 12h14" />', 16, 2)} />
        </button>
        <textarea
          class="pb-input"
          data-input
          rows="1"
          role="combobox"
          aria-label="Prompt"
          aria-autocomplete="list"
          aria-expanded="false"
          aria-controls={`${uid}-menu`}
          aria-haspopup="listbox"
          placeholder={placeholder}></textarea>
        <button type="button" class="pb-model-btn" data-model-btn aria-haspopup="menu" aria-expanded="false" aria-label={`Model: ${current?.name ?? ""}. Choose model`}>
          <span data-model-name>{current?.name}</span>
          <span class="pb-ink-3" set:html={svg('<path d="M6 9l6 6 6-6" />', 11, 2.4)} />
        </button>
        <button type="button" class="pb-icon-btn pb-mic" data-mic aria-label="Start dictation" aria-pressed="false">
          <span class="pb-mic-icon" set:html={svg('<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3" />', 15, 2)} />
          <span class="pb-eq" aria-hidden="true"><span></span><span></span><span></span></span>
        </button>
        <button type="button" class="pb-send" data-send aria-label="Send" disabled>
          <Fragment set:html={svg('<path d="M12 19V5M5 12l7-7 7 7" />', 16, 2.4)} />
        </button>
      </div>
    </div>
    <input type="file" multiple hidden data-file tabindex="-1" aria-hidden="true" />
  </div>
  <p class="pb-sr" aria-live="polite" data-live></p>
</div>

<script>
  interface Source {
    key: string;
    name: string;
    desc: string;
    iconSvg: string;
    attach?: boolean;
    connect?: boolean;
    connected?: boolean;
  }
  interface Command {
    key: string;
    name: string;
    desc: string;
  }
  interface Model {
    key: string;
    name: string;
    tag?: string;
    flagship?: boolean;
  }
  interface Config {
    uid: string;
    sources: Source[];
    commands: Command[];
    models: Model[];
    model: string;
    placeholder: string;
    autoplay: boolean;
    loop: boolean;
    dictationLang: string | null;
  }
  interface Row {
    key: string;
    name: string;
    desc: string;
    source?: Source;
  }
  interface AutoStep {
    draft: string;
    active?: number;
    connect?: boolean;
    modelOpen?: boolean;
    model?: "first-flagship" | "default";
    hold: number;
  }

  /* minimal Web Speech API surface (not in the DOM lib) */
  interface SpeechResultList {
    length: number;
    [index: number]: { 0: { transcript: string }; isFinal: boolean };
  }
  interface Recognition extends EventTarget {
    lang: string;
    continuous: boolean;
    interimResults: boolean;
    start(): void;
    stop(): void;
    abort(): void;
    onresult: ((event: { resultIndex: number; results: SpeechResultList }) => void) | null;
    onerror: ((event: { error: string }) => void) | null;
    onend: (() => void) | null;
  }
  type RecognitionCtor = new () => Recognition;

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

  /* self-running demo: walk the @ menu, then the / menu, then upgrade the model */
  const AUTO_STEPS: AutoStep[] = [
    { draft: "", connect: false, model: "default", hold: 1100 },
    { draft: "@", active: 0, hold: 900 },
    { draft: "@", active: 1, hold: 620 },
    { draft: "@", active: 4, hold: 620 },
    { draft: "@", active: 6, hold: 700 },
    { draft: "@", active: 6, connect: true, hold: 1000 },
    { draft: "", hold: 700 },
    { draft: "/", active: 0, hold: 900 },
    { draft: "/", active: 1, hold: 620 },
    { draft: "/", active: 3, hold: 1000 },
    { draft: "", hold: 800 },
    { draft: "", modelOpen: true, hold: 1200 },
    { draft: "", model: "first-flagship", hold: 2400 },
    { draft: "", hold: 900 },
  ];

  /* the last @word or /word being typed, if any */
  function parseToken(draft: string): { kind: "at" | "slash"; query: string; start: number } | null {
    const match = /(^|\s)([@/])([\w-]*)$/.exec(draft);
    if (!match) return null;
    return { kind: match[2] === "@" ? "at" : "slash", query: match[3].toLowerCase(), start: match.index + match[1].length };
  }

  function init(root: HTMLElement) {
    const cfg = JSON.parse(root.querySelector("script[data-vs-config]")?.textContent || "{}") as Config;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
    const q = <T extends HTMLElement = HTMLElement>(sel: string) => root.querySelector<T>(sel)!;
    const anchor = q("[data-anchor]");
    const menu = q("[data-menu]");
    const hl = q("[data-hl]");
    const optionsEl = q("[data-options]");
    const emptyEl = q("[data-empty]");
    const menuFoot = q("[data-menu-foot]");
    const modelMenu = q("[data-model-menu]");
    const sweep = q("[data-sweep]");
    const measure = q("[data-measure]");
    const attachmentsEl = q("[data-attachments]");
    const controls = q("[data-controls]");
    const plusBtn = q<HTMLButtonElement>("[data-plus]");
    const input = q<HTMLTextAreaElement>("[data-input]");
    const modelBtn = q<HTMLButtonElement>("[data-model-btn]");
    const modelName = q("[data-model-name]");
    const micBtn = q<HTMLButtonElement>("[data-mic]");
    const sendBtn = q<HTMLButtonElement>("[data-send]");
    const fileInput = q<HTMLInputElement>("[data-file]");
    const live = q("[data-live]");
    const tall = root.classList.contains("pb--tall");

    const sources = cfg.sources.map((s) => ({ ...s }));
    let model = cfg.models.find((m) => m.key === cfg.model) ?? cfg.models[0];
    const defaultModel = model;
    let attachments: { name: string; file?: File }[] = [];
    let dismissed = false;
    let plusOpen = false;
    let modelOpen = false;
    let active = 0;
    let engaged = false;
    let rows: Row[] = [];
    let menuKind: "at" | "slash" | null = null;
    let lastKey = "";
    let listening = false;
    let recognition: Recognition | null = null;

    /* ── menus ─────────────────────────────────────────── */
    const token = () => (dismissed ? null : parseToken(input.value));
    const computeMenu = () => {
      const t = token();
      const kind: "at" | "slash" | null = plusOpen ? "at" : (t?.kind ?? null);
      const query = plusOpen ? "" : (t?.query ?? "");
      const list: Row[] =
        kind === "at"
          ? sources.filter((s) => s.name.toLowerCase().includes(query)).map((s) => ({ key: s.key, name: s.name, desc: s.desc, source: s }))
          : kind === "slash"
            ? cfg.commands.filter((c) => c.name.replace(/^\//, "").toLowerCase().startsWith(query))
            : [];
      return { kind, query, list };
    };

    const placeHighlight = () => {
      const target = optionsEl.children[active] as HTMLElement | undefined;
      const show = !!target && engaged && rows.length > 0;
      if (target) {
        hl.style.top = `${target.offsetTop}px`;
        hl.style.height = `${target.offsetHeight}px`;
      }
      hl.style.opacity = show ? "1" : "0";
      optionsEl.querySelectorAll("[role=option]").forEach((el, i) => el.setAttribute("aria-selected", String(i === active)));
      if (target && engaged) input.setAttribute("aria-activedescendant", target.id);
      else input.removeAttribute("aria-activedescendant");
    };

    const renderMenu = () => {
      const { kind, query, list } = computeMenu();
      const key = `${kind}|${query}`;
      if (key !== lastKey) {
        active = 0;
        engaged = false;
        lastKey = key;
      }
      menuKind = kind;
      rows = list;
      const open = kind !== null;
      const wasOpen = !menu.hidden;
      menu.hidden = !open;
      input.setAttribute("aria-expanded", String(open));
      plusBtn.setAttribute("aria-expanded", String(plusOpen));
      plusBtn.classList.toggle("is-active", plusOpen);
      if (!open) {
        input.removeAttribute("aria-activedescendant");
        return;
      }
      if (!wasOpen && !reduce.matches) {
        menu.classList.remove("is-anim");
        void menu.offsetWidth;
        menu.classList.add("is-anim");
      }
      menu.setAttribute("aria-label", kind === "at" ? "Sources and files" : "Commands");
      optionsEl.innerHTML = list
        .map((row, i) => {
          const s = row.source;
          const connect = s?.connect
            ? `<span class="pb-connect${s.connected ? " is-connected" : ""}" data-connect="${escapeHtml(s.key)}">${s.connected ? "Connected" : "Connect"}</span>`
            : "";
          return `<div class="pb-option" role="option" id="${cfg.uid}-opt-${i}" aria-selected="false" data-index="${i}">${
            s ? `<span class="pb-option-icon">${s.iconSvg}</span>` : ""
          }<span class="pb-option-name">${escapeHtml(row.name)}</span><span class="pb-option-desc">${escapeHtml(row.desc)}</span>${connect}</div>`;
        })
        .join("");
      emptyEl.hidden = list.length > 0;
      emptyEl.textContent = list.length ? "" : `No matches for “${query}”`;
      menuFoot.textContent = kind === "at" ? "Type to search sources & files" : "Type to search commands";
      if (active >= list.length) active = 0;
      placeHighlight();
    };

    const closeMenus = () => {
      plusOpen = false;
      setModelOpen(false);
      renderMenu();
    };

    const toggleConnect = (key: string) => {
      const s = sources.find((x) => x.key === key);
      if (!s) return;
      s.connected = !s.connected;
      live.textContent = `${s.name} ${s.connected ? "connected" : "disconnected"}.`;
      renderMenu();
    };

    const pick = (row: Row | undefined) => {
      if (!row) return;
      const t = token();
      const before = t ? input.value.slice(0, t.start) : input.value;
      const s = row.source;
      if (s?.connect && !s.connected) {
        toggleConnect(s.key);
        return;
      }
      if (s?.attach) {
        if (t) input.value = before;
        plusOpen = false;
        fileInput.click();
      } else if (menuKind === "at") {
        input.value = `${before}@${row.name} `;
      } else {
        input.value = `${before}${row.name} `;
      }
      plusOpen = false;
      dismissed = false;
      input.focus();
      input.setSelectionRange(input.value.length, input.value.length);
      sync();
    };

    optionsEl.addEventListener("mousedown", (e) => e.preventDefault());
    optionsEl.addEventListener("mousemove", (e) => {
      const opt = (e.target as Element).closest<HTMLElement>("[data-index]");
      if (!opt) return;
      const i = Number(opt.dataset.index);
      if (i !== active || !engaged) {
        active = i;
        engaged = true;
        placeHighlight();
      }
    });
    menu.addEventListener("mouseleave", () => {
      engaged = false;
      placeHighlight();
    });
    optionsEl.addEventListener("click", (e) => {
      const connect = (e.target as Element).closest<HTMLElement>("[data-connect]");
      if (connect) {
        e.stopPropagation();
        toggleConnect(connect.dataset.connect!);
        return;
      }
      const opt = (e.target as Element).closest<HTMLElement>("[data-index]");
      if (opt) pick(rows[Number(opt.dataset.index)]);
    });

    plusBtn.addEventListener("click", () => {
      setModelOpen(false);
      plusOpen = !plusOpen;
      renderMenu();
      input.focus();
    });

    /* ── model menu ────────────────────────────────────── */
    function setModelOpen(open: boolean, focusMenu = false) {
      modelOpen = open;
      modelBtn.setAttribute("aria-expanded", String(open));
      if (!open) {
        modelMenu.hidden = true;
        return;
      }
      plusOpen = false;
      renderMenu();
      modelMenu.hidden = false;
      const a = anchor.getBoundingClientRect();
      const t = modelBtn.getBoundingClientRect();
      modelMenu.style.left = `${Math.max(0, Math.min(t.left - a.left, a.width - modelMenu.offsetWidth))}px`;
      modelMenu.style.bottom = `${a.bottom - t.top + 8}px`;
      if (!reduce.matches) {
        modelMenu.classList.remove("is-anim");
        void modelMenu.offsetWidth;
        modelMenu.classList.add("is-anim");
      }
      if (focusMenu) modelMenu.querySelector<HTMLElement>('[aria-checked="true"]')?.focus();
    }
    const selectModel = (next: Model, announce = true) => {
      const changed = next.key !== model.key;
      model = next;
      modelName.textContent = next.name;
      modelBtn.setAttribute("aria-label", `Model: ${next.name}. Choose model`);
      modelMenu.querySelectorAll<HTMLElement>("[data-model]").forEach((el) => el.setAttribute("aria-checked", String(el.dataset.model === next.key)));
      setModelOpen(false);
      sync();
      if (changed && next.flagship) celebrate();
      if (announce) {
        live.textContent = `Model: ${next.name}`;
        root.dispatchEvent(new CustomEvent("vs-prompt-model", { bubbles: true, detail: { key: next.key, name: next.name } }));
      }
    };
    modelBtn.addEventListener("click", (e) => setModelOpen(!modelOpen, e.detail === 0));
    modelMenu.addEventListener("mousedown", (e) => e.preventDefault());
    modelMenu.addEventListener("click", (e) => {
      const row = (e.target as Element).closest<HTMLElement>("[data-model]");
      const next = cfg.models.find((m) => m.key === row?.dataset.model);
      if (!next) return;
      const viaKeyboard = e.detail === 0;
      selectModel(next);
      (viaKeyboard ? modelBtn : input).focus();
    });
    modelMenu.addEventListener("keydown", (e) => {
      if (e.key === "Escape") {
        e.preventDefault();
        e.stopPropagation();
        setModelOpen(false);
        modelBtn.focus();
      } else if (e.key === "Tab") setModelOpen(false);
    });

    /* rainbow sweep across the composer interior */
    let sweepAnim: Animation | null = null;
    function celebrate() {
      if (reduce.matches || sweepAnim?.playState === "running") return;
      sweepAnim = sweep.animate(
        [
          { transform: "translateX(-110%)", opacity: 0 },
          { opacity: 1, offset: 0.2 },
          { opacity: 1, offset: 0.75 },
          { transform: "translateX(230%)", opacity: 0 },
        ],
        { duration: 650, easing: "cubic-bezier(0.16, 1, 0.3, 1)" },
      );
    }

    /* ── attachments ───────────────────────────────────── */
    const fileSvg = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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" /></svg>';
    const xSvg = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6L6 18M6 6l12 12" /></svg>';
    const renderAttachments = () => {
      attachmentsEl.hidden = attachments.length === 0;
      attachmentsEl.innerHTML = attachments
        .map(
          (a, i) =>
            `<span class="pb-chip">${fileSvg}<span class="pb-chip-name">${escapeHtml(a.name)}</span><button type="button" class="pb-chip-x" data-remove="${i}" aria-label="Remove ${escapeHtml(a.name)}">${xSvg}</button></span>`,
        )
        .join("");
      sync();
    };
    attachmentsEl.addEventListener("click", (e) => {
      const btn = (e.target as Element).closest<HTMLElement>("[data-remove]");
      if (!btn) return;
      const removed = attachments[Number(btn.dataset.remove)];
      attachments = attachments.filter((_, i) => i !== Number(btn.dataset.remove));
      renderAttachments();
      live.textContent = `Removed ${removed?.name ?? "attachment"}.`;
      input.focus();
    });
    fileInput.addEventListener("change", () => {
      const files = Array.from(fileInput.files ?? []);
      attachments = [...attachments, ...files.map((file) => ({ name: file.name, file }))];
      fileInput.value = "";
      renderAttachments();
      if (files.length) live.textContent = `Attached ${files.map((f) => f.name).join(", ")}.`;
      input.focus();
    });

    /* ── layout: wrap text above the controls, then grow to a max ── */
    const sync = () => {
      const draft = input.value;
      measure.textContent = draft;
      const fixed = 28 * 3 + modelBtn.offsetWidth + 16;
      const inlineWidth = controls.clientWidth - fixed;
      const wide = tall || draft.includes("\n") || measure.offsetWidth + 8 > inlineWidth;
      root.classList.toggle("is-wide", wide);
      root.classList.toggle("has-attachments", attachments.length > 0);
      const min = tall ? 68 : 28;
      const max = tall ? 160 : 100;
      input.style.height = "0px";
      const h = input.scrollHeight;
      input.style.height = `${Math.min(Math.max(h, min), max)}px`;
      input.style.overflowY = h > max ? "auto" : "hidden";
      sendBtn.disabled = !(draft.trim().length > 0 || attachments.length > 0);
      renderMenu();
    };

    const send = () => {
      if (sendBtn.disabled) return;
      const text = input.value.trim();
      const mentions = sources.filter((s) => text.includes(`@${s.name}`)).map((s) => s.name);
      const command = cfg.commands.find((c) => text.startsWith(c.name) || text.includes(` ${c.name}`))?.name ?? null;
      root.dispatchEvent(
        new CustomEvent("vs-prompt-submit", {
          bubbles: true,
          detail: {
            text,
            model: { key: model.key, name: model.name },
            sources: mentions,
            command,
            attachments: attachments.map((a) => a.name),
            files: attachments.flatMap((a) => (a.file ? [a.file] : [])),
          },
        }),
      );
      input.value = "";
      attachments = [];
      renderAttachments();
      closeMenus();
      live.textContent = "Message sent.";
    };
    sendBtn.addEventListener("click", send);

    input.addEventListener("input", () => {
      dismissed = false;
      plusOpen = false;
      sync();
    });
    input.addEventListener("keydown", (e) => {
      if (menuKind && rows.length > 0) {
        if (e.key === "ArrowDown" || e.key === "ArrowUp") {
          e.preventDefault();
          if (engaged) active = (active + (e.key === "ArrowDown" ? 1 : rows.length - 1)) % rows.length;
          engaged = true;
          placeHighlight();
          return;
        }
        if ((e.key === "Enter" && !e.shiftKey && !e.isComposing) || e.key === "Tab") {
          e.preventDefault();
          pick(rows[active]);
          return;
        }
      }
      if (e.key === "Escape") {
        if (menuKind || modelOpen) e.preventDefault();
        dismissed = true;
        closeMenus();
        return;
      }
      if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
        e.preventDefault();
        send();
      }
    });
    input.addEventListener("blur", () => {
      // Leaving the field closes the listbox (clicks on options are kept by preventDefault).
      requestAnimationFrame(() => {
        if (!root.contains(document.activeElement)) {
          plusOpen = false;
          dismissed = true;
          renderMenu();
        }
      });
    });

    /* ── dictation (Web Speech API, only on click) ─────── */
    const w = window as unknown as { SpeechRecognition?: RecognitionCtor; webkitSpeechRecognition?: RecognitionCtor };
    const Speech = w.SpeechRecognition ?? w.webkitSpeechRecognition;
    const markUnsupported = (message: string) => {
      micBtn.setAttribute("aria-disabled", "true");
      micBtn.classList.add("is-unsupported");
      micBtn.title = message;
      micBtn.setAttribute("aria-label", `Dictation unavailable: ${message}`);
      live.textContent = message;
    };
    const setListening = (on: boolean) => {
      listening = on;
      micBtn.classList.toggle("is-listening", on);
      micBtn.setAttribute("aria-pressed", String(on));
      micBtn.setAttribute("aria-label", on ? "Stop dictation" : "Start dictation");
      input.placeholder = on ? "Listening…" : cfg.placeholder;
    };
    micBtn.addEventListener("click", () => {
      if (micBtn.getAttribute("aria-disabled") === "true") {
        live.textContent = micBtn.title;
        return;
      }
      if (!Speech) {
        markUnsupported("This browser doesn't support dictation.");
        return;
      }
      if (listening) {
        recognition?.stop();
        return;
      }
      const base = input.value.trimEnd();
      recognition = new Speech();
      recognition.lang = cfg.dictationLang ?? (document.documentElement.lang || navigator.language);
      recognition.continuous = false;
      recognition.interimResults = true;
      recognition.onresult = (event) => {
        let transcript = "";
        for (let i = 0; i < event.results.length; i += 1) transcript += event.results[i][0].transcript;
        input.value = base ? `${base} ${transcript.trim()}` : transcript.trim();
        sync();
      };
      recognition.onerror = (event) => {
        if (event.error === "not-allowed" || event.error === "service-not-allowed") markUnsupported("Microphone access is blocked.");
        else if (event.error !== "no-speech" && event.error !== "aborted") live.textContent = "Dictation stopped.";
      };
      recognition.onend = () => {
        setListening(false);
        input.focus();
      };
      try {
        recognition.start();
        setListening(true);
        live.textContent = "Listening…";
      } catch {
        markUnsupported("Dictation couldn't start.");
      }
    });

    /* ── outside clicks close the menus ────────────────── */
    const onPointerDown = (e: PointerEvent) => {
      if ((modelOpen || plusOpen) && !root.contains(e.target as Node)) closeMenus();
    };
    document.addEventListener("pointerdown", onPointerDown);

    /* ── autoplay walkthrough ──────────────────────────── */
    let auto = cfg.autoplay && !reduce.matches;
    let step = 0;
    let autoTimer: number | undefined;
    let inView = false;
    const flagship = cfg.models.find((m) => m.flagship);
    const applyStep = () => {
      if (!auto || !inView) return;
      if (step >= AUTO_STEPS.length) {
        if (!cfg.loop) return stopAuto(false);
        step = 0;
      }
      const s = AUTO_STEPS[step];
      dismissed = false;
      input.value = s.draft;
      sync();
      if (s.active !== undefined && rows.length) {
        active = Math.min(s.active, rows.length - 1);
        engaged = true;
        placeHighlight();
      }
      if (s.connect !== undefined) {
        const target = sources.find((x) => x.connect);
        if (target && !!target.connected !== s.connect) toggleConnect(target.key);
      }
      if (s.modelOpen) setModelOpen(true);
      if (s.model === "default" && model.key !== defaultModel.key) selectModel(defaultModel, false);
      if (s.model === "first-flagship" && flagship) selectModel(flagship, false);
      step += 1;
      autoTimer = window.setTimeout(applyStep, s.hold);
    };
    const stopAuto = (clearDraft: boolean) => {
      if (!auto) return;
      auto = false;
      window.clearTimeout(autoTimer);
      io.disconnect();
      if (clearDraft) input.value = "";
      dismissed = false;
      setModelOpen(false);
      sync();
    };
    const takeOver = (e: Event) => {
      if (!auto) return;
      stopAuto(e.target === input);
    };
    root.addEventListener("pointerdown", takeOver, true);
    root.addEventListener("keydown", takeOver, true);
    root.addEventListener("focusin", takeOver, true);
    const io = new IntersectionObserver(([entry]) => {
      inView = entry.isIntersecting;
      window.clearTimeout(autoTimer);
      if (inView && auto) applyStep();
    });
    if (auto) io.observe(root);

    // Rewrap when the composer's width changes.
    const ro = new ResizeObserver(() => sync());
    ro.observe(controls);
    sync();

    return () => {
      window.clearTimeout(autoTimer);
      io.disconnect();
      ro.disconnect();
      sweepAnim?.cancel();
      recognition?.abort();
      document.removeEventListener("pointerdown", onPointerDown);
    };
  }

  const teardowns = new Set<() => void>();
  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-prompt-bar: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-prompt-bar {
    --pb-radius: 14px;
    --pb-ctl-radius: 8px;
    width: 100%;
    color: var(--vs-ink);
    font-family: var(--vs-font-sans);
    text-align: left;
  }
  .pb--pill {
    --pb-radius: var(--vs-radius-pill);
    --pb-ctl-radius: var(--vs-radius-pill);
  }
  .pb--pill.is-wide,
  .pb--pill.has-attachments {
    --pb-radius: 24px;
  }
  .pb--tall {
    --pb-radius: 22px;
  }
  .pb-sr {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  .pb-ink-3 {
    display: inline-flex;
    color: var(--vs-ink-3);
  }
  .pb-anchor {
    position: relative;
  }
  button {
    font: inherit;
    color: inherit;
    background: none;
    border: 0;
    padding: 0;
    cursor: pointer;
  }
  button:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }

  /* ── @ / slash menu ─────────────────────────────────── */
  .pb-menu {
    position: absolute;
    z-index: 10;
    right: 0;
    bottom: 100%;
    left: 0;
    margin-bottom: 8px;
    padding: 4px;
    border-radius: var(--vs-radius-card);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-raised);
    transform-origin: bottom center;
  }
  .pb-menu[hidden],
  .pb-model-menu[hidden],
  .pb-empty[hidden],
  .pb-attachments[hidden] {
    display: none;
  }
  .pb-menu.is-anim,
  .pb-model-menu.is-anim {
    animation: vs-pop-in 180ms var(--vs-ease-out) both;
  }
  .pb-model-menu.is-anim {
    transform-origin: bottom left;
  }
  .pb-hl {
    position: absolute;
    right: 4px;
    left: 4px;
    height: 0;
    border-radius: 6px;
    background: var(--vs-hover);
    opacity: 0;
    pointer-events: none;
    transition:
      top 220ms var(--vs-ease-out),
      height 220ms var(--vs-ease-out),
      opacity 150ms ease;
  }
  .pb-options {
    position: relative;
  }
  .vs-prompt-bar :global(.pb-option) {
    position: relative;
    display: flex;
    width: 100%;
    height: 36px;
    align-items: center;
    gap: 10px;
    padding: 0 8px;
    border-radius: 6px;
    cursor: pointer;
  }
  .vs-prompt-bar :global(.pb-option-icon) {
    display: flex;
    width: 22px;
    height: 22px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    color: var(--vs-ink-2);
  }
  .vs-prompt-bar :global(.pb-option-name) {
    flex-shrink: 0;
    font-size: 12.5px;
    font-weight: 500;
    color: var(--vs-ink);
  }
  .vs-prompt-bar :global(.pb-option-desc) {
    min-width: 0;
    flex: 1;
    overflow: hidden;
    font-size: 12px;
    color: var(--vs-ink-3);
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .vs-prompt-bar :global(.pb-connect) {
    flex-shrink: 0;
    font-size: 12px;
    font-weight: 500;
    color: var(--vs-accent-ink);
    transition: color 100ms ease-out;
  }
  .vs-prompt-bar :global(.pb-connect:hover) {
    text-decoration: underline;
  }
  .vs-prompt-bar :global(.pb-connect.is-connected) {
    color: var(--vs-green);
    text-decoration: none;
  }
  .pb-empty {
    display: flex;
    height: 36px;
    align-items: center;
    padding: 0 8px;
    font-size: 12px;
    color: var(--vs-ink-3);
  }
  .pb-menu-foot {
    margin-top: 4px;
    padding: 6px 8px 4px;
    border-top: 1px solid var(--vs-line);
    font-size: 11px;
    color: var(--vs-ink-3);
  }

  /* ── model menu ─────────────────────────────────────── */
  .pb-model-menu {
    position: absolute;
    z-index: 10;
    width: 176px;
    padding: 4px;
    border-radius: var(--vs-radius-card);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-raised);
  }
  .pb-model-list {
    display: flex;
    flex-direction: column;
  }
  .pb-model-row {
    display: flex;
    width: 100%;
    height: 30px;
    align-items: center;
    gap: 8px;
    padding: 0 8px;
    border-radius: 6px;
    text-align: left;
  }
  .pb-model-row:focus-visible {
    box-shadow: none;
  }
  .pb-model-name {
    min-width: 0;
    flex: 1;
    overflow: hidden;
    font-size: 12.5px;
    font-weight: 500;
    color: var(--vs-ink);
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .pb-model-tag {
    flex-shrink: 0;
    font-size: 11px;
    color: var(--vs-ink-3);
  }
  .pb-model-check {
    display: inline-flex;
    flex-shrink: 0;
    color: var(--vs-ink);
    visibility: hidden;
  }
  .pb-model-row[aria-checked="true"] .pb-model-check {
    visibility: visible;
  }

  /* ── composer ───────────────────────────────────────── */
  .pb-composer {
    position: relative;
    isolation: isolate;
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 6px;
    overflow: hidden;
    border: 1px solid var(--vs-line);
    border-radius: var(--pb-radius);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-card);
    transition:
      border-color 150ms ease-out,
      border-radius 150ms ease-out;
  }
  .pb--tall .pb-composer {
    gap: 10px;
    padding: 14px;
  }
  .pb-composer:focus-within {
    border-color: var(--vs-line-strong);
  }
  .pb-sweep {
    position: absolute;
    z-index: -1;
    inset: 0;
    overflow: hidden;
    border-radius: inherit;
    pointer-events: none;
  }
  .pb-sweep-band {
    position: absolute;
    top: -20%;
    bottom: -20%;
    left: 0;
    width: 55%;
    opacity: 0;
    background: linear-gradient(
      90deg,
      transparent,
      oklch(0.72 0.19 25 / 0.55),
      oklch(0.8 0.16 65 / 0.55),
      oklch(0.88 0.16 100 / 0.55),
      oklch(0.78 0.17 150 / 0.55),
      oklch(0.8 0.12 210 / 0.55),
      oklch(0.66 0.18 260 / 0.55),
      oklch(0.64 0.2 305 / 0.55),
      transparent
    );
    filter: blur(10px);
    transform: translateX(-110%);
  }
  .pb-measure {
    position: absolute;
    visibility: hidden;
    pointer-events: none;
    font-size: 13px;
    line-height: 18px;
    white-space: pre;
  }
  .pb-attachments {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    padding: 2px 2px 0;
  }
  .pb--pill .pb-attachments {
    padding-inline: 4px;
  }
  .vs-prompt-bar :global(.pb-chip) {
    display: flex;
    height: 26px;
    align-items: center;
    gap: 6px;
    padding: 4px 4px 4px 6px;
    border-radius: var(--vs-radius-chip);
    background: var(--vs-field);
    box-shadow: var(--vs-shadow-hairline);
    font-size: 11.5px;
    color: var(--vs-ink-2);
    animation: vs-pop-in 200ms var(--vs-ease-out) both;
  }
  .pb--pill :global(.pb-chip) {
    border-radius: var(--vs-radius-pill);
  }
  .vs-prompt-bar :global(.pb-chip-name) {
    max-width: 144px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .vs-prompt-bar :global(.pb-chip-x) {
    display: flex;
    width: 24px;
    height: 24px;
    margin: -4px 0;
    align-items: center;
    justify-content: center;
    border-radius: 5px;
    color: var(--vs-ink-3);
    transition: background-color 100ms ease-out, color 100ms ease-out;
  }
  .pb--pill :global(.pb-chip-x) {
    border-radius: var(--vs-radius-pill);
  }
  .vs-prompt-bar :global(.pb-chip-x:hover) {
    background: color-mix(in srgb, var(--vs-line) 70%, transparent);
    color: var(--vs-ink);
  }

  /* controls grid: inline, or text on its own row once it wraps */
  .pb-controls {
    display: grid;
    grid-template-columns: 28px minmax(0, 1fr) auto 28px 28px;
    align-items: end;
    gap: 6px 4px;
  }
  .pb-plus {
    grid-area: 1 / 1;
  }
  .pb-input {
    grid-area: 1 / 2;
  }
  .pb-model-btn {
    grid-area: 1 / 3;
  }
  .pb-mic {
    grid-area: 1 / 4;
  }
  .pb-send {
    grid-area: 1 / 5;
  }
  .is-wide .pb-controls {
    grid-template-columns: 28px auto minmax(0, 1fr) 28px 28px;
  }
  .is-wide .pb-input {
    grid-area: 1 / 1 / 2 / -1;
  }
  .is-wide .pb-plus {
    grid-area: 2 / 1;
  }
  .is-wide .pb-model-btn {
    grid-area: 2 / 2;
    justify-self: start;
  }
  .is-wide .pb-mic {
    grid-area: 2 / 4;
  }
  .is-wide .pb-send {
    grid-area: 2 / 5;
  }

  .pb-icon-btn {
    display: flex;
    width: 28px;
    height: 28px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    justify-self: start;
    border-radius: var(--pb-ctl-radius);
    color: var(--vs-ink-3);
    transition:
      background-color 150ms ease-out,
      color 150ms ease-out,
      transform 150ms ease-out;
  }
  .pb-icon-btn:hover,
  .pb-plus.is-active {
    background: var(--vs-hover);
    color: var(--vs-ink);
  }
  .pb-icon-btn:active {
    transform: scale(0.94);
  }
  .pb-input {
    width: 100%;
    min-width: 0;
    min-height: 28px;
    padding: 5px 4px;
    border: 0;
    background: transparent;
    color: var(--vs-ink);
    font: inherit;
    font-size: 13px;
    line-height: 18px;
    overflow-wrap: anywhere;
    resize: none;
    outline: none;
  }
  .pb--tall .pb-input {
    min-height: 68px;
    padding: 8px;
    font-size: 14px;
    line-height: 20px;
  }
  .pb-input::placeholder {
    color: var(--vs-ink-3);
  }
  .pb-model-btn {
    display: flex;
    height: 28px;
    flex-shrink: 0;
    align-items: center;
    gap: 4px;
    padding: 0 6px;
    border-radius: var(--pb-ctl-radius);
    color: var(--vs-ink-2);
    font-size: 12px;
    font-weight: 500;
    white-space: nowrap;
    transition: background-color 150ms ease-out, color 150ms ease-out;
  }
  .pb-model-btn:hover,
  .pb-model-btn[aria-expanded="true"] {
    background: var(--vs-hover);
    color: var(--vs-ink);
  }

  /* dictation */
  .pb-eq {
    display: none;
    height: 14px;
    align-items: center;
    gap: 2.5px;
  }
  .pb-eq span {
    width: 2.5px;
    height: 100%;
    border-radius: var(--vs-radius-pill);
    background: currentColor;
    animation: vs-prompt-bar-eq 900ms ease-in-out infinite;
  }
  .pb-eq span:nth-child(2) {
    animation-delay: 150ms;
  }
  .pb-eq span:nth-child(3) {
    animation-delay: 300ms;
  }
  @keyframes vs-prompt-bar-eq {
    0%,
    100% {
      transform: scaleY(0.35);
    }
    50% {
      transform: scaleY(1);
    }
  }
  .pb-mic.is-listening {
    background: var(--vs-accent-tint);
    color: var(--vs-accent-ink);
  }
  .pb-mic.is-listening .pb-eq {
    display: flex;
  }
  .pb-mic.is-listening .pb-mic-icon {
    display: none;
  }
  .pb-mic-icon {
    display: flex;
  }
  .pb-mic.is-unsupported {
    opacity: 0.5;
    cursor: not-allowed;
  }

  .pb-send {
    display: flex;
    width: 28px;
    height: 28px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    border-radius: var(--pb-ctl-radius);
    background: var(--vs-ink);
    color: var(--vs-surface);
    transition:
      background-color 200ms ease-out,
      color 200ms ease-out,
      transform 200ms ease-out;
  }
  .pb-send:enabled:active {
    transform: scale(0.94);
  }
  .pb-send:disabled {
    background: var(--vs-line-strong);
    color: var(--vs-ink-2);
    cursor: default;
  }

  @media (prefers-reduced-motion: reduce) {
    .pb-menu.is-anim,
    .pb-model-menu.is-anim,
    .vs-prompt-bar :global(.pb-chip) {
      animation: none;
    }
    .pb-hl,
    .pb-composer {
      transition: none;
    }
    .pb-eq span {
      animation: none;
      transform: scaleY(0.7);
    }
  }
  @media (forced-colors: active) {
    .pb-hl {
      outline: 1px solid Highlight;
    }
    .pb-send:disabled {
      opacity: 0.5;
    }
  }
</style>
```
