# Prompt bar

> The composer of a chat with an agent: @ to mention a source, / for a command, a model picker, dictation and send, rounded or pill-shaped.

A one-line composer that grows into two rows once the message would wrap. Typing @ lists the sources to mention and / the commands, both filtered as you type; the + button opens the sources too, with a Connect link for one not yet connected. The model picker opens above its button, and choosing the flagship sweeps a band of colour across the bar. Dictation listens with three bouncing bars and writes what it heard. A switch under the bar turns its corners from rounded to a pill.

A React component by Bruno Delorence: https://brunodelorence.com/components/prompt-bar. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: AI.

## Anatomy

1. **Composer**: One grid with every control in it. It stays one row while the message fits and splits into two once it would wrap.
2. **Add button**: Opens the + menu: the same sources as `@`, plus files to attach through `onAttach`.
3. **Text box**: A combobox: an `@word` or `/word` before the caret opens its menu, filtered as you type. It grows with its lines up to 136 px, then scrolls.
4. **Model picker**: The chosen model’s name. Opens a 176 px menu over itself; picking a flagship model sweeps a band of colour across the composer.
5. **Dictation**: Listens until it hears something, with three bars bouncing in the button. Pressing it again stops.
6. **Send**: Quiet and disabled while there is nothing to send, ink once there is. Enter sends too; Shift+Enter breaks the line.
7. **Menu**: Pops in from its edge, 180 ms from 95%, just above the composer. A quiet line at its foot says typing filters it.
8. **Highlight**: One highlight that glides to the row under the pointer or the arrow keys, 220 ms. Enter or Tab puts the row in.

## When to use

- The composer of a chat with an agent: one line to write in, with send, dictation and a model picker beside it.
- When a message can point at sources: `@` lists the data, files and apps the agent can read, `/` the commands it knows.
- Products that offer a few models and want the choice next to the message, not buried in settings.
- Short prompts that sometimes grow: the bar stays one row until the text would wrap, then gives the message its own row.

## When not to use

- Long-form writing or editing documents. Use a full editor with formatting.
- A plain search field with no agent behind it. Use a search input or a command palette.
- Chats between people: the model picker and the commands mean nothing there. Use a simpler message field.
- Picking from many models with prices and limits to compare. Give that a settings page; the picker holds a handful.
- Places where @ and / are ordinary text, like code or handles: the menus would keep opening.

## Installation

Install it with the shadcn CLI, into components/prompt-bar of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/prompt-bar.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { PromptBar, type PromptSource } from './prompt-bar';

const sources: PromptSource[] = [
  { id: 'files', label: 'Add photos & files', detail: 'Upload from your computer', attach: true },
  { id: 'docs', label: 'Team docs', detail: 'Specs, notes and plans' },
  { id: 'mail', label: 'Mail', detail: 'Read and manage mail', connect: true },
];

