# Selection actions

> Highlight a passage and a small bar floats up under it: describe an edit, or ask to explain, improve, shorten, change the tone or fix the grammar.

Select words in the paragraph and a pill-shaped bar appears under them. Type what to change, or pick an action; the chevron opens three more. The rewrite streams into the passage behind a caret, its newest letters still blurred, and the bar morphs to offer Keep, Discard or another try. Explain answers in a note under the bar and leaves the text as it was.

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

Category: AI.

## Anatomy

1. **Paragraph**: Ordinary selectable text. A drag or a double click ends in a selection; from the keyboard, Enter picks the next sentence and Shift with the arrows moves its end by a word.
2. **Mark**: The selection redrawn as a tinted mark, trimmed of spaces, so it stays when focus moves into the bar. Rewrites stream into it.
3. **Bar**: Centred under the mark, 8 px below it and kept inside the stage. It glides when the mark moves (320 ms) and pops in from 95 % (220 ms).
4. **Field**: Describes any other edit in words. Typing locks the bar’s width and lets the field take it over while the actions fold away.
5. **Action**: One request in one press: Explain and Improve stand out; their words drop to icons on a narrow stage.
6. **More**: The chevron opens Shorten, Tone and Grammar and folds the field away; it turns half a turn as it does.

## When to use

- Editing prose in place with an agent: select a passage, ask for a change, keep or drop the answer.
- A few fixed edits (improve, shorten, tone, grammar) plus one line to describe any other.
- Rewrites the reader should see in context before they land, with Keep, Discard and another try.
- Asking what a passage means without leaving the text: Explain answers in a note under the bar.

## When not to use

- Formatting a selection (bold, links, lists). Use a format toolbar; this bar asks a model, it doesn’t style.
- Whole-document requests, or a conversation about the text. Use a chat panel beside it.
- Read-only text where nothing can change: offer copy or quote actions instead.
- Touch-first pages where the system selection menu already covers the job: the bar sits under the selection, where handles and the native menu compete.
- Long answers. The bar and its note are sized for a sentence or two, not a page.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/selection-actions.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 { SelectionActions, type RewriteRequest } from './selection-actions';

async function rewrite({ action, passage, instruction }: RewriteRequest) {
  const response = await fetch('/api/rewrite', {
    method: 'POST',
    body: JSON.stringify({ action, passage, instruction }),
  });
  return response.text();
}

