# Format toolbar

> A floating toolbar over selected text: block types, bold, italic, underline, links, colours, bullets and an orb that suggests rewrites.

A block of text edited in place. Select some words and a toolbar rises over them while the rest of the text steps back to grey. It switches the block between Heading, Subheading, Paragraph and Quote, formats the words, picks their colour and, from an iridescent orb, offers a few rewrites.

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

Category: Inputs.

## Anatomy

1. **Text**: One block edited in place, 20.4/38. While the toolbar shows, the words outside the selection step back to grey.
2. **Selection**: A rounded fill behind each selected line, as tall as the line, drawn by the component rather than the browser.
3. **Toolbar**: Floats above the selection’s first line from just before its start, glides 380 ms to the next selection and scales down on a narrow stage.
4. **Rewrite orb**: Opens the rewrites: it thinks for 900 ms, then offers up to three of the selected words. Its colours swirl while hovered or open.
5. **Turn into**: The block’s type, named on the button: Heading, Subheading, Paragraph or Quote.
6. **Toggles**: Bold, italic and underline (⌘B, ⌘I, ⌘U), pressed when the whole selection has the mark.
7. **Tip**: Names the tool under the pointer or the keyboard focus, with its shortcut. Hidden while a menu is open.
8. **Colour dot**: Shows the selection’s text colour and opens a row of six, the first taking it back to the block’s own.

## When to use

- Formatting prose in place, where the words are the page and a fixed toolbar would crowd it.
- Notes, drafts and comments where a selection is the natural start: select, then act on it.
- A short set of inline marks (bold, italic, underline, a link, a colour) plus the block’s type.
- Offering rewrites of the selected words next to the formats, from the same place.

## When not to use

- Touch-first editing: the phone’s own selection menu sits where this toolbar would. Use a toolbar docked above the keyboard.
- Documents with tables, images or many block types. Use a full editor with a fixed toolbar and a `/` menu.
- Plain inputs such as a title or a search field: formatting there only gets in the way.
- Actions that don’t depend on a selection (save, share, undo). Put them in the page’s own toolbar.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/format-toolbar.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 { FormatToolbar } from './format-toolbar';

export function NoteEditor() {
  return (
    <FormatToolbar
      autoplay={false}
      defaultText="Select any words to format them."
      onChange={({ runs, block }) => console.log(block, runs)}
    />
  );
}
```

## States

- **At rest**: No selection: just the text, which fades towards the foot of the stage.
- **Selected**: The toolbar fades in over the selection in 180 ms, and the rest of the text greys.
- **Tip**: A tool under the pointer or the keyboard focus names itself and its shortcut.
- **Formatted**: Bold, italic and a colour on the selection: the toggles show pressed, the dot takes the colour.
- **Turn into**: The block’s four types, each drawn in its own style, with a check on the current one.
- **Colours**: Six swatches in a row; the picked one is raised. Arrow keys move along them.
- **Link field**: An address field under the link tool. Enter adds it (`https://` when no scheme is typed); ⌘K opens it too.
- **Thinking**: A sheen runs over the word while the orb swirls: both keep moving here, as they do while it works.
- **Rewrites**: Up to three suggestions, each with a label (the orb keeps swirling while the menu is open). Picking one replaces the selection and keeps it selected.
- **Heading, bulleted**: The block turned into a heading, with the bullet on: the block’s type and bullet apply to the whole text.
- **Narrow stage**: Under 560 px the text and the tools shrink, and Turn into shows ¶ instead of its name.

## How it works

- The text is a contentEditable block whose content React leaves alone. After each edit, its runs of text and marks are read back from the page, and written afresh if the browser left anything of its own behind.
- Formats are applied to the runs, which are written out again. The selection is put back by its character offsets, so it survives every change.
- The toolbar rises above the selection’s first line, starting a little before it, and glides when the selection moves. On a narrow stage it scales down to fit.
- The selected words keep their colour while the rest turns grey and fades towards the foot. Each selected line gets a rounded fill as tall as the line, and the demo’s selection is drawn with the Custom Highlight API rather than taking the page’s.
- The orb’s colours turn while it thinks, then it offers rewrites made on the spot: written-out alternatives for the demo’s words, and for any other words three simple passes (tighter, sentence case, title case).
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop. Each text colour has a night value, written on the run as --color and --color-dark, so coloured text stays readable on the dark editor; the orb is art and keeps its colours.

## API reference

- **defaultText** (`string`, default `TEXT`): The text to start from, without formatting (by default, a paragraph about a made-up writing app).
- **defaultBlock** (`'heading' | 'subheading' | 'paragraph' | 'quote'`, default `'paragraph'`): The block’s type to start from.
- **defaultState** (`FormatToolbarState`): Opens in a given moment, with the demo off: the selected span, formats already on the text, the bullet, the open menu (rewrite, block, color or link) and its highlighted row, a tool’s tip, and the orb still thinking.
- **rewrites** (`(text: string) => Rewrite[]`, default `rewritesOf`): Up to three rewrites of the selected text for the orb, each a label and a text (rewrites.ts).
- **onChange** (`(value: { runs: Run[]; block: Block; listed: boolean }) => void`): Called after each change with the runs, the block’s type and whether it has its bullet.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a ghost pointer selects words, shows Bold’s tip, applies it and opens the block types.
- **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 stage.

## Accessibility

- The text is a multiline textbox. ⌘B, ⌘I and ⌘U toggle formats and ⌘K opens the link field (Ctrl elsewhere), and the browser’s own formatting commands are routed through the runs.
- The toolbar is a role="toolbar": Tab from the text reaches it, the arrow keys, Home and End move along it, and Escape returns to the text with the selection intact. A press on it never takes focus from the text.
- The block types are a menu of menuitemradio items, the colours a group of pressed buttons, and the rewrites a menu marked busy while the orb thinks. Each tool states its pressed state and shortcut, and its tip shows on hover or keyboard focus.
- Format changes, block changes and rewrites are announced in a polite live region. Under reduced motion the orb doesn’t think first and the demo never plays.

## Source

### format-toolbar/format-toolbar.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent,
} from 'react';
import { GhostPointer, runDemo, type Point } from './ghost-pointer';
import { rewritesOf, type Rewrite } from './rewrites';
import {
  COLORS,
  format,
  fromDOM,
  marksOf,
  rangeOf,
  replace,
  select,
  selectedSpan,
  serialize,
  textOf,
  toHTML,
  type Marks,
  type Run,
  type Span,
  type Swatch,
} from './rich-text';
import { BLOCKS, Toolbar, type Block, type Menu, type Toggle, type Tool } from './toolbar';
import { useAutoplay } from './use-autoplay';
import styles from './format-toolbar.module.css';

/*
 * Format toolbar: a block of text edited in place, with a toolbar that floats over what is
 * selected.
 *
 * - The text is a contentEditable block whose content React leaves alone after the first render.
 *   The browser types into it; the runs are read back from the DOM (rich-text.ts) and written
 *   afresh when the browser left anything of its own behind. Formats are applied to the runs,
 *   which are then written out again and the selection put back by its character offsets.
 * - A selection brings the toolbar up over it (toolbar.tsx), from the selection's start; it
 *   glides when the selection changes. While it shows, the rest of the text steps back to grey.
 * - Bold, italic, underline and links take ⌘B, ⌘I, ⌘U and ⌘K too (Ctrl elsewhere). The block's
 *   type switches between Heading, Subheading, Paragraph and Quote; the list button makes it a
 *   bullet; the dot opens a row of colours. The orb shimmers, then offers rewrites of the
 *   selection (rewrites.ts), made on the spot.
 * - While on screen and untouched, a ghost pointer selects the first words, shows Bold's tip,
 *   makes them bold and opens the block's types. The demo draws a selection of its own instead
 *   of taking the page's.
 * - Light or dark: every colour is a light-dark() token in the CSS, and each text colour has a
 *   value for either scheme (rich-text.ts), so the scheme is the page's color-scheme, or the one
 *   the colorScheme prop sets.
 */

/** The custom highlight that paints a selection drawn by hand (see the stylesheet). */
const PAINTED = 'format-toolbar-selection';

const TEXT =
  'Margins is a calm, focused writing space made to keep long drafts readable and easy to shape. With blocks in 9 different styles, Margins gives every idea a place, whether you’re outlining an essay or planning a launch.';

/** The words the demo selects: the start of the text. */
const DEMO_WORDS = 'Margins is a calm, focused writing space';

/** How long the orb thinks before it offers its rewrites, in ms. */
const THINKING = 900;

const NAMES: Record<Toggle, string> = { bold: 'Bold', italic: 'Italic', underline: 'Underline' };

/** The keyboard shortcuts, with Cmd on a Mac and Ctrl elsewhere. */
const SHORTCUTS: Record<string, Toggle | 'link'> = {
  b: 'bold',
  i: 'italic',
  u: 'underline',
  k: 'link',
};

/** The browser's own formatting commands, which the runs take instead. */
const COMMANDS: Record<string, Toggle> = {
  formatBold: 'bold',
  formatItalic: 'italic',
  formatUnderline: 'underline',
};

/** What the demo does, one step at a time. */
type DemoAct =
  | { type: 'reset' }
  | { type: 'select'; span: Span | null; settled: boolean }
  | { type: 'hint'; tool: Tool | null }
  | { type: 'toggle'; mark: Toggle }
  | { type: 'menu'; menu: Menu | null }
  | { type: 'menuActive'; index: number };

/** A selected line's fill, in the text's own pixels. */
type Box = { left: number; top: number; width: number; height: number };

/**
 * A moment to open in, drawn as the demo draws it (its selection painted, the toolbar over it),
 * with the demo off. The reader takes over from it: a press on a tool works on its selection, a
 * click in the text drops it for their own.
 */
export type FormatToolbarState = {
  /** The selected words, in characters: the toolbar shows over them. */
  selection?: Span;
  /** Formats already on the text: marks over spans, applied in order. */
  formats?: { span: Span; marks: Marks }[];
  /** The bullet is on. */
  listed?: boolean;
  /** The menu open under its tool (with a selection). */
  menu?: Menu | null;
  /** The tool whose tip shows, while no menu is open. */
  hint?: Tool | null;
  /** With the rewrite menu: the orb still thinking, before its suggestions. */
  thinking?: boolean;
  /** The highlighted row of the open menu. */
  menuActive?: number;
};

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