export function AgentComposer({ onSend }: { onSend: (message: string, model: string) => void }) {
  return (
    <PromptBar
      sources={sources}
      commands={[{ name: 'summarize', detail: 'Digest the thread so far' }]}
      onSend={onSend}
      onAttach={() => document.querySelector<HTMLInputElement>('#upload')?.click()}
      shapeSwitch={false}
      autoplay={false}
    />
  );
}
```

## States

- **Empty**: The placeholder shows and Send waits, greyed and disabled.
- **Ready to send**: Any text turns Send to ink. Everything still fits on one row.
- **Two rows**: Once the message would wrap it takes the first row; the buttons drop below it.
- **Mention menu**: Typing `@` lists the sources; picking one writes `@Name` in place of the word.
- **Command menu**: Typing `/` lists the commands, filtered by the letters after it.
- **Add menu**: The + button opens the sources too, and takes the focus so the arrow keys work at once.
- **Model menu**: A tick on the current model. The flagship’s colour sweep plays once on choosing it, so it isn’t shown locked.
- **Listening**: The button turns blue with bouncing bars, and the placeholder says “Listening…”. The bars keep moving.
- **Pill**: Fully round corners on the bar and its buttons; 24 px corners once the message wraps.

## How it works

- The composer is one grid. While the message fits on its line the + button, the text box, the model picker, dictation and send sit in one row; an invisible copy of the message in the same type measures it, and once it is wider than the room the row leaves the text box, the message takes the whole first row and the buttons drop below it, the model picker beside the +. A ResizeObserver keeps the choice right as the bar changes width, and the text box grows with its lines up to seven.
- @ and / are read from the word that ends at the caret, wherever it is in the message: an @word opens the sources and a /word the commands, filtered by what follows the sign at the start of any word ("fla" finds Flavor records). Picking an entry replaces the word with "@Name " or "/name " and puts the caret after it.
- Every menu pops in from the edge it opens from, 180 ms from 95 % and transparent on a fast-out curve, and closes at once. The source and command menus span the bar; the model menu is 176 px wide, opens 8 px above its button and is kept inside the bar.
- Each menu has one highlight under its rows that glides to the row under the pointer or the keyboard (top and height over 220 ms, fading in over 150 ms). Rows are a fixed height, so its place is a sum, not a measurement. The demo moves it unseen, as a script would, and a new query hides it again.
- Choosing a flagship model plays a flash on a canvas behind the bar’s content: a gradient whose hue runs from red at the left edge to violet at the right, and whose stops’ alpha is redrawn every frame. A front crosses the bar in about 110 ms; each point it passes holds its brightest, paler colour for 55 ms, dims to a faint tint, and the tint fades out by 900 ms.
- Dictation tints the microphone button and swaps its icon for three bars that bounce by scaleY (900 ms each, 150 ms apart), with “Listening…” in the empty box and send disabled. Without onDictate it hears a sample sentence after 2.25 s; pressing the button again stops.
- Send is a quiet grey square until there is something to send, then turns to ink (200 ms). Enter sends, Shift+Enter starts a new line, and the message clears.
- While on screen and untouched, a demo replays a 13.5 s round: it types @ and walks the sources, connects the last one, types / and walks the commands, opens the model menu and picks the flagship, flash included, then puts the model back. Its changes never reach the callbacks and are not announced; any key or press hands the bar to the reader.
- Every colour is a light-dark() token on the root, so the bar follows the page’s color-scheme or the colorScheme prop. Each shadow is written once with all its layers, those a scheme does not use being transparent: long soft lifts by day, short dark ones at night.

## API reference

- **sources** (`PromptSource[]`, default `seven sample sources`): What @ and + list: { id, label, detail?, icon?, connect?, attach? }. connect shows a Connect link until it is connected; attach calls onAttach instead of mentioning it.
- **commands** (`PromptCommand[]`, default `five sample commands`): What / lists: { name, detail? }, the name without its slash.
- **models** (`PromptModel[]`, default `three sample models`): What the picker offers: { id, label, note?, flagship? }. Choosing a flagship model plays the flash.
- **model** (`string`): The chosen model’s id, when the parent keeps it (with onModelChange).
- **defaultModel** (`string`, default `'vanilla-1'`): The model chosen at first, when the bar keeps it.
- **onModelChange** (`(id: string) => void`): Called when the reader chooses another model.
- **shape** (`'rounded' | 'pill'`): The bar’s corners, when the parent keeps them (with onShapeChange).
- **defaultShape** (`'rounded' | 'pill'`, default `'rounded'`): The corners at first: 14 px, or a pill (24 px once the message wraps).
- **onShapeChange** (`(shape: 'rounded' | 'pill') => void`): Called when the reader switches the shape.
- **shapeSwitch** (`boolean`, default `true`): Shows the Rounded | Pill switch under the bar.
- **defaultState** (`PromptBarState`): Opens the bar mid-way: value (the message; an @ or / word at its end opens that menu), menu ('plus' or 'model'), active (the lit row), connected (source ids) and listening. A menu it opens doesn’t take the focus. Setting it turns the demo off.
- **onSend** (`(message: string, model: string) => void`): Called with the trimmed message and the model’s id when the reader sends.
- **onAttach** (`(source: PromptSource) => void`): Called when a source marked attach is picked, to open a file picker, say.
- **onConnect** (`(source: PromptSource) => void`): Called when a source marked connect is connected.
- **onDictate** (`() => Promise<string>`): Listens and resolves to what was heard, added to the message. Without it, a sample sentence arrives after 2.25 s.
- **placeholder** (`string`, default `'Write a message…'`): The empty box’s words.
- **listeningLabel** (`string`, default `'Listening…'`): The empty box’s words while dictation listens.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo plays the @ and / menus and the model picker by itself.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root.

## Accessibility

- The text box is a combobox named “Prompt”. While an @ or / menu is open it controls a listbox of options and points at the highlighted one with aria-activedescendant, so focus never leaves the text: the up and down arrows move, Enter or Tab picks, Escape closes.
- The + and model buttons say they open menus and whether they are expanded. Their menus take the focus when they open: arrows, Home and End move, Enter or Space picks, Escape closes and returns to the button, Tab closes. Models are menuitemradio entries with aria-checked.
- A source that needs connecting says so in its name (“Mail, Read and manage mail, not connected”); Enter on it connects it, and Enter again mentions it. The Connect link itself is for the pointer.
- The dictation button is a toggle (aria-pressed) whose name changes from “Start dictation” to “Stop dictation”; Send is disabled while there is nothing to send. The shape switch is a group of two toggle buttons.
- A polite live region says what changed: the model chosen, a source connected, listening, what was heard, a message sent. Focus rings are visible on every button; the text box shows focus by its border.
- Under reduced motion the menus appear without popping, the highlight jumps, the flash is skipped, the bars stand still and the demo never plays.

## Source

### prompt-bar/prompt-bar.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type ChangeEvent,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
} from 'react';
import {
  COMMANDS,
  MODELS,
  SAMPLE_TRANSCRIPT,
  type PromptCommand,
  type PromptModel,
  type PromptShape,
  type PromptSource,
} from './data';
import { playGlow } from './glow';
import {
  ArrowUpIcon,
  BarsIcon,
  BoardIcon,
  ChatIcon,
  CheckIcon,
  ChevronIcon,
  ClipIcon,
  GlobeIcon,
  LayersIcon,
  MailIcon,
  MicIcon,
  PlusIcon,
} from './icons';
import { useAutoplay } from './use-autoplay';
import styles from './prompt-bar.module.css';

export type { PromptCommand, PromptModel, PromptShape, PromptSource } from './data';

/*
 * Prompt bar: the composer of a chat with an agent. A line to write in, a + button for sources
 * and files, a model picker, dictation and send; typing @ lists the sources to mention, typing /
 * the commands. Rounded or pill-shaped.
 *
 * - The composer is one grid. While the message fits on its line, everything sits in one row;
 *   once it would wrap (measured by an invisible copy of the text in the same type) the message
 *   takes the whole first row and the buttons drop below it, the model picker beside the +.
 *   The text box grows with its lines.
 * - @ and / are read from the word just before the caret: an @word or /word opens its menu,
 *   filtered by what follows the sign, and picking an entry replaces the word with "@Name " or
 *   "/name ". The text box stays focused throughout (a combobox with a listbox); arrows move the
 *   highlight, Enter or Tab picks, Escape closes.
 * - Every menu pops in from its edge (180 ms, from 95 %) and has one highlight that glides to the
 *   row under the pointer or the keyboard (220 ms by top). It shows only for those: the demo
 *   moves it unseen, as a script would.
 * - Choosing a flagship model sweeps a band of spectrum colours across the composer on a canvas
 *   behind its content (glow.ts).
 * - Dictation listens until it hears something (a sample sentence after 2.25 s, or what onDictate
 *   resolves to), with three bars bouncing in the button; pressing it again stops.
 * - While on screen and untouched, a demo types @ and walks the sources, connects one, tries a
 *   / command, then picks the flagship model; it puts the model back before it starts over. Its
 *   changes never reach the callbacks; any key or press hands the bar to the reader.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

/** Row heights in the menus, and the menus' inner padding: the highlight's top follows them. */
const ROW = 36;
const MODEL_ROW = 30;
const MENU_PAD = 4;
/** The model menu's width; it opens over the picker, kept inside the bar. */
const MODEL_MENU = 176;
/** The tallest the text box grows before it scrolls. */
const MAX_TEXT = 136;
/** How long the sample dictation listens before it hears its sentence. */
const LISTEN = 2250;

const SOURCES: PromptSource[] = [
  {
    id: 'files',
    label: 'Add photos & files',
    detail: 'Upload from your computer',
    icon: <ClipIcon />,
    attach: true,
  },
  { id: 'scoop-data', label: 'Scoop Data', detail: 'Sales & churn metrics', icon: <BarsIcon /> },
  {
    id: 'flavor-records',
    label: 'Flavor records',
    detail: '26 makers, tags, links',
    icon: <LayersIcon />,
  },
  { id: 'web', label: 'Web search', detail: 'Real-time news and info', icon: <GlobeIcon /> },
  { id: 'board', label: 'Design board', detail: 'Design-to-code workflows', icon: <BoardIcon /> },
  { id: 'chat', label: 'Team chat', detail: 'Read and manage threads', icon: <ChatIcon /> },
  { id: 'mail', label: 'Mail', detail: 'Read and manage mail', icon: <MailIcon />, connect: true },
];

/** The demo's script, in ms from the start of a round. */
const DEMO = {
  start: 600,
  mention: 0,
  sources: [900, 1534, 2150],
  connect: 2850,
  mentionEnd: 3851,
  command: 4550,
  commands: [5451, 6084],
  commandEnd: 7084,
  modelOpen: 7884,
  modelPick: 9084,
  modelBack: 12383,
  round: 13483,
};

/**
 * A state to open in, held still (the demo stays off): for docs and tests. An @word or /word at
 * the end of `value` opens its menu.
 */
export type PromptBarState = {
  /** The message in the text box. */
  value?: string;
  /** The + menu or the model menu open. */
  menu?: 'plus' | 'model';
  /** The open menu's highlighted row, drawn lit. */
  active?: number;
  /** Source ids already connected. */
  connected?: string[];
  /** Dictation listening; it hears nothing until the button is pressed again. */
  listening?: boolean;
};

type Word = { kind: 'mention' | 'command'; from: number; query: string };
type Menu = Word | { kind: 'plus' } | { kind: 'model' } | null;

/** The @word or /word that ends at the caret, if any. */
function wordAt(text: string, caret: number): Word | null {
  let from = caret;
  while (from > 0 && !/\s/.test(text[from - 1])) from--;
  const word = text.slice(from, caret);
  if (word.startsWith('@')) return { kind: 'mention', from, query: word.slice(1) };
  if (word.startsWith('/')) return { kind: 'command', from, query: word.slice(1) };
  return null;
}

/** Whether `query` begins the text or one of its words ("fla" finds Flavor records). */
const matches = (text: string, query: string) => {
  const q = query.trim().toLowerCase();
  const lower = text.toLowerCase();
  if (lower.startsWith(q)) return true;
  // From any later word on, in its own spelling: "rec" and "records" both find Flavor records.
  return [...lower.matchAll(/[\s-]+/g)].some((gap) =>
    lower.startsWith(q, gap.index + gap[0].length),
  );
};

export function PromptBar({
  sources = SOURCES,
  commands = COMMANDS,
  models = MODELS,
  model,
  defaultModel = 'vanilla-1',
  onModelChange,
  shape,
  defaultShape = 'rounded',
  onShapeChange,
  shapeSwitch = true,
  onSend,
  onAttach,
  onConnect,
  onDictate,
  placeholder = 'Write a message…',
  listeningLabel = 'Listening…',
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** What @ and + list. */
  sources?: PromptSource[];
  /** What / lists. */
  commands?: PromptCommand[];
  /** What the model picker offers. */
  models?: PromptModel[];
  /** The chosen model's id, when the parent keeps it. */
  model?: string;
  /** The model chosen at first, when the bar keeps it. */
  defaultModel?: string;
  onModelChange?: (id: string) => void;
  /** Rounded corners, or a pill (rounder when the message wraps). */
  shape?: PromptShape;
  defaultShape?: PromptShape;
  onShapeChange?: (shape: PromptShape) => void;
  /** Shows the Rounded | Pill switch under the bar. */
  shapeSwitch?: boolean;
  /** Called with the message and the model's id when the reader sends. */
  onSend?: (message: string, model: string) => void;
  /** Called when a source marked attach is picked (a file picker, say). */
  onAttach?: (source: PromptSource) => void;
  /** Called when a source marked connect is connected. */
  onConnect?: (source: PromptSource) => void;
  /** Listens and resolves to what was heard; without it, a sample sentence after 2.25 s. */
  onDictate?: () => Promise<string>;
  placeholder?: string;
  /** The placeholder while dictation listens. */
  listeningLabel?: string;
  /** While on screen and untouched, a demo plays the menus by itself. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens in this state, held still: the demo stays off. */
  defaultState?: PromptBarState;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const demo = autoplay && !defaultState && playing;
  const uid = useId();

  const [value, setValue] = useState(defaultState?.value ?? '');
  const [menu, setMenu] = useState<Menu>(() => {
    if (defaultState?.menu) return { kind: defaultState.menu };
    const text = defaultState?.value ?? '';
    return text ? wordAt(text, text.length) : null;
  });
  const [active, setActive] = useState(() =>
    defaultState?.active !== undefined
      ? defaultState.active
      : defaultState?.menu === 'model'
        ? Math.max(
            0,
            models.findIndex((m) => m.id === (model ?? defaultModel)),
          )
        : 0,
  );
  /** The highlight shows for the pointer and the keyboard, not for the demo. */
  const [lit, setLit] = useState(defaultState?.active !== undefined);
  const [connected, setConnected] = useState<string[]>(defaultState?.connected ?? []);
  const [demoConnected, setDemoConnected] = useState<string[]>([]);
  const [listening, setListening] = useState(defaultState?.listening ?? false);
  const [ownModel, setOwnModel] = useState(defaultModel);
  /** The demo's model, shown over the real one while it plays. */
  const [demoModel, setDemoModel] = useState<string | null>(null);
  const [ownShape, setOwnShape] = useState<PromptShape>(defaultShape);
  const [stacked, setStacked] = useState(false);
  const [modelAt, setModelAt] = useState<{ left: number; bottom: number } | null>(null);
  const [glow, setGlow] = useState(0);
  const [message, setMessage] = useState('');

  const anchor = useRef<HTMLDivElement>(null);
  const composer = useRef<HTMLDivElement>(null);
  const grid = useRef<HTMLDivElement>(null);
  const text = useRef<HTMLTextAreaElement>(null);
  const measure = useRef<HTMLSpanElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const plusButton = useRef<HTMLButtonElement>(null);
  const modelButton = useRef<HTMLButtonElement>(null);
  const menuBox = useRef<HTMLDivElement>(null);
  /** Where the caret goes after an entry is put in, once the text has rendered. */
  const caretTo = useRef<number | null>(null);
  const dictation = useRef(0);
  const listenTimer = useRef(0);
  /** Whether the demo has changed anything the reader should not inherit. */
  const demoTouched = useRef(false);
  /** Set once the reader opens the + or model menu (one opened by defaultState keeps no focus). */
  const menuOpened = useRef(false);

  const modelId = demoModel ?? model ?? ownModel;
  const current = models.find((entry) => entry.id === modelId) ?? models[0];
  const form = shape ?? ownShape;
  const linked = (id: string) => connected.includes(id) || demoConnected.includes(id);

  const sourceItems =
    menu?.kind === 'mention'
      ? sources.filter((source) => matches(source.label, menu.query))
      : menu?.kind === 'plus'
        ? sources
        : [];
  const commandItems =
    menu?.kind === 'command' ? commands.filter((command) => matches(command.name, menu.query)) : [];
  const count =
    menu?.kind === 'model'
      ? models.length
      : menu?.kind === 'command'
        ? commandItems.length
        : sourceItems.length;

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (words: string) => setMessage((now) => (now === words ? `${words} ` : words));

  const open = (next: Menu, first = 0) => {
    setMenu(next);
    setActive(first);
    setLit(false);
  };

  // --- Layout: one row or two, the text box's height, the model menu's place ---------------

  const layout = useEffectEvent(() => {
    const box = grid.current;
    const copy = measure.current;
    const picker = modelButton.current;
    if (!box || !copy || !picker) return;
    // One row leaves the text box what the four buttons and the gaps do not take, less its own
    // 4 px padding on each side.
    const room = box.clientWidth - 28 * 3 - picker.offsetWidth - 4 * 4 - 8;
    const wraps = value.includes('\n') || copy.offsetWidth > room;
    if (wraps !== stacked) setStacked(wraps);
    if (menu?.kind === 'model' && anchor.current) {
      const frame = anchor.current.getBoundingClientRect();
      const button = picker.getBoundingClientRect();
      // In layout pixels, should the bar be drawn scaled (a zoomed preview).
      const scale = frame.width / (anchor.current.offsetWidth || frame.width) || 1;
      const width = frame.width / scale;
      const left = Math.max(0, Math.min((button.left - frame.left) / scale, width - MODEL_MENU));
      const bottom = (frame.bottom - button.top) / scale + 8;
      if (!modelAt || modelAt.left !== left || modelAt.bottom !== bottom)
        setModelAt({ left, bottom });
    }
  });

  useLayoutEffect(() => layout());

  useEffect(() => {
    const box = composer.current;
    if (!box) return;
    const observer = new ResizeObserver(() => layout());
    observer.observe(box);
    return () => observer.disconnect();
  }, []);

  // The text box is as tall as its lines, up to MAX_TEXT.
  useLayoutEffect(() => {
    const box = text.current;
    if (!box) return;
    box.style.height = 'auto';
    const height = Math.max(28, Math.min(box.scrollHeight, MAX_TEXT));
    box.style.height = `${height}px`;
    box.style.overflowY = box.scrollHeight > MAX_TEXT ? 'auto' : 'hidden';
  }, [value, stacked, form]);

  // After an entry is put in, the caret lands right after it.
  useLayoutEffect(() => {
    const at = caretTo.current;
    const box = text.current;
    if (at === null || !box) return;
    caretTo.current = null;
    box.focus();
    box.setSelectionRange(at, at);
  }, [value]);

  // The flash for a flagship model.
  useEffect(() => {
    if (!glow || !canvas.current) return;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    return playGlow(canvas.current);
  }, [glow]);

  // A press outside an open menu and its trigger closes it.
  useEffect(() => {
    if (!menu) return;
    const close = (event: PointerEvent) => {
      const target = event.target as Node;
      if (menuBox.current?.contains(target)) return;
      if (menu.kind === 'plus' && plusButton.current?.contains(target)) return;
      if (menu.kind === 'model' && modelButton.current?.contains(target)) return;
      if ((menu.kind === 'mention' || menu.kind === 'command') && text.current?.contains(target))
        return;
      setMenu(null);
    };
    document.addEventListener('pointerdown', close);
    return () => document.removeEventListener('pointerdown', close);
  }, [menu]);

  useEffect(
    () => () => {
      window.clearTimeout(listenTimer.current);
    },
    [],
  );

  // --- Writing -----------------------------------------------------------------------------

  const onChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
    const next = event.target.value;
    setValue(next);
    const word = wordAt(next, event.target.selectionStart ?? next.length);
    const any =
      word?.kind === 'mention'
        ? sources.some((source) => matches(source.label, word.query))
        : word?.kind === 'command'
          ? commands.some((command) => matches(command.name, word.query))
          : false;
    if (word && any) {
      const same = menu?.kind === word.kind;
      // A new menu, or a new query, starts again from the first entry, unhighlighted.
      if (!same || (menu && 'query' in menu && menu.query !== word.query)) {
        setActive(0);
        setLit(false);
      }
      setMenu(word);
    } else if (menu) setMenu(null);
  };

  /** Puts `entry` in place of [from, to) and a space after it, the caret following. */
  const insert = (entry: string, from: number, to: number) => {
    const before = value.slice(0, from);
    if (!entry) {
      caretTo.current = before.length;
      setValue(before + value.slice(to));
      return;
    }
    const lead = before && !/\s$/.test(before) ? ' ' : '';
    const next = `${before}${lead}${entry} ${value.slice(to).replace(/^ /, '')}`;
    caretTo.current = before.length + lead.length + entry.length + 1;
    setValue(next);
  };

  const send = () => {
    const words = value.trim();
    if (!words || listening) return;
    onSend?.(words, modelId);
    setValue('');
    setMenu(null);
    announce('Message sent');
  };

  // --- Picking -----------------------------------------------------------------------------

  const connect = (source: PromptSource) => {
    if (linked(source.id)) return;
    setConnected((now) => [...now, source.id]);
    onConnect?.(source);
    announce(`${source.label} connected`);
  };

  const chooseModel = (entry: PromptModel) => {
    setMenu(null);
    modelButton.current?.focus({ preventScroll: true });
    if (entry.id === modelId) return;
    setOwnModel(entry.id);
    onModelChange?.(entry.id);
    if (entry.flagship) setGlow((n) => n + 1);
    announce(`Model: ${entry.label}`);
  };

  /** Picks row `index` of the open menu; `keyboard` lets Enter connect a source first. */
  const pick = (index: number, keyboard = false) => {
    if (!menu) return;
    if (menu.kind === 'model') {
      const entry = models[index];
      if (entry) chooseModel(entry);
      return;
    }
    const box = text.current;
    const caret = box?.selectionStart ?? value.length;
    if (menu.kind === 'command') {
      const command = commandItems[index];
      if (!command) return;
      setMenu(null);
      insert(`/${command.name}`, menu.from, caret);
      return;
    }
    const source = sourceItems[index];
    if (!source) return;
    if (keyboard && source.connect && !linked(source.id)) {
      connect(source);
      return;
    }
    setMenu(null);
    if (source.attach) {
      onAttach?.(source);
      if (menu.kind === 'plus') box?.focus();
      else insert('', menu.from, caret);
      return;
    }
    if (menu.kind === 'mention') insert(`@${source.label}`, menu.from, caret);
    else insert(`@${source.label}`, caret, caret);
  };

  const move = (step: number | 'first' | 'last') => {
    if (!count) return;
    setLit(true);
    setActive((now) =>
      step === 'first' ? 0 : step === 'last' ? count - 1 : (now + step + count) % count,
    );
  };

  const onTextKey = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
    if (event.nativeEvent.isComposing) return;
    if (menu && (menu.kind === 'mention' || menu.kind === 'command') && count) {
      if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
        event.preventDefault();
        move(event.key === 'ArrowDown' ? 1 : -1);
        return;
      }
      if (event.key === 'Enter' || event.key === 'Tab') {
        event.preventDefault();
        pick(active, true);
        return;
      }
      if (event.key === 'Escape') {
        event.preventDefault();
        setMenu(null);
        return;
      }
    }
    if (event.key === 'Enter' && !event.shiftKey) {
      event.preventDefault();
      send();
    }
  };

  /** Keys on the + and model menus, which hold the focus while they are open. */
  const onMenuKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    const keys: Record<string, () => void> = {
      ArrowDown: () => move(1),
      ArrowUp: () => move(-1),
      Home: () => move('first'),
      End: () => move('last'),
      Enter: () => pick(active, true),
      ' ': () => pick(active, true),
      Escape: () => {
        const back = menu?.kind === 'model' ? modelButton.current : plusButton.current;
        setMenu(null);
        back?.focus();
      },
      Tab: () => setMenu(null),
    };
    const run = keys[event.key];
    if (!run) return;
    if (event.key !== 'Tab') event.preventDefault();
    run();
  };

  // The + and model menus take the focus when they open, so arrows work at once.
  // (A frame later: the model menu is hidden until its place has been measured.)
  useEffect(() => {
    if (demo || !menuOpened.current || (menu?.kind !== 'plus' && menu?.kind !== 'model')) return;
    const frame = requestAnimationFrame(() => menuBox.current?.focus({ preventScroll: true }));
    return () => cancelAnimationFrame(frame);
  }, [menu?.kind, demo]);

  const toggleMenu = (kind: 'plus' | 'model', keyboard: boolean) => {
    if (menu?.kind === kind) {
      setMenu(null);
      return;
    }
    const first =
      kind === 'model'
        ? Math.max(
            0,
            models.findIndex((m) => m.id === modelId),
          )
        : 0;
    menuOpened.current = true;
    open({ kind }, first);
    if (keyboard) setLit(true);
  };

  // --- Dictation ---------------------------------------------------------------------------

  const hear = (heard: string, token: number) => {
    if (token !== dictation.current) return;
    setListening(false);
    const words = heard.trim();
    if (!words) {
      announce('Nothing heard');
      return;
    }
    setValue((now) => (now.trim() ? `${now.replace(/\s*$/, ' ')}${words}` : words));
    // The words go where the reader can carry on writing.
    if (root.current?.contains(document.activeElement)) {
      const box = text.current;
      caretTo.current =
        (box?.value.trim() ? box.value.replace(/\s*$/, ' ').length : 0) + words.length;
    }
    announce(`Heard: ${words}`);
  };

  const dictate = () => {
    const token = ++dictation.current;
    window.clearTimeout(listenTimer.current);
    if (listening) {
      setListening(false);
      announce('Dictation stopped');
      return;
    }
    setListening(true);
    setMenu(null);
    announce('Listening');
    if (onDictate)
      onDictate().then(
        (heard) => hear(heard, token),
        () => hear('', token),
      );
    else listenTimer.current = window.setTimeout(() => hear(SAMPLE_TRANSCRIPT, token), LISTEN);
  };

  // --- Shape -------------------------------------------------------------------------------

  const setShape = (next: PromptShape) => {
    if (next === form) return;
    setOwnShape(next);
    onShapeChange?.(next);
  };

  // --- Demo --------------------------------------------------------------------------------

  const demoStep = useEffectEvent((step: string, arg = 0) => {
    demoTouched.current = true;
    const connectable = sources.reduce((last, source, i) => (source.connect ? i : last), -1);
    const sourceStops = [Math.min(1, sources.length - 1), Math.min(4, sources.length - 1)];
    sourceStops.push(connectable >= 0 ? connectable : sources.length - 1);
    const commandStops = [Math.min(1, commands.length - 1), Math.min(3, commands.length - 1)];
    switch (step) {
      case 'mention':
        setDemoConnected([]);
        setValue('@');
        open({ kind: 'mention', from: 0, query: '' });
        break;
      case 'source':
        setActive(Math.max(0, sourceStops[arg]));
        break;
      case 'connect':
        if (connectable >= 0) setDemoConnected([sources[connectable].id]);
        break;
      case 'command':
        setValue('/');
        open({ kind: 'command', from: 0, query: '' });
        break;
      case 'commandStop':
        setActive(Math.max(0, commandStops[arg]));
        break;
      case 'close':
        setValue('');
        setMenu(null);
        break;
      case 'modelOpen':
        open(
          { kind: 'model' },
          Math.max(
            0,
            models.findIndex((m) => m.id === modelId),
          ),
        );
        break;
      case 'modelPick': {
        const flagship = models.find((m) => m.flagship) ?? models[0];
        setMenu(null);
        if (flagship && flagship.id !== modelId) {
          setDemoModel(flagship.id);
          if (flagship.flagship) setGlow((n) => n + 1);
        }
        break;
      }
      case 'modelBack':
        setDemoModel(null);
        break;
    }
  });

  const demoReset = useEffectEvent(() => {
    if (!demoTouched.current) return;
    demoTouched.current = false;
    setValue('');
    setMenu(null);
    setLit(false);
    setDemoModel(null);
    setDemoConnected([]);
  });

  useEffect(() => {
    if (!demo) {
      demoReset();
      return;
    }
    const timers: number[] = [];
    const at = (ms: number, step: string, arg?: number) =>
      timers.push(window.setTimeout(() => demoStep(step, arg), ms));
    const round = (start: number) => {
      at(start + DEMO.mention, 'mention');
      DEMO.sources.forEach((ms, i) => at(start + ms, 'source', i));
      at(start + DEMO.connect, 'connect');
      at(start + DEMO.mentionEnd, 'close');
      at(start + DEMO.command, 'command');
      DEMO.commands.forEach((ms, i) => at(start + ms, 'commandStop', i));
      at(start + DEMO.commandEnd, 'close');
      at(start + DEMO.modelOpen, 'modelOpen');
      at(start + DEMO.modelPick, 'modelPick');
      at(start + DEMO.modelBack, 'modelBack');
      timers.push(
        window.setTimeout(() => {
          timers.length = 0;
          round(0);
        }, start + DEMO.round),
      );
    };
    round(DEMO.start);
    return () => timers.forEach((timer) => window.clearTimeout(timer));
  }, [demo]);

  // --- Rendering ---------------------------------------------------------------------------

  const listId = `${uid}-menu`;
  const optionId = (i: number) => `${uid}-option-${i}`;
  const comboOpen = !!menu && (menu.kind === 'mention' || menu.kind === 'command') && count > 0;
  const ready = !!value.trim() && !listening;
  const rowHeight = menu?.kind === 'model' ? MODEL_ROW : ROW;

  const glide = (
    <span
      className={styles.glide}
      data-part="highlight"
      aria-hidden="true"
      style={{
        top: MENU_PAD + rowHeight * Math.min(active, Math.max(0, count - 1)),
        height: rowHeight,
        opacity: lit ? 1 : 0,
      }}
    />
  );

  const rowEvents = (i: number) => ({
    onPointerMove: () => {
      if (active !== i || !lit) {
        setActive(i);
        setLit(true);
      }
    },
    onPointerDown: (event: { preventDefault: () => void }) => event.preventDefault(),
    onClick: () => pick(i),
  });

  let popup: ReactNode = null;
  if (menu && count) {
    if (menu.kind === 'model') {
      popup = (
        <div
          key="model"
          ref={menuBox}
          id={listId}
          role="menu"
          aria-label="Models"
          tabIndex={-1}
          aria-activedescendant={optionId(active)}
          className={`${styles.menu} ${styles.modelMenu}`}
          data-part="menu"
          style={modelAt ?? { visibility: 'hidden' }}
          onKeyDown={onMenuKey}
          onPointerLeave={() => setLit(false)}
        >
          {glide}
          {models.map((entry, i) => (
            <div
              key={entry.id}
              id={optionId(i)}
              role="menuitemradio"
              aria-checked={entry.id === modelId}
              className={styles.modelRow}
              data-part="model-row"
              {...rowEvents(i)}
            >
              <span className={styles.modelName}>{entry.label}</span>
              {entry.note ? <span className={styles.modelNote}>{entry.note}</span> : null}
              <span
                className={styles.check}
                data-part="check"
                data-on={entry.id === modelId || undefined}
              >
                <CheckIcon />
              </span>
            </div>
          ))}
        </div>
      );
    } else {
      const commandMenu = menu.kind === 'command';
      const plus = menu.kind === 'plus';
      popup = (
        <div
          key={menu.kind}
          ref={menuBox}
          id={listId}
          role={plus ? 'menu' : 'listbox'}
          aria-label={commandMenu ? 'Commands' : 'Sources and files'}
          tabIndex={plus ? -1 : undefined}
          aria-activedescendant={plus ? optionId(active) : undefined}
          className={styles.menu}
          data-part="menu"
          onKeyDown={plus ? onMenuKey : undefined}
          onPointerLeave={() => setLit(false)}
        >
          {glide}
          {commandMenu
            ? commandItems.map((command, i) => (
                <div
                  key={command.name}
                  id={optionId(i)}
                  role="option"
                  aria-selected={i === active}
                  className={styles.row}
                  data-part="command"
                  {...rowEvents(i)}
                >
                  <span className={styles.label}>/{command.name}</span>
                  {command.detail ? <span className={styles.detail}>{command.detail}</span> : null}
                </div>
              ))
            : sourceItems.map((source, i) => {
                const on = linked(source.id);
                const state = source.connect ? (on ? ', connected' : ', not connected') : '';
                return (
                  <div
                    key={source.id}
                    id={optionId(i)}
                    role={plus ? 'menuitem' : 'option'}
                    aria-selected={plus ? undefined : i === active}
                    aria-label={`${source.label}${source.detail ? `, ${source.detail}` : ''}${state}`}
                    className={styles.row}
                    data-part="source"
                    data-id={source.id}
                    {...rowEvents(i)}
                  >
                    <span className={styles.icon}>{source.icon}</span>
                    <span className={styles.label}>{source.label}</span>
                    {source.detail ? <span className={styles.detail}>{source.detail}</span> : null}
                    {source.connect ? (
                      on ? (
                        <span className={styles.connected} data-part="connected">
                          Connected
                        </span>
                      ) : (
                        <span
                          className={styles.connect}
                          data-part="connect"
                          onClick={(event) => {
                            event.stopPropagation();
                            connect(source);
                          }}
                        >
                          Connect
                        </span>
                      )
                    ) : null}
                  </div>
                );
              })}
          <div className={styles.hint} data-part="hint" aria-hidden="true">
            {commandMenu ? 'Type to search commands' : 'Type to search sources & files'}
          </div>
        </div>
      );
    }
  }

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.frame}>
        <div className={styles.bar}>
          <div ref={anchor} className={styles.anchor}>
            {popup}
            <div
              ref={composer}
              className={styles.composer}
              data-part="composer"
              data-shape={form}
              data-stacked={stacked || undefined}
            >
              <canvas ref={canvas} className={styles.canvas} aria-hidden="true" />
              <span ref={measure} className={styles.measure} aria-hidden="true">
                {value}
              </span>
              <div ref={grid} className={styles.grid}>
                <button
                  ref={plusButton}
                  type="button"
                  className={`${styles.button} ${styles.plus}`}
                  data-part="plus"
                  aria-label="Add attachments and sources"
                  aria-haspopup="menu"
                  aria-expanded={menu?.kind === 'plus'}
                  aria-controls={menu?.kind === 'plus' ? listId : undefined}
                  onClick={(event) => toggleMenu('plus', event.detail === 0)}
                >
                  <PlusIcon />
                </button>
                <textarea
                  ref={text}
                  rows={1}
                  className={styles.text}
                  data-part="text"
                  value={value}
                  placeholder={listening ? listeningLabel : placeholder}
                  aria-label="Prompt"
                  role="combobox"
                  aria-autocomplete="list"
                  aria-expanded={comboOpen}
                  aria-controls={comboOpen ? listId : undefined}
                  aria-activedescendant={comboOpen && lit ? optionId(active) : undefined}
                  onChange={onChange}
                  onKeyDown={onTextKey}
                />
                <button
                  ref={modelButton}
                  type="button"
                  className={styles.model}
                  data-part="model"
                  aria-label={`Choose model, ${current?.label ?? ''}`}
                  aria-haspopup="menu"
                  aria-expanded={menu?.kind === 'model'}
                  aria-controls={menu?.kind === 'model' ? listId : undefined}
                  onClick={(event) => toggleMenu('model', event.detail === 0)}
                >
                  {current?.label}
                  <span className={styles.chevron}>
                    <ChevronIcon />
                  </span>
                </button>
                <button
                  type="button"
                  className={`${styles.button} ${styles.mic}`}
                  data-part="mic"
                  aria-label={listening ? 'Stop dictation' : 'Start dictation'}
                  aria-pressed={listening}
                  onClick={dictate}
                >
                  {listening ? (
                    <span className={styles.bars} aria-hidden="true">
                      <span />
                      <span />
                      <span />
                    </span>
                  ) : (
                    <MicIcon />
                  )}
                </button>
                <button
                  type="button"
                  className={`${styles.button} ${styles.send}`}
                  data-part="send"
                  data-ready={ready || undefined}
                  aria-label="Send"
                  disabled={!ready}
                  onClick={send}
                >
                  <ArrowUpIcon />
                </button>
              </div>
            </div>
          </div>
        </div>
      </div>
      {shapeSwitch ? (
        <div className={styles.switch} data-part="shape-switch" role="group" aria-label="Shape">
          {(['rounded', 'pill'] as const).map((option) => (
            <button
              key={option}
              type="button"
              aria-pressed={form === option}
              onClick={() => setShape(option)}
            >
              {option === 'rounded' ? 'Rounded' : 'Pill'}
            </button>
          ))}
        </div>
      ) : null}
      <div className={styles.live} aria-live="polite">
        {message}
      </div>
    </div>
  );
}
```