export function Draft({ text, onSave }: { text: string; onSave: (text: string) => void }) {
  return (
    <SelectionActions
      text={text}
      defaultSelection={null}
      rewrite={rewrite}
      onChange={onSave}
      autoplay={false}
    />
  );
}
```

## States

- **Nothing selected**: Just the paragraph. The bar fades out in 180 ms when the selection goes.
- **Passage selected**: The mark and the bar: the field, Explain, Improve and the chevron.
- **More actions**: The field folds away while Shorten, Tone and Grammar open out (400 ms).
- **Typed instruction**: The field holds the bar’s width; the actions fold and the send button grows in. Enter sends too.
- **Working**: A spinner and a shimmering label for at least 700 ms. Both keep moving here; they stop under reduced motion.
- **Streaming**: Held half-way: the newest six characters blurred and fading, a caret ahead of them.
- **Review**: The rewrite sits in the mark until Keep, Discard or another try. `onChange` fires on Keep.
- **Explained**: The answer hangs in a note under the bar and the text stays; Got it closes it.

## How it works

- The paragraph is ordinary selectable text. When a drag or a double click ends inside it, the selection is measured as character offsets (a Range from the paragraph’s start to the selection’s), trimmed of spaces, and drawn as a <mark> in its place, so it holds while the focus moves into the bar. While the reader drags, the browser’s own highlight wears the mark’s colour; a click in the text clears the mark.
- The bar stands centred under the mark’s bounding box, 8 px below it and never closer than 8 px to the stage’s edges. Whenever the mark moves (a new selection, or a rewrite growing and shrinking it) the bar glides there on 320 ms of a steep ease-in-out; it pops in from 95 % in 220 ms when a selection appears and fades out in 180 ms when it goes.
- The idle bar is three folding parts, each a max-width, opacity and transform transition of 400 ms on a soft ease-out. The chevron folds the field away (it slides 8 px left as it fades), opens Shorten, Tone and Grammar and turns half round. Typing locks the bar’s width, so the field takes it over while the actions fold and a send button grows in from 88 %.
- Between its states (actions, working, review) the bar swaps its content at once and eases its width from the old value to the new with the Web Animations API (320 ms), the content centred and clipped, so it seems to morph rather than jump.
- A request shows a spinner (a 12 px ring turning every 700 ms) and a shimmering “Improving…”, a text-clipped gradient sweeping every 1.8 s, for at least 700 ms. Then the answer streams into the mark two characters at a time, about 220 a second: the last six are blurred by 1.6 px and fade out to the right under a mask, led by a 2 px caret.
- Keep writes the rewrite into the text, keeps it selected and calls onChange; Discard puts the passage back; the round arrow asks again with the next attempt number. rewrite and explain may return a string or a promise, so a real model plugs straight in; without them a stand-in answers, with written rewrites for the sample and small rule-based ones for anything else.
- While on screen and untouched, a demo runs through it by itself: Improve then Discard, Shorten from the chevron and another try, Explain, a typed “Make it punchier” kept, then a fresh selection dragged word by word across the sentence. Its edits never reach onChange and are not announced; any key or press hands the component to the reader.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme or the colorScheme prop. The bar’s shadow is one list with each layer coloured for one scheme and clear in the other: a ring and long soft layers by day, a lighter ring and one dark drop at night.

## API reference

- **text** (`string`, default `a two-sentence sample`): The paragraph the reader selects in.
- **defaultSelection** (`[number, number] | null`, default `the last sentence`): What starts selected, as [start, end) character offsets into text; null starts with nothing selected and the bar hidden.
- **defaultState** (`SelectionActionsState`): Opens held still, with no demo: phase ('actions', 'working', 'streaming' or 'review'), action (the request, 'improve' by default), more (the extra actions open), draft (words in the field), progress (0 to 1 streamed) and result (the answer).
- **rewrite** (`(request: RewriteRequest) => string | Promise<string>`, default `a stand-in`): Rewrites the selected passage: { action, passage, text, instruction?, attempt }. action is improve, shorten, tone, grammar, or custom for a typed instruction; attempt counts the retries.
- **explain** (`(request: ExplainRequest) => string | Promise<string>`, default `a stand-in`): Explains the selected passage: { passage, text, attempt }. The answer streams into a note under the bar.
- **onChange** (`(text: string) => void`): Called with the whole new text when the reader keeps a rewrite.
- **placeholder** (`string`, default `'Describe edits'`): The field’s placeholder, and its accessible name.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo runs through the actions, a typed edit and a fresh selection 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 paragraph can take the focus: Enter or Space selects the next sentence, Shift with the left and right arrows moves the selection’s end by a word, and Escape clears it. A hidden hint, tied to the paragraph by aria-describedby, says so. The selection is a <mark>, marked aria-busy while a rewrite streams into it.
- The bar is a toolbar named “Edit the selection”: Tab reaches the field and every button, and the left and right arrows walk the buttons. The chevron is a button with aria-expanded whose name says what it does (“Show more actions”, “Show fewer actions”); the send and retry buttons are named, every icon is aria-hidden.
- Parts of the bar that are folded away are inert, so neither Tab nor a screen reader lands on them. When the bar changes state with the focus in it, the focus moves to the new state’s first control (Keep after a rewrite), and while it works the bar itself holds the focus; when the bar closes, the focus goes back to the paragraph.
- Escape in the bar clears the field, discards a rewrite, or closes the bar. A polite live region says what happens: the passage selected, “Improving…”, the rewrite with “Keep it or discard it”, the explanation, kept or discarded.
- Focus rings are visible on the paragraph and on every button. Under reduced motion nothing glides, folds, pops or shimmers, the spinner holds still, the answer appears whole after a short wait, and the demo never plays.

## Source

### selection-actions/selection-actions.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type FormEvent,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
} from 'react';
import {
  CheckIcon,
  ChevronIcon,
  CrossIcon,
  ExplainIcon,
  GrammarIcon,
  ImproveIcon,
  RetryIcon,
  SendIcon,
  ShortenIcon,
  ToneIcon,
} from './icons';
import {
  SAMPLE_TEXT,
  defaultExplain,
  defaultRewrite,
  type ExplainRequest,
  type RewriteAction,
  type RewriteRequest,
} from './rewrite';
import { useAutoplay } from './use-autoplay';
import styles from './selection-actions.module.css';

export type { ExplainRequest, RewriteAction, RewriteRequest } from './rewrite';

/*
 * Selection actions: highlight a passage of a paragraph and a small bar floats up under it, to
 * describe an edit in words or hand the passage to the agent (Explain, Improve, and behind the
 * chevron Shorten, Tone, Grammar). The rewrite streams into the passage, then waits for Keep or
 * Discard.
 *
 * - Real selection: the paragraph is ordinary selectable text. When a drag (or a double click)
 *   ends inside it, the selection is read as character offsets, trimmed of spaces and drawn as a
 *   <mark> instead, so it survives focus moving into the bar. A click in the text clears it.
 *   From the keyboard, Enter picks the next sentence and Shift + arrows move its end by a word.
 * - The bar stands centred under the mark's bounding box, 8 px below it, kept inside the stage.
 *   It glides there whenever the mark moves (320 ms, a steep ease-in-out), pops in when a
 *   selection appears (220 ms, from 95 %) and fades out when it goes (180 ms).
 * - The bar sizes to what it holds. Opening the chevron folds the field away (it slides 8 px
 *   left as it fades) while the three extra actions open out; typing locks the bar's width and
 *   lets the field take it over while the actions fold and the send button grows in. These are
 *   max-width transitions (400 ms, a soft ease-out). Between its states (actions, working,
 *   review) the content swaps at once and the bar's width eases from the old to the new
 *   (320 ms, Web Animations), so it seems to morph.
 * - A request first shows a spinner and a shimmering “Improving…” for at least 700 ms. Then the
 *   answer streams into the mark two characters at a time, about 220 a second: the last six are
 *   blurred and fade out to the right, and a caret leads them. The bar follows the mark as it
 *   shrinks and grows. When the text is in, the bar offers Keep, Discard and another try.
 *   Explain streams its answer into a note under the bar instead, and the text stays.
 * - While on screen and untouched, a demo runs through it all by itself: Improve and Discard,
 *   Shorten from the chevron and another try, Explain, a typed instruction kept, then a fresh
 *   selection dragged across the sentence. Any real input hands it 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.
 */

type Range = { start: number; end: number };
type Kind = RewriteAction | 'explain';
type Mode = 'idle' | 'working' | 'streaming' | 'review';
type Job = {
  kind: Kind;
  instruction?: string;
  attempt: number;
  passage: string;
  result: string;
  shown: number;
  byDemo: boolean;
};

type Action = { id: Exclude<Kind, 'custom'>; label: string; busy: string; icon: ReactNode };

const ACTIONS: Action[] = [
  { id: 'explain', label: 'Explain', busy: 'Explaining…', icon: <ExplainIcon /> },
  { id: 'improve', label: 'Improve', busy: 'Improving…', icon: <ImproveIcon /> },
  { id: 'shorten', label: 'Shorten', busy: 'Shortening…', icon: <ShortenIcon /> },
  { id: 'tone', label: 'Tone', busy: 'Adjusting tone…', icon: <ToneIcon /> },
  { id: 'grammar', label: 'Grammar', busy: 'Fixing grammar…', icon: <GrammarIcon /> },
];
const busyLabel = (kind: Kind) => ACTIONS.find((action) => action.id === kind)?.busy ?? 'Editing…';

/** The least time the working state shows, so a fast answer still reads as work. */
const THINK = 700;
/** Streaming: characters per millisecond, and how many trailing ones are still settling. */
const RATE = 2 / 9;
const TAIL = 6;
/** The bar's gap under the mark, and its margin from the stage's edges. */
const GAP = 8;
const EDGE = 8;
/** The width morph between the bar's states. */
const MORPH: KeyframeAnimationOptions = { duration: 320, easing: 'cubic-bezier(0.23, 1, 0.32, 1)' };
/** The fade of a bar that closes. */
const FADE_OUT = 180;

const sentencesOf = (text: string): Range[] => {
  const out: Range[] = [];
  for (const match of text.matchAll(/[^.!?]+[.!?]*/g)) {
    const lead = match[0].length - match[0].trimStart().length;
    const start = match.index + lead;
    const end = match.index + match[0].trimEnd().length;
    if (end > start) out.push({ start, end });
  }
  return out;
};

/** The selection's offsets in `root`'s text, trimmed of spaces; null if empty or outside. */
function readSelection(root: HTMLElement, text: string): Range | null {
  const selection = window.getSelection();
  if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
  const range = selection.getRangeAt(0);
  if (!root.contains(range.commonAncestorContainer)) return null;
  const before = document.createRange();
  before.setStart(root, 0);
  before.setEnd(range.startContainer, range.startOffset);
  let start = before.toString().length;
  let end = start + range.toString().length;
  while (start < end && /\s/.test(text[start])) start++;
  while (end > start && /\s/.test(text[end - 1])) end--;
  return end > start ? { start, end } : null;
}

const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));

/** A state to open in, held still: setting it turns the demo off. */
export type SelectionActionsState = {
  /** The bar's state: its actions (the default), working on a request, streaming the answer in, or the answer to review. */
  phase?: 'actions' | 'working' | 'streaming' | 'review';
  /** The request behind working, streaming and review; 'improve' by default. */
  action?: RewriteAction | 'explain';
  /** The chevron's extra actions open (actions phase). */
  more?: boolean;
  /** Words already typed in the field (actions phase), or the instruction behind a 'custom' request. */
  draft?: string;
  /** How much of the answer has streamed in, 0 to 1 (streaming phase); 0.5 by default. */
  progress?: number;
  /** The answer on show; the built-in stand-in's by default. */
  result?: string;
};

export function SelectionActions({
  text: initialText = SAMPLE_TEXT,
  defaultSelection,
  rewrite = defaultRewrite,
  explain = defaultExplain,
  onChange,
  placeholder = 'Describe edits',
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** The paragraph. */
  text?: string;
  /** What starts selected, as [start, end) character offsets; null for nothing. Defaults to the last sentence. */
  defaultSelection?: [number, number] | null;
  /** Rewrites a passage; may return a promise. Defaults to a stand-in with written answers. */
  rewrite?: (request: RewriteRequest) => string | Promise<string>;
  /** Explains a passage; may return a promise. */
  explain?: (request: ExplainRequest) => string | Promise<string>;
  /** Called with the whole text when the reader keeps a rewrite. */
  onChange?: (text: string) => void;
  /** The field's placeholder. */
  placeholder?: string;
  /** While on screen and untouched, a demo runs through the actions 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 (no demo): for docs, tests and screenshots. */
  defaultState?: SelectionActionsState;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const firstSelection = (): Range | null => {
    if (defaultSelection === null) return null;
    if (defaultSelection) return { start: defaultSelection[0], end: defaultSelection[1] };
    const all = sentencesOf(initialText);
    return all.length ? all[all.length - 1] : null;
  };

  /** A state to open in turns the demo off. */
  const plays = autoplay && defaultState === undefined;
  /** The request a held working, streaming or review state shows. */
  const firstJob = (): Job | null => {
    const phase = defaultState?.phase ?? 'actions';
    const range = firstSelection();
    if (phase === 'actions' || !range) return null;
    const kind = defaultState?.action ?? 'improve';
    const passage = initialText.slice(range.start, range.end);
    const instruction = kind === 'custom' ? defaultState?.draft || 'Make it punchier' : undefined;
    const result =
      defaultState?.result ??
      (kind === 'explain'
        ? defaultExplain({ passage, text: initialText, attempt: 0 })
        : defaultRewrite({ action: kind, passage, text: initialText, instruction, attempt: 0 }));
    const progress = Math.min(1, Math.max(0, defaultState?.progress ?? 0.5));
    const shown =
      phase === 'working'
        ? 0
        : phase === 'streaming'
          ? Math.round(result.length * progress)
          : result.length;
    return { kind, instruction, attempt: 0, passage, result, shown, byDemo: false };
  };
  const [startJob] = useState(firstJob);

  const [text, setText] = useState(initialText);
  const [selection, setSelection] = useState<Range | null>(firstSelection);
  /** A demo drag in progress: the mark grows, the bar waits for the release. */
  const [dragging, setDragging] = useState(false);
  const [mode, setMode] = useState<Mode>(() => (startJob ? (defaultState?.phase as Mode) : 'idle'));
  const [job, setJob] = useState<Job | null>(() => startJob ?? null);
  const [draft, setDraft] = useState('');
  const [more, setMore] = useState(() => !startJob && !!defaultState?.more);
  /** The bar's width while the field holds words, so the field can take it over. */
  const [lock, setLock] = useState<number | null>(null);
  /** The bar is on screen: open, or still fading out. */
  const [present, setPresent] = useState(selection !== null);
  const [fading, setFading] = useState(false);
  const [message, setMessage] = useState('');
  /** The demo's pointer: the control it hovers and the one it presses. */
  const [hover, setHover] = useState<string | null>(null);
  const [press, setPress] = useState<string | null>(null);
  const [reduced, setReduced] = useState(false);

  const doc = useRef<HTMLDivElement>(null);
  const para = useRef<HTMLParagraphElement>(null);
  const mark = useRef<HTMLElement>(null);
  const anchor = useRef<HTMLDivElement>(null);
  const bar = useRef<HTMLDivElement>(null);
  const group = useRef<HTMLDivElement>(null);
  const field = useRef<HTMLInputElement>(null);
  const jobId = useRef(0);
  const morphFrom = useRef<number | null>(null);
  const note = useRef<HTMLDivElement>(null);
  const refocus = useRef(false);
  const placed = useRef(false);
  const hintId = useId();

  const open = selection !== null && !dragging;
  const busy = mode !== 'idle';

  useEffect(() => {
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const read = () => setReduced(motion.matches);
    read();
    motion.addEventListener('change', read);
    return () => motion.removeEventListener('change', read);
  }, []);

  // A held state with words in the field: lock the bar at its width with the actions, as typing does.
  const startDraft = useRef(startJob ? '' : (defaultState?.draft ?? ''));
  useLayoutEffect(() => {
    const words = startDraft.current;
    startDraft.current = '';
    if (!words || !bar.current) return;
    setLock(bar.current.getBoundingClientRect().width);
    setDraft(words);
  }, []);

  // The bar mounts with the selection and stays for its fade-out once the selection goes.
  useEffect(() => {
    if (open) {
      setPresent(true);
      setFading(false);
      return;
    }
    setFading(true);
    const timer = window.setTimeout(() => {
      setPresent(false);
      setFading(false);
      placed.current = false;
    }, FADE_OUT);
    return () => window.clearTimeout(timer);
  }, [open]);

  /** Before the bar changes state: remember its width for the morph, and whether it had focus. */
  const beforeChange = () => {
    morphFrom.current = bar.current?.getBoundingClientRect().width ?? null;
    refocus.current ||= !!bar.current?.contains(document.activeElement);
  };

  const clearJob = () => {
    jobId.current++;
    setJob(null);
    setMode('idle');
    setDraft('');
    setMore(false);
    setLock(null);
  };

  /** Asks for a rewrite (or an explanation) of the selected passage, then streams it in. */
  const run = async (
    kind: Kind,
    instruction: string | undefined,
    attempt: number,
    byDemo: boolean,
  ) => {
    if (!selection) return;
    const id = ++jobId.current;
    const passage = text.slice(selection.start, selection.end);
    beforeChange();
    setJob({ kind, instruction, attempt, passage, result: '', shown: 0, byDemo });
    setMode('working');
    setMore(false);
    setLock(null);
    if (!byDemo) setMessage(busyLabel(kind));
    const started = performance.now();
    let result: string;
    try {
      result =
        kind === 'explain'
          ? await explain({ passage, text, attempt })
          : await rewrite({ action: kind, passage, text, instruction, attempt });
    } catch {
      if (id !== jobId.current) return;
      beforeChange();
      clearJob();
      setMessage('Something went wrong; the text is unchanged.');
      return;
    }
    const left = THINK - (performance.now() - started);
    if (left > 0) await wait(reduced ? Math.min(left, 300) : left);
    if (id !== jobId.current) return;
    const finish = () => {
      beforeChange();
      setJob((current) => current && { ...current, result, shown: result.length });
      setMode('review');
      if (!byDemo)
        setMessage(
          kind === 'explain'
            ? `${result} Press Got it to close.`
            : `Rewritten: ${result} Keep it or discard it.`,
        );
    };
    if (reduced || !result) return finish();
    setJob((current) => current && { ...current, result, shown: 0 });
    setMode('streaming');
    const t0 = performance.now();
    const step = (now: number) => {
      if (id !== jobId.current) return;
      const shown = Math.min(result.length, Math.floor(((now - t0) * RATE) / 2) * 2);
      if (shown >= result.length) return finish();
      setJob((current) => (current && current.shown !== shown ? { ...current, shown } : current));
      requestAnimationFrame(step);
    };
    requestAnimationFrame(step);
  };

  const keep = (byDemo: boolean) => {
    if (!job || !selection || mode !== 'review') return;
    beforeChange();
    if (job.kind === 'explain') {
      clearJob();
      return;
    }
    const next = text.slice(0, selection.start) + job.result + text.slice(selection.end);
    setText(next);
    setSelection({ start: selection.start, end: selection.start + job.result.length });
    clearJob();
    if (!byDemo) {
      setMessage('Kept the rewrite.');
      onChange?.(next);
    }
  };

  const discard = (byDemo: boolean) => {
    if (mode !== 'review') return;
    beforeChange();
    clearJob();
    if (!byDemo) setMessage('Discarded; the text is unchanged.');
  };

  const retry = (byDemo: boolean) => {
    if (!job || mode !== 'review') return;
    void run(job.kind, job.instruction, job.attempt + 1, byDemo);
  };

  const toggleMore = () => {
    refocus.current = false;
    setMore((value) => !value);
  };

  const close = () => {
    if (busy && mode !== 'review') return;
    if (bar.current?.contains(document.activeElement)) para.current?.focus();
    clearJob();
    setSelection(null);
  };

  const select = (range: Range | null, announce = true) => {
    setSelection(range);
    setDraft('');
    setMore(false);
    setLock(null);
    if (announce && range) setMessage(`Selected: ${text.slice(range.start, range.end)}`);
  };

  // ---- Reading the reader's selection ----

  const onParaPointerDown = (event: React.PointerEvent) => {
    take();
    if (busy || event.button !== 0) return;
    // The mark gives way to the browser's own highlight while the reader drags.
    if (selection) select(null, false);
  };

  const onParaPointerUp = () => {
    if (busy || !para.current) return;
    // Let the browser settle the selection (a double click selects a word on release).
    requestAnimationFrame(() => {
      if (!para.current) return;
      const range = readSelection(para.current, text);
      if (range) {
        window.getSelection()?.removeAllRanges();
        select(range);
      }
    });
  };

  const onParaKeyDown = (event: ReactKeyboardEvent) => {
    take();
    if (busy) return;
    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      const all = sentencesOf(text);
      if (!all.length) return;
      const at = selection ? all.findIndex((s) => s.start >= selection.end) : 0;
      select(all[at < 0 ? 0 : at]);
    } else if (event.shiftKey && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) {
      event.preventDefault();
      const start = selection?.start ?? 0;
      let end = selection?.end ?? 0;
      if (event.key === 'ArrowRight') {
        const next = text.slice(end).match(/^\s*\S+/);
        end = next ? end + next[0].length : end;
      } else {
        const back = text.slice(start, end).match(/\s*\S+$/);
        end = back ? end - back[0].length : start;
      }
      select(end > start ? { start, end } : null);
    } else if (event.key === 'Escape' && selection) {
      event.preventDefault();
      close();
    }
  };

  // ---- The bar ----

  const submit = (event?: FormEvent) => {
    event?.preventDefault();
    const words = draft.trim();
    if (words) void run('custom', words, 0, false);
  };

  const onDraft = (value: string) => {
    if (value && lock === null && bar.current) setLock(bar.current.getBoundingClientRect().width);
    if (!value) setLock(null);
    setDraft(value);
  };

  const onBarKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    take();
    if (event.key === 'Escape') {
      event.preventDefault();
      if (draft) onDraft('');
      else if (mode === 'review') discard(false);
      else close();
      return;
    }
    // A toolbar: left and right arrows walk its buttons (the field keeps its own arrows).
    if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
    if (event.target instanceof HTMLInputElement) return;
    const buttons = [...(bar.current?.querySelectorAll<HTMLButtonElement>('button') ?? [])].filter(
      (button) => !button.closest('[inert]') && !button.disabled,
    );
    const at = buttons.indexOf(event.target as HTMLButtonElement);
    if (at < 0) return;
    event.preventDefault();
    const step = event.key === 'ArrowRight' ? 1 : -1;
    buttons[(at + step + buttons.length) % buttons.length]?.focus();
  };

  // The width morph between states, and focus kept inside the bar when its buttons change.
  useLayoutEffect(() => {
    const element = bar.current;
    const from = morphFrom.current;
    morphFrom.current = null;
    if (!element) return;
    if (from !== null && !reduced) {
      const to = element.getBoundingClientRect().width;
      if (Math.abs(to - from) > 0.5)
        element.animate([{ width: `${from}px` }, { width: `${to}px` }], MORPH);
    }
    if (refocus.current) {
      // While the bar has no buttons (working), it holds the focus itself until they come back.
      const target =
        element.querySelector<HTMLElement>('[data-first]') ??
        element.querySelector<HTMLElement>('button:not([disabled]), input');
      (target ?? element).focus();
      refocus.current = !target;
    }
  }, [mode, job?.attempt, job?.kind, reduced]);

  // Where the bar stands: centred under the mark, 8 px below it, kept inside the stage.
  const place = useCallback(() => {
    const stage = root.current;
    const box = doc.current;
    const target = mark.current;
    const float = anchor.current;
    if (!stage || !box || !target || !float) return;
    const outer = stage.getBoundingClientRect();
    const frame = box.getBoundingClientRect();
    const rect = target.getBoundingClientRect();
    const half = (lock ?? (group.current?.offsetWidth ?? 0) + 8) / 2;
    let x = rect.left + rect.width / 2 - frame.left;
    const min = outer.left + EDGE - frame.left + half;
    const max = outer.right - EDGE - frame.left - half;
    x = min > max ? (min + max) / 2 : Math.min(Math.max(x, min), max);
    const y = rect.bottom - frame.top + GAP;
    // An explanation hangs under the bar: the paragraph's frame makes room for it.
    box.style.paddingBottom = note.current ? `${56 + note.current.offsetHeight}px` : '';
    const transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0) translateX(-50%)`;
    if (float.style.transform === transform) return;
    if (!placed.current) {
      // The first placement lands at once; later ones glide.
      float.style.transition = 'none';
      float.style.transform = transform;
      void float.offsetWidth;
      float.style.transition = '';
      placed.current = true;
    } else float.style.transform = transform;
  }, [root, lock]);

  useLayoutEffect(place);

  useEffect(() => {
    const observer = new ResizeObserver(() => place());
    if (doc.current) observer.observe(doc.current);
    if (group.current) observer.observe(group.current);
    if (note.current) observer.observe(note.current);
    void document.fonts?.ready.then(() => place());
    return () => observer.disconnect();
  }, [place, present, mode, job?.kind]);

  // ---- The demo ----

  const latest = useRef({ run, keep, discard, retry, mode });
  latest.current = { run, keep, discard, retry, mode };
  const draftRef = useRef(draft);
  draftRef.current = draft;
  const lockRef = useRef(lock);
  lockRef.current = lock;

  useEffect(() => {
    if (!plays || !playing) return;
    let alive = true;
    const stop = new Error('stopped');
    const pause = (ms: number) =>
      wait(ms).then(() => {
        if (!alive) throw stop;
      });
    const until = async (done: () => boolean) => {
      while (!done()) await pause(60);
    };
    const tap = async (control: string, act: () => void, aim = 480) => {
      setHover(control);
      await pause(aim);
      setPress(control);
      await pause(130);
      setPress(null);
      act();
      await pause(60);
      setHover(null);
    };
    const reviewed = () => latest.current.mode === 'review';
    const demo = async () => {
      const sample = sentencesOf(initialText).at(-1);
      // Start from the paragraph as written, its last sentence selected.
      jobId.current++;
      setJob(null);
      setMode('idle');
      setMore(false);
      setDraft('');
      setLock(null);
      setText(initialText);
      setSelection(sample ?? null);
      for (;;) {
        await pause(1500);
        await tap('improve', () => void latest.current.run('improve', undefined, 0, true));
        await until(reviewed);
        await pause(1500);
        await tap('discard', () => latest.current.discard(true), 420);
        await pause(1000);
        await tap('more', () => setMore(true), 420);
        await pause(900);
        await tap('shorten', () => void latest.current.run('shorten', undefined, 0, true), 520);
        await until(reviewed);
        await pause(1300);
        await tap('retry', () => latest.current.retry(true), 520);
        await until(reviewed);
        await pause(1300);
        await tap('discard', () => latest.current.discard(true), 420);
        await pause(1000);
        await tap('explain', () => void latest.current.run('explain', undefined, 0, true));
        await until(reviewed);
        await pause(2600);
        await tap('keep', () => latest.current.keep(true), 420);
        await pause(900);
        // A typed instruction, kept.
        await pause(400);
        for (const letter of 'Make it punchier') {
          const typed = `${draftRef.current}${letter}`;
          if (!lockRef.current && bar.current) setLock(bar.current.getBoundingClientRect().width);
          setDraft(typed);
          await pause(48 + Math.random() * 40);
        }
        await pause(450);
        await tap(
          'send',
          () => void latest.current.run('custom', 'Make it punchier', 0, true),
          300,
        );
        await until(reviewed);
        await pause(1500);
        await tap('keep', () => latest.current.keep(true), 420);
        await pause(1600);
        // Let go of the selection, put the paragraph back, and drag a new one.
        setSelection(null);
        await pause(700);
        setText(initialText);
        await pause(500);
        if (!sample) continue;
        const words = [...initialText.slice(sample.start, sample.end).matchAll(/\S+/g)];
        setDragging(true);
        for (const word of words) {
          setSelection({ start: sample.start, end: sample.start + word.index + word[0].length });
          await pause(55);
        }
        await pause(200);
        setDragging(false);
      }
    };
    demo().catch(() => {});
    return () => {
      alive = false;
      setHover(null);
      setPress(null);
      setDragging(false);
    };
  }, [plays, playing, initialText]);

  // ---- Drawing ----

  const before = selection ? text.slice(0, selection.start) : text;
  const after = selection ? text.slice(selection.end) : '';
  let marked: ReactNode = selection ? text.slice(selection.start, selection.end) : null;
  if (job && mode === 'streaming' && job.kind !== 'explain') {
    const cut = Math.max(0, job.shown - TAIL);
    marked = (
      <>
        {job.result.slice(0, cut)}
        <span className={styles.tail}>{job.result.slice(cut, job.shown)}</span>
        <span className={styles.caret} aria-hidden="true" />
      </>
    );
  } else if (job && mode === 'review' && job.kind !== 'explain') marked = job.result;

  const explaining = job?.kind === 'explain' && (mode === 'streaming' || mode === 'review');
  const explanation = explaining && job && (
    <div ref={note} className={styles.note} data-part="note">
      {mode === 'streaming' ? (
        <>
          {job.result.slice(0, Math.max(0, job.shown - TAIL))}
          <span className={styles.tail}>
            {job.result.slice(Math.max(0, job.shown - TAIL), job.shown)}
          </span>
          <span className={styles.caret} aria-hidden="true" />
        </>
      ) : (
        job.result
      )}
    </div>
  );

  const typed = draft !== '' && mode === 'idle';
  const fieldWidth = lock !== null ? lock - 40 : 145;
  const flag = (control: string) => ({
    'data-hover': hover === control ? '' : undefined,
    'data-press': press === control ? '' : undefined,
  });

  const actionButton = (action: Action, first = false) => (
    <button
      key={action.id}
      type="button"
      className={styles.action}
      data-part="action"
      data-action={action.id}
      onClick={() => void run(action.id, undefined, 0, false)}
      data-first={first ? '' : undefined}
      {...flag(action.id)}
    >
      {action.icon}
      <span className={styles.label}>{action.label}</span>
    </button>
  );

  let content: ReactNode;
  if (mode === 'idle') {
    content = (
      <>
        <div
          className={styles.fieldWrap}
          style={{
            maxWidth: more ? 0 : fieldWidth,
            opacity: more ? 0 : 1,
            transform: more ? 'translateX(-8px)' : 'translateX(0)',
          }}
          inert={more}
        >
          <form className={styles.form} style={{ width: fieldWidth }} onSubmit={submit}>
            <input
              ref={field}
              className={styles.input}
              data-part="field"
              aria-label={placeholder}
              placeholder={placeholder}
              value={draft}
              onChange={(event) => onDraft(event.target.value)}
              enterKeyHint="send"
              autoComplete="off"
            />
          </form>
        </div>
        <div
          className={styles.actions}
          style={{
            maxWidth: typed ? 0 : more ? 462 : 224,
            opacity: typed ? 0 : 1,
            transform: typed ? 'translateX(-8px)' : 'translateX(0)',
          }}
          inert={typed}
        >
          {/* With the field folded away, the divider before the actions folds too. */}
          <span
            className={styles.rule}
            style={{ marginLeft: more ? -7 : 4, opacity: more ? 0 : 1 }}
            aria-hidden="true"
          />
          {ACTIONS.slice(0, 2).map((action, index) => actionButton(action, index === 0 && more))}
          <div
            className={styles.extra}
            data-part="extra"
            style={{ maxWidth: more ? 262 : 0, opacity: more ? 1 : 0, marginLeft: more ? 2 : 0 }}
            inert={!more}
          >
            {ACTIONS.slice(2).map((action) => actionButton(action))}
          </div>
          <span className={styles.ruleSoft} aria-hidden="true" />
          <button
            type="button"
            className={styles.more}
            data-part="more"
            aria-label={more ? 'Show fewer actions' : 'Show more actions'}
            aria-expanded={more}
            onClick={toggleMore}
            {...flag('more')}
          >
            <span className={styles.chevron} style={{ transform: `rotate(${more ? 180 : 0}deg)` }}>
              <ChevronIcon />
            </span>
          </button>
        </div>
        <div
          className={styles.sendWrap}
          style={{
            maxWidth: typed ? 30 : 0,
            opacity: typed ? 1 : 0,
            transform: `scale(${typed ? 1 : 0.88})`,
          }}
          inert={!typed}
        >
          <button
            type="button"
            className={styles.send}
            data-part="send"
            aria-label="Send edit instruction"
            onClick={() => submit()}
            {...flag('send')}
          >
            <SendIcon />
          </button>
        </div>
      </>
    );
  } else if (mode === 'working' || mode === 'streaming') {
    content = (
      <span className={styles.busy} data-part="busy">
        <span className={styles.spinner} aria-hidden="true" />
        <span className={mode === 'working' ? styles.shimmer : undefined}>
          {busyLabel(job?.kind ?? 'custom')}
        </span>
      </span>
    );
  } else {
    const explained = job?.kind === 'explain';
    content = (
      <>
        <button
          type="button"
          className={styles.keep}
          data-part="keep"
          onClick={() => keep(false)}
          data-first=""
          {...flag('keep')}
        >
          <CheckIcon />
          {explained ? 'Got it' : 'Keep'}
        </button>
        {!explained && (
          <button
            type="button"
            className={styles.action}
            data-part="discard"
            onClick={() => discard(false)}
            {...flag('discard')}
          >
            <CrossIcon />
            Discard
          </button>
        )}
        <span className={styles.ruleSoft} aria-hidden="true" />
        <button
          type="button"
          className={styles.retry}
          data-part="retry"
          aria-label={explained ? 'Explain again' : 'Try again'}
          onClick={() => retry(false)}
          {...flag('retry')}
        >
          <RetryIcon />
        </button>
      </>
    );
  }

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.column}>
        <div ref={doc} className={styles.doc} data-explaining={explaining ? '' : undefined}>
          <p
            ref={para}
            className={styles.text}
            data-part="text"
            data-busy={busy ? '' : undefined}
            tabIndex={0}
            aria-describedby={hintId}
            onPointerDown={onParaPointerDown}
            onPointerUp={onParaPointerUp}
            onKeyDown={onParaKeyDown}
          >
            {before}
            {selection && (
              <mark
                ref={mark}
                className={styles.mark}
                data-part="mark"
                aria-busy={mode === 'working' || mode === 'streaming' ? true : undefined}
              >
                {marked}
              </mark>
            )}
            {after}
          </p>
          {present && (
            <div
              ref={anchor}
              className={styles.anchor}
              data-closing={fading ? '' : undefined}
              inert={fading}
            >
              <div
                ref={bar}
                className={styles.bar}
                data-part="bar"
                role="toolbar"
                aria-label="Edit the selection"
                tabIndex={-1}
                style={lock !== null && mode === 'idle' ? { width: lock } : undefined}
                onKeyDown={onBarKeyDown}
              >
                <div
                  ref={group}
                  className={styles.group}
                  style={lock !== null && mode === 'idle' ? { width: lock - 8 } : undefined}
                >
                  {content}
                </div>
              </div>
              {explanation}
            </div>
          )}
        </div>
      </div>
      <p id={hintId} className={styles.hidden}>
        Select words with the pointer, or press Enter to select a sentence and Shift with the arrow
        keys to change where it ends. Then Tab to the actions.
      </p>
      <p className={styles.hidden} aria-live="polite">
        {message}
      </p>
    </div>
  );
}
```

### selection-actions/selection-actions.module.css

```css
/* Selection actions (selection-actions.tsx): a paragraph on a soft grey stage, a passage marked
   in it, and the floating bar under the mark; at night a graphite stage and a bar 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). The bar's shadow is one list for both schemes, each layer coloured
   for one of them and clear in the other.

   Motion: the bar's anchor glides by transform; inside the bar, parts fold and open by
   max-width, opacity and transform; the bar's own width morphs by a Web Animation (in the TSX). */