export function FormatToolbar({
  defaultText = TEXT,
  defaultBlock = 'paragraph',
  rewrites = rewritesOf,
  onChange,
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** The text to start from, without formatting. */
  defaultText?: string;
  /** The block's type to start from. */
  defaultBlock?: Block;
  /** Suggestions for the orb: up to three rewrites of the selected text (rewrites.ts). */
  rewrites?: (text: string) => Rewrite[];
  /** Called with the runs, the block's type and its bullet after each change. */
  onChange?: (value: { runs: Run[]; block: Block; listed: boolean }) => void;
  /** While on screen and untouched, a ghost pointer selects words and formats them. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens in a given moment, with the demo off (see FormatToolbarState). */
  defaultState?: FormatToolbarState;
  /** Extra class for the stage. */
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [initial] = useState<Run[]>(() =>
    (defaultState?.formats ?? []).reduce<Run[]>(
      (runs, each) => format(runs, each.span, each.marks),
      [{ text: defaultText, marks: {} }],
    ),
  );
  const [html] = useState(() => toHTML(initial));
  const [runs, setRuns] = useState(initial);
  const [block, setBlock] = useState<Block>(defaultBlock);
  const [listed, setListed] = useState(defaultState?.listed ?? false);
  /** The reader's selection in the text, when it is not empty. */
  const [span, setSpan] = useState<Span | null>(null);
  const [demoSpan, setDemoSpan] = useState<Span | null>(defaultState?.selection ?? null);
  /** The demo's drag is over: its toolbar shows. */
  const [demoSettled, setDemoSettled] = useState(!!defaultState?.selection);
  /** Where the focus is: in the text, in the toolbar, or elsewhere. */
  const [focusIn, setFocusIn] = useState<'text' | 'toolbar' | null>(null);
  const [place, setPlace] = useState({ x: 0, y: 0, fit: 1 });
  const [menu, setMenu] = useState<Menu | null>(defaultState?.menu ?? null);
  const [menuActive, setMenuActive] = useState(defaultState?.menuActive ?? -1);
  const [hint, setHint] = useState<Tool | null>(defaultState?.hint ?? null);
  const [thinking, setThinking] = useState(defaultState?.thinking ?? false);
  const [narrow, setNarrow] = useState(false);
  const [boxes, setBoxes] = useState<Box[]>([]);
  const [message, setMessage] = useState('');

  const stage = root;
  const paper = useRef<HTMLDivElement>(null);
  const editor = useRef<HTMLDivElement>(null);
  const toolbar = useRef<HTMLDivElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const current = useRef(initial);
  /** Where the toolbar stands while it shows, for its glide to the next place. */
  const placed = useRef<{ x: number; y: number; fit: number } | null>(null);
  const thinkTimer = useRef(0);
  /** The open menu was opened from the keyboard: its items take the focus. */
  const menuByKeyboard = useRef(false);
  /** What to focus once the next render is on screen. */
  const focusNext = useRef<(() => HTMLElement | null | undefined) | null>(null);

  const active = demoSpan ?? span;
  const shown = !!active && (demoSpan ? demoSettled : focusIn !== null);
  const drawn = demoSpan ?? (focusIn === 'toolbar' ? span : null);
  const marks: Marks = active ? marksOf(runs, active) : {};
  const color = COLORS.find((swatch) => swatch.id === (marks.color ?? '')) ?? COLORS[0];
  const suggestions = active ? rewrites(textOf(runs).slice(active.start, active.end)) : [];

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

  const emit = (next: { runs?: Run[]; block?: Block; listed?: boolean }) =>
    onChange?.({
      runs: next.runs ?? current.current,
      block: next.block ?? block,
      listed: next.listed ?? listed,
    });

  /** Takes in a new version of the text. */
  const takeRuns = (next: Run[]) => {
    current.current = next;
    setRuns(next);
    emit({ runs: next });
  };

  /** Writes `next` into the page and selects `keep` again. */
  const write = (next: Run[], keep: Span | null) => {
    const element = editor.current;
    if (!element) return;
    element.innerHTML = toHTML(next);
    if (keep && document.activeElement === element) select(element, keep);
  };

  /** After the browser typed: reads the runs back, and tidies the DOM if it needs it. */
  const sync = () => {
    const element = editor.current;
    if (!element) return;
    const next = fromDOM(element);
    if (serialize(element) !== toHTML(next)) write(next, selectedSpan(element));
    takeRuns(next);
  };

  const apply = (patch: Marks, said: string | null) => {
    if (!active) return;
    const next = format(current.current, active, patch);
    takeRuns(next);
    write(next, active);
    if (said) announce(said);
  };

  const toggle = (mark: Toggle, byReader: boolean) => {
    if (!active) return;
    const on = !marksOf(current.current, active)[mark];
    apply({ [mark]: on }, byReader ? `${NAMES[mark]} ${on ? 'on' : 'off'}.` : null);
  };

  /** Back from the toolbar to the text, with the selection it was working on. */
  const backToText = () => {
    const element = editor.current;
    if (!element) return;
    element.focus();
    if (active) select(element, active);
  };

  const openMenu = (next: Menu | null, byKeyboard: boolean) => {
    window.clearTimeout(thinkTimer.current);
    setMenu(next);
    setMenuActive(-1);
    setHint(null);
    if (next === 'rewrite' && !reducedMotion()) {
      setThinking(true);
      thinkTimer.current = window.setTimeout(() => setThinking(false), THINKING);
    } else setThinking(false);
    menuByKeyboard.current = byKeyboard;
    if (next === 'link') focusNext.current = () => toolbar.current?.querySelector('input');
    else if (next && byKeyboard) focusNext.current = () => firstInMenu();
    else if (!next && byKeyboard && menu) {
      focusNext.current = () => toolbar.current?.querySelector(`[data-tool="${menu}"]`);
    }
  };

  /** The item of the open menu that takes the focus: the current one, or the first. */
  const firstInMenu = () =>
    toolbar.current?.querySelector<HTMLElement>(
      '[role="menu"] [tabindex="0"], [role="group"] [tabindex="0"]',
    );

  const chooseBlock = (next: Block, byKeyboard: boolean) => {
    setBlock(next);
    setMenu(null);
    emit({ block: next });
    announce(`Turned into ${BLOCKS.find((each) => each.block === next)?.label ?? next}.`);
    if (byKeyboard) focusNext.current = () => toolbar.current?.querySelector('[data-tool="block"]');
  };

  const toggleList = () => {
    setListed(!listed);
    emit({ listed: !listed });
    announce(listed ? 'Bullet off.' : 'Bullet on.');
  };

  const chooseColor = (swatch: Swatch, byKeyboard: boolean) => {
    apply({ color: swatch.id }, `Text colour: ${swatch.name}.`);
    setMenu(null);
    if (byKeyboard) focusNext.current = () => toolbar.current?.querySelector('[data-tool="color"]');
  };

  const link = (href: string | null) => {
    apply({ href: href ?? undefined }, href ? 'Link added.' : 'Link removed.');
    setMenu(null);
    backToText();
  };

  const rewrite = (choice: Rewrite) => {
    const element = editor.current;
    if (!active || !element) return;
    const next = replace(current.current, active, choice.text);
    const keep = { start: active.start, end: active.start + choice.text.length };
    takeRuns(next);
    element.innerHTML = toHTML(next);
    setMenu(null);
    announce(`Rewritten: ${choice.text}`);
    element.focus();
    select(element, keep);
  };

  // The browser's own edits that the runs do not take: line breaks, its formatting commands and
  // rich pastes (a paste comes in as plain text).
  const beforeInput = useEffectEvent((event: InputEvent) => {
    const element = editor.current;
    if (!element) return;
    const type = event.inputType;
    if (type === 'insertParagraph' || type === 'insertLineBreak') event.preventDefault();
    else if (type.startsWith('format')) {
      event.preventDefault();
      if (COMMANDS[type]) toggle(COMMANDS[type], true);
    } else if (type === 'insertFromPaste' || type === 'insertFromDrop') {
      event.preventDefault();
      const text = (event.dataTransfer?.getData('text/plain') ?? '').replace(/\s+/g, ' ');
      const at = selectedSpan(element);
      if (!text || !at) return;
      const next = replace(current.current, at, text);
      takeRuns(next);
      const caret = at.start + text.length;
      write(next, { start: caret, end: caret });
    }
  });
  useEffect(() => {
    const element = editor.current;
    if (!element) return;
    const listener = (event: InputEvent) => beforeInput(event);
    element.addEventListener('beforeinput', listener);
    return () => element.removeEventListener('beforeinput', listener);
  }, []);

  // The reader's selection, while it is in the text. Working in the toolbar keeps it.
  const selectionChanged = useEffectEvent(() => {
    const element = editor.current;
    if (!element || toolbar.current?.contains(document.activeElement)) return;
    const next = selectedSpan(element);
    const kept = next && next.end > next.start ? next : null;
    setSpan((now) =>
      now && kept && now.start === kept.start && now.end === kept.end ? now : kept,
    );
    if (!kept) setMenu(null);
  });
  useEffect(() => {
    const listener = () => selectionChanged();
    document.addEventListener('selectionchange', listener);
    return () => document.removeEventListener('selectionchange', listener);
  }, []);

  // The layout follows the stage's own width, as the observer reports it.
  useEffect(() => {
    const element = stage.current;
    if (!element) return;
    const observer = new ResizeObserver(([entry]) => setNarrow(entry.contentRect.width < 560));
    observer.observe(element);
    return () => observer.disconnect();
  }, []);

  // The toolbar's place: above the selection's first line, from a little before its start, inside
  // the stage, which it fits by scaling down when the stage is narrower than the toolbar. From
  // one selection to the next it glides; where it first shows, it fades in.
  useLayoutEffect(() => {
    const element = editor.current;
    const frame = stage.current;
    const bar = toolbar.current;
    const was = placed.current;
    placed.current = null;
    if (!shown || !active || !element || !frame || !bar) return;
    const rects = Array.from(rangeOf(element, active).getClientRects()).filter(
      (rect) => rect.width > 0.5,
    );
    if (!rects.length) return;
    const box = frame.getBoundingClientRect();
    const scale = box.width / frame.offsetWidth || 1;
    const first = rects[0];
    const fit = Math.min(1, (frame.offsetWidth - 16) / bar.offsetWidth);
    const x = Math.max(
      8,
      Math.min(
        frame.offsetWidth - bar.offsetWidth * fit - 8,
        (first.left - box.left) / scale - (narrow ? 24 : 58),
      ),
    );
    // The line's top, as tall as its line-height, from the middle of its text.
    const line = parseFloat(getComputedStyle(element).lineHeight) || first.height;
    const top = (first.top + first.height / 2 - box.top) / scale - line / 2;
    const y = top - bar.offsetHeight * fit - (narrow ? 16 : 34);
    const next = { x, y, fit };
    placed.current = next;
    setPlace((now) => (now.x === x && now.y === y && now.fit === fit ? now : next));
    if (!was || (was.x === x && was.y === y) || reducedMotion()) return;
    bar.animate(
      [
        { transform: `translate(${was.x}px, ${was.y}px) scale(${was.fit})` },
        { transform: `translate(${x}px, ${y}px) scale(${fit})` },
      ],
      { duration: 380, easing: 'cubic-bezier(0.2, 0.9, 0.25, 1)' },
    );
  }, [active, runs, shown, block, listed, narrow]);

  // The selection's fill: a rounded box behind each of its lines, as tall as the line. The page's
  // own selection only darkens the words.
  useLayoutEffect(() => {
    const element = editor.current;
    const sheet = paper.current;
    const target = demoSpan ?? span;
    if (!target || !element || !sheet) {
      setBoxes((now) => (now.length ? [] : now));
      return;
    }
    const frame = sheet.getBoundingClientRect();
    const scale = frame.width / sheet.offsetWidth || 1;
    const line = parseFloat(getComputedStyle(element).lineHeight) || 38;
    const lines: Box[] = [];
    for (const rect of rangeOf(element, target).getClientRects()) {
      if (rect.width < 0.5) continue;
      const middle = (rect.top + rect.height / 2 - frame.top) / scale;
      const left = (rect.left - frame.left) / scale - 3;
      const right = (rect.right - frame.left) / scale + 3;
      const same = lines.find((box) => Math.abs(box.top + box.height / 2 - middle) < line / 3);
      if (same) {
        const end = Math.max(same.left + same.width, right);
        same.left = Math.min(same.left, left);
        same.width = end - same.left;
      } else lines.push({ left, top: middle - line / 2, width: right - left, height: line });
    }
    setBoxes(lines);
  }, [demoSpan, span, runs, block, listed, narrow]);

  // The words of a selection drawn by hand (the demo's, or the reader's while they work the
  // toolbar) stay dark, as the page's own selection keeps them, with a custom highlight.
  useLayoutEffect(() => {
    const element = editor.current;
    if (!drawn || !element || typeof Highlight === 'undefined' || !('highlights' in CSS)) return;
    const range = rangeOf(element, drawn);
    const highlight = CSS.highlights.get(PAINTED) ?? new Highlight();
    CSS.highlights.set(PAINTED, highlight);
    highlight.add(range);
    return () => {
      highlight.delete(range);
    };
  }, [drawn, runs]);

  // Focus lands where the reader's action leads, once it is on screen.
  useLayoutEffect(() => {
    const next = focusNext.current;
    if (!next) return;
    focusNext.current = null;
    next()?.focus({ preventScroll: true });
  });

  useEffect(() => () => window.clearTimeout(thinkTimer.current), []);

  // The rewrites arrive: a menu opened from the keyboard hands them the focus.
  useLayoutEffect(() => {
    if (menu === 'rewrite' && !thinking && menuByKeyboard.current) firstInMenu()?.focus();
  }, [menu, thinking]);

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const command = event.metaKey || event.ctrlKey ? SHORTCUTS[event.key.toLowerCase()] : undefined;
    if (command === 'link') {
      event.preventDefault();
      if (active) openMenu('link', true);
    } else if (command) {
      event.preventDefault();
      toggle(command, true);
    } else if (event.key === 'Escape' && span) {
      // Leaves the toolbar: the selection folds to its end.
      event.preventDefault();
      event.stopPropagation();
      document.getSelection()?.collapseToEnd();
    }
  };

  // The demo: select the first words, show Bold's tip, make them bold, open the block's types.
  const demoAct = useEffectEvent((act: DemoAct) => {
    if (act.type === 'reset') {
      const element = editor.current;
      current.current = initial;
      setRuns(initial);
      if (element) element.innerHTML = toHTML(initial);
      setBlock(defaultBlock);
      setListed(false);
      setDemoSpan(null);
      setMenu(null);
      setHint(null);
    } else if (act.type === 'select') {
      setDemoSpan(act.span);
      setDemoSettled(act.settled);
      if (!act.span) setMenu(null);
    } else if (act.type === 'hint') setHint(act.tool);
    else if (act.type === 'toggle') toggle(act.mark, false);
    else if (act.type === 'menu') openMenu(act.menu, false);
    else setMenuActive(act.index);
  });
  const demo = autoplay && !defaultState && playing && focusIn === null;
  useEffect(() => {
    const frame = stage.current;
    const ghost = pointer.current;
    const element = editor.current;
    if (!demo || !frame || !ghost || !element) return;
    const end = textOf(initial).indexOf(DEMO_WORDS) + DEMO_WORDS.length;
    if (end < DEMO_WORDS.length) return;
    // Where the caret would stand before character `offset`, in the stage's pixels.
    const caretAt = (offset: number): Point => {
      const box = frame.getBoundingClientRect();
      const scale = box.width / frame.offsetWidth || 1;
      const range = rangeOf(
        element,
        offset ? { start: offset - 1, end: offset } : { start: 0, end: 1 },
      );
      const rect = range.getBoundingClientRect();
      return {
        x: ((offset ? rect.right : rect.left) - box.left) / scale,
        y: (rect.top + rect.height / 2 - box.top) / scale,
      };
    };
    const tool = (name: Tool) => toolbar.current?.querySelector(`[data-tool="${name}"]`);
    const stop = runDemo(frame, ghost, async ({ wait, moveTo, press }) => {
      for (let round = 0; ; round += 1) {
        await wait(round ? 1300 : 700);
        demoAct({ type: 'reset' });
        await wait(300);
        // A drag across the words, a word at a time.
        await moveTo(caretAt(0), { shape: 'text' });
        await press(() => {});
        const stops = [...DEMO_WORDS.matchAll(/\S+/g)].map(
          (word) => (word.index ?? 0) + word[0].length,
        );
        for (const stop of stops) {
          await moveTo(caretAt(stop));
          demoAct({ type: 'select', span: { start: 0, end: stop }, settled: false });
        }
        await wait(250);
        demoAct({ type: 'select', span: { start: 0, end }, settled: true });
        await wait(800);
        await moveTo(tool('bold'), { shape: 'hand' });
        demoAct({ type: 'hint', tool: 'bold' });
        await wait(1300);
        await press(() => demoAct({ type: 'toggle', mark: 'bold' }));
        await wait(900);
        demoAct({ type: 'hint', tool: null });
        await moveTo(tool('block'), { at: { x: 0.45, y: 0.5 } });
        await press(() => demoAct({ type: 'menu', menu: 'block' }));
        await wait(500);
        const rows = toolbar.current?.querySelectorAll('[role="menuitemradio"]') ?? [];
        for (const index of [0, 1, 3]) {
          await moveTo(rows[index], { at: { x: 0.45, y: 0.5 } });
          demoAct({ type: 'menuActive', index });
          await wait(450);
        }
        await moveTo(tool('block'), { at: { x: 0.45, y: 0.5 } });
        await press(() => demoAct({ type: 'menu', menu: null }));
        await wait(1100);
        await moveTo(
          { x: frame.offsetWidth * 0.86, y: frame.offsetHeight * 0.9 },
          { shape: 'arrow' },
        );
        demoAct({ type: 'select', span: null, settled: false });
      }
    });
    return () => {
      stop();
      setDemoSpan(null);
      setHint(null);
      setMenuActive(-1);
    };
  }, [demo, initial]);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-narrow={narrow || undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onFocus={(event) => {
        const inToolbar = !!toolbar.current?.contains(event.target);
        setFocusIn(inToolbar ? 'toolbar' : 'text');
        // A selection from defaultState gives way to the reader's own once they are in the text.
        if (!inToolbar && !demo) setDemoSpan(null);
      }}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) {
          setFocusIn(null);
          setMenu(null);
        }
      }}
    >
      <div className={styles.clip}>
        <div className={styles.column}>
          <div
            ref={paper}
            className={styles.paper}
            data-block={block}
            data-listed={listed || undefined}
          >
            {boxes.map((box, index) => (
              <span
                key={index}
                className={styles.fill}
                data-part="selection"
                style={{ left: box.left, top: box.top, width: box.width, height: box.height }}
                aria-hidden="true"
              />
            ))}
            <div
              ref={editor}
              className={styles.editor}
              data-active={shown || undefined}
              contentEditable
              role="textbox"
              aria-multiline="true"
              aria-label="Text"
              aria-roledescription={listed ? 'bulleted text' : undefined}
              spellCheck={false}
              dangerouslySetInnerHTML={{ __html: html }}
              onInput={(event) => {
                if (!(event.nativeEvent as InputEvent).isComposing) sync();
              }}
              onCompositionEnd={sync}
              onKeyDown={onKeyDown}
            />
          </div>
        </div>
      </div>
      <Toolbar
        ref={toolbar}
        shown={shown}
        x={place.x}
        y={place.y}
        fit={place.fit}
        marks={marks}
        block={block}
        listed={listed}
        color={color}
        menu={menu}
        hint={hint}
        menuActive={menuActive}
        thinking={thinking}
        rewrites={suggestions}
        onHint={setHint}
        onMenu={openMenu}
        onToggle={(mark) => toggle(mark, true)}
        onBlock={chooseBlock}
        onList={toggleList}
        onColor={chooseColor}
        onLink={link}
        onRewrite={rewrite}
        onLeave={backToText}
      />
      <GhostPointer ref={pointer} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### format-toolbar/format-toolbar.module.css