### prompt-bar/prompt-bar.module.css

```css
/* Prompt bar (prompt-bar.tsx): a soft grey stage with the composer near its foot, 420 px wide at
   most, its menus opening above it; at night a graphite stage with the composer a step lighter.

   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it
   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop
   sets (data-color-scheme). Each shadow is one list for both schemes, each layer coloured for one
   of them and clear in the other.

   Motion: the menus pop in (opacity and scale), the highlight glides by top, the shape eases its
   corners, the dictation bars bounce by scaleY. The flagship flash is drawn on a canvas. */

.root {
  /* The stage, the composer and menus, the highlight, and the shape switch's track. */
  --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));
  --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
  --hover: light-dark(oklch(97% 0.002 247.839), oklch(28.9% 0.006 271.22));
  --field: light-dark(oklch(96.1% 0.001 286.375), oklch(29.3% 0.006 271.223));
  /* Words: strong, for buttons, and quiet. */
  --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));
  --ink-2: light-dark(oklch(50.6% 0.01 264.477), oklch(73.1% 0.008 260.731));
  --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));
  /* Edges: the composer's border, and the same focused (also the idle send button). */
  --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
  --line-strong: light-dark(oklch(91.2% 0.005 258.326), oklch(35.6% 0.007 264.474));
  /* Connect, dictation listening, Connected, and the focus ring. */
  --accent-ink: light-dark(oklch(55.6% 0.187 255.617), oklch(78.8% 0.113 248.33));
  --accent-tint: light-dark(oklch(96% 0.019 252.878), oklch(68% 0.173 253.301 / 0.16));
  --green: light-dark(oklch(60.3% 0.155 150.883), oklch(70.5% 0.154 153.814));
  --focus: light-dark(oklch(62.6% 0.205 254.947), oklch(68% 0.173 253.301));
  /* Shadow layers: rings, the day's long soft lifts, the night's short dark ones. */
  --ring-card: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.11));
  --ring-menu: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.13));
  --ring-btn: light-dark(oklch(91.2% 0.005 258.326), oklch(100% 0 0 / 0.1));
  --day-10: light-dark(rgb(0 0 0 / 0.04), transparent);
  --day-8: light-dark(rgb(0 0 0 / 0.03), transparent);
  --day-5: light-dark(rgb(0 0 0 / 0.02), transparent);
  --day-3: light-dark(rgb(0 0 0 / 0.012), transparent);
  --night-20: light-dark(transparent, oklch(0% 0 0 / 0.2));
  --night-22: light-dark(transparent, oklch(0% 0 0 / 0.22));
  --night-30: light-dark(transparent, oklch(0% 0 0 / 0.3));
  --shadow-card:
    0 0 0 1px var(--ring-card), 0 18px 47px var(--day-8), 0 7.5px 19px var(--day-5),
    0 4px 10.5px var(--day-5), 0 2.3px 5.8px var(--day-3), 0 1.2px 3.1px var(--day-3),
    0 0.5px 1.3px var(--day-3), 0 1px 2px var(--night-20), 0 2px 6px var(--night-20);
  --shadow-menu:
    0 0 0 1px var(--ring-menu), 0 17.5px 23.4px var(--day-10), 0 9.4px 12.5px var(--day-8),
    0 5.25px 7px var(--day-5), 0 2.8px 3.7px -2px var(--day-3), 0 1.2px 1.5px var(--day-3),
    0 2px 10px var(--night-22);
  --shadow-btn: 0 0 0 1px var(--ring-btn), 0 0 4px var(--day-10), 0 1px 2px var(--night-30);
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease: cubic-bezier(0.4, 0, 0.2, 1);
  position: relative;
  container-type: inline-size;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 408px;
  padding: 12px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--canvas);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-size: 14px;
  line-height: 1.5;
  letter-spacing: -0.14px;
  font-feature-settings: normal;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

:where(.root) :where(button, textarea) {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
}
:where(.root) :where(button) {
  cursor: pointer;
}
.root button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 0;
}

/* Icons keep the size they are drawn at, whatever the page says about svg. */
.root svg {
  flex-shrink: 0;
}
.root svg[width='11'] {
  width: 11px;
  height: 11px;
}
.root svg[width='13'] {
  width: 13px;
  height: 13px;
}
.root svg[width='15'] {
  width: 15px;
  height: 15px;
}
.root svg[width='16'] {
  width: 16px;
  height: 16px;
}

.frame {
  width: 100%;
  max-width: 480px;
}

/* The composer sits at the foot of a 384 px column, so the menus have room above it. */
.bar {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  box-sizing: border-box;
  width: 100%;
  max-width: 420px;
  min-height: 384px;
  margin: 0 auto;
  padding-bottom: 32px;
}

.anchor {
  position: relative;
}

/* --- The composer ---------------------------------------------------------------------------- */

.composer {
  position: relative;
  isolation: isolate;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  padding: 6px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--surface);
  box-shadow: var(--shadow-card);
  transition:
    border-color 150ms var(--ease),
    border-radius 150ms var(--ease);
}
.composer:focus-within {
  border-color: var(--line-strong);
}
.composer[data-shape='pill'] {
  border-radius: 9999px;
}
.composer[data-shape='pill'][data-stacked] {
  border-radius: 24px;
}

/* The flagship flash, behind the content and inside the corners. */
.canvas {
  position: absolute;
  inset: 0;
  z-index: -1;
  width: 100%;
  height: 100%;
  border-radius: inherit;
  pointer-events: none;
}

/* An unseen copy of the message on one line: its width says whether the message still fits. */
.measure {
  position: absolute;
  top: 0;
  left: 0;
  visibility: hidden;
  font-size: 13px;
  line-height: 18px;
  white-space: pre;
  pointer-events: none;
}

.grid {
  display: grid;
  grid-template-columns: 28px minmax(0, 1fr) auto 28px 28px;
  align-items: end;
  column-gap: 4px;
  row-gap: 6px;
}
.plus {
  grid-area: 1 / 1;
  justify-self: start;
}
.text {
  grid-area: 1 / 2;
}
.model {
  grid-area: 1 / 3;
}
.mic {
  grid-area: 1 / 4;
}
.send {
  grid-area: 1 / 5;
}
/* Two rows: the message on top, the + and the model below it on the left, dictation and send on
   the right. */
.composer[data-stacked] .grid {
  grid-template-columns: 28px auto minmax(0, 1fr) 28px 28px;
}
.composer[data-stacked] .text {
  grid-area: 1 / 1 / 2 / -1;
}
.composer[data-stacked] .plus {
  grid-area: 2 / 1;
}
.composer[data-stacked] .model {
  grid-area: 2 / 2;
  justify-self: start;
}
.composer[data-stacked] .mic {
  grid-area: 2 / 4;
}
.composer[data-stacked] .send {
  grid-area: 2 / 5;
}

.text {
  box-sizing: border-box;
  width: 100%;
  min-width: 0;
  height: 28px;
  min-height: 28px;
  padding: 5px 4px;
  overflow-y: hidden;
  resize: none;
  color: var(--ink);
  font-size: 13px;
  line-height: 18px;
  overflow-wrap: anywhere;
  outline: none;
}
.text::placeholder {
  color: var(--ink-3);
  opacity: 1;
}

.button {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  border-radius: 8px;
  color: var(--ink-3);
  transition:
    background-color 150ms var(--ease),
    color 150ms var(--ease),
    transform 150ms var(--ease);
}
.button:hover {
  background: var(--hover);
  color: var(--ink);
}
.button:active {
  transform: scale(0.94);
}
.button svg {
  display: block;
}

.model {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  gap: 4px;
  height: 28px;
  padding: 0 6px;
  border-radius: 8px;
  color: var(--ink-2);
  font-size: 12px;
  font-weight: 500;
  line-height: 18px;
  white-space: nowrap;
  transition:
    background-color 150ms var(--ease),
    color 150ms var(--ease);
}
.model:hover {
  background: var(--hover);
  color: var(--ink);
}
.chevron {
  display: flex;
  color: var(--ink-3);
}

.composer[data-shape='pill'] .button,
.composer[data-shape='pill'] .model {
  border-radius: 9999px;
}

/* Listening: a blue tint and three bouncing bars. */
.mic[aria-pressed='true'],
.mic[aria-pressed='true']:hover {
  background: var(--accent-tint);
  color: var(--accent-ink);
}
.bars {
  display: flex;
  align-items: center;
  gap: 2.5px;
  height: 14px;
}
.bars span {
  width: 2.5px;
  height: 100%;
  border-radius: 9999px;
  background: currentColor;
  animation: bounce 900ms ease-in-out infinite;
}
.bars span:nth-child(2) {
  animation-delay: 150ms;
}
.bars span:nth-child(3) {
  animation-delay: 300ms;
}
@keyframes bounce {
  0%,
  100% {
    transform: scaleY(0.35);
  }
  50% {
    transform: scaleY(1);
  }
}

/* Send: quiet while there is nothing to send, ink once there is. */
.send,
.send:hover {
  background: var(--line-strong);
  color: var(--ink-2);
  transition:
    background-color 200ms var(--ease),
    color 200ms var(--ease),
    transform 200ms var(--ease);
}
.send[data-ready],
.send[data-ready]:hover {
  background: var(--ink);
  color: var(--surface);
}
.send:disabled {
  cursor: default;
  transform: none;
}

/* --- Menus ----------------------------------------------------------------------------------- */

.menu {
  position: absolute;
  right: 0;
  bottom: 100%;
  left: 0;
  z-index: 10;
  margin-bottom: 8px;
  padding: 4px;
  border-radius: 10px;
  background: var(--surface);
  box-shadow: var(--shadow-menu);
  outline: none;
  transform-origin: center bottom;
  animation: pop 180ms var(--ease-out) both;
}
.modelMenu {
  right: auto;
  width: 176px;
  margin-bottom: 0;
  transform-origin: left bottom;
}
@keyframes pop {
  from {
    opacity: 0;
    transform: scale(0.95);
  }
}

.glide {
  position: absolute;
  right: 4px;
  left: 4px;
  border-radius: 6px;
  background: var(--hover);
  pointer-events: none;
  transition:
    top 220ms var(--ease-out),
    height 220ms var(--ease-out),
    opacity 150ms;
}

.row,
.modelRow {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  border-radius: 6px;
  white-space: nowrap;
  cursor: pointer;
  user-select: none;
}
.row {
  gap: 10px;
  height: 36px;
  padding: 0 8px;
}
.icon {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  color: var(--ink-2);
}
.label {
  flex-shrink: 0;
  color: var(--ink);
  font-size: 12.5px;
  font-weight: 500;
}
.detail {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  color: var(--ink-3);
  font-size: 12px;
  text-overflow: ellipsis;
}
.connect,
.connected {
  flex-shrink: 0;
  font-size: 12px;
  font-weight: 500;
}
.connect {
  color: var(--accent-ink);
  transition: color 100ms var(--ease);
}
.connect:hover {
  text-decoration: underline;
}
.connected {
  color: var(--green);
}
.hint {
  margin-top: 4px;
  padding: 6px 8px 4px;
  border-top: 1px solid var(--line);
  color: var(--ink-3);
  font-size: 11px;
}

.modelRow {
  gap: 8px;
  height: 30px;
  padding: 0 8px;
}
.modelName {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  color: var(--ink);
  font-size: 12.5px;
  font-weight: 500;
  text-overflow: ellipsis;
}
.modelNote {
  flex-shrink: 0;
  color: var(--ink-3);
  font-size: 11px;
}
.check {
  display: flex;
  flex-shrink: 0;
  color: var(--ink);
  visibility: hidden;
}
.check[data-on] {
  visibility: visible;
}

/* --- The shape switch ------------------------------------------------------------------------ */

.switch {
  position: absolute;
  bottom: 10px;
  left: 50%;
  display: flex;
  padding: 2px;
  border-radius: 9999px;
  background: var(--field);
  transform: translateX(-50%);
}
.switch button {
  padding: 2px 8px;
  border-radius: 9999px;
  color: var(--ink-3);
  font-size: 11.5px;
  font-weight: 500;
  transition:
    background-color 150ms var(--ease),
    color 150ms var(--ease),
    box-shadow 150ms var(--ease);
}
.switch button:hover {
  color: var(--ink-2);
}
.switch button[aria-pressed='true'] {
  background: var(--surface);
  color: var(--ink);
  box-shadow: var(--shadow-btn);
}

.live {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .menu {
    animation: none;
  }
  .glide,
  .composer,
  .button,
  .model,
  .switch button {
    transition: none;
  }
  .bars span {
    animation: none;
    transform: scaleY(0.7);
  }
}
```