.root {
  /* The stage, the bar, and the hover fills. */
  --canvas: oklch(96.1% 0.002 247.84);
  --surface: oklch(100% 0 0);
  --hover: oklch(97% 0.002 247.839);
  --hover-2: oklch(93.3% 0.003 247.86);
  /* Words: strong, secondary and quiet. */
  --ink: oklch(24.7% 0.006 258.361);
  --ink-2: oklch(50.6% 0.01 264.477);
  --ink-3: oklch(69.5% 0.009 264.505);
  /* Hairlines: the stage's edge and the soft divider, and the stronger divider. */
  --line: oklch(94.6% 0.003 264.542);
  --line-strong: oklch(91.2% 0.005 258.326);
  /* The mark: the accent mixed thinly into the surface by day, a translucent tint at night. */
  --accent: oklch(62.6% 0.205 254.947);
  --mark: color-mix(in srgb, oklch(62.6% 0.205 254.947) 14%, oklch(100% 0 0));
  /* The bar's ring and lift: the day's long soft layers, the night's one dark drop. */
  --bar-ring: oklch(94.6% 0.003 264.542);
  --day-1: rgb(0 0 0 / 0.05);
  --day-2: rgb(0 0 0 / 0.04);
  --day-3: rgb(0 0 0 / 0.03);
  --day-4: rgb(0 0 0 / 0.02);
  --night: transparent;
  --focus: oklch(62.6% 0.205 254.947);
  position: relative;
  container-type: inline-size;
  display: grid;
  place-items: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 272px;
  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;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --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));
    --hover-2: light-dark(oklch(93.3% 0.003 247.86), oklch(31.8% 0.007 274.747));
    --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));
    --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));
    --accent: light-dark(oklch(62.6% 0.205 254.947), oklch(68% 0.173 253.301));
    --mark: light-dark(
      color-mix(in srgb, oklch(62.6% 0.205 254.947) 14%, oklch(100% 0 0)),
      oklch(68% 0.173 253.301 / 0.16)
    );
    --bar-ring: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.15));
    --day-1: light-dark(rgb(0 0 0 / 0.05), transparent);
    --day-2: light-dark(rgb(0 0 0 / 0.04), transparent);
    --day-3: light-dark(rgb(0 0 0 / 0.03), transparent);
    --day-4: light-dark(rgb(0 0 0 / 0.02), transparent);
    --night: light-dark(transparent, oklch(0% 0 0 / 0.34));
    --focus: light-dark(oklch(62.6% 0.205 254.947), oklch(68% 0.173 253.301));
  }
}
.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, input) {
  margin: 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: 2px;
}