```css
/* Format toolbar (format-toolbar.tsx, toolbar.tsx, orb.tsx): a stage with a block of text, which
   fades towards its foot, and the toolbar that floats over its selection.

   Memory. The stage is one layout boundary of a fixed height. The text and the toolbar each paint
   on a layer of their own: typing and formatting repaint the text alone, and the toolbar moves
   and fades with transforms and opacity. A layer that changes size gets new buffers, so each
   keeps one size: the text's is clipped to its box, and the toolbar's tips and menus are small
   layers of their own. The text fades into the stage's colour painted over it, in its own layer:
   a mask would be a second layer as large, painted again with every change. Set --stage with the
   stage's background.

   Colours are the tokens below, light, then light-dark(light, dark) pairs: 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 pairs sit in @supports, which a build that
   rewrites light-dark() for older browsers leaves as written; without light-dark(), the light
   colours stay. The text colours a reader picks come from rich-text.ts, with a value for each
   scheme; the orb keeps its colours. */

.root {
  /* The stage, which the text fades into, and its edge. */
  --stage: #e1e1e1;
  --stage-edge: rgb(0 0 0 / 0.06);
  /* The words; the rest of the text while the toolbar shows; the toolbar's icons, and the
     chevron; the rewrites' labels and their notes. */
  --ink: #161616;
  --dim: #5c5c5c;
  --icon: #555;
  --chevron: #9a9a9a;
  --ink-muted: #8a8a8a;
  /* The selection's fill; a link; the bar beside a quote. */
  --selection: #d1d1d1;
  --accent: #2f55e4;
  --quote: #c4c4c4;
  /* The toolbar, top to bottom, and the line between its groups. */
  --bar-top: #f4f4f4;
  --bar-foot: #eeeeee;
  --line: #e3e3e3;
  /* A tool under the pointer, or on: a raised tile, top to bottom. */
  --tile-top: #f7f8f9;
  --tile-foot: #ececec;
  /* The menus; a colour under the pointer or picked; the tip; the link's field. */
  --surface: #f7f7f7;
  --surface-2: #fff;
  --hint: #f3f3f3;
  --field: #fff;
  /* A row of a menu under the pointer, and the block's button; a menu's check. */
  --hover: rgb(0 0 0 / 0.05);
  --hover-soft: rgb(0 0 0 / 0.045);
  --check: #3a3a3a;
  /* While the orb thinks, a sheen runs across grey words. */
  --sheen: #9a9a9a;
  --sheen-glint: #e2c4f7;
  /* The toolbar's top light, hairline and lift. */
  --bar-gloss: rgb(255 255 255 / 0.9);
  --bar-edge: rgb(0 0 0 / 0.04);
  --bar-shade: rgb(0 0 0 / 0.1);
  /* A raised tool's top light, hairline, shade and the blue glow at its left. */
  --tile-gloss: #fff;
  --tile-edge: rgb(0 0 0 / 0.04);
  --tile-shade: rgb(0 0 0 / 0.09);
  --tile-glow: rgb(120 170 230 / 0.45);
  /* The tip's hairline and shade; a menu's top light, hairline and lift (--shadow); a picked
     colour's shade. */
  --hint-edge: rgb(0 0 0 / 0.03);
  --hint-shade: rgb(0 0 0 / 0.08);
  --menu-gloss: rgb(255 255 255 / 0.9);
  --menu-edge: rgb(0 0 0 / 0.04);
  --shadow: rgb(0 0 0 / 0.18);
  --pick-shade: rgb(0 0 0 / 0.08);
  /* The link's field: its edge, and its edge and ring with the focus. */
  --field-edge: #e2e2e2;
  --field-focus: #c9c9c9;
  --field-ring: rgb(0 0 0 / 0.05);
  --focus: #3b82f6;
  /* The demo's pointer looks like the system's on both: a dark arrow with a white edge, a white
     hand outlined in dark. The I-beam, a stroke alone, turns light at night. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-outline: #141414;
  --pointer-beam: #141414;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  height: 440px;
  contain: size layout;
  border-radius: 18px;
  background: var(--stage);
  box-shadow: inset 0 0 0 1px var(--stage-edge);
  color: var(--ink);
  font:
    400 16px/1.4 Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#e1e1e1, #141415);
    --stage-edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --ink: light-dark(#161616, #ececec);
    --dim: light-dark(#5c5c5c, #8e8e8e);
    --icon: light-dark(#555, #c2c2c2);
    --chevron: light-dark(#9a9a9a, #8a8a8a);
    --ink-muted: light-dark(#8a8a8a, #9c9c9c);
    --selection: light-dark(#d1d1d1, #38383b);
    --accent: light-dark(#2f55e4, #7c9dff);
    --quote: light-dark(#c4c4c4, #555558);
    --bar-top: light-dark(#f4f4f4, #2f2f31);
    --bar-foot: light-dark(#eeeeee, #2a2a2c);
    --line: light-dark(#e3e3e3, #3c3c3f);
    --tile-top: light-dark(#f7f8f9, #444447);
    --tile-foot: light-dark(#ececec, #39393c);
    --surface: light-dark(#f7f7f7, #2a2a2c);
    --surface-2: light-dark(#fff, #3d3d40);
    --hint: light-dark(#f3f3f3, #313133);
    --field: light-dark(#fff, #1d1d1f);
    --hover: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.07));
    --hover-soft: light-dark(rgb(0 0 0 / 0.045), rgb(255 255 255 / 0.07));
    --check: light-dark(#3a3a3a, #d4d4d4);
    --bar-gloss: light-dark(rgb(255 255 255 / 0.9), rgb(255 255 255 / 0.07));
    --bar-edge: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.07));
    --bar-shade: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.5));
    --tile-gloss: light-dark(#fff, rgb(255 255 255 / 0.1));
    --tile-edge: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.05));
    --tile-shade: light-dark(rgb(0 0 0 / 0.09), rgb(0 0 0 / 0.4));
    --tile-glow: light-dark(rgb(120 170 230 / 0.45), rgb(120 170 230 / 0.35));
    --hint-edge: light-dark(rgb(0 0 0 / 0.03), rgb(255 255 255 / 0.07));
    --hint-shade: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.45));
    --menu-gloss: light-dark(rgb(255 255 255 / 0.9), rgb(255 255 255 / 0.06));
    --menu-edge: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.07));
    --shadow: light-dark(rgb(0 0 0 / 0.18), rgb(0 0 0 / 0.6));
    --pick-shade: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.4));
    --field-edge: light-dark(#e2e2e2, #3c3c3f);
    --field-focus: light-dark(#c9c9c9, #6b6b6f);
    --field-ring: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.08));
    --focus: light-dark(#3b82f6, #5b9bff);
    --pointer-beam: light-dark(#141414, #f0f0f0);
  }
}
/* The colorScheme prop. Without it the root sets nothing and inherits the page's color-scheme. */
.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;
}
.root *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}
.root :where(button, input) {
  margin: 0;
  font: inherit;
  color: inherit;
}
.root :where(button) {
  cursor: pointer;
}
.root :where(button, input):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The text: a column in the middle, fading towards its foot, clipped to the stage by a plain
   rectangle (a rounded clip would need a mask the size of the stage). */
.clip {
  position: absolute;
  inset: 0;
  overflow: hidden;
}
.column {
  position: absolute;
  top: 180px;
  right: 0;
  left: 0;
  width: min(560px, 100% - 48px);
  margin: 0 auto;
}
/* The text's layer, cut off where the fade ends. */
.paper {
  position: relative;
  max-height: 210px;
  overflow: clip;
  will-change: transform;
}
/* The fade: the stage's colour, from clear just under the first line to solid at the foot. */
.paper::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom, transparent 8px, var(--stage) 210px);
  pointer-events: none;
}
.editor {
  position: relative;
  min-height: 38px;
  color: var(--ink);
  font-size: 20.4px;
  line-height: 38px;
  white-space: pre-wrap;
  overflow-wrap: break-word;
  outline: none;
  caret-color: var(--ink);
  cursor: text;
  transition: color 0.2s ease;
}
/* While the toolbar shows, the rest of the text steps back and the selection stays dark. */
.editor[data-active] {
  color: var(--dim);
}
.editor::selection,
.editor *::selection {
  background-color: transparent;
  color: var(--ink);
}
/* A rule of its own: a browser that doesn't know ::highlight() drops the whole selector list. */
.editor::highlight(format-toolbar-selection),
.editor *::highlight(format-toolbar-selection) {
  color: var(--ink);
}
/* The selection's fill, a box behind each of its lines (format-toolbar.tsx). */
.fill {
  position: absolute;
  border-radius: 4px;
  background: var(--selection);
  pointer-events: none;
}
.editor strong {
  font-weight: 700;
}
.editor u {
  text-decoration-thickness: 1.5px;
  text-underline-offset: 4px;
}
.editor a {
  color: var(--accent);
  text-decoration: underline;
  text-decoration-thickness: 1.5px;
  text-underline-offset: 4px;
}
/* A run in a colour of its own (rich-text.ts writes its value as --color, and the one it takes at
   night as --color-dark). */
.editor [data-color] {
  color: var(--color);
}
@supports (color: light-dark(#fff, #000)) {
  .editor [data-color] {
    color: light-dark(var(--color), var(--color-dark));
  }
}

/* The block's types. */
.paper[data-block='heading'] .editor {
  font-size: 30px;
  font-weight: 700;
  line-height: 40px;
  letter-spacing: -0.015em;
}
.paper[data-block='subheading'] .editor {
  font-size: 24px;
  font-weight: 700;
  line-height: 34px;
  letter-spacing: -0.01em;
}
.paper[data-block='quote'] .editor {
  padding-left: 20px;
  box-shadow: inset 3px 0 0 var(--quote);
  font-style: italic;
}
/* The bullet, outside the text so the caret never meets it. */
.paper[data-listed] {
  padding-left: 28px;
}
.paper[data-listed]::before {
  content: '';
  position: absolute;
  top: 16px;
  left: 9px;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: currentColor;
}
.paper[data-listed][data-block='heading']::before {
  top: 17px;
}

/* The toolbar: placed by a transform; it fades in, and glides between selections. */
.toolbar {
  position: absolute;
  z-index: 20;
  top: 0;
  left: 0;
  transform-origin: 0 0;
  opacity: 0;
  pointer-events: none;
  will-change: transform;
  transition: opacity 0.18s ease;
}
.toolbar[data-shown] {
  opacity: 1;
  pointer-events: auto;
}
.bar {
  display: flex;
  align-items: center;
  height: 62px;
  padding: 0 10.5px;
  border-radius: 18px;
  background: linear-gradient(var(--bar-top), var(--bar-foot));
  box-shadow:
    inset 0 1px 0 var(--bar-gloss),
    0 0 0 0.5px var(--bar-edge),
    0 4px 14px -2px var(--bar-shade);
  white-space: nowrap;
}
.slot {
  position: relative;
  display: flex;
  flex: none;
}
.slot + .slot {
  margin-left: 9px;
}
.divider {
  flex: none;
  width: 1px;
  height: 36px;
  margin: 0 16px;
  background: var(--line);
}

.tool {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: var(--icon);
  transition:
    background-color 0.15s ease,
    box-shadow 0.15s ease;
}
.tool svg {
  display: block;
  width: 24px;
  height: 24px;
}
/* The dot sits closer to the lines around it than the other tools do. */
.tool[data-tool='color'] {
  margin: 0 -8.5px 0 -11.5px;
}
/* A tool under the pointer, or on: a soft raised tile, lit from the top. */
.tool:hover,
.tool[data-hinted],
.tool[aria-pressed='true'],
.tool[aria-expanded='true'] {
  background: linear-gradient(var(--tile-top), var(--tile-foot));
  box-shadow:
    inset 0 1px 0 var(--tile-gloss),
    0 0 0 0.5px var(--tile-edge),
    0 2px 3px var(--tile-shade),
    -2px 0 6px -3px var(--tile-glow);
}
.boldGlyph {
  color: var(--ink);
  font-size: 26px;
  font-weight: 400;
  line-height: 1;
  transform: scaleX(0.86);
}
.tool[aria-pressed='true'] .boldGlyph {
  font-weight: 700;
}
/* The colour dot: a small sphere of the selection's colour. Its swatch (rich-text.ts) comes as
   --swatch, and as --swatch-dark when it has one of its own at night. */
.dot {
  --tone: var(--swatch);
  display: block;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: radial-gradient(
    circle at 35% 28%,
    color-mix(in srgb, var(--tone), #fff 32%) 0%,
    var(--tone) 52%,
    color-mix(in srgb, var(--tone), #000 28%) 100%
  );
  box-shadow:
    inset 0 -1px 2px rgb(0 0 0 / 0.15),
    0 1px 2px rgb(0 0 0 / 0.18);
}
@supports (color: light-dark(#fff, #000)) {
  .dot {
    --tone: light-dark(var(--swatch), var(--swatch-dark, var(--swatch)));
  }
}

/* The block's type, as its name and a chevron. */
.blockButton {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 40px;
  margin: 0 -6.6px 0 -7.5px;
  padding: 0 6px 0 8px;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: var(--ink);
  font-size: 18px;
  line-height: 1;
  transition: background-color 0.15s ease;
}
.blockButton:hover,
.blockButton[data-hinted],
.blockButton[aria-expanded='true'] {
  background: var(--hover-soft);
}
.blockShort {
  display: none;
}
.chevron {
  width: 18px;
  height: 18px;
  color: var(--chevron);
}

/* The rewrite orb: a soap bubble that turns its colours while it shimmers. Its rim and gloss lie
   over the colours, on a layer that stays still. */
.orbButton {
  display: grid;
  place-items: center;
  width: 42px;
  height: 42px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
}
.orb {
  position: relative;
  display: block;
  width: 42px;
  height: 42px;
  overflow: hidden;
  border-radius: 50%;
  box-shadow:
    0 0 0 1px rgb(255 255 255 / 0.55),
    0 1px 3px rgb(90 70 150 / 0.22);
  transition: transform 0.2s ease;
}
.orbButton:active .orb {
  transform: scale(0.94);
}
.orbColors {
  position: absolute;
  inset: -25%;
  background:
    radial-gradient(circle at 36% 52%, #f494d6 0%, transparent 32%),
    radial-gradient(circle at 72% 30%, #86cff6 0%, transparent 34%),
    radial-gradient(circle at 56% 72%, #ad7ef0 0%, transparent 36%),
    radial-gradient(circle at 46% 34%, #f3e3fc 0%, transparent 24%), #cdbdf3;
}
.orbButton:hover .orbColors,
.orbButton[aria-expanded='true'] .orbColors {
  animation: swirl 2.6s linear infinite;
}
@keyframes swirl {
  to {
    transform: rotate(360deg);
  }
}
.orbGloss {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background:
    radial-gradient(circle at 30% 24%, rgb(255 255 255 / 0.7) 0%, transparent 26%),
    radial-gradient(circle, rgb(255 255 255 / 0.15) 0 40%, transparent 66%);
  box-shadow:
    inset 0 0 1.5px 1.5px rgb(132 124 204 / 0.7),
    inset 0 0 2px 4px rgb(255 255 255 / 0.55);
}
.sparkles {
  position: absolute;
  inset: 0;
  width: 33px;
  height: 33px;
  margin: auto;
  fill: #fff;
  filter: drop-shadow(0 0 2px rgb(255 255 255 / 0.6));
}

/* The tip over a hovered tool: light, with the tool's name and shortcut. */
.hint {
  position: absolute;
  z-index: 3;
  bottom: calc(100% + 23px);
  left: 50%;
  height: 34px;
  padding: 0 12px;
  border-radius: 10px;
  background: var(--hint);
  box-shadow:
    0 0 0 1px var(--hint-edge),
    0 2px 8px var(--hint-shade);
  color: var(--ink);
  font-size: 14.5px;
  line-height: 34px;
  white-space: nowrap;
  translate: -50% 0;
  pointer-events: none;
  /* A layer of its own, so that the toolbar's keeps its size. */
  will-change: transform;
  animation: pop 0.14s cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes pop {
  from {
    opacity: 0;
    transform: translateY(4px) scale(0.97);
  }
}

/* The menus under the tools: the rewrites, the block's types, the colours, the link. */
.popover {
  position: absolute;
  z-index: 2;
  top: calc(100% + 21px);
  left: 0;
  display: grid;
  gap: 2px;
  min-width: 184px;
  padding: 4px;
  border-radius: 12px;
  background: var(--surface);
  box-shadow:
    inset 0 1px 0 var(--menu-gloss),
    0 0 0 1px var(--menu-edge),
    0 12px 24px -8px var(--shadow);
  white-space: normal;
  /* A layer of its own, like the tip. */
  will-change: transform;
  animation: pop 0.16s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.menuItem,
.suggestion {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  width: 100%;
  min-height: 36px;
  padding: 0 10px;
  border: 0;
  border-radius: 8px;
  background: transparent;
  color: var(--ink);
  text-align: left;
  transition: background-color 0.12s ease;
}
.menuItem:hover,
.menuItem[data-active],
.suggestion:hover,
.suggestion[data-active] {
  background: var(--hover);
}
.menuSample {
  font-size: 15px;
}
.menuSample[data-block='heading'] {
  font-size: 17px;
  font-weight: 700;
}
.menuSample[data-block='subheading'] {
  font-weight: 700;
}
.menuSample[data-block='quote'] {
  padding-left: 8px;
  box-shadow: inset 2px 0 0 var(--quote);
  font-style: italic;
}
.menuCheck {
  display: grid;
  color: var(--check);
  opacity: 0;
}
.menuCheck[data-on] {
  opacity: 1;
}
.menuCheck svg {
  width: 16px;
  height: 16px;
}
.suggestion {
  display: grid;
  gap: 2px;
  width: 280px;
  padding: 8px 10px;
}
.suggestionLabel {
  color: var(--ink-muted);
  font-size: 12px;
  font-weight: 500;
}
.suggestionText {
  font-size: 14px;
  line-height: 20px;
}
.noRewrite {
  width: 280px;
  margin: 0;
  padding: 10px 12px;
  color: var(--ink-muted);
  font-size: 14px;
}
/* Thinking: a sheen runs across the words. */
.thinking {
  width: 280px;
  margin: 0;
  padding: 10px 12px;
  background: linear-gradient(90deg, var(--sheen) 30%, var(--sheen-glint) 50%, var(--sheen) 70%) 0
    0 / 300% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  font-size: 14px;
  animation: sheen 1.2s linear infinite;
}
@keyframes sheen {
  from {
    background-position: 100% 0;
  }
  to {
    background-position: 0 0;
  }
}
.swatches {
  left: 50%;
  display: flex;
  gap: 2px;
  min-width: 0;
  translate: -50% 0;
}
.swatch {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 0;
  border-radius: 8px;
  background: transparent;
}
.swatch:hover,
.swatch[aria-pressed='true'] {
  background: var(--surface-2);
  box-shadow: 0 1px 3px var(--pick-shade);
}
.swatch .dot {
  width: 20px;
  height: 20px;
}
.linkForm {
  display: flex;
  gap: 4px;
}
.address {
  width: 210px;
  height: 36px;
  padding: 0 10px;
  border: 0;
  border-radius: 8px;
  background: var(--field);
  box-shadow: inset 0 0 0 1px var(--field-edge);
  font-size: 14px;
}
.root .address:focus-visible {
  outline: none;
  box-shadow:
    inset 0 0 0 1px var(--field-focus),
    0 0 0 3px var(--field-ring);
}

/* Narrow stages (the component sets data-narrow from its width): smaller text, a smaller
   toolbar, and the block's type as a pilcrow. */
.root[data-narrow] .column {
  top: 150px;
  width: calc(100% - 32px);
}
.root[data-narrow] .editor {
  font-size: 16px;
  line-height: 28px;
}
.root[data-narrow] .paper[data-block='heading'] .editor {
  font-size: 23px;
  line-height: 31px;
}
.root[data-narrow] .paper[data-block='subheading'] .editor {
  font-size: 19px;
  line-height: 27px;
}
.root[data-narrow] .paper[data-listed]::before {
  top: 11px;
}
.root[data-narrow] .bar {
  height: 44px;
  padding: 0 5px;
  border-radius: 14px;
}
.root[data-narrow] .slot + .slot {
  margin-left: 0;
}
.root[data-narrow] .divider {
  height: 22px;
  margin: 0 3px;
}
.root[data-narrow] :is(.tool, .orbButton, .orb) {
  width: 30px;
  height: 30px;
}
.root[data-narrow] .tool svg {
  width: 18px;
  height: 18px;
}
.root[data-narrow] .boldGlyph {
  font-size: 18px;
}
.root[data-narrow] .dot {
  width: 18px;
  height: 18px;
}
.root[data-narrow] .sparkles {
  width: 23.5px;
  height: 23.5px;
}
.root[data-narrow] .blockButton {
  gap: 0;
  height: 30px;
  margin: 0;
  padding: 0 0 0 5px;
  font-size: 16px;
}
.root[data-narrow] .tool[data-tool='color'] {
  margin: 0;
}
.root[data-narrow] .blockLabel {
  display: none;
}
.root[data-narrow] .blockShort {
  display: block;
}
.root[data-narrow] .chevron {
  width: 14px;
  height: 14px;
}
.root[data-narrow] .hint {
  bottom: calc(100% + 14px);
}
.root[data-narrow] .popover {
  top: calc(100% + 12px);
}
.root[data-narrow] :is(.suggestion, .thinking, .noRewrite) {
  width: 240px;
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .editor,
  .toolbar,
  .tool,
  .blockButton,
  .orb,
  .menuItem,
  .suggestion {
    transition: none;
  }
}
```