### prompt-bar/glow.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The flash that greets a flagship model: a band of spectrum colours that sweeps across the
 * composer from left to right in about 110 ms, lighting each point as it passes, and leaves a dim
 * tint behind that fades out by 900 ms.
 *
 * Each point across the width has its own hue, red at the left edge to violet at the right, so
 * the whole flash is a single horizontal gradient redrawn every frame: only its stops' alpha
 * changes. The canvas sits behind the composer's content and is cleared when the flash ends.
 */

/** How long the flash lasts, in ms. */
export const GLOW_DURATION = 900;
/** Where the sweeping front is at 0 ms, in widths, and how far it travels per ms. */
const FRONT_START = 0.08;
const FRONT_SPEED = 0.0085;
/**
 * How long a point stays at its brightest once the front has passed (ms), how fast it dims after
 * (ms), and the softness of the front (ms).
 */
const HOLD = 55;
const DECAY = 35;
const LEAD = 25;
/** The tint a lit point keeps, then lets go of between TAIL_HOLD and the end. */
const TAIL = 0.17;
const TAIL_HOLD = 280;
const PEAK = 0.95;
const STOPS = 32;

/** Hue across the width: red, a long run through orange and yellow, then green to violet. */
const HUES: [number, number][] = [
  [0, -10],
  [0.3, 55],
  [0.45, 80],
  [0.6, 130],
  [0.8, 185],
  [0.9, 230],
  [1, 280],
];
function hueAt(u: number) {
  for (let i = 1; i < HUES.length; i++) {
    const [u1, h1] = HUES[i];
    const [u0, h0] = HUES[i - 1];
    if (u <= u1) return h0 + ((h1 - h0) * (u - u0)) / (u1 - u0);
  }
  return HUES[HUES.length - 1][1];
}