/* The paragraph's column: 460 px at most, centred. */
.column {
  width: 100%;
  max-width: 460px;
}

/* The paragraph and room under it for the bar (and, with an explanation, for the note). */
.doc {
  position: relative;
  padding-bottom: 48px;
  transition: padding-bottom 320ms cubic-bezier(0.23, 1, 0.32, 1);
}

.text {
  margin: 0;
  font-size: 13px;
  line-height: 1.625;
  color: var(--ink);
  cursor: text;
  user-select: text;
  -webkit-user-select: text;
  border-radius: 4px;
}
.text:focus {
  outline: none;
}
.text:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 4px;
}
.text[data-busy] {
  cursor: default;
  user-select: none;
  -webkit-user-select: none;
}
/* The browser's own highlight, while the reader drags, matches the mark. */
.text::selection,
.text *::selection {
  background: var(--mark);
  color: var(--ink);
}

.mark {
  border-radius: 3px;
  background: var(--mark);
  color: var(--ink);
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}

/* Streaming: the newest characters still settle (blurred, fading to the right), led by a caret. */
.tail {
  filter: blur(1.6px);
  mask-image: linear-gradient(90deg, #000 20%, rgb(0 0 0 / 0.2));
}
.caret {
  display: inline-block;
  width: 2px;
  height: 1.05em;
  margin-left: 1.5px;
  border-radius: 1px;
  background: var(--ink);
  vertical-align: text-bottom;
  translate: 0 -0.5px;
}

/* The bar's anchor: placed under the mark by transform (set in the TSX), centred on its point. */
.anchor {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  will-change: transform;
  transition:
    transform 320ms cubic-bezier(0.77, 0, 0.175, 1),
    opacity 180ms ease-out;
}
.anchor[data-closing] {
  opacity: 0;
  pointer-events: none;
}

.bar {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 2px;
  box-sizing: border-box;
  width: fit-content;
  max-width: calc(100cqw - 16px);
  height: 36px;
  padding: 4px;
  overflow: hidden;
  border-radius: 999px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--bar-ring),
    0 8px 28px var(--night),
    0 25px 50px var(--day-1),
    0 12px 24px var(--day-2),
    0 6px 12px var(--day-3),
    0 3px 6px var(--day-4),
    0 1.5px 3px var(--day-4);
  color: var(--ink);
  font-weight: 400;
  animation: pop 220ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
.bar:focus {
  outline: none;
}
@keyframes pop {
  from {
    opacity: 0;
    transform: scale(0.95);
  }
}

.group {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  gap: 2px;
  width: fit-content;
}

/* The idle bar's parts fold and open by max-width (values in the TSX). */
.fieldWrap,
.actions,
.extra,
.sendWrap {
  display: flex;
  min-width: 0;
  align-items: center;
  overflow: hidden;
  transition-property: max-width, opacity, transform, margin;
  transition-duration: 400ms;
  transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
}
.actions,
.extra {
  gap: 2px;
}

.form {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  height: 28px;
  margin: 0;
  transition: width 400ms cubic-bezier(0.23, 1, 0.32, 1);
}
.input {
  box-sizing: border-box;
  width: 100%;
  height: 28px;
  padding: 0 10px 0 12px;
  font-size: 12.5px;
  color: var(--ink);
  outline: none;
}
.input::placeholder {
  color: var(--ink-3);
  opacity: 1;
}

.rule,
.ruleSoft {
  flex-shrink: 0;
  width: 1px;
  height: 16px;
}
.rule {
  margin: 0 4px;
  background: var(--line-strong);
  transition:
    margin 400ms cubic-bezier(0.23, 1, 0.32, 1),
    opacity 400ms cubic-bezier(0.23, 1, 0.32, 1);
}
.ruleSoft {
  margin: 0 2px;
  background: var(--line);
}

.action {
  display: inline-flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  gap: 4px;
  height: 28px;
  padding: 0 10px;
  border-radius: 999px;
  color: var(--ink);
  font-size: 12px;
  line-height: 1;
  white-space: nowrap;
  user-select: none;
  -webkit-user-select: none;
  transition-property: transform, background-color, opacity;
  transition-duration: 150ms;
  transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
.action svg,
.keep svg,
.retry svg,
.more svg {
  flex-shrink: 0;
}

.more {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border-radius: 999px;
  color: var(--ink);
  transition:
    background-color 200ms cubic-bezier(0.4, 0, 0.2, 1),
    transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.chevron {
  display: flex;
  transition: transform 400ms cubic-bezier(0.23, 1, 0.32, 1);
}

.send {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border-radius: 999px;
  background: var(--ink);
  color: var(--surface);
  transition:
    opacity 200ms cubic-bezier(0.4, 0, 0.2, 1),
    transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}

/* Working: a small spinner and the action's words, shimmering until the answer starts. */
.busy {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 28px;
  padding: 0 10px;
  color: var(--ink-2);
  font-size: 12.5px;
  white-space: nowrap;
}
.spinner {
  flex-shrink: 0;
  box-sizing: border-box;
  width: 12px;
  height: 12px;
  border: 1.5px solid var(--line-strong);
  border-top-color: var(--ink-2);
  border-radius: 50%;
  animation: spin 700ms linear infinite;
}
@keyframes spin {
  to {
    transform: rotate(1turn);
  }
}
.shimmer {
  display: inline-block;
  background-image: linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%);
  background-size: 200% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  animation: shimmer 1.8s linear infinite;
}
@keyframes shimmer {
  from {
    background-position: 150% center;
  }
  to {
    background-position: -50% center;
  }
}

/* Review: Keep (solid), Discard, and another try. */
.keep {
  display: inline-flex;
  flex-shrink: 0;
  align-items: center;
  gap: 4px;
  height: 28px;
  padding: 0 10px;
  border-radius: 999px;
  background: var(--ink);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--canvas);
  font-size: 12.5px;
  white-space: nowrap;
  transition:
    opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
    transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.retry {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border-radius: 999px;
  color: var(--ink-3);
  transition:
    background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
    color 150ms cubic-bezier(0.4, 0, 0.2, 1),
    transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
}

/* Hover (the pointer's, or the demo's) and press. */
@media (hover: hover) {
  .action:hover,
  .more:hover {
    background: var(--hover);
  }
  .keep:hover {
    opacity: 0.9;
  }
  .retry:hover {
    background: var(--hover-2);
    color: var(--ink-2);
  }
}
.action[data-hover],
.more[data-hover] {
  background: var(--hover);
}
.keep[data-hover] {
  opacity: 0.9;
}
.retry[data-hover] {
  background: var(--hover-2);
  color: var(--ink-2);
}
.action:active,
.action[data-press],
.more:active,
.more[data-press],
.keep:active,
.keep[data-press],
.retry:active,
.retry[data-press] {
  transform: scale(0.96);
}
.send:active,
.send[data-press] {
  transform: scale(0.94);
}

/* An explanation: a note hanging under the bar, streamed like a rewrite. */
.note {
  position: absolute;
  top: 44px;
  left: 50%;
  box-sizing: border-box;
  width: max-content;
  max-width: min(300px, calc(100cqw - 32px));
  padding: 9px 12px;
  translate: -50% 0;
  border-radius: 12px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--bar-ring),
    0 8px 28px var(--night),
    0 25px 50px var(--day-1),
    0 12px 24px var(--day-2),
    0 6px 12px var(--day-3),
    0 3px 6px var(--day-4),
    0 1.5px 3px var(--day-4);
  color: var(--ink-2);
  font-size: 12.5px;
  line-height: 1.55;
  text-wrap: pretty;
  animation: pop 220ms cubic-bezier(0.23, 1, 0.32, 1) both;
}

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

/* A narrow stage: the actions keep their icons and drop their words (the words stay for screen
   readers), so the bar fits a phone. */
@container (max-width: 440px) {
  .action .label {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  .action:has(.label) {
    width: 28px;
    padding: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .bar,
  .note {
    animation: none;
  }
  .anchor,
  .doc,
  .form,
  .fieldWrap,
  .actions,
  .extra,
  .sendWrap,
  .rule,
  .chevron {
    transition: none;
  }
  .spinner,
  .shimmer {
    animation: none;
  }
  .shimmer {
    color: var(--ink-2);
    background: none;
  }
}
```

### selection-actions/rewrite.ts

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

/*
 * The stand-in for a model: what the component answers when no rewrite or explain function is
 * given. The sample passage has written answers, a couple per action so another try reads
 * differently; any other passage the reader selects gets a small rule-based edit (tidied,
 * trimmed of filler, warmed up), so every button always does something sensible.
 */

export type RewriteAction = 'improve' | 'shorten' | 'tone' | 'grammar' | 'custom';

export type RewriteRequest = {
  /** Which button asked, or 'custom' for a typed instruction. */
  action: RewriteAction;
  /** The selected words. */
  passage: string;
  /** The whole text, for context. */
  text: string;
  /** What the reader typed, for 'custom'. */
  instruction?: string;
  /** 0 the first time, then 1, 2… for each retry of the same request. */
  attempt: number;
};

export type ExplainRequest = Omit<RewriteRequest, 'action' | 'instruction'>;

export const SAMPLE_TEXT =
  'Pistachio holds the top slot all weekend. Churn it first thing Saturday so the batch has time to firm up before the afternoon rush.';

const SAMPLE_PASSAGE =
  'Churn it first thing Saturday so the batch has time to firm up before the afternoon rush.';

const WRITTEN: Record<RewriteAction | 'explain', string[]> = {
  improve: [
    'Churn pistachio first thing Saturday so the batch has time to fully firm before the afternoon rush.',
    'Churn the pistachio early on Saturday so the batch sets fully before the afternoon rush.',
  ],
  custom: [
    'Churn pistachio first thing Saturday so the batch has time to fully firm before the afternoon rush.',
    'Churn pistachio at opening on Saturday: it needs hours to set before the afternoon rush.',
  ],
  shorten: [
    'Churn it early Saturday so it sets before the rush.',
    'Churn it Saturday morning so it firms up by noon.',
  ],
  tone: [
    'Let’s churn it first thing Saturday, so the batch can settle before the afternoon crowd.',
    'Please churn it first thing on Saturday, so the batch is firm before the afternoon rush.',
  ],
  grammar: [
    'Churn it first thing on Saturday, so the batch has time to firm up before the afternoon rush.',
    'Churn it first thing on Saturday so that the batch has time to firm up before the afternoon rush.',
  ],
  explain: [
    'Make the pistachio batch first on Saturday morning: ice cream needs a few hours in the freezer to firm up, and it should be ready before the busy afternoon.',
    'A scheduling note. Freshly churned ice cream is soft, so the batch is started early to have set by the time the afternoon queue arrives.',
  ],
};

const FILLER =
  /\b(?:really|very|just|quite|actually|basically|simply|that|all|first thing|up)\b\s*/gi;

/** Single spaces, a capital first letter and closing punctuation. */
function tidy(words: string) {
  const clean = words
    .replace(/\s+/g, ' ')
    .replace(/\s+([,.;:!?])/g, '$1')
    .trim();
  if (!clean) return clean;
  const capped = clean[0].toUpperCase() + clean.slice(1);
  return /[.!?…:;,"”’)]$/.test(capped) ? capped : `${capped}.`;
}

function shorten(passage: string) {
  const trimmed = tidy(passage.replace(FILLER, ''));
  return trimmed.length < passage.length ? trimmed : tidy(passage.split(/[,;:]/)[0]);
}

export function defaultRewrite({ action, passage, attempt }: RewriteRequest): string {
  if (passage.trim() === SAMPLE_PASSAGE) {
    const answers = WRITTEN[action];
    return answers[attempt % answers.length];
  }
  switch (action) {
    case 'shorten':
      return shorten(passage);
    case 'tone':
      return tidy(`${attempt % 2 ? 'Please note:' : 'Heads up:'} ${passage.trim()}`);
    case 'grammar':
      return tidy(passage);
    default:
      return tidy(attempt % 2 ? passage.replace(FILLER, '') : passage.replace(/\s*\bvery\b/gi, ''));
  }
}

export function defaultExplain({ passage, attempt }: ExplainRequest): string {
  if (passage.trim() === SAMPLE_PASSAGE) return WRITTEN.explain[attempt % WRITTEN.explain.length];
  const words = passage.trim().split(/\s+/).length;
  return `In plain words: ${shorten(passage)} (${words} ${words === 1 ? 'word' : 'words'} selected.)`;
}
```

### selection-actions/icons.tsx

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

/*
 * The bar's glyphs, drawn on a 24-unit grid and shown at 14 px (the send arrow at 16 px). Each is
 * a plain stroke in currentColor, so the buttons colour them; all are decorative (aria-hidden),
 * since every button carries its own words.
 */

type Props = { size?: number; stroke?: number };

function Glyph({ size = 14, stroke = 1.8, children }: Props & { children: React.ReactNode }) {
  return (
    <svg
      width={size}
      height={size}
      style={{ width: size, height: size }}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={stroke}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
    >
      {children}
    </svg>
  );
}

/** A round speech bubble holding a question mark. */
export function ExplainIcon() {
  return (
    <Glyph>
      <path d="M12 2.5a9.5 9.5 0 1 1-4.9 17.64L2.9 21.1l.98-4.1A9.5 9.5 0 0 1 12 2.5Z" />
      <path d="M9.4 9.3a2.6 2.6 0 1 1 3.7 2.36c-.66.3-1.1.9-1.1 1.64v.4" />
      <path d="M12 17.1v.01" />
    </Glyph>
  );
}

/** A four-pointed spark with curved flanks. */
export function ImproveIcon() {
  return (
    <Glyph>
      <path d="M12 3.5q.6 7.9 8.5 8.5-7.9.6-8.5 8.5-.6-7.9-8.5-8.5 7.9-.6 8.5-8.5Z" />
    </Glyph>
  );
}

/** Scissors: two rings, two crossing blades. */
export function ShortenIcon() {
  return (
    <Glyph>
      <circle cx="5.5" cy="6" r="3" />
      <circle cx="5.5" cy="18" r="3" />
      <path d="M8 7.8 20 17.5M8 16.2 20 6.5" />
    </Glyph>
  );
}

/** A smiling face. */
export function ToneIcon() {
  return (
    <Glyph>
      <circle cx="12" cy="12" r="9.5" />
      <path d="M8.6 9.4h.01M15.4 9.4h.01" />
      <path d="M8 14.6c1 1.3 2.4 2 4 2s3-.7 4-2" />
    </Glyph>
  );
}

/** A text frame with a capital T, and the handles of a selection on its sides. */
export function GrammarIcon() {
  return (
    <Glyph>
      <path d="M3.5 10V6a2.5 2.5 0 0 1 2.5-2.5h12A2.5 2.5 0 0 1 20.5 6v4M3.5 14v4A2.5 2.5 0 0 0 6 20.5h12a2.5 2.5 0 0 0 2.5-2.5v-4" />
      <path d="M8.5 8.5h7M12 8.5v7.5" />
      <path d="M2 10.5h3v3H2zM19 10.5h3v3h-3z" />
    </Glyph>
  );
}

export function ChevronIcon() {
  return (
    <Glyph>
      <path d="m9.5 6 6 6-6 6" />
    </Glyph>
  );
}

export function SendIcon() {
  return (
    <Glyph size={16} stroke={2.4}>
      <path d="M12 20.5V4M5 11l7-7 7 7" />
    </Glyph>
  );
}

export function CheckIcon() {
  return (
    <Glyph>
      <path d="m5.5 12.5 4 4L18.5 7.5" />
    </Glyph>
  );
}

export function CrossIcon() {
  return (
    <Glyph>
      <path d="m7 7 10 10M17 7 7 17" />
    </Glyph>
  );
}

/** A circular arrow, for another try. */
export function RetryIcon() {
  return (
    <Glyph>
      <path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3" />
      <path d="M19.8 3.8v3.9h-3.9" />
    </Glyph>
  );
}
```

### selection-actions/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 };
}
```