### format-toolbar/toolbar.tsx

```tsx
'use client';

import { useState, type CSSProperties, type KeyboardEvent, type ReactNode, type Ref } from 'react';
import { CheckIcon, ChevronIcon, ItalicIcon, LinkIcon, ListIcon, UnderlineIcon } from './icons';
import { Orb } from './orb';
import type { Rewrite } from './rewrites';
import { COLORS, type Marks, type Swatch } from './rich-text';
import styles from './format-toolbar.module.css';

/*
 * The floating toolbar: the rewrite orb, the block's type, bold, italic, underline, a link, the
 * text colour and a bulleted list. A hovered or focused tool names itself, with its shortcut, in
 * a light tip above it. The orb, the block's type and the colour open small menus under their
 * tool; the link opens an address field. A press on the toolbar never takes the focus from the
 * text, so the selection stays; from the keyboard it is a toolbar (Tab from the text reaches it,
 * the arrow keys move along it, Escape goes back to the text).
 */

export type Block = 'heading' | 'subheading' | 'paragraph' | 'quote';
export type Tool =
  'rewrite' | 'block' | 'bold' | 'italic' | 'underline' | 'link' | 'color' | 'list';
export type Menu = 'rewrite' | 'block' | 'color' | 'link';
export type Toggle = 'bold' | 'italic' | 'underline';

export const BLOCKS: { block: Block; label: string }[] = [
  { block: 'heading', label: 'Heading' },
  { block: 'subheading', label: 'Subheading' },
  { block: 'paragraph', label: 'Paragraph' },
  { block: 'quote', label: 'Quote' },
];

/** Each tool's name, and its shortcut where it has one. */
const HINTS: Record<Tool, string> = {
  rewrite: 'Rewrite',
  block: 'Turn into',
  bold: 'Bold ⌘B',
  italic: 'Italic ⌘I',
  underline: 'Underline ⌘U',
  link: 'Link ⌘K',
  color: 'Text colour',
  list: 'Bulleted list',
};

/** Moves the focus along a row of buttons with the arrow keys, Home and End. */
function rove(event: KeyboardEvent<HTMLElement>, vertical = false) {
  const back = vertical ? 'ArrowUp' : 'ArrowLeft';
  const forth = vertical ? 'ArrowDown' : 'ArrowRight';
  if (![back, forth, 'Home', 'End'].includes(event.key)) return;
  const buttons = Array.from(
    event.currentTarget.querySelectorAll<HTMLElement>(':scope > * > button, :scope > button'),
  );
  const at = buttons.indexOf(document.activeElement as HTMLElement);
  if (at < 0) return;
  event.preventDefault();
  event.stopPropagation();
  const count = buttons.length;
  const next =
    event.key === 'Home'
      ? 0
      : event.key === 'End'
        ? count - 1
        : (at + (event.key === forth ? 1 : -1) + count) % count;
  buttons.forEach((button, i) => (button.tabIndex = i === next ? 0 : -1));
  buttons[next].focus();
}

export function Toolbar({
  ref,
  shown,
  x,
  y,
  fit,
  marks,
  block,
  listed,
  color,
  menu,
  hint,
  menuActive,
  thinking,
  rewrites,
  onHint,
  onMenu,
  onToggle,
  onBlock,
  onList,
  onColor,
  onLink,
  onRewrite,
  onLeave,
}: {
  ref: Ref<HTMLDivElement>;
  shown: boolean;
  /** Where the toolbar's top left corner goes, in the stage's pixels. */
  x: number;
  y: number;
  /** How much the toolbar shrinks to fit a narrow stage: 1 for not at all. */
  fit: number;
  /** What the selected text has in common: the toggles show it pressed. */
  marks: Marks;
  block: Block;
  listed: boolean;
  /** The selection's colour, shown in the dot. */
  color: Swatch;
  /** The open menu, if any. */
  menu: Menu | null;
  /** The tool whose tip shows. */
  hint: Tool | null;
  /** The highlighted row of the open menu, for the demo's pointer. */
  menuActive: number;
  /** The orb is working on its suggestions. */
  thinking: boolean;
  rewrites: Rewrite[];
  onHint: (tool: Tool | null) => void;
  /** Opens a menu, or closes it with null; `byKeyboard` moves the focus into it. */
  onMenu: (menu: Menu | null, byKeyboard: boolean) => void;
  onToggle: (mark: Toggle) => void;
  /** Picks a block type; `byKeyboard` sends the focus back to the block's button. */
  onBlock: (block: Block, byKeyboard: boolean) => void;
  onList: () => void;
  /** Picks a colour; `byKeyboard` sends the focus back to the dot. */
  onColor: (swatch: Swatch, byKeyboard: boolean) => void;
  /** Sets the selection's link, or removes it with null. */
  onLink: (href: string | null) => void;
  onRewrite: (rewrite: Rewrite) => void;
  /** Escape: back to the text. */
  onLeave: () => void;
}) {
  const [address, setAddress] = useState('');

  /** A tool's button, its tip and the menu it opens, in a slot of the row. */
  const slot = (
    tool: Tool,
    props: {
      label: string;
      className?: string;
      pressed?: boolean;
      opens?: Menu;
      keys?: string;
      first?: boolean;
      onPress: (byKeyboard: boolean) => void;
    },
    content: ReactNode,
    popover?: ReactNode,
  ) => (
    <span className={styles.slot}>
      <button
        type="button"
        className={props.className ?? styles.tool}
        data-tool={tool}
        data-hinted={hint === tool || undefined}
        aria-label={props.label}
        aria-pressed={props.pressed}
        aria-haspopup={props.opens ? (props.opens === 'color' ? 'true' : 'menu') : undefined}
        aria-expanded={props.opens ? menu === props.opens : undefined}
        aria-keyshortcuts={props.keys}
        tabIndex={props.first ? 0 : -1}
        onPointerEnter={() => onHint(tool)}
        onPointerLeave={() => onHint(null)}
        onFocus={(event) => onHint(event.currentTarget.matches(':focus-visible') ? tool : null)}
        onBlur={() => onHint(null)}
        onClick={(event) => props.onPress(event.detail === 0)}
      >
        {content}
      </button>
      {hint === tool && !menu && (
        <span className={styles.hint} data-part="hint" aria-hidden="true">
          {HINTS[tool]}
        </span>
      )}
      {popover}
    </span>
  );

  const current = BLOCKS.find((each) => each.block === block) ?? BLOCKS[2];

  return (
    <div
      ref={ref}
      className={styles.toolbar}
      data-shown={shown || undefined}
      style={{ transform: `translate(${x}px, ${y}px) scale(${fit})` }}
      inert={!shown}
      // A press here keeps the selection in the text (the address field excepted).
      onPointerDown={(event) => {
        if (!(event.target instanceof HTMLInputElement)) event.preventDefault();
      }}
      onKeyDown={(event) => {
        if (event.key !== 'Escape') return;
        event.preventDefault();
        event.stopPropagation();
        if (menu) onMenu(null, true);
        else onLeave();
      }}
    >
      <div
        className={styles.bar}
        role="toolbar"
        aria-label="Format the text"
        onKeyDown={(event) => rove(event)}
      >
        {slot(
          'rewrite',
          {
            label: 'Rewrite with suggestions',
            className: styles.orbButton,
            opens: 'rewrite',
            first: true,
            onPress: (byKeyboard) => onMenu(menu === 'rewrite' ? null : 'rewrite', byKeyboard),
          },
          <Orb />,
          menu === 'rewrite' && (
            <div
              className={styles.popover}
              data-part="menu"
              role="menu"
              aria-label="Rewrites"
              aria-busy={thinking}
              onKeyDown={(event) => rove(event, true)}
            >
              {thinking ? (
                <p className={styles.thinking}>Thinking…</p>
              ) : !rewrites.length ? (
                <p className={styles.noRewrite}>No rewrites for these words.</p>
              ) : (
                rewrites.map((rewrite, index) => (
                  <button
                    key={rewrite.label}
                    type="button"
                    className={styles.suggestion}
                    role="menuitem"
                    data-active={index === menuActive || undefined}
                    tabIndex={index ? -1 : 0}
                    onClick={() => onRewrite(rewrite)}
                  >
                    <span className={styles.suggestionLabel}>{rewrite.label}</span>
                    <span className={styles.suggestionText}>{rewrite.text}</span>
                  </button>
                ))
              )}
            </div>
          ),
        )}
        <span className={styles.divider} aria-hidden="true" />
        {slot(
          'block',
          {
            label: `Turn into: ${current.label}`,
            className: styles.blockButton,
            opens: 'block',
            onPress: (byKeyboard) => onMenu(menu === 'block' ? null : 'block', byKeyboard),
          },
          <>
            <span className={styles.blockLabel}>{current.label}</span>
            <span className={styles.blockShort} aria-hidden="true">
              ¶
            </span>
            <ChevronIcon className={styles.chevron} />
          </>,
          menu === 'block' && (
            <div
              className={styles.popover}
              data-part="menu"
              role="menu"
              aria-label="Turn into"
              onKeyDown={(event) => rove(event, true)}
            >
              {BLOCKS.map((each, index) => (
                <button
                  key={each.block}
                  type="button"
                  className={styles.menuItem}
                  role="menuitemradio"
                  aria-checked={each.block === block}
                  data-block={each.block}
                  data-active={index === menuActive || undefined}
                  tabIndex={each.block === block ? 0 : -1}
                  onClick={(event) => onBlock(each.block, event.detail === 0)}
                >
                  <span className={styles.menuSample} data-block={each.block}>
                    {each.label}
                  </span>
                  <span className={styles.menuCheck} data-on={each.block === block || undefined}>
                    <CheckIcon />
                  </span>
                </button>
              ))}
            </div>
          ),
        )}
        <span className={styles.divider} aria-hidden="true" />
        {slot(
          'bold',
          {
            label: 'Bold',
            pressed: !!marks.bold,
            keys: 'Meta+B Control+B',
            onPress: () => onToggle('bold'),
          },
          <span className={styles.boldGlyph} aria-hidden="true">
            B
          </span>,
        )}
        {slot(
          'italic',
          {
            label: 'Italic',
            pressed: !!marks.italic,
            keys: 'Meta+I Control+I',
            onPress: () => onToggle('italic'),
          },
          <ItalicIcon />,
        )}
        {slot(
          'underline',
          {
            label: 'Underline',
            pressed: !!marks.underline,
            keys: 'Meta+U Control+U',
            onPress: () => onToggle('underline'),
          },
          <UnderlineIcon />,
        )}
        {slot(
          'link',
          {
            label: marks.href ? 'Remove the link' : 'Link',
            pressed: !!marks.href,
            keys: 'Meta+K Control+K',
            onPress: () => {
              if (marks.href) onLink(null);
              else {
                setAddress('');
                onMenu(menu === 'link' ? null : 'link', true);
              }
            },
          },
          <LinkIcon />,
          menu === 'link' && (
            <form
              className={`${styles.popover} ${styles.linkForm}`}
              data-part="menu"
              onSubmit={(event) => {
                event.preventDefault();
                const text = address.trim();
                if (text) onLink(/^[a-z][\w+.-]*:/i.test(text) ? text : `https://${text}`);
                else onMenu(null, true);
              }}
            >
              <input
                className={styles.address}
                value={address}
                placeholder="Paste a link"
                aria-label="Link address"
                inputMode="url"
                autoComplete="off"
                spellCheck={false}
                onChange={(event) => setAddress(event.target.value)}
              />
              <button type="submit" className={styles.tool} aria-label="Add the link">
                <CheckIcon />
              </button>
            </form>
          ),
        )}
        <span className={styles.divider} aria-hidden="true" />
        {slot(
          'color',
          {
            label: `Text colour: ${color.name}`,
            opens: 'color',
            onPress: (byKeyboard) => onMenu(menu === 'color' ? null : 'color', byKeyboard),
          },
          <span
            className={styles.dot}
            style={{ '--swatch': color.swatch, '--swatch-dark': color.swatchDark } as CSSProperties}
          />,
          menu === 'color' && (
            <div
              className={`${styles.popover} ${styles.swatches}`}
              data-part="menu"
              role="group"
              aria-label="Text colour"
              onKeyDown={(event) => rove(event)}
            >
              {COLORS.map((swatch) => (
                <button
                  key={swatch.id || 'default'}
                  type="button"
                  className={styles.swatch}
                  aria-label={swatch.name}
                  aria-pressed={swatch.id === color.id}
                  tabIndex={swatch.id === color.id ? 0 : -1}
                  onClick={(event) => onColor(swatch, event.detail === 0)}
                >
                  <span
                    className={styles.dot}
                    style={
                      {
                        '--swatch': swatch.swatch,
                        '--swatch-dark': swatch.swatchDark,
                      } as CSSProperties
                    }
                  />
                </button>
              ))}
            </div>
          ),
        )}
        <span className={styles.divider} aria-hidden="true" />
        {slot('list', { label: 'Bulleted list', pressed: listed, onPress: onList }, <ListIcon />)}
      </div>
    </div>
  );
}
```

### format-toolbar/rich-text.ts

```tsx
/*
 * The text's model. A block is a list of runs: pieces of text that share the same marks (bold,
 * italic, underline, a colour, a link). The editor writes the runs as HTML, reads them back from
 * the DOM after the browser has typed into it, and formats a selection by cutting the runs at its
 * edges and changing the marks of the runs in between. Positions are character offsets into the
 * block, so a selection survives a fresh render.
 */