/** How freshly lit `u` is at `t`: 1 as the front passes, falling to 0. */
function freshness(u: number, t: number) {
  const age = t - Math.max(0, (u - FRONT_START) / FRONT_SPEED);
  return age < 0 ? Math.exp(-((age / LEAD) ** 2)) : Math.exp(-Math.max(0, age - HOLD) / DECAY);
}

/** The flash's alpha at `u` (0 to 1 across the width), `t` ms after it began. */
function alphaAt(u: number, t: number) {
  const tail =
    TAIL * (t < TAIL_HOLD ? 1 : 1 - Math.min(1, (t - TAIL_HOLD) / (GLOW_DURATION - TAIL_HOLD)));
  const lit = Math.max(0, (u - FRONT_START) / FRONT_SPEED);
  const age = t - lit;
  if (age < 0) return PEAK * Math.exp(-((age / LEAD) ** 2));
  return Math.min(1, tail + (PEAK - tail) * Math.exp(-Math.max(0, age - HOLD) / DECAY));
}

/**
 * Plays the flash on `canvas`, sized to its box at the device's pixel ratio. Returns a function
 * that stops it and clears the canvas.
 */
export function playGlow(canvas: HTMLCanvasElement): () => void {
  const context = canvas.getContext('2d');
  if (!context) return () => {};
  const ratio = Math.min(2, window.devicePixelRatio || 1);
  const width = Math.max(1, Math.round(canvas.clientWidth * ratio));
  const height = Math.max(1, Math.round(canvas.clientHeight * ratio));
  canvas.width = width;
  canvas.height = height;
  let frame = 0;
  const start = performance.now();
  const draw = (now: number) => {
    const t = now - start;
    context.clearRect(0, 0, width, height);
    if (t >= GLOW_DURATION) return;
    const gradient = context.createLinearGradient(0, 0, width, 0);
    for (let i = 0; i <= STOPS; i++) {
      const u = i / STOPS;
      // Freshly lit points are paler, as if overexposed.
      const light = 58 + 12 * freshness(u, t);
      gradient.addColorStop(u, `hsl(${hueAt(u)} 100% ${light}% / ${alphaAt(u, t).toFixed(3)})`);
    }
    context.fillStyle = gradient;
    context.fillRect(0, 0, width, height);
    frame = requestAnimationFrame(draw);
  };
  frame = requestAnimationFrame(draw);
  return () => {
    cancelAnimationFrame(frame);
    context.clearRect(0, 0, width, height);
  };
}
```

### prompt-bar/data.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import type { ReactNode } from 'react';

/** Something a message can draw on: picked from the @ menu or the + menu. */
export type PromptSource = {
  id: string;
  label: string;
  /** The quieter words after the label. */
  detail?: string;
  /** A 22 px icon (decorative). */
  icon?: ReactNode;
  /** Shows a Connect button until it is connected. */
  connect?: boolean;
  /** Picking it calls onAttach instead of mentioning it (a file picker, say). */
  attach?: boolean;
};

/** A / command. Its name is written without the slash. */
export type PromptCommand = { name: string; detail?: string };

export type PromptModel = {
  id: string;
  label: string;
  /** The quieter word after the name. */
  note?: string;
  /** Choosing it sweeps a flash of colour across the composer. */
  flagship?: boolean;
};

export type PromptShape = 'rounded' | 'pill';

export const COMMANDS: PromptCommand[] = [
  { name: 'compare', detail: 'Flavor vs. last summer' },
  { name: 'churn-plan', detail: 'Draft a churn schedule' },
  { name: 'restock', detail: 'Build a reorder list' },
  { name: 'draft-email', detail: 'Write a supplier email' },
  { name: 'summarize', detail: 'Digest the thread so far' },
];

export const MODELS: PromptModel[] = [
  { id: 'sprinkles-5', label: 'Sprinkles 5', note: 'Flagship', flagship: true },
  { id: 'vanilla-1', label: 'Vanilla 1', note: 'Basic' },
  { id: 'freezer-burn-0.4', label: 'Freezer Burn 0.4', note: 'Stale' },
];

/** What the sample dictation hears. */
export const SAMPLE_TRANSCRIPT = 'Compare pistachio weekends to last summer';
```

### prompt-bar/icons.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The prompt bar's icons, drawn on a 24-unit grid and stroked with the text colour, except the
 * three coloured marks of the sample sources. All are decorative: the buttons carry the names.
 */

const stroke = {
  fill: 'none',
  stroke: 'currentColor',
  strokeLinecap: 'round',
  strokeLinejoin: 'round',
} as const;

export function PlusIcon() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" {...stroke} strokeWidth={2} aria-hidden="true">
      <line x1="12" y1="5" x2="12" y2="19" />
      <line x1="5" y1="12" x2="19" y2="12" />
    </svg>
  );
}

export function ArrowUpIcon() {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 24 24"
      {...stroke}
      strokeWidth={2.4}
      aria-hidden="true"
    >
      <line x1="12" y1="19" x2="12" y2="5" />
      <polyline points="5 12 12 5 19 12" />
    </svg>
  );
}

export function ChevronIcon() {
  return (
    <svg
      width="11"
      height="11"
      viewBox="0 0 24 24"
      {...stroke}
      strokeWidth={2.4}
      aria-hidden="true"
    >
      <polyline points="6 9 12 15 18 9" />
    </svg>
  );
}

export function CheckIcon() {
  return (
    <svg
      width="13"
      height="13"
      viewBox="0 0 24 24"
      {...stroke}
      strokeWidth={2.5}
      aria-hidden="true"
    >
      <polyline points="4.5 12.5 9.5 17.5 19.5 6.5" />
    </svg>
  );
}