export type Marks = {
  bold?: boolean;
  italic?: boolean;
  underline?: boolean;
  /** The id of one of COLORS. */
  color?: string;
  /** A link's address. */
  href?: string;
};

export type Run = { text: string; marks: Marks };

/** A stretch of the block, in characters: from `start` up to, not including, `end`. */
export type Span = { start: number; end: number };

/**
 * A text colour: its name, the colour of its swatch and the colour it gives the text, and the two
 * at night where they differ. The stylesheet picks one or the other with light-dark().
 */
export type Swatch = {
  id: string;
  name: string;
  swatch: string;
  value: string;
  swatchDark?: string;
  valueDark?: string;
};

/**
 * Text colours. The first, with an empty id, takes the colour back to the block's own; its swatch
 * is the text's grey, light at night. At night the others lighten, to read on the dark paper.
 */
export const COLORS: Swatch[] = [
  { id: '', name: 'Default', swatch: '#4a4a4a', swatchDark: '#d6d6d6', value: 'inherit' },
  { id: 'red', name: 'Red', swatch: '#ec5a51', value: '#d93d33', valueDark: '#ff7b72' },
  { id: 'orange', name: 'Orange', swatch: '#f08c2e', value: '#cf6a0b', valueDark: '#f5a35c' },
  { id: 'green', name: 'Green', swatch: '#3fb26b', value: '#1f8746', valueDark: '#5cc98a' },
  { id: 'blue', name: 'Blue', swatch: '#4f72f7', value: '#2f55e4', valueDark: '#7c9dff' },
  { id: 'purple', name: 'Purple', swatch: '#9d6ae6', value: '#7a43cc', valueDark: '#b48cff' },
];