export function MicIcon() {
  return (
    <svg width="15" height="15" viewBox="0 0 24 24" {...stroke} strokeWidth={2} aria-hidden="true">
      <rect x="9" y="2" width="6" height="13" rx="3" />
      <path d="M5 10v1.5a7 7 0 0 0 14 0V10" />
      <line x1="12" y1="18.5" x2="12" y2="22" />
    </svg>
  );
}

/** A paper clip: one wire bent back on itself. */
export function ClipIcon() {
  return (
    <svg
      width="15"
      height="15"
      viewBox="0 0 24 24"
      {...stroke}
      strokeWidth={1.8}
      aria-hidden="true"
    >
      <path d="M20.5 11.2 12 19.7a5.6 5.6 0 0 1-7.9-7.9l8.4-8.4a3.8 3.8 0 0 1 5.4 5.4l-8.4 8.3a1.9 1.9 0 0 1-2.7-2.7l7.9-7.8" />
    </svg>
  );
}

/** Three bars on a base line. */
export function BarsIcon() {
  return (
    <svg
      width="15"
      height="15"
      viewBox="0 0 24 24"
      {...stroke}
      strokeWidth={1.8}
      aria-hidden="true"
    >
      <line x1="2.5" y1="20" x2="21.5" y2="20" />
      <line x1="5" y1="20" x2="5" y2="11" />
      <line x1="10" y1="20" x2="10" y2="4.5" />
      <line x1="15" y1="20" x2="15" y2="13" />
    </svg>
  );
}

/** Three sheets stacked. */
export function LayersIcon() {
  return (
    <svg
      width="15"
      height="15"
      viewBox="0 0 24 24"
      {...stroke}
      strokeWidth={1.8}
      aria-hidden="true"
    >
      <polygon points="12 2.5 21.5 7.2 12 12 2.5 7.2" />
      <polyline points="2.5 12 12 16.8 21.5 12" />
      <polyline points="2.5 16.8 12 21.5 21.5 16.8" />
    </svg>
  );
}

/** A globe: its rim, one meridian and the equator. */
export function GlobeIcon() {
  return (
    <svg
      width="15"
      height="15"
      viewBox="0 0 24 24"
      {...stroke}
      strokeWidth={1.8}
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="9.5" />
      <ellipse cx="12" cy="12" rx="4" ry="9.5" />
      <line x1="2.5" y1="12" x2="21.5" y2="12" />
    </svg>
  );
}

/** A board of four coloured tiles. */
export function BoardIcon() {
  return (
    <svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true">
      <rect x="2.5" y="2.5" width="8.6" height="8.6" rx="2.6" fill="#f2603c" />
      <rect x="12.9" y="2.5" width="8.6" height="8.6" rx="4.3" fill="#a463f2" />
      <rect x="2.5" y="12.9" width="8.6" height="8.6" rx="4.3" fill="#1aa9f0" />
      <rect x="12.9" y="12.9" width="8.6" height="8.6" rx="2.6" fill="#17c27c" />
    </svg>
  );
}

/** Two speech bubbles, one over the other. */
export function ChatIcon() {
  return (
    <svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true">
      <path
        d="M3 4.8A2.3 2.3 0 0 1 5.3 2.5h9.4A2.3 2.3 0 0 1 17 4.8v5.9a2.3 2.3 0 0 1-2.3 2.3H8.2L4.6 15.6c-.6.4-1.6 0-1.6-.8Z"
        fill="#2fb7e8"
      />
      <path
        d="M21 11.8a2.3 2.3 0 0 0-2.3-2.3h-.9v1.6a3.6 3.6 0 0 1-3.6 3.6H10v1.3a2.3 2.3 0 0 0 2.3 2.3h3.5l3.6 2.6c.6.4 1.6 0 1.6-.8Z"
        fill="#e8b02f"
      />
      <circle cx="7.4" cy="7.8" r="1.1" fill="#fff" />
      <circle cx="10" cy="7.8" r="1.1" fill="#fff" />
      <circle cx="12.6" cy="7.8" r="1.1" fill="#fff" />
    </svg>
  );
}

/** An envelope with a coloured flap. */
export function MailIcon() {
  return (
    <svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true">
      <rect x="2" y="4.5" width="20" height="15" rx="2.8" fill="#3d7cf0" />
      <path
        d="M2.6 6.6 12 13.4l9.4-6.8"
        fill="none"
        stroke="#fff"
        strokeWidth={2}
        strokeLinejoin="round"
      />
      <path
        d="M2 16.5 8.6 11M22 16.5 15.4 11"
        fill="none"
        stroke="#9cc0ff"
        strokeWidth={1.4}
        strokeLinecap="round"
      />
    </svg>
  );
}
```

### prompt-bar/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```