/** The marks with nothing switched off left in them, so that two equal sets compare equal. */
function clean(marks: Marks): Marks {
  const out: Marks = {};
  if (marks.bold) out.bold = true;
  if (marks.italic) out.italic = true;
  if (marks.underline) out.underline = true;
  if (marks.color) out.color = marks.color;
  if (marks.href) out.href = marks.href;
  return out;
}

const same = (a: Marks, b: Marks) =>
  !!a.bold === !!b.bold &&
  !!a.italic === !!b.italic &&
  !!a.underline === !!b.underline &&
  (a.color ?? '') === (b.color ?? '') &&
  (a.href ?? '') === (b.href ?? '');

/** Drops empty runs and joins neighbours that look the same. */
export function normalize(runs: Run[]): Run[] {
  const out: Run[] = [];
  for (const run of runs) {
    if (!run.text) continue;
    const last = out[out.length - 1];
    if (last && same(last.marks, run.marks)) last.text += run.text;
    else out.push({ text: run.text, marks: clean(run.marks) });
  }
  return out;
}

export const textOf = (runs: Run[]) => runs.map((run) => run.text).join('');

/** The runs, with one cut at `offset` so that a run starts there. */
function cut(runs: Run[], offset: number): Run[] {
  const out: Run[] = [];
  let at = 0;
  for (const run of runs) {
    const end = at + run.text.length;
    if (offset > at && offset < end) {
      out.push({ text: run.text.slice(0, offset - at), marks: run.marks });
      out.push({ text: run.text.slice(offset - at), marks: run.marks });
    } else out.push(run);
    at = end;
  }
  return out;
}

/** Sets marks on the characters of `span`; a mark set to false or undefined comes off. */
export function format(runs: Run[], span: Span, patch: Marks): Run[] {
  let at = 0;
  const out = cut(cut(runs, span.start), span.end).map((run) => {
    const start = at;
    at += run.text.length;
    const inside = start >= span.start && at <= span.end;
    return inside ? { text: run.text, marks: { ...run.marks, ...patch } } : run;
  });
  return normalize(out);
}

/** What the characters of `span` share: a mark counts only if every one of them has it. */
export function marksOf(runs: Run[], span: Span): Marks {
  let at = 0;
  let common: Marks | null = null;
  for (const run of runs) {
    const start = at;
    at += run.text.length;
    if (at <= span.start || start >= span.end) continue;
    const marks = run.marks;
    common = common
      ? clean({
          bold: common.bold && marks.bold,
          italic: common.italic && marks.italic,
          underline: common.underline && marks.underline,
          color: common.color === marks.color ? common.color : undefined,
          href: common.href === marks.href ? common.href : undefined,
        })
      : clean(marks);
  }
  return common ?? {};
}

/** Puts plain `text` in place of `span`, in the marks of the text it replaces. */
export function replace(runs: Run[], span: Span, text: string): Run[] {
  const before: Run[] = [];
  const inside: Run[] = [];
  const after: Run[] = [];
  let at = 0;
  for (const run of cut(cut(runs, span.start), span.end)) {
    const start = at;
    at += run.text.length;
    if (at <= span.start) before.push(run);
    else if (start >= span.end) after.push(run);
    else inside.push(run);
  }
  const marks = inside[0]?.marks ?? before[before.length - 1]?.marks ?? after[0]?.marks ?? {};
  return normalize([...before, { text, marks }, ...after]);
}

const escapeText = (text: string) =>
  text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapeAttribute = (text: string) => escapeText(text).replace(/"/g, '&quot;');

/** The tags that wrap one run's text, outermost first. */
function wrap(text: string, marks: Marks): string {
  let html = text;
  if (marks.bold) html = `<strong>${html}</strong>`;
  if (marks.italic) html = `<em>${html}</em>`;
  if (marks.underline) html = `<u>${html}</u>`;
  const color = COLORS.find((swatch) => swatch.id && swatch.id === marks.color);
  // Its value for each scheme, as custom properties: the stylesheet colours the run with them.
  if (color) {
    const style = `--color:${color.value};--color-dark:${color.valueDark ?? color.value}`;
    html = `<span data-color="${color.id}" style="${style}">${html}</span>`;
  }
  if (marks.href) html = `<a href="${escapeAttribute(marks.href)}">${html}</a>`;
  return html;
}

/** The block's HTML. */
export const toHTML = (runs: Run[]) =>
  runs.map((run) => wrap(escapeText(run.text), run.marks)).join('');

/** Reads the runs back from the editor's DOM, after the browser has typed into it. */
export function fromDOM(root: HTMLElement): Run[] {
  const runs: Run[] = [];
  const walk = (node: Node, marks: Marks) => {
    if (node.nodeType === Node.TEXT_NODE) {
      runs.push({ text: node.textContent ?? '', marks });
      return;
    }
    if (!(node instanceof HTMLElement)) return;
    const next: Marks = { ...marks };
    const tag = node.tagName;
    if (tag === 'STRONG' || tag === 'B') next.bold = true;
    if (tag === 'EM' || tag === 'I') next.italic = true;
    if (tag === 'U') next.underline = true;
    if (tag === 'A' && node.getAttribute('href')) next.href = node.getAttribute('href') ?? '';
    if (node.dataset.color) next.color = node.dataset.color;
    node.childNodes.forEach((child) => walk(child, next));
  };
  root.childNodes.forEach((child) => walk(child, {}));
  return normalize(runs);
}

/**
 * The editor's DOM written the way toHTML writes it. When it differs from toHTML(fromDOM(root)),
 * the browser has left something of its own behind (a <br>, a styled <span>, two runs that should
 * be one), and the editor renders its runs afresh.
 */
export function serialize(root: Node): string {
  let html = '';
  root.childNodes.forEach((node) => {
    if (node.nodeType === Node.TEXT_NODE) {
      html += escapeText(node.textContent ?? '');
      return;
    }
    if (!(node instanceof HTMLElement)) return;
    const inner = serialize(node);
    const tag = node.tagName;
    if (tag === 'STRONG' || tag === 'EM' || tag === 'U') {
      html += `<${tag.toLowerCase()}>${inner}</${tag.toLowerCase()}>`;
    } else if (tag === 'SPAN' && node.dataset.color) {
      html += wrap(inner, { color: node.dataset.color });
    } else if (tag === 'A' && node.getAttribute('href')) {
      html += wrap(inner, { href: node.getAttribute('href') ?? '' });
    } else html += `<${tag}?>${inner}`;
  });
  return html;
}

/** The character offset of a DOM position inside `root`. */
export function offsetOf(root: Node, node: Node, offset: number): number {
  const range = document.createRange();
  range.setStart(root, 0);
  range.setEnd(node, offset);
  return range.toString().length;
}

/** The DOM position of a character offset: in the text node that holds it. */
function pointAt(root: Node, offset: number): { node: Node; offset: number } {
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
  let left = offset;
  let last: Text | null = null;
  for (let node = walker.nextNode(); node; node = walker.nextNode()) {
    const text = node as Text;
    if (left <= text.data.length) return { node: text, offset: left };
    left -= text.data.length;
    last = text;
  }
  return last
    ? { node: last, offset: last.data.length }
    : { node: root, offset: root.childNodes.length };
}

/** A DOM range over `span`. */
export function rangeOf(root: Node, span: Span): Range {
  const range = document.createRange();
  const start = pointAt(root, span.start);
  const end = pointAt(root, span.end);
  range.setStart(start.node, start.offset);
  range.setEnd(end.node, end.offset);
  return range;
}

/** The selection's span inside `root`, when the selection is there. */
export function selectedSpan(root: Node): Span | null {
  const selection = document.getSelection();
  if (!selection?.rangeCount) return null;
  const range = selection.getRangeAt(0);
  if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null;
  const start = offsetOf(root, range.startContainer, range.startOffset);
  const end = offsetOf(root, range.endContainer, range.endOffset);
  return { start: Math.min(start, end), end: Math.max(start, end) };
}

/** Selects `span` inside `root`. */
export function select(root: Node, span: Span) {
  const selection = document.getSelection();
  if (!selection) return;
  selection.removeAllRanges();
  selection.addRange(rangeOf(root, span));
}
```

### format-toolbar/rewrites.ts

```tsx
/*
 * The orb's rewrite suggestions, made on the spot with no service behind them: written-out
 * alternatives for the demo's own words, and for any other selection three simple passes (without
 * filler words, in sentence case, in title case). Pass your own through the `rewrites` prop.
 */

export type Rewrite = { label: string; text: string };

/** Alternatives written out for the demo's first words. */
const WRITTEN: Record<string, Rewrite[]> = {
  'Margins is a calm, focused writing space': [
    { label: 'Shorter', text: 'Margins is a quiet place to write' },
    { label: 'Warmer', text: 'Margins is a cosy, focused home for your writing' },
    { label: 'Plainer', text: 'Margins is a simple app for writing' },
  ],
};

const FILLER = /\b(very|really|just|quite|simply|actually|basically|truly|rather)\s+/gi;

const sentenceCase = (text: string) => {
  const lower = text.toLowerCase();
  return lower.charAt(0).toUpperCase() + lower.slice(1);
};

const titleCase = (text: string) =>
  text
    .toLowerCase()
    .replace(/(^|\s)(\p{L})/gu, (_, space: string, letter: string) => space + letter.toUpperCase());

/** Up to three rewrites of `text`, each different from it. */
export function rewritesOf(text: string): Rewrite[] {
  const trimmed = text.trim();
  if (WRITTEN[trimmed]) return WRITTEN[trimmed];
  const passes: Rewrite[] = [
    { label: 'Tighter', text: trimmed.replace(FILLER, '').replace(/\s{2,}/g, ' ') },
    { label: 'Sentence case', text: sentenceCase(trimmed) },
    { label: 'Title case', text: titleCase(trimmed) },
  ];
  const seen = new Set([trimmed]);
  return passes.filter((pass) => {
    if (!pass.text || seen.has(pass.text)) return false;
    seen.add(pass.text);
    return true;
  });
}
```

### format-toolbar/orb.tsx

```tsx
/*
 * The rewrite button's orb: a soap bubble of pink, violet and sky blue with a lilac rim, a white
 * halo inside it, a gloss at its top left and two four-pointed sparkles. Its colours sit on a
 * layer that turns while it shimmers (the stylesheet spins it with a transform); the rim, the
 * gloss and the sparkles stay.
 */

import styles from './format-toolbar.module.css';

export function Orb() {
  return (
    <span className={styles.orb} aria-hidden="true">
      <span className={styles.orbColors} />
      <span className={styles.orbGloss} />
      <svg className={styles.sparkles} viewBox="0 0 24 24">
        <path d="M13.5 3.9c.55 3.5 2 5.05 5.6 5.6-3.6.55-5.05 2.1-5.6 5.6-.55-3.5-2-5.05-5.6-5.6 3.6-.55 5.05-2.1 5.6-5.6z" />
        <path d="M7 11.4c.47 3 1.7 4.33 4.8 4.8-3.08.47-4.33 1.8-4.8 4.8-.47-3-1.7-4.33-4.8-4.8 3.08-.47 4.33-1.8 4.8-4.8z" />
      </svg>
    </span>
  );
}
```

### format-toolbar/icons.tsx

```tsx
/*
 * The toolbar's icons: outlined, drawn on a 20 × 20 grid with round caps, in the current colour.
 * They are decorative; the buttons that hold them carry the labels.
 */

type IconProps = { className?: string };

const outline = {
  viewBox: '0 0 20 20',
  fill: 'none',
  stroke: 'currentColor',
  strokeWidth: 1.5,
  strokeLinecap: 'round' as const,
  strokeLinejoin: 'round' as const,
  'aria-hidden': true,
};

/** A slanted capital with serifs: italic. */
export function ItalicIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M8.6 3.6h6.2M5.2 16.4h6.2M11.7 3.6 8.3 16.4" />
    </svg>
  );
}

/** A U over a line: underline. */
export function UnderlineIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M5.6 3.4v6.2a4.4 4.4 0 0 0 8.8 0V3.4M4.2 16.8h11.6" />
    </svg>
  );
}

/** Two links of a chain. */
export function LinkIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="m8.2 11.8 3.6-3.6" />
      <path d="m11.4 13.8-1.9 1.9a3.6 3.6 0 0 1-5.2-5.2l1.9-1.9M8.6 6.2l1.9-1.9a3.6 3.6 0 0 1 5.2 5.2l-1.9 1.9" />
    </svg>
  );
}

/** Three lines, each after a dot: a bulleted list. */
export function ListIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="M7.6 5.2h9M7.6 10h9M7.6 14.8h9" />
      <path d="M3.6 5.2h.01M3.6 10h.01M3.6 14.8h.01" strokeWidth="2.2" />
    </svg>
  );
}

/** A chevron pointing down. */
export function ChevronIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="m5.6 8 4.4 4.4L14.4 8" />
    </svg>
  );
}

/** A check mark. */
export function CheckIcon({ className }: IconProps) {
  return (
    <svg className={className} {...outline}>
      <path d="m4.8 10.4 3.4 3.4 7-7.2" />
    </svg>
  );
}
```

### format-toolbar/ghost-pointer.tsx

```tsx
'use client';

import type { Ref } from 'react';
import styles from './ghost-pointer.module.css';

/*
 * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps:
 * wait, glide the pointer onto a control, press it and call the component's own handler, as a
 * click would. The pointer takes the shape a real one would: an arrow, a hand over buttons, an
 * I-beam over text. Its shapes are drawn once, each in a small layer of its own, and swapped by
 * scaling those layers to nothing and back: a change of opacity or of a rule matched through the
 * pointer would reach the drawings, and Safari would lay out the whole page again. Stopping the
 * demo (the reader takes over, or it scrolls away) ends the step in progress at once: its promise
 * rejects, which ends the script right there, and the pointer fades.
 */

export type Shape = 'arrow' | 'hand' | 'text';

/** A point in the stage's own pixels. */
export type Point = { x: number; y: number };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /**
   * Glides the pointer onto `target`: an element (its middle, or a point given as shares of its
   * size) or a point on the stage.
   */
  moveTo: (
    target: Element | Point | null | undefined,
    options?: { at?: Point; shape?: Shape },
  ) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Plays `script` with `pointer` over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  const shapes = Array.from(pointer.querySelectorAll<HTMLElement>('[data-shape-of]'));
  let current: Shape | null = null;
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.92 };

  /** Shows `shape` and hides the others. */
  const become = (shape: Shape) => {
    if (shape === current) return;
    current = shape;
    for (const box of shapes) {
      box.style.transform = box.dataset.shapeOf === shape ? 'scale(1)' : 'scale(0)';
    }
  };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  /** A point of `target`, in the stage's own pixels (the stage may sit in a scaled container). */
  const locate = (target: Element, share: Point = { x: 0.5, y: 0.5 }) => {
    const frame = stage.getBoundingClientRect();
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    return {
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    };
  };

  const place = ({ x, y }: Point) => `translate(${x}px, ${y}px)`;

  const moveTo: Demo['moveTo'] = (target, options = {}) => {
    if (options.shape) become(options.shape);
    if (!target) return step(Promise.resolve());
    const to = target instanceof Element ? locate(target, options.at) : target;
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.14, y: (from.y + to.y) / 2 + dx * 0.14 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(900, Math.max(340, Math.hypot(dx, dy) * 1.5)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const press: Demo['press'] = async (action) => {
    const shape = shapes.find((box) => box.dataset.shapeOf === current);
    const tap = shape?.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.84)', offset: 0.35 },
        { transform: 'scale(1)' },
      ],
      {
        duration: 260,
        easing: 'ease-in-out',
      },
    );
    await wait(90);
    action();
    if (tap) await step(tap.finished);
  };

  become('arrow');
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, moveTo, press }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/** The pointer the demo moves around. It is decorative: the demo's effects show on screen. */
export function GhostPointer({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} className={styles.ghost} aria-hidden="true">
      <span className={styles.shape} data-shape-of="arrow">
        <svg viewBox="0 0 20 20">
          <path d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z" />
        </svg>
      </span>
      <span className={styles.shape} data-shape-of="hand">
        <svg viewBox="0 0 20 20">
          <path d="M6.1 2.7a1.5 1.5 0 0 1 3 0v4.7a1.25 1.25 0 0 1 2.5.2v.4a1.2 1.2 0 0 1 2.4.4v.6a1.1 1.1 0 0 1 2.2.3V13c0 2.6-1.6 4.6-4.4 4.6H9.6c-1.6 0-2.6-.7-3.5-1.9L3.3 12.1c-.6-.8-.4-1.8.4-2.2.7-.4 1.5-.1 2 .5l.4.6z" />
          <path d="M9.1 8v2.6M11.6 8.3v2.3M14 9.2v1.9" fill="none" />
        </svg>
      </span>
      <span className={styles.shape} data-shape-of="text">
        <svg viewBox="0 0 20 20">
          <path d="M7 3h1.4c.7 0 1.2.3 1.6.8.4-.5.9-.8 1.6-.8H13M10 3.9v12.2M7 17h1.4c.7 0 1.2-.3 1.6-.8.4.5.9.8 1.6.8H13" />
        </svg>
      </span>
    </span>
  );
}
```

### format-toolbar/ghost-pointer.module.css

```css
/* The demo's pointer (ghost-pointer.tsx): an arrow, a hand over buttons, an I-beam over text. Its
   tip sits at the point it is moved to. Each shape sits in a small layer of its own. Its colours
   are the stage's tokens (format-toolbar.module.css). */

.ghost {
  position: absolute;
  z-index: 40;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.3s ease;
}
.shape {
  position: absolute;
  width: 22px;
  height: 22px;
  /* The script shows one shape at a time by its scale. */
  transform: scale(0);
  will-change: transform;
}
.shape svg {
  display: block;
  width: 22px;
  height: 22px;
  overflow: visible;
  filter: drop-shadow(0 1px 1.2px var(--pointer-shadow));
}
.shape[data-shape-of='arrow'] {
  top: -2.2px;
  left: -3.3px;
  transform-origin: 3.3px 2.2px;
  fill: var(--pointer);
  stroke: var(--pointer-edge);
  stroke-width: 1.3;
  stroke-linejoin: round;
}
.shape[data-shape-of='hand'] {
  top: -1.3px;
  left: -8.4px;
  transform-origin: 8.4px 1.3px;
  fill: var(--pointer-edge);
  stroke: var(--pointer-outline);
  stroke-width: 1.1;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.shape[data-shape-of='text'] {
  top: -11px;
  left: -11px;
  transform-origin: 11px 11px;
  fill: none;
  stroke: var(--pointer-beam);
  stroke-width: 1.3;
  stroke-linecap: round;
  stroke-linejoin: round;
  paint-order: stroke;
}

@media (prefers-reduced-motion: reduce) {
  .ghost {
    transition: none;
  }
}
```

### format-toolbar/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 };
}
```