# Sheet menu

> A spreadsheet’s desktop menu bar: menus that switch as the pointer slides across the titles, submenus that open on a rest, and commands the toolbar mirrors.

A spreadsheet window with a real desktop menu bar. You can click a title and slide across the others, rest on Text and pick Italic, or drive it all from the keyboard. The commands format the selected cell or the sheet (text styles, colours, borders, font size, number formats, alignment, gridlines, frozen rows), the toolbar follows, and Undo takes any of it back.

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

Category: Navigation.

## Anatomy

1. **Menu bar**: A `menubar` of titles after the app’s mark. One title takes the Tab stop; ← and → move along it.
2. **Open title**: Shaded while its menu is open. With one open, hovering another title switches to its menu.
3. **Menu**: A card under its title, kept inside the window: it shifts left at the right edge. Every menu is in the page from the start, hidden.
4. **Submenu**: Opens after the pointer rests 180 ms on its item and closes 260 ms after it leaves, so a diagonal path is forgiven. It flips to whichever side has room.
5. **Check item**: A `menuitemcheckbox` or `menuitemradio` whose check follows the selected cell: Bold, since the header row is bold.
6. **Pressed tool**: The toolbar mirrors the selected cell: a format it has is drawn pressed (`aria-pressed`).
7. **Formula bar**: The selected cell’s name and contents, so a command’s target is always named.
8. **Selection**: One frame over the selected cell, moved by its row and column. Every command acts here.

## When to use

- A desktop-style app window with more commands than a toolbar can hold: File, Edit, Format, Data.
- Commands that act on a selection (a cell, a row, a shape), where the toolbar mirrors what is selected.
- Readers who work from the keyboard: arrows across the titles, into submenus, a letter to jump, `⌘B` and `⌘Z` on the grid.
- Settings that are on or off, or one of several: check items and radio items say which at a glance.
- Deep but stable trees of commands, two levels at most, where a forgiving submenu delay saves misses.

## When not to use

- A site’s navigation. Menubar roles promise app behaviour; use links in a nav.
- Two or three actions. Show them as buttons; a menu only hides them.
- Touch-first or phone layouts. Hover-to-switch and submenus are a desktop habit; use a sheet or a list of actions.
- Menus deeper than two levels. Flatten them, or move the rarer ones into a dialog.
- The only way to reach a command. Keep its shortcut or a toolbar button too.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19 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 { SheetMenu } from './sheet-menu';
import { blankSheet } from './sheet';

export function Planner() {
  // An empty sheet; leave initialSheet out for the sample budget.
  return <SheetMenu initialSheet={blankSheet()} autoplay={false} />;
}
```

## States

- **Closed**: Nothing open; A1 selected, so the toolbar shows Bold pressed.
- **Menu open**: A click on a title opens its menu with nothing lit, as on a desktop.
- **Item lit**: The item under the pointer or the arrow keys is shaded. Enter or a click runs it.
- **Submenu open**: Format › Text, its parent item kept lit. Checks follow the selected cell.
- **Unavailable items**: Undo, Redo and Paste are greyed until there is history or a clipboard; they stay focusable but do nothing.
- **Toolbar menu**: The font size dropdown opens the same kind of menu, its radio on the cell’s size.
- **Formatted cell**: Visual design in italic, red and filled: the toolbar’s Italic, Text color and Fill are pressed.
- **Sheet settings**: Gridlines hidden, the header row frozen and a colour scale on Hours: commands on the sheet, not a cell.

## How it works

- One state holds which menu is open, which item is lit, which submenu is open and what is lit in it. A click opens a menu, and while one is open, hovering another title swaps it, as on a desktop.
- Submenus wait for the pointer: one opens after its item has been hovered for 180 ms, and an open one closes 260 ms after the pointer moves to another item, which forgives a diagonal path to it.
- Every command is a pure function from one sheet to the next, so Undo and Redo are two stacks of sheets. The toolbar’s pressed buttons, its font size, its number format and the menus’ checks are all read from the selected cell.
- Every menu and submenu is in the page from the start, hidden and inert, and opening one shows it. An open menu measures itself once and shifts to stay inside the window; its submenus open on whichever side has room, or over it on a phone.
- The selected cell’s frame and the frozen rows’ line move by their transform, and the stage is a single layout boundary, so a command lays out the window alone.
- Every colour is a light-dark() token on the root, the cell formats, the toolbar’s colour bar and the app’s mark included, so the window follows the page’s color-scheme, or the colorScheme prop.

## API reference

- **initialSheet** (`Sheet`, default `createSheet()`): The sheet the window opens with: 16 rows by 6 columns of values and formats, plus gridlines, frozen rows and a colour scale. A small project budget when left out.
- **defaultState** (`SheetMenuState`): Opens with a menu shown and a cell selected: menu (a menu bar menu’s id, or 'toolbar-size' | 'toolbar-number'), active and sub (its lit item and the one whose submenu is open), subActive (the submenu’s lit item) and at ({ row, col }, from 0). Turns the demo off; the menu stays open through presses outside until the window is used.
- **onCommand** (`(command: string, sheet: Sheet) => void`): Called when a command runs, from a menu, the toolbar or a shortcut, with its id and the sheet after it.
- **onSelect** (`(cell: string) => void`): Called when a cell is selected, with its name, as “A4”.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer selects a cell, opens Format, slides across the bar, runs Text › Italic, then hides the gridlines. The next round puts both back.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root, a stage with the window.

## Accessibility

- The menu bar is an ARIA menubar with a single tab stop. Its titles carry aria-haspopup and aria-expanded, and its items are menuitem, menuitemcheckbox or menuitemradio with aria-checked; unavailable items are aria-disabled, and shortcuts are exposed as aria-keyshortcuts.
- The keys work as on a desktop: ← and → move across the titles, and between menus once one is open; ↓, Enter or Space open a menu, and ↑, ↓, Home and End move inside it; → opens a submenu and ← closes it. Enter runs a command, Escape steps back, a letter jumps to the item it starts, and the focus returns to the title or the cell.
- The toolbar is an ARIA toolbar with arrow keys and pressed states, Text color, Fill color and Borders included. The grid’s selected cell takes the focus, moves with the arrow keys and answers ⌘B, ⌘I, ⌘U, ⌘Z, ⇧⌘Z, ⌘C, ⌘X, ⌘V and Delete, and each command the reader runs is announced (“Italic on in A4”, “Borders on in A4”, “Gridlines hidden”).
- Under reduced motion the menus appear at once and the demo does not play.

## Source

### sheet-menu/sheet-menu.tsx

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

'use client';

import {
  Fragment,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import { DemoPointer, runDemo } from './demo-pointer';
import { AppMark, Glyph, Smiley } from './icons';
import { MENUS, TOOLBAR_MENUS, itemsOf, type Menu, type MenuEntry } from './menus';
import {
  COLUMNS,
  NUMBER_FORMATS,
  ROWS,
  apply,
  createSheet,
  display,
  formatOf,
  refOf,
  scaleOf,
  type At,
  type Cell,
  type Sheet,
} from './sheet';
import { useAutoplay } from './use-autoplay';
import styles from './sheet-menu.module.css';

/*
 * Sheet menu: a spreadsheet's window with a desktop menu bar. The commands act on the selected
 * cell or on the sheet, and the toolbar mirrors the selected cell as it changes.
 *
 * - The menu bar works like a desktop one. A click opens a menu; while one is open, hovering
 *   another title switches to it. A submenu opens when its item has been hovered for a moment,
 *   and closes a moment after the pointer moves to another item, so a diagonal path to it is
 *   forgiven. A click outside or Escape closes everything.
 * - Keyboard, as on a desktop: ← and → move across the titles (and from menu to menu once one is
 *   open), ↓, Enter or Space open a menu, ↑ and ↓ move in it, → opens a submenu and ← closes it,
 *   Enter runs a command, Escape steps back, and a letter jumps to the item it starts. The grid
 *   takes the arrow keys too, and ⌘B, ⌘I, ⌘U, ⌘Z.
 * - Every command is a pure function of the sheet (sheet.ts), so Undo keeps the sheets before.
 * - Every menu is in the page from the start, hidden: opening one shows it, and nothing is added
 *   or removed while the demo plays.
 * - While on screen and untouched, a demo pointer selects a cell and runs Format › Text › Italic,
 *   then hides the gridlines; the next round puts both back.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

type Entry = Extract<MenuEntry, { kind: 'item' }>;

/** The open menu, its lit item, the item whose submenu is open, and the submenu's lit item. */
type Menus = {
  open: string | null;
  active: string | null;
  sub: string | null;
  subActive: string | null;
};

type Doc = { sheet: Sheet; past: Sheet[]; future: Sheet[] };

const CLOSED: Menus = { open: null, active: null, sub: null, subActive: null };

/**
 * A state to open in, for docs and tests: a menu shown (a menu bar menu's id, or the toolbar's
 * `toolbar-size` or `toolbar-number`), its lit item, the item whose submenu is open and the
 * submenu's lit item, and the selected cell. Giving one turns the demo off; a menu opened by it
 * stays open through presses outside until the window is used.
 */
export type SheetMenuState = {
  menu?: string;
  active?: string;
  sub?: string;
  subActive?: string;
  at?: At;
};

/** How long the pointer rests on an item before its submenu opens, or before another closes. */
const OPEN_DELAY = 180;
const CLOSE_DELAY = 260;

const ALL_MENUS = [...MENUS, ...TOOLBAR_MENUS];
const menuOf = (id: string | null) => ALL_MENUS.find((menu) => menu.id === id);
const entryOf = (entries: MenuEntry[], id: string | null) =>
  itemsOf(entries).find((entry) => entry.id === id);

/** The toolbar's buttons that toggle or set a format. */
const MARKS = [
  { id: 'bold', label: 'Bold' },
  { id: 'italic', label: 'Italic' },
  { id: 'underline', label: 'Underline' },
  { id: 'strike', label: 'Strikethrough' },
] as const;
/** The toolbar's colour and border toggles: on and off. */
const PAINTS = [
  { id: 'color', label: 'Text color' },
  { id: 'fill', label: 'Fill color' },
  { id: 'border', label: 'Borders' },
] as const;
const ALIGNS = [
  { id: 'align:left', label: 'Align left' },
  { id: 'align:center', label: 'Align center' },
  { id: 'align:right', label: 'Align right' },
] as const;
const VALIGNS = [
  { id: 'valign:middle', label: 'Align middle' },
  { id: 'valign:bottom', label: 'Align bottom' },
  { id: 'valign:top', label: 'Align top' },
] as const;

export function SheetMenu({
  initialSheet,
  autoplay = true,
  colorScheme,
  defaultState,
  onCommand,
  onSelect,
  className = '',
}: {
  /** The sheet the window opens with; a small project budget when left out. */
  initialSheet?: Sheet;
  /** While on screen and untouched, a demo pointer formats a cell through the menus. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens with a menu shown and a cell selected (see SheetMenuState); turns the demo off. */
  defaultState?: SheetMenuState;
  /** A command has run (from a menu, the toolbar or a shortcut), with the sheet after it. */
  onCommand?: (command: string, sheet: Sheet) => void;
  /** A cell was selected, by its name: "A4". */
  onSelect?: (cell: string) => void;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [doc, setDoc] = useState<Doc>(() => ({
    sheet: initialSheet ?? createSheet(),
    past: [],
    future: [],
  }));
  const [at, setAt] = useState<At>(() => defaultState?.at ?? { row: 0, col: 0 });
  const [clip, setClip] = useState<Cell | null>(null);
  const [menus, setMenus] = useState<Menus>(() =>
    defaultState?.menu
      ? {
          open: defaultState.menu,
          active: defaultState.sub ?? defaultState.active ?? null,
          sub: defaultState.sub ?? null,
          subActive: defaultState.subActive ?? null,
        }
      : CLOSED,
  );
  /** A menu opened by defaultState, held through presses outside until the window is used. */
  const held = useRef(!!defaultState?.menu);
  const [titleStop, setTitleStop] = useState(MENUS[0].id);
  const [toolStop, setToolStop] = useState(0);
  const [focused, setFocused] = useState(false);
  const [message, setMessage] = useState('');
  const id = useId();

  const stage = useRef<HTMLDivElement>(null);
  const frame = useRef<HTMLDivElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  /** The latest state, for handlers and timers that run between renders. */
  const live = useRef({ doc, at, clip, menus });
  /** The submenu's open or close, waiting for the pointer to rest. */
  const timer = useRef(0);
  /** Whether the reader is at the controls, so the focus follows what is lit. */
  const byReader = useRef(false);
  /** Where the focus goes once the next change is on screen. */
  const focusNext = useRef<string | null>(null);
  const stopDemo = useRef<(() => void) | null>(null);

  const { sheet } = doc;
  const cell = sheet.cells[at.row][at.col];
  const format = formatOf(cell);
  const scale = sheet.scale === null ? null : scaleOf(sheet, sheet.scale);

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

  const setMenusLive = (next: Menus) => {
    const now = live.current.menus;
    if (
      next.open === now.open &&
      next.active === now.active &&
      next.sub === now.sub &&
      next.subActive === now.subActive
    )
      return;
    live.current.menus = next;
    setMenus(next);
  };
  const patch = (next: Partial<Menus>) => setMenusLive({ ...live.current.menus, ...next });

  const commit = (next: Doc) => {
    live.current.doc = next;
    setDoc(next);
  };

  /** Whether an entry can run now: some depend on the sheet's history or the clipboard. */
  const enabled = (entry: Entry) => {
    if (entry.disabled) return false;
    if (entry.id === 'undo') return doc.past.length > 0;
    if (entry.id === 'redo') return doc.future.length > 0;
    if (entry.id === 'paste') return clip !== null;
    return true;
  };

  /** Whether a checkable entry is on, for the selected cell or the sheet. */
  const checked = (entryId: string) => {
    const [kind, option] = entryId.split(':');
    switch (kind) {
      case 'bold':
      case 'italic':
      case 'underline':
      case 'strike':
      case 'color':
      case 'fill':
      case 'border':
        return format[kind];
      case 'size':
        return format.size === Number(option);
      case 'number':
        return format.number === option;
      case 'align':
        return format.align === option;
      case 'valign':
        return format.valign === option;
      case 'wrap':
        return format.wrap === option;
      case 'scale':
        return sheet.scale === at.col;
      case 'freeze':
        return sheet.frozen === Number(option);
      default:
        return false;
    }
  };

  const labelOf = (entry: Entry) =>
    entry.id === 'gridlines' && !sheet.gridlines ? 'Show gridlines' : entry.label;

  /** What a command did, for screen readers. */
  const describe = (command: string, before: Sheet, after: Sheet, here: At) => {
    const name = refOf(here);
    const [kind, option] = command.split(':');
    const cellFormat = formatOf(after.cells[here.row][here.col]);
    switch (kind) {
      case 'bold':
      case 'italic':
      case 'underline':
      case 'strike': {
        const label = MARKS.find((mark) => mark.id === kind)?.label ?? kind;
        return `${label} ${cellFormat[kind] ? 'on' : 'off'} in ${name}`;
      }
      case 'color':
      case 'fill':
      case 'border': {
        const label = PAINTS.find((paint) => paint.id === kind)?.label ?? kind;
        return `${label} ${cellFormat[kind] ? 'on' : 'off'} in ${name}`;
      }
      case 'size':
        return `Font size ${option} in ${name}`;
      case 'number':
        return `${NUMBER_FORMATS.find((entry) => entry.id === option)?.label} format in ${name}`;
      case 'decimals':
        return `${option === 'more' ? 'More' : 'Fewer'} decimal places in ${name}`;
      case 'align':
      case 'valign':
        return `Aligned ${option} in ${name}`;
      case 'wrap':
        return `${option === 'wrap' ? 'Wrapping' : option === 'clip' ? 'Clipping' : 'Overflowing'} text in ${name}`;
      case 'scale':
        return after.scale === null
          ? 'Color scale off'
          : `Color scale on column ${COLUMNS[here.col]}`;
      case 'gridlines':
        return after.gridlines ? 'Gridlines shown' : 'Gridlines hidden';
      case 'freeze':
        return option === '0'
          ? 'No rows frozen'
          : `${option} ${option === '1' ? 'row' : 'rows'} frozen`;
      case 'delete':
        return `Cleared ${name}`;
      case 'row':
        return `Row inserted ${option} row ${here.row + 1}`;
      case 'column':
        return `Column inserted ${option} of column ${COLUMNS[here.col]}`;
      case 'sort':
        return `Sorted by column ${COLUMNS[here.col]}, ${option === 'desc' ? 'Z to A' : 'A to Z'}`;
      case 'new':
        return 'New sheet';
      default:
        return before === after ? '' : 'Done';
    }
  };

  /** Runs a command on the selected cell or the sheet; `reader` announces what it did. */
  const run = (command: string, reader: boolean) => {
    const after = runCommand(command, reader);
    if (after) onCommand?.(command, after);
  };

  /** Runs a command; returns the sheet after it, or null when it could not run. */
  const runCommand = (command: string, reader: boolean): Sheet | null => {
    const { doc, at: here, clip } = live.current;
    const { sheet, past, future } = doc;
    const say = (text: string) => reader && text && announce(text);
    if (command === 'undo' || command === 'redo') {
      const undo = command === 'undo';
      const from = undo ? past : future;
      if (!from.length) return null;
      const target = from[from.length - 1];
      commit(
        undo
          ? { sheet: target, past: past.slice(0, -1), future: [...future, sheet] }
          : { sheet: target, past: [...past, sheet], future: future.slice(0, -1) },
      );
      say(undo ? 'Undone' : 'Redone');
      return target;
    }
    const current = sheet.cells[here.row][here.col];
    if (command === 'copy' || command === 'cut') {
      live.current.clip = current;
      setClip(current);
      if (command === 'copy') {
        say(`Copied ${refOf(here)}`);
        return sheet;
      }
    }
    let next = sheet;
    if (command === 'cut') next = apply(sheet, here, 'delete');
    else if (command === 'paste') {
      if (!clip) return null;
      const cells = sheet.cells.map((row) => row.slice());
      cells[here.row][here.col] = { value: clip.value, format: { ...clip.format } };
      next = { ...sheet, cells };
    } else next = apply(sheet, here, command);
    if (next !== sheet) commit({ sheet: next, past: [...past, sheet].slice(-60), future: [] });
    if (command === 'cut') say(`Cut ${refOf(here)}`);
    else if (command === 'paste') say(`Pasted in ${refOf(here)}`);
    else say(describe(command, sheet, next, here));
    return next;
  };

  const select = (next: At) => {
    live.current.at = next;
    setAt(next);
    onSelect?.(refOf(next));
  };

  // ---- Menus ----

  const firstOrLast = (entries: MenuEntry[], last: boolean) => {
    const items = itemsOf(entries);
    return (last ? items[items.length - 1] : items[0])?.id ?? null;
  };

  const openMenu = (menuId: string, light: 'first' | 'last' | null) => {
    window.clearTimeout(timer.current);
    const menu = menuOf(menuId);
    if (!menu) return;
    setMenusLive({
      open: menuId,
      active: light ? firstOrLast(menu.entries, light === 'last') : null,
      sub: null,
      subActive: null,
    });
  };

  /** Closes every menu; the focus goes back to the menu's title or button when asked. */
  const closeMenus = (focus: 'title' | 'grid' | null) => {
    window.clearTimeout(timer.current);
    const open = live.current.menus.open;
    setMenusLive(CLOSED);
    if (!byReader.current || !open) return;
    if (focus === 'title')
      focusNext.current = open.startsWith('toolbar') ? `tool:${open}` : `title:${open}`;
    if (focus === 'grid') focusNext.current = 'grid';
  };

  /** While a menu bar menu is open, hovering another title opens that one instead. */
  const hoverTitle = (menuId: string) => {
    const open = live.current.menus.open;
    if (open && open !== menuId && !open.startsWith('toolbar')) openMenu(menuId, null);
  };

  /** The pointer on an item of the open menu (`sub` false) or of its submenu. */
  const hoverEntry = (entry: Entry, sub: boolean) => {
    window.clearTimeout(timer.current);
    const now = live.current.menus;
    if (sub) {
      patch({ active: now.sub, subActive: entry.id });
      return;
    }
    patch({ active: entry.id, subActive: null });
    if (entry.items && now.sub !== entry.id)
      timer.current = window.setTimeout(
        () => patch({ sub: entry.id, subActive: null }),
        OPEN_DELAY,
      );
    else if (!entry.items && now.sub)
      timer.current = window.setTimeout(() => patch({ sub: null }), CLOSE_DELAY);
  };

  /** A press on an item: a submenu opens at once, a command runs and the menus close. */
  const pressEntry = (entry: Entry, reader: boolean, keyboard = false) => {
    if (!enabled(entry)) return;
    if (entry.items) {
      window.clearTimeout(timer.current);
      patch({
        active: entry.id,
        sub: entry.id,
        subActive: keyboard ? firstOrLast(entry.items, false) : null,
      });
      return;
    }
    closeMenus('grid');
    run(entry.id, reader);
  };

  /** The entries of the open menu, or of its open submenu. */
  const entriesOf = (sub: boolean) => {
    const { open, sub: parent } = live.current.menus;
    const entries = menuOf(open)?.entries ?? [];
    return sub ? (entryOf(entries, parent)?.items ?? []) : entries;
  };

  const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>, sub: boolean) => {
    const now = live.current.menus;
    const items = itemsOf(entriesOf(sub));
    const current = sub ? now.subActive : now.active;
    const index = items.findIndex((entry) => entry.id === current);
    const light = (entry: Entry | undefined) => {
      if (!entry) return;
      window.clearTimeout(timer.current);
      patch(sub ? { subActive: entry.id } : { active: entry.id, sub: null, subActive: null });
    };
    const titles = MENUS.map((menu) => menu.id);
    const step = (direction: 1 | -1) => {
      const at = titles.indexOf(now.open ?? '');
      if (at < 0) return;
      const next = titles[(at + direction + titles.length) % titles.length];
      setTitleStop(next);
      openMenu(next, 'first');
    };
    const key = event.key;
    if (key === 'ArrowDown' || key === 'ArrowUp') {
      event.preventDefault();
      const direction = key === 'ArrowDown' ? 1 : -1;
      light(items[(index + direction + items.length) % items.length] ?? items[0]);
    } else if (key === 'Home' || key === 'End') {
      event.preventDefault();
      light(key === 'Home' ? items[0] : items[items.length - 1]);
    } else if (key === 'ArrowRight') {
      event.preventDefault();
      const entry = items[index];
      if (!sub && entry?.items) pressEntry(entry, true, true);
      else step(1);
    } else if (key === 'ArrowLeft') {
      event.preventDefault();
      if (sub) patch({ sub: null, subActive: null });
      else step(-1);
    } else if (key === 'Enter' || key === ' ') {
      event.preventDefault();
      const entry = items[index];
      if (entry) pressEntry(entry, true, true);
    } else if (key === 'Escape') {
      event.preventDefault();
      if (sub) patch({ sub: null, subActive: null });
      else closeMenus('title');
    } else if (key === 'Tab') {
      closeMenus(null);
    } else if (key.length === 1 && /\S/.test(key) && !event.metaKey && !event.ctrlKey) {
      // Typeahead: the next item whose label starts with that letter.
      const letter = key.toLowerCase();
      const order = [...items.slice(index + 1), ...items.slice(0, index + 1)];
      light(order.find((entry) => entry.label.toLowerCase().startsWith(letter)));
    }
  };

  const onTitleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, menuId: string) => {
    const titles = MENUS.map((menu) => menu.id);
    const index = titles.indexOf(menuId);
    const key = event.key;
    if (key === 'ArrowRight' || key === 'ArrowLeft' || key === 'Home' || key === 'End') {
      event.preventDefault();
      const next =
        key === 'Home'
          ? 0
          : key === 'End'
            ? titles.length - 1
            : (index + (key === 'ArrowRight' ? 1 : -1) + titles.length) % titles.length;
      setTitleStop(titles[next]);
      focusNext.current = `title:${titles[next]}`;
      if (live.current.menus.open) openMenu(titles[next], null);
    } else if (key === 'ArrowDown' || key === 'Enter' || key === ' ') {
      event.preventDefault();
      openMenu(menuId, 'first');
    } else if (key === 'ArrowUp') {
      event.preventDefault();
      openMenu(menuId, 'last');
    } else if (key === 'Escape' && live.current.menus.open) {
      event.preventDefault();
      closeMenus('title');
    }
  };

  // The focus follows what is lit while the reader is at the controls; never during the demo.
  useLayoutEffect(() => {
    const box = frame.current;
    if (!box) return;
    const target = focusNext.current;
    focusNext.current = null;
    const find = (selector: string) => box.querySelector<HTMLElement>(selector);
    if (target === 'grid') return void find('[data-cell][tabindex="0"]')?.focus();
    if (target?.startsWith('title:'))
      return void find(`[data-title="${target.slice(6)}"]`)?.focus();
    if (target?.startsWith('tool:')) return void find(`[data-tool="${target.slice(5)}"]`)?.focus();
    if (!byReader.current || !box.contains(document.activeElement)) return;
    const { open, active, sub, subActive } = menus;
    if (!open) return;
    const key = subActive ? `${open}/${sub}/${subActive}` : active ? `${open}/${active}` : null;
    if (key) find(`[data-entry="${key}"]`)?.focus();
  });

  // An open menu stays inside the window: it shifts left when it would cross its right edge, and
  // its submenus open on whichever side has room, or over it on a narrow window.
  useLayoutEffect(() => {
    const box = frame.current;
    if (!box || !menus.open) return;
    const panel = box.querySelector<HTMLElement>(`[data-menu="${menus.open}"]`);
    if (!panel) return;
    panel.style.removeProperty('--shift');
    const bounds = box.getBoundingClientRect();
    const scale = bounds.width / box.offsetWidth || 1;
    const rect = panel.getBoundingClientRect();
    const inset = 6;
    const left = (rect.left - bounds.left) / scale;
    const width = rect.width / scale;
    const shift = Math.min(0, box.offsetWidth - inset - (left + width));
    panel.style.setProperty('--shift', `${Math.max(shift, inset - left)}px`);
    const room = box.offsetWidth - (left + shift + width);
    panel.dataset.side = room >= width * 0.98 ? 'right' : left + shift >= width ? 'left' : 'over';
  }, [menus.open]);

  // A press outside the menus closes them.
  useEffect(() => {
    if (!menus.open) return;
    const onPress = (event: globalThis.PointerEvent) => {
      if (held.current) return;
      if (event.target instanceof Element && event.target.closest('[data-menu-zone]')) return;
      closeMenus(null);
    };
    document.addEventListener('pointerdown', onPress);
    return () => document.removeEventListener('pointerdown', onPress);
  }, [menus.open]);

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

  // ---- Grid ----

  const onGridKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const here = live.current.at;
    const key = event.key;
    const meta = event.metaKey || event.ctrlKey;
    const moves: Record<string, At> = {
      ArrowUp: { row: Math.max(0, here.row - 1), col: here.col },
      ArrowDown: { row: Math.min(ROWS - 1, here.row + 1), col: here.col },
      ArrowLeft: { row: here.row, col: Math.max(0, here.col - 1) },
      ArrowRight: { row: here.row, col: Math.min(COLUMNS.length - 1, here.col + 1) },
    };
    if (key in moves && !meta) {
      event.preventDefault();
      select(moves[key]);
      focusNext.current = 'grid';
      return;
    }
    const shortcut = meta
      ? (
          {
            b: 'bold',
            i: 'italic',
            u: 'underline',
            z: event.shiftKey ? 'redo' : 'undo',
            c: 'copy',
            x: 'cut',
            v: 'paste',
          } as Record<string, string>
        )[key.toLowerCase()]
      : key === 'Delete' || key === 'Backspace'
        ? 'delete'
        : undefined;
    if (shortcut) {
      event.preventDefault();
      run(shortcut, true);
    }
  };

  // ---- Demo ----

  const actions = useRef({ select, openMenu, hoverTitle, hoverEntry, pressEntry, closeMenus });
  useLayoutEffect(() => {
    actions.current = { select, openMenu, hoverTitle, hoverEntry, pressEntry, closeMenus };
  });

  // The demo selects the cell "Visual design", opens Format, slides over Data and back, then runs
  // Text › Italic; it opens Format again and hides the gridlines. The next round undoes both the
  // same way, so the sheet is back as it was.
  const demo = autoplay && !defaultState && playing && !focused;
  useEffect(() => {
    const box = stage.current;
    const arrow = pointer.current;
    if (!demo || !box || !arrow) return;
    // The demo's own presses leave the focus where it is.
    byReader.current = false;
    const find = (selector: string) => box.querySelector(selector);
    const format = MENUS.find((menu) => menu.id === 'format');
    const text = format && entryOf(format.entries, 'text');
    const italic = text?.items && entryOf(text.items, 'italic');
    const bold = text?.items && entryOf(text.items, 'bold');
    const gridlines = format && entryOf(format.entries, 'gridlines');
    if (!text || !italic || !gridlines) return;
    const stopRun = runDemo(box, arrow, async ({ wait, moveTo, press, hide }) => {
      await wait(700);
      for (;;) {
        await moveTo(find('[data-cell="3-0"]'), { x: 0.45, y: 0.55 });
        await press(() => actions.current.select({ row: 3, col: 0 }));
        await wait(500);
        await moveTo(find('[data-title="format"]'), { x: 0.5, y: 0.6 });
        await press(() => actions.current.openMenu('format', null));
        await wait(420);
        // Across the bar: the menus follow the pointer.
        await moveTo(find('[data-title="data"]'), { x: 0.5, y: 0.6 });
        actions.current.hoverTitle('data');
        await wait(520);
        await moveTo(find('[data-title="format"]'), { x: 0.45, y: 0.6 });
        actions.current.hoverTitle('format');
        await wait(360);
        await moveTo(find('[data-entry="format/text"]'), { x: 0.35, y: 0.5 });
        actions.current.hoverEntry(text, false);
        await wait(520);
        await moveTo(find('[data-entry="format/text/bold"]'), { x: 0.3, y: 0.55 });
        if (bold) actions.current.hoverEntry(bold, true);
        await wait(160);
        await moveTo(find('[data-entry="format/text/italic"]'), { x: 0.32, y: 0.5 });
        actions.current.hoverEntry(italic, true);
        await wait(380);
        await press(() => actions.current.pressEntry(italic, false));
        await wait(1100);
        await moveTo(find('[data-title="format"]'), { x: 0.5, y: 0.6 });
        await press(() => actions.current.openMenu('format', null));
        await wait(380);
        await moveTo(find('[data-entry="format/gridlines"]'), { x: 0.4, y: 0.5 });
        actions.current.hoverEntry(gridlines, false);
        await wait(420);
        await press(() => actions.current.pressEntry(gridlines, false));
        await wait(1600);
        hide();
        await wait(1400);
      }
    });
    let stopped = false;
    const stop = () => {
      if (stopped) return;
      stopped = true;
      stopRun();
      actions.current.closeMenus(null);
    };
    stopDemo.current = stop;
    return stop;
  }, [demo]);

  /** The reader takes over: the demo stops at once and waits until they leave it alone. */
  const readerActs = () => {
    byReader.current = true;
    held.current = false;
    stopDemo.current?.();
    stopDemo.current = null;
    take();
  };

  // ---- Drawing ----

  const fine = (event: PointerEvent) => event.pointerType !== 'touch';

  /** One menu's panel: its items, a submenu beside each item that has one. */
  const renderMenu = (menu: Menu, label: string, sub: Entry | null = null) => {
    const shown = sub ? menus.open === menu.id && menus.sub === sub.id : menus.open === menu.id;
    const entries = sub ? (sub.items ?? []) : menu.entries;
    const lit = sub ? menus.subActive : menus.active;
    const path = (entry: Entry) =>
      sub ? `${menu.id}/${sub.id}/${entry.id}` : `${menu.id}/${entry.id}`;
    return (
      <div
        role="menu"
        aria-label={label}
        id={sub ? `${id}-${menu.id}-${sub.id}` : `${id}-${menu.id}`}
        className={sub ? `${styles.menu} ${styles.submenu}` : styles.menu}
        data-part={sub ? 'submenu' : 'menu'}
        data-menu={sub ? undefined : menu.id}
        data-open={shown || undefined}
        data-wide={menu.id === 'toolbar-number' || undefined}
        data-narrow={menu.id === 'toolbar-size' || undefined}
        inert={!shown}
        onKeyDown={(event) => {
          event.stopPropagation();
          onMenuKeyDown(event, !!sub);
        }}
        onPointerEnter={(event) => {
          // Back in the submenu: its item stays lit and the submenu stays open.
          if (sub && fine(event)) {
            window.clearTimeout(timer.current);
            patch({ active: sub.id });
          }
        }}
        onPointerLeave={(event) => {
          if (!fine(event) || sub) return;
          window.clearTimeout(timer.current);
          if (!live.current.menus.sub) patch({ active: null });
        }}
      >
        {entries.map((entry) => {
          if (entry.kind === 'separator')
            return (
              <div
                key={entry.id}
                role="separator"
                className={styles.separator}
                data-part="separator"
              />
            );
          const on = entry.check ? checked(entry.id) : undefined;
          const available = enabled(entry);
          return (
            <div key={entry.id} role="none" className={styles.entry}>
              <button
                type="button"
                role={
                  entry.check === 'radio'
                    ? 'menuitemradio'
                    : entry.check
                      ? 'menuitemcheckbox'
                      : 'menuitem'
                }
                className={styles.item}
                data-part="item"
                tabIndex={-1}
                data-entry={path(entry)}
                data-lit={lit === entry.id || (!sub && menus.sub === entry.id) || undefined}
                aria-checked={entry.check ? !!on : undefined}
                aria-disabled={!available || undefined}
                aria-haspopup={entry.items ? 'menu' : undefined}
                aria-expanded={entry.items ? shown && menus.sub === entry.id : undefined}
                aria-controls={entry.items ? `${id}-${menu.id}-${entry.id}` : undefined}
                aria-keyshortcuts={entry.keys}
                onPointerEnter={(event) => fine(event) && hoverEntry(entry, !!sub)}
                onClick={(event) => pressEntry(entry, true, event.detail === 0)}
              >
                <span className={styles.itemLabel}>{labelOf(entry)}</span>
                {entry.shortcut && (
                  <span className={styles.shortcut} aria-hidden="true">
                    {entry.shortcut}
                  </span>
                )}
                {entry.check && <Glyph name="check" className={styles.check} />}
                {entry.items && <Glyph name="chevronRight" className={styles.chevron} />}
              </button>
              {entry.items && !sub && renderMenu(menu, entry.label, entry)}
            </div>
          );
        })}
      </div>
    );
  };

  /** A toolbar button that sets a format, pressed while the selected cell has it. */
  const tool = (
    command: string,
    label: string,
    index: number,
    content = <Glyph name={command} className={styles.toolIcon} />,
    pressed?: boolean,
  ) => (
    <button
      type="button"
      className={styles.tool}
      data-part="tool"
      aria-label={label}
      title={label}
      aria-pressed={pressed}
      tabIndex={toolStop === index ? 0 : -1}
      data-toolbar-item={index}
      onClick={() => run(command, true)}
    >
      {content}
    </button>
  );

  const onToolbarKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
    const items = Array.from(
      event.currentTarget.querySelectorAll<HTMLElement>('[data-toolbar-item]'),
    );
    const index = items.findIndex((item) => item === event.target);
    if (index < 0) return;
    event.preventDefault();
    const next = (index + (event.key === 'ArrowRight' ? 1 : -1) + items.length) % items.length;
    setToolStop(next);
    items[next].focus();
  };

  /** A toolbar button that opens one of the toolbar's menus. */
  const menuButton = (menuId: string, label: string, index: number, text: string) => {
    const menu = menuOf(menuId);
    if (!menu) return null;
    const open = menus.open === menuId;
    return (
      <span className={styles.toolMenu} data-menu-zone="">
        <button
          type="button"
          className={`${styles.tool} ${styles.toolText}`}
          data-part="dropdown"
          aria-label={`${label}: ${text}`}
          aria-haspopup="menu"
          aria-expanded={open}
          aria-controls={`${id}-${menuId}`}
          tabIndex={toolStop === index ? 0 : -1}
          data-toolbar-item={index}
          data-tool={menuId}
          onClick={() => (open ? closeMenus(null) : openMenu(menuId, null))}
          onKeyDown={(event) => {
            if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
            event.preventDefault();
            openMenu(menuId, event.key === 'ArrowUp' ? 'last' : 'first');
          }}
        >
          {text}
          <Glyph name="chevronDown" className={styles.toolChevron} />
        </button>
        {renderMenu(menu, label)}
      </span>
    );
  };

  const numberLabel =
    NUMBER_FORMATS.find((entry) => entry.id === format.number)?.label ?? 'General';

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onPointerMove={(event) => fine(event) && readerActs()}
      onFocus={() => setFocused(true)}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
      }}
    >
      <div ref={stage} className={styles.stage}>
        <div ref={frame} className={styles.window}>
          <div
            className={styles.menubar}
            role="menubar"
            aria-label="Sheet"
            data-menu-zone=""
            data-part="menubar"
          >
            <AppMark className={styles.mark} />
            {MENUS.map((menu) => (
              <div key={menu.id} className={styles.titleWrap}>
                <button
                  type="button"
                  role="menuitem"
                  className={styles.title}
                  data-part="title"
                  tabIndex={titleStop === menu.id ? 0 : -1}
                  aria-haspopup="menu"
                  aria-expanded={menus.open === menu.id}
                  aria-controls={`${id}-${menu.id}`}
                  data-title={menu.id}
                  onPointerEnter={(event) => fine(event) && hoverTitle(menu.id)}
                  onClick={() => {
                    setTitleStop(menu.id);
                    if (live.current.menus.open === menu.id) closeMenus(null);
                    else openMenu(menu.id, null);
                  }}
                  onKeyDown={(event) => onTitleKeyDown(event, menu.id)}
                >
                  {menu.label}
                </button>
                {renderMenu(menu, menu.label)}
              </div>
            ))}
          </div>

          <div
            className={styles.toolbar}
            data-part="toolbar"
            role="toolbar"
            aria-label="Formatting"
            onKeyDown={onToolbarKeyDown}
          >
            <span className={styles.status} aria-hidden="true">
              <Smiley className={styles.smiley} />
            </span>
            <span className={styles.divider} />
            {menuButton('toolbar-size', 'Font size', 0, String(format.size))}
            {MARKS.map((mark, i) => (
              <Fragment key={mark.id}>
                {tool(mark.id, mark.label, i + 1, undefined, format[mark.id])}
              </Fragment>
            ))}
            {PAINTS.map((paint, i) => (
              <Fragment key={paint.id}>
                {tool(paint.id, paint.label, i + 5, undefined, format[paint.id])}
              </Fragment>
            ))}
            <span className={styles.divider} />
            {ALIGNS.map((entry, i) => (
              <Fragment key={entry.id}>
                {tool(
                  entry.id,
                  entry.label,
                  i + 8,
                  undefined,
                  cell?.format.align === entry.id.slice(6),
                )}
              </Fragment>
            ))}
            <span className={styles.divider} />
            {tool(
              format.wrap === 'wrap' ? 'wrap:overflow' : 'wrap:wrap',
              'Wrap text',
              11,
              <Glyph name="wrap" className={styles.toolIcon} />,
              format.wrap === 'wrap',
            )}
            {VALIGNS.map((entry, i) => (
              <Fragment key={entry.id}>
                {tool(
                  entry.id,
                  entry.label,
                  i + 12,
                  undefined,
                  cell?.format.valign === entry.id.slice(7),
                )}
              </Fragment>
            ))}
            <span className={styles.divider} />
            {tool(
              format.number === 'currency' ? 'number:general' : 'number:currency',
              'Currency',
              15,
              <span className={styles.toolGlyph}>$</span>,
              format.number === 'currency',
            )}
            {tool(
              format.number === 'percent' ? 'number:general' : 'number:percent',
              'Percent',
              16,
              <span className={styles.toolGlyph}>%</span>,
              format.number === 'percent',
            )}
            {tool('decimals:less', 'Fewer decimal places', 17)}
            {tool('decimals:more', 'More decimal places', 18)}
            {menuButton('toolbar-number', 'Number format', 19, numberLabel)}
          </div>

          <div className={styles.panel}>
            <div className={styles.formula} data-part="formula">
              <span className={styles.name} aria-label="Selected cell" data-part="name">
                {refOf(at)}
              </span>
              <span className={styles.fx} aria-hidden="true">
                fx
              </span>
              <span className={styles.value} aria-label="Cell contents">
                {cell ? String(cell.value) : ''}
              </span>
            </div>
            <div
              className={styles.grid}
              data-part="grid"
              role="grid"
              aria-label="Sheet"
              aria-rowcount={ROWS + 1}
              aria-colcount={COLUMNS.length + 1}
              data-gridlines={sheet.gridlines || undefined}
              onKeyDown={onGridKeyDown}
            >
              <div className={styles.row} role="row">
                <span className={`${styles.head} ${styles.corner}`} role="presentation" />
                {COLUMNS.map((column) => (
                  <span
                    key={column}
                    className={`${styles.head} ${styles.colHead}`}
                    role="columnheader"
                  >
                    {column}
                  </span>
                ))}
              </div>
              {sheet.cells.map((cells, row) => (
                <div key={row} className={styles.row} role="row">
                  <span className={`${styles.head} ${styles.rowHead}`} role="rowheader">
                    {row + 1}
                  </span>
                  {cells.map((entry, col) => {
                    const look = formatOf(entry);
                    const tint = scale && col === sheet.scale ? scale[row] : null;
                    const style = {
                      '--size': look.size / 12,
                      '--tint': tint ?? undefined,
                    } as CSSProperties;
                    const selected = row === at.row && col === at.col;
                    return (
                      <div
                        key={col}
                        className={styles.cell}
                        data-part="cell"
                        role="gridcell"
                        aria-selected={selected}
                        tabIndex={selected ? 0 : -1}
                        data-cell={`${row}-${col}`}
                        data-bold={look.bold || undefined}
                        data-italic={look.italic || undefined}
                        data-underline={look.underline || undefined}
                        data-strike={look.strike || undefined}
                        data-color={look.color || undefined}
                        data-fill={look.fill || undefined}
                        data-border={look.border || undefined}
                        data-align={look.align}
                        data-valign={look.valign}
                        data-wrap={look.wrap}
                        data-tint={tint !== null || undefined}
                        style={style}
                        onClick={() => select({ row, col })}
                      >
                        <span className={styles.cellText}>{display(entry)}</span>
                      </div>
                    );
                  })}
                </div>
              ))}
              <span
                className={styles.selection}
                data-part="selection"
                style={{ '--at-row': at.row, '--at-col': at.col } as CSSProperties}
                aria-hidden="true"
              />
              <span
                className={styles.freeze}
                data-part="freeze"
                style={{ '--frozen': sheet.frozen } as CSSProperties}
                data-shown={sheet.frozen > 0 || undefined}
                aria-hidden="true"
              />
            </div>
          </div>
        </div>
        <DemoPointer ref={pointer} />
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### sheet-menu/sheet-menu.module.css

```css
/* Sheet menu (sheet-menu.tsx): a light grey window on a white stage, its menu bar and toolbar on
   top and the grid in a white panel; at night a dark grey window on a near-black stage, the grid
   in a near-black panel and the menus a step lighter. Sizes are in em of the menus' text: 13px
   where the whole window fits, down to 10.5px on a phone, where the window shows the columns it
   has room for.

   The stage is one layout boundary of a set height, so what the demo changes (a menu shown, a
   cell's format, the toolbar's state) lays out the stage alone; the pointer moves on a small
   layer of its own. */

.root {
  /* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the root
     inherits from the page, or the one the colorScheme prop sets (below). */
  /* The stage and the grid's panel; the window's chrome; the menus; the grid's headers. */
  --surface: #fff;
  --surface-2: #f4f4f4;
  --menu: #fff;
  --head: #fcfcfc;
  /* Text; toolbar icons and checks; submenu chevrons; shortcuts and headers; fx; what is off. */
  --ink: #1f1f1f;
  --ink-soft: #3d3d3d;
  --ink-chevron: #4a4a4a;
  --ink-muted: #8a8a8a;
  --ink-faint: #9a9a9a;
  --ink-off: #b4b4b4;
  /* Gridlines and the formula bar's; a menu's separators; the panel's edge; the toolbar's
     dividers; the line under the frozen rows. */
  --line: #ececec;
  --menu-line: #ececec;
  --panel-line: #e9e9e9;
  --divider: #e6e6e6;
  --freeze: #cfcfcf;
  /* The stage's edge, the window's and a menu's. */
  --stage-edge: rgb(0 0 0 / 0.06);
  --window-edge: rgb(0 0 0 / 0.05);
  --menu-edge: rgb(0 0 0 / 0.07);
  /* An open title, a lit item, a tool under the pointer, a pressed tool and its icon. */
  --title-on: #ececec;
  --lit: #f4f4f4;
  --hover: #ebebeb;
  --pressed: #e3e3e3;
  --pressed-ink: #000;
  /* The selected cell's frame and the focus ring, and what would sit on them. */
  --accent: #2563eb;
  --accent-ink: #fff;
  /* The status chip, its face and edge. */
  --status: #84f5bf;
  --status-ink: #065f3a;
  --status-edge: rgb(30 130 80 / 0.2);
  /* A cell's text colour and fill (the toolbar's text colour bar too). */
  --cell-red: #d93025;
  --cell-fill: #fff3bf;
  /* The app's mark: its tile and cells. */
  --mark: #0a0a0a;
  --mark-ink: #fff;
  /* A menu's soft drop and the shade right under it. */
  --shadow: rgb(0 0 0 / 0.1);
  --shadow-near: rgb(0 0 0 / 0.04);
  --col-w: 9.85em;
  --row-h: 1.856em;
  --row-head: 3.3em;
  --col-head: 1.91em;
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--stage-edge);
  color: var(--ink);
  font-family:
    Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#fff, #121214);
    --surface-2: light-dark(#f4f4f4, #1e1e20);
    --menu: light-dark(#fff, #262628);
    --head: light-dark(#fcfcfc, #18181a);
    --ink: light-dark(#1f1f1f, #ececec);
    --ink-soft: light-dark(#3d3d3d, #c7c7c7);
    --ink-chevron: light-dark(#4a4a4a, #b4b4b8);
    --ink-muted: light-dark(#8a8a8a, #98989d);
    --ink-faint: light-dark(#9a9a9a, #7c7c80);
    --ink-off: light-dark(#b4b4b4, #646468);
    --line: light-dark(#ececec, #2a2a2d);
    --menu-line: light-dark(#ececec, #38383b);
    --panel-line: light-dark(#e9e9e9, #2c2c2f);
    --divider: light-dark(#e6e6e6, #343437);
    --freeze: light-dark(#cfcfcf, #4a4a4e);
    --stage-edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --window-edge: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.07));
    --menu-edge: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.1));
    --title-on: light-dark(#ececec, #313134);
    --lit: light-dark(#f4f4f4, #37373a);
    --hover: light-dark(#ebebeb, #2c2c2f);
    --pressed: light-dark(#e3e3e3, #3a3a3d);
    --pressed-ink: light-dark(#000, #fff);
    --accent: light-dark(#2563eb, #4b8bff);
    --accent-ink: light-dark(#fff, #fff);
    --status: light-dark(#84f5bf, #1d4a37);
    --status-ink: light-dark(#065f3a, #84f5bf);
    --status-edge: light-dark(rgb(30 130 80 / 0.2), rgb(132 245 191 / 0.22));
    --cell-red: light-dark(#d93025, #ff7a70);
    --cell-fill: light-dark(#fff3bf, #3d330c);
    --mark: light-dark(#0a0a0a, #ececec);
    --mark-ink: light-dark(#fff, #1e1e20);
    --shadow: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.5));
    --shadow-near: light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.3));
  }
}

/* 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;
}
/* Buttons take the stage's type; the classes below set their own on top. */
.root :where(button) {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  cursor: default;
}

.stage {
  --pad-x: clamp(12px, 2.6cqi, 24px);
  --pad-y: clamp(14px, 2.6cqi, 24px);
  position: relative;
  height: calc(2 * var(--pad-y) + 41.14em + 6px);
  padding: var(--pad-y) var(--pad-x);
  contain: layout;
  font-size: clamp(10.5px, calc((100cqi - 2 * var(--pad-x)) / 64.45), 13px);
  line-height: 1.25;
}

/* The window: menu bar, toolbar and the grid's panel. */
.window {
  position: relative;
  width: min(100%, 64.3em + 2px);
  height: calc(41.14em + 6px);
  margin: 0 auto;
  overflow: hidden;
  overflow: clip;
  border: 1px solid var(--window-edge);
  border-radius: 0.8em;
  background: var(--surface-2);
}

.menubar {
  position: relative;
  z-index: 3;
  display: flex;
  align-items: center;
  height: 3.12em;
  padding-left: 1.06em;
}
.mark {
  flex: none;
  width: 1.81em;
  height: 1.81em;
  margin-right: 1.06em;
}
.titleWrap {
  position: relative;
  flex: none;
}
.title {
  display: block;
  height: 1.91em;
  padding: 0 0.745em;
  border-radius: 0.3em;
  white-space: nowrap;
}
.title[aria-expanded='true'] {
  background: var(--title-on);
}
.title:focus-visible,
.tool:focus-visible,
.item:focus-visible,
.cell:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* A menu: a white card under its title, or beside its item for a submenu (at night, a dark grey
   card a step above the window). */
.menu {
  --shift: 0px;
  position: absolute;
  top: calc(100% + 0.5em);
  left: 0;
  z-index: 1;
  width: 17.7em;
  padding: 0.45em 0.3em;
  border: 1px solid var(--menu-edge);
  border-radius: 0.6em;
  background: var(--menu);
  box-shadow:
    0 6px 16px -4px var(--shadow),
    0 1px 3px var(--shadow-near);
  transform: translateX(var(--shift));
  visibility: hidden;
  opacity: 0;
  transition:
    opacity 0.1s ease,
    visibility 0s 0.1s;
}
.menu[data-open] {
  visibility: visible;
  opacity: 1;
  transition: opacity 0.08s ease;
}
.menu[data-narrow] {
  width: 7.4em;
}
.menu[data-wide] {
  width: 11.5em;
}
.entry {
  position: relative;
}
.submenu {
  top: calc(-0.45em - 1px);
  left: calc(100% + 0.7em + 1px);
  transform: none;
}
.menu[data-side='left'] .submenu {
  left: auto;
  right: calc(100% + 0.7em + 1px);
}
/* No room on either side: the submenu sits over its menu, a row lower, at the window's edge. */
.menu[data-side='over'] .submenu {
  top: calc(1.4em - 1px);
  left: auto;
  right: -0.3em;
}
.item {
  display: flex;
  align-items: center;
  gap: 0.6em;
  width: 100%;
  height: 2.11em;
  padding: 0 0.65em 0 0.6em;
  border-radius: 0.3em;
  text-align: left;
  white-space: nowrap;
}
.item[data-lit] {
  background: var(--lit);
}
.item[aria-disabled] {
  color: var(--ink-off);
}
.itemLabel {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
.shortcut {
  color: var(--ink-muted);
  letter-spacing: 0.04em;
}
.check {
  flex: none;
  width: 1em;
  height: 1em;
  color: var(--ink-soft);
  visibility: hidden;
}
.item[aria-checked='true'] .check {
  visibility: visible;
}
.chevron {
  flex: none;
  width: 0.95em;
  height: 0.95em;
  color: var(--ink-chevron);
}
.separator {
  height: 0.6em;
  margin: 0 -0.3em;
  background: linear-gradient(var(--menu-line), var(--menu-line)) center / 100% 1px no-repeat;
}

/* The toolbar. */
.toolbar {
  position: relative;
  z-index: 2;
  display: flex;
  align-items: center;
  height: 3.52em;
  padding-left: 1.06em;
  white-space: nowrap;
}
.status {
  display: grid;
  flex: none;
  place-items: center;
  width: 1.86em;
  height: 1.76em;
  border-radius: 0.4em;
  background: var(--status);
  box-shadow: inset 0 0 0 1px var(--status-edge);
  color: var(--status-ink);
}
.smiley {
  width: 1.3em;
  height: 1.3em;
}
.divider {
  flex: none;
  width: 1px;
  height: 1.35em;
  margin: 0 0.7em;
  background: var(--divider);
}
.status + .divider {
  margin: 0 1.05em 0 0.85em;
}
.tool {
  display: inline-grid;
  flex: none;
  place-items: center;
  min-width: 2.15em;
  height: 2.05em;
  border-radius: 0.35em;
  color: var(--ink-soft);
  transition: background-color 0.12s ease;
}
.tool:hover {
  background: var(--hover);
}
.tool[aria-pressed='true'] {
  background: var(--pressed);
  color: var(--pressed-ink);
}
.toolIcon {
  width: 1.42em;
  height: 1.42em;
}
.toolGlyph {
  font-size: 1.2em;
  line-height: 1;
}
.toolMenu {
  position: relative;
  flex: none;
}
.toolText {
  display: inline-flex;
  gap: 0.4em;
  padding: 0 0.45em;
}
.toolChevron {
  width: 0.85em;
  height: 0.85em;
}

/* The grid's panel: the formula bar, the headers and the cells. */
.panel {
  position: relative;
  z-index: 1;
  margin: 0 0.9em;
  overflow: hidden;
  border: 1px solid var(--panel-line);
  border-radius: 0.6em;
  background: var(--surface);
}
.formula {
  display: flex;
  align-items: center;
  height: 2em;
  border-bottom: 1px solid var(--line);
}
.name {
  flex: none;
  align-self: stretch;
  display: flex;
  align-items: center;
  width: 6.28em;
  padding-left: 0.8em;
  border-right: 1px solid var(--line);
  color: var(--ink-muted);
  font-size: 0.86em;
}
.fx {
  flex: none;
  padding: 0 0.9em 0 1.1em;
  color: var(--ink-faint);
  font-family: Georgia, 'Times New Roman', serif;
  font-size: 0.9em;
  font-style: italic;
}
.value {
  min-width: 0;
  overflow: hidden;
  font-size: 0.93em;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.grid {
  position: relative;
  overflow: hidden;
}
.row {
  display: flex;
  width: max-content;
}
.head {
  display: grid;
  flex: none;
  place-items: center;
  border-right: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  background: var(--head);
  color: var(--ink-muted);
  font-family: 'JetBrains Mono', JetBrains, ui-monospace, Menlo, monospace;
  font-size: 0.8em;
}
.corner,
.rowHead {
  width: calc(var(--row-head) / 0.8);
}
.corner,
.colHead {
  height: calc(var(--col-head) / 0.8);
}
.colHead {
  width: calc(var(--col-w) / 0.8);
}
.rowHead {
  height: calc(var(--row-h) / 0.8);
}
.cell {
  --size: 1;
  --tint: 0;
  display: flex;
  flex: none;
  align-items: flex-end;
  width: var(--col-w);
  height: var(--row-h);
  padding: 0.18em 0.45em;
  border-right: 1px solid transparent;
  border-bottom: 1px solid transparent;
  font-size: 1em;
  outline: 0;
}
.grid[data-gridlines] .cell {
  border-color: var(--line);
}
.cellText {
  min-width: 0;
  font-size: calc(var(--size) * 0.93em);
  line-height: 1.2;
  white-space: nowrap;
}
.cell[data-wrap='overflow'] .cellText {
  overflow: visible;
}
.cell[data-wrap='clip'] {
  overflow: hidden;
}
.cell[data-wrap='wrap'] {
  overflow: hidden;
}
.cell[data-wrap='wrap'] .cellText {
  white-space: normal;
}
.cell[data-align='left'] {
  justify-content: flex-start;
}
.cell[data-align='center'] {
  justify-content: center;
}
.cell[data-align='right'] {
  justify-content: flex-end;
}
.cell[data-valign='top'] {
  align-items: flex-start;
}
.cell[data-valign='middle'] {
  align-items: center;
}
.cell[data-bold] {
  font-weight: 700;
}
.cell[data-italic] {
  font-style: italic;
}
.cell[data-underline] .cellText {
  text-decoration: underline;
}
.cell[data-strike] .cellText {
  text-decoration: line-through;
}
.cell[data-underline][data-strike] .cellText {
  text-decoration: underline line-through;
}
.cell[data-color] .cellText {
  color: var(--cell-red);
}
.cell[data-fill] {
  background: var(--cell-fill);
}
.cell[data-border] {
  box-shadow: inset 0 0 0 1px var(--ink);
}
.cell[data-tint] {
  background: rgb(87 187 138 / calc(0.1 + var(--tint) * 0.45));
}
/* The selected cell's frame, and the line under the frozen rows: both move by their transform. */
.selection {
  position: absolute;
  top: 0;
  left: 0;
  width: calc(var(--col-w) + 1px);
  height: calc(var(--row-h) + 1px);
  margin: -1px 0 0 -1px;
  border: 2px solid var(--accent);
  pointer-events: none;
  transform: translate(
    calc(var(--row-head) + var(--at-col) * var(--col-w)),
    calc(var(--col-head) + var(--at-row) * var(--row-h))
  );
}
.freeze {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 3px;
  margin-top: -2px;
  background: var(--freeze);
  pointer-events: none;
  visibility: hidden;
  transform: translateY(calc(var(--col-head) + var(--frozen) * var(--row-h)));
}
.freeze[data-shown] {
  visibility: visible;
}

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

### sheet-menu/menus.ts

```tsx
import { NUMBER_FORMATS, SIZES } from './sheet';

/*
 * The menus: the menu bar's six, and the two the toolbar opens (font size and number format).
 * An entry runs the command named by its id (sheet.ts), or opens the submenu of its `items`.
 * Which entries are checked, unavailable or relabelled depends on the sheet, so the component
 * works it out as it draws them.
 */

export type MenuEntry =
  | { kind: 'separator'; id: string }
  | {
      kind: 'item';
      /** The command it runs, unique within its menu. */
      id: string;
      label: string;
      /** The shortcut as it is drawn: "⌘Z". */
      shortcut?: string;
      /** The same, as aria-keyshortcuts spells it. */
      keys?: string;
      /** Shows a check: one of several (radio) or on and off (checkbox). */
      check?: 'radio' | 'checkbox';
      /** Never available in this sheet. */
      disabled?: boolean;
      /** Opens a submenu. */
      items?: MenuEntry[];
    };

export type Menu = { id: string; label: string; entries: MenuEntry[] };

let separators = 0;
const separator = (): MenuEntry => ({ kind: 'separator', id: `separator-${(separators += 1)}` });

const item = (
  id: string,
  label: string,
  more: Omit<Extract<MenuEntry, { kind: 'item' }>, 'kind' | 'id' | 'label'> = {},
): MenuEntry => ({ kind: 'item', id, label, ...more });

const SIZE_ITEMS = SIZES.map((size) => item(`size:${size}`, String(size), { check: 'radio' }));
const NUMBER_ITEMS = NUMBER_FORMATS.map(({ id, label }) =>
  item(`number:${id}`, label, { check: 'radio' }),
);

export const MENUS: Menu[] = [
  {
    id: 'file',
    label: 'File',
    entries: [
      item('new', 'New sheet'),
      item('copy-sheet', 'Make a copy', { disabled: true }),
      separator(),
      item('download', 'Download', {
        items: [
          item('download:csv', 'Comma-separated values', { disabled: true }),
          item('download:pdf', 'PDF document', { disabled: true }),
        ],
      }),
      separator(),
      item('print', 'Print', { shortcut: '⌘P', keys: 'Meta+P', disabled: true }),
    ],
  },
  {
    id: 'edit',
    label: 'Edit',
    entries: [
      item('undo', 'Undo', { shortcut: '⌘Z', keys: 'Meta+Z' }),
      item('redo', 'Redo', { shortcut: '⇧⌘Z', keys: 'Shift+Meta+Z' }),
      separator(),
      item('cut', 'Cut', { shortcut: '⌘X', keys: 'Meta+X' }),
      item('copy', 'Copy', { shortcut: '⌘C', keys: 'Meta+C' }),
      item('paste', 'Paste', { shortcut: '⌘V', keys: 'Meta+V' }),
      separator(),
      item('delete', 'Delete values', { shortcut: '⌫', keys: 'Delete' }),
    ],
  },
  {
    id: 'insert',
    label: 'Insert',
    entries: [
      item('row:above', 'Row above'),
      item('row:below', 'Row below'),
      separator(),
      item('column:left', 'Column left'),
      item('column:right', 'Column right'),
    ],
  },
  {
    id: 'format',
    label: 'Format',
    entries: [
      item('number', 'Number', { items: NUMBER_ITEMS }),
      separator(),
      item('size', 'Font size', { items: SIZE_ITEMS }),
      item('text', 'Text', {
        items: [
          item('bold', 'Bold', { check: 'checkbox', keys: 'Meta+B' }),
          item('italic', 'Italic', { check: 'checkbox', keys: 'Meta+I' }),
          item('underline', 'Underline', { check: 'checkbox', keys: 'Meta+U' }),
          item('strike', 'Strikethrough', { check: 'checkbox' }),
        ],
      }),
      item('align', 'Alignment', {
        items: [
          item('align:left', 'Left', { check: 'radio' }),
          item('align:center', 'Center', { check: 'radio' }),
          item('align:right', 'Right', { check: 'radio' }),
          separator(),
          item('valign:top', 'Top', { check: 'radio' }),
          item('valign:middle', 'Middle', { check: 'radio' }),
          item('valign:bottom', 'Bottom', { check: 'radio' }),
        ],
      }),
      item('wrap', 'Wrap text', {
        items: [
          item('wrap:overflow', 'Overflow', { check: 'radio' }),
          item('wrap:wrap', 'Wrap', { check: 'radio' }),
          item('wrap:clip', 'Clip', { check: 'radio' }),
        ],
      }),
      separator(),
      item('conditional', 'Conditional formatting', {
        items: [
          item('scale', 'Color scale', { check: 'checkbox' }),
          item('scale:clear', 'Clear rules'),
        ],
      }),
      separator(),
      item('gridlines', 'Hide gridlines'),
      item('freeze', 'Freeze', {
        items: [
          item('freeze:0', 'No rows', { check: 'radio' }),
          item('freeze:1', '1 row', { check: 'radio' }),
          item('freeze:2', '2 rows', { check: 'radio' }),
        ],
      }),
    ],
  },
  {
    id: 'data',
    label: 'Data',
    entries: [
      item('sort:asc', 'Sort sheet A → Z'),
      item('sort:desc', 'Sort sheet Z → A'),
      separator(),
      item('filter', 'Create a filter', { disabled: true }),
    ],
  },
  {
    id: 'help',
    label: 'Help',
    entries: [
      item('shortcuts', 'Keyboard shortcuts', { shortcut: '⌘/', keys: 'Meta+/', disabled: true }),
      item('notes', 'Release notes', { disabled: true }),
    ],
  },
];

/** The menus the toolbar's two dropdowns open. */
export const TOOLBAR_MENUS: Menu[] = [
  { id: 'toolbar-size', label: 'Font size', entries: SIZE_ITEMS },
  { id: 'toolbar-number', label: 'Number format', entries: NUMBER_ITEMS },
];

/** A menu's entries that can be lit: the items, separators left out. */
export const itemsOf = (entries: MenuEntry[]) =>
  entries.filter((entry): entry is Extract<MenuEntry, { kind: 'item' }> => entry.kind === 'item');
```

### sheet-menu/sheet.ts

```tsx
/*
 * The sheet: sixteen rows of six columns, each cell a value and its format, plus the sheet's own
 * settings (gridlines, frozen rows, a colour scale on a column). Every command is a pure function
 * from one sheet to the next, so undo and redo are a matter of keeping the old ones.
 */

export const ROWS = 16;
export const COLUMNS = ['A', 'B', 'C', 'D', 'E', 'F'];

export type NumberFormat = 'general' | 'number' | 'currency' | 'percent';
export type Align = 'left' | 'center' | 'right';
export type VAlign = 'top' | 'middle' | 'bottom';
export type Wrap = 'overflow' | 'wrap' | 'clip';

export type CellFormat = {
  bold?: boolean;
  italic?: boolean;
  underline?: boolean;
  strike?: boolean;
  /** Text in the accent colour. */
  color?: boolean;
  /** A pale fill behind the cell. */
  fill?: boolean;
  /** A frame around the cell. */
  border?: boolean;
  /** In points; 12 is the sheet's own size. */
  size?: number;
  /** Left unset, text sits left and numbers right. */
  align?: Align;
  valign?: VAlign;
  wrap?: Wrap;
  number?: NumberFormat;
  /** Decimal places a number shows; left unset, as many as it has (or 2 once formatted). */
  decimals?: number;
};

export type Cell = { value: string | number; format: CellFormat };

export type Sheet = {
  /** By row, then column; null is an empty cell. */
  cells: (Cell | null)[][];
  gridlines: boolean;
  /** How many rows stay at the top. */
  frozen: number;
  /** The column drawn with a colour scale, if any. */
  scale: number | null;
};

/** A cell's place: its row and column, from 0. */
export type At = { row: number; col: number };

export const SIZES = [8, 9, 10, 11, 12, 14, 18, 24];

export const NUMBER_FORMATS: { id: NumberFormat; label: string }[] = [
  { id: 'general', label: 'General' },
  { id: 'number', label: 'Number' },
  { id: 'currency', label: 'Currency' },
  { id: 'percent', label: 'Percent' },
];

export const refOf = ({ row, col }: At) => `${COLUMNS[col]}${row + 1}`;

const empty = () =>
  Array.from({ length: ROWS }, () => Array<Cell | null>(COLUMNS.length).fill(null));

/** A small project budget to format. */
export function createSheet(): Sheet {
  const cells = empty();
  const rows: (string | number)[][] = [
    ['Task', 'Owner', 'Status', 'Hours', 'Rate', 'Cost'],
    ['Research', 'Mara', 'Done', 12, 85, 1020],
    ['Wireframes', 'Theo', 'Done', 18, 85, 1530],
    ['Visual design', 'Ines', 'In review', 26, 95, 2470],
    ['Prototype', 'Theo', 'Doing', 14, 95, 1330],
    ['Testing', 'Mara', 'To do', 8, 85, 680],
  ];
  rows.forEach((values, row) =>
    values.forEach((value, col) => {
      cells[row][col] = { value, format: row === 0 ? { bold: true } : {} };
    }),
  );
  return { cells, gridlines: true, frozen: 0, scale: null };
}

export function blankSheet(): Sheet {
  return { cells: empty(), gridlines: true, frozen: 0, scale: null };
}

/** A cell's format, every field filled in. */
export function formatOf(cell: Cell | null): Required<Omit<CellFormat, 'decimals'>> & {
  decimals?: number;
} {
  const format = cell?.format ?? {};
  return {
    bold: !!format.bold,
    italic: !!format.italic,
    underline: !!format.underline,
    strike: !!format.strike,
    color: !!format.color,
    fill: !!format.fill,
    border: !!format.border,
    size: format.size ?? 12,
    align: format.align ?? (typeof cell?.value === 'number' ? 'right' : 'left'),
    valign: format.valign ?? 'bottom',
    wrap: format.wrap ?? 'overflow',
    number: format.number ?? 'general',
    decimals: format.decimals,
  };
}

const placesOf = (value: number) => (String(value).split('.')[1] ?? '').length;

/** What a cell shows: its value, through its number format. */
export function display(cell: Cell | null) {
  if (!cell) return '';
  const { value } = cell;
  if (typeof value !== 'number') return value;
  const { number, decimals } = formatOf(cell);
  const places = decimals ?? (number === 'general' ? placesOf(value) : 2);
  const grouped = (n: number) =>
    n.toLocaleString('en-US', { minimumFractionDigits: places, maximumFractionDigits: places });
  if (number === 'currency') return `${value < 0 ? '-' : ''}$${grouped(Math.abs(value))}`;
  if (number === 'percent') return `${grouped(value * 100)}%`;
  if (number === 'number') return grouped(value);
  return value.toFixed(places);
}

const cellAt = (sheet: Sheet, at: At) => sheet.cells[at.row]?.[at.col] ?? null;

/** The sheet with the cell at `at` changed by `change` (an empty cell gets an empty value). */
function withCell(sheet: Sheet, at: At, change: (cell: Cell) => Cell): Sheet {
  const cells = sheet.cells.map((row) => row.slice());
  cells[at.row][at.col] = change(cellAt(sheet, at) ?? { value: '', format: {} });
  return { ...sheet, cells };
}

const withFormat = (sheet: Sheet, at: At, format: CellFormat) =>
  withCell(sheet, at, (cell) => ({ ...cell, format: { ...cell.format, ...format } }));

/** Inserts an empty row before `index`; the last row falls off. */
function insertRow(sheet: Sheet, index: number): Sheet {
  const cells = sheet.cells.slice();
  cells.splice(index, 0, Array<Cell | null>(COLUMNS.length).fill(null));
  return { ...sheet, cells: cells.slice(0, ROWS) };
}

/** Inserts an empty column before `index`; the last column falls off. */
function insertColumn(sheet: Sheet, index: number): Sheet {
  const cells = sheet.cells.map((row) => {
    const next = row.slice();
    next.splice(index, 0, null);
    return next.slice(0, COLUMNS.length);
  });
  return { ...sheet, cells, scale: null };
}

/** Sorts the rows under the header by column `col`; empty rows stay at the bottom. */
function sortRows(sheet: Sheet, col: number, direction: 1 | -1): Sheet {
  const [header, ...rest] = sheet.cells;
  const filled = rest.filter((row) => row.some(Boolean));
  const blank = rest.filter((row) => !row.some(Boolean));
  const key = (row: (Cell | null)[]) => row[col]?.value ?? '';
  filled.sort((a, b) => {
    const x = key(a);
    const y = key(b);
    if (typeof x === 'number' && typeof y === 'number') return (x - y) * direction;
    return String(x).localeCompare(String(y)) * direction;
  });
  return { ...sheet, cells: [header, ...filled, ...blank] };
}

/**
 * Applies the command `id` to the sheet, at the selected cell. Returns the same sheet when the
 * command changes nothing there.
 */
export function apply(sheet: Sheet, at: At, id: string): Sheet {
  const cell = cellAt(sheet, at);
  const format = formatOf(cell);
  const [kind, option] = id.split(':');
  switch (kind) {
    case 'bold':
    case 'italic':
    case 'underline':
    case 'strike':
    case 'color':
    case 'fill':
    case 'border':
      return withFormat(sheet, at, { [kind]: !format[kind] });
    case 'size':
      return withFormat(sheet, at, { size: Number(option) });
    case 'number':
      return withFormat(sheet, at, { number: option as NumberFormat, decimals: undefined });
    case 'decimals': {
      if (typeof cell?.value !== 'number') return sheet;
      const now = format.decimals ?? (format.number === 'general' ? placesOf(cell.value) : 2);
      return withFormat(sheet, at, {
        decimals: Math.min(6, Math.max(0, now + (option === 'more' ? 1 : -1))),
      });
    }
    case 'align':
      return withFormat(sheet, at, { align: option as Align });
    case 'valign':
      return withFormat(sheet, at, { valign: option as VAlign });
    case 'wrap':
      return withFormat(sheet, at, { wrap: option as Wrap });
    case 'scale':
      return { ...sheet, scale: option === 'clear' || sheet.scale === at.col ? null : at.col };
    case 'gridlines':
      return { ...sheet, gridlines: !sheet.gridlines };
    case 'freeze':
      return { ...sheet, frozen: Number(option) };
    case 'delete':
      if (!cell) return sheet;
      return withCell(sheet, at, (current) => ({ ...current, value: '' }));
    case 'row':
      return insertRow(sheet, option === 'above' ? at.row : at.row + 1);
    case 'column':
      return insertColumn(sheet, option === 'left' ? at.col : at.col + 1);
    case 'sort':
      return sortRows(sheet, at.col, option === 'desc' ? -1 : 1);
    case 'new':
      return blankSheet();
    default:
      return sheet;
  }
}

/** The colour scale's tint for each row of column `col`: 0 for its smallest number, 1 its largest. */
export function scaleOf(sheet: Sheet, col: number) {
  const values = sheet.cells.map((row) => row[col]?.value);
  const numbers = values.filter((value): value is number => typeof value === 'number');
  const low = Math.min(...numbers);
  const high = Math.max(...numbers);
  return values.map((value) =>
    typeof value === 'number' ? (high > low ? (value - low) / (high - low) : 1) : null,
  );
}
```

### sheet-menu/icons.tsx

```tsx
import type { ReactNode } from 'react';

/*
 * The window's drawings: line icons on a 16 px grid in the text's colour (the text colour's bar is
 * drawn in the colour it sets), the app's mark and the status chip's face. They are decorative;
 * every button carries its own name. Their other colours are tokens of the window's stylesheet.
 */

function Icon({ className, children }: { className?: string; children: ReactNode }) {
  return (
    <svg
      className={className}
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.25"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      {children}
    </svg>
  );
}

/** The text colour's red; the mark's tile and cells. */
const RED = { fill: 'var(--cell-red)' };
const TILE = { fill: 'var(--mark)' };
const CELL = { fill: 'var(--mark-ink)' };

const PATHS: Record<string, ReactNode> = {
  bold: <path d="M4.75 2.75h4a2.55 2.55 0 0 1 0 5.1h-4zM4.75 7.85h4.7a2.7 2.7 0 0 1 0 5.4h-4.7z" />,
  italic: <path d="M7 2.75h4.5M4.5 13.25H9M9.4 2.75l-2.8 10.5" />,
  underline: <path d="M5 2.75v4.5a3 3 0 0 0 6 0v-4.5M4.75 13.5h6.5" />,
  strike: (
    <path d="M2.75 8h10.5M11 4.4c-.55-1.05-1.7-1.65-3.1-1.65-1.95 0-3.25 1-3.25 2.35 0 .5.15.95.5 1.3M5 11.4c.55 1.15 1.75 1.85 3.25 1.85 2 0 3.35-1.05 3.35-2.45 0-.35-.1-.7-.25-1" />
  ),
  color: (
    <>
      <path d="m4.75 10.5 3.25-8 3.25 8M6.05 7.5h3.9" />
      <rect x="3" y="12.6" width="10" height="1.9" rx="0.5" style={RED} stroke="none" />
    </>
  ),
  fill: (
    <>
      <path d="M3 7.6 7.1 3.5l4.4 4.4-4.1 4.1a1 1 0 0 1-1.4 0L3 9a1 1 0 0 1 0-1.4zM5.3 2.5l1.8 1M3.1 7.9h8.2" />
      <path d="M13 8.9s1 1.25 1 1.9a1 1 0 0 1-2 0c0-.65 1-1.9 1-1.9z" />
    </>
  ),
  border: (
    <>
      <rect x="2.5" y="2.5" width="11" height="11" rx="0.6" />
      <path d="M8 2.5v11M2.5 8h11" strokeDasharray="1 1.6" />
    </>
  ),
  'align:left': <path d="M2.5 3.5h11M2.5 6.5h7M2.5 9.5h11M2.5 12.5h7" />,
  'align:center': <path d="M2.5 3.5h11M4.5 6.5h7M2.5 9.5h11M4.5 12.5h7" />,
  'align:right': <path d="M2.5 3.5h11M6.5 6.5h7M2.5 9.5h11M6.5 12.5h7" />,
  wrap: <path d="M2.5 4h11M2.5 8h8.75a2 2 0 0 1 0 4H8.5m1.5-1.6L8.4 12 10 13.6M2.5 12h3" />,
  'valign:middle': (
    <path d="M2.5 8h11M8 1.75v4M6.25 4 8 5.75 9.75 4M8 14.25v-4M6.25 12 8 10.25 9.75 12" />
  ),
  'valign:bottom': <path d="M3 13.5h10M8 2.5v8M5.25 7.75 8 10.5l2.75-2.75" />,
  'valign:top': <path d="M3 2.5h10M8 13.5v-8M5.25 8.25 8 5.5l2.75 2.75" />,
  'decimals:less': (
    <>
      <path d="M11 11.75H5.25m1.6-1.6-1.6 1.6 1.6 1.6" />
      <ellipse cx="9.4" cy="4.6" rx="1.15" ry="1.85" />
      <circle cx="6.6" cy="6.3" r="0.15" strokeWidth="1.3" />
    </>
  ),
  'decimals:more': (
    <>
      <path d="M4.75 11.75h6.5m-1.6-1.6 1.6 1.6-1.6 1.6" />
      <ellipse cx="7.7" cy="4.6" rx="1.05" ry="1.85" />
      <ellipse cx="10.8" cy="4.6" rx="1.05" ry="1.85" />
      <circle cx="5.25" cy="6.3" r="0.15" strokeWidth="1.3" />
    </>
  ),
  chevronDown: <path d="m5 6.75 3 3 3-3" />,
  chevronRight: <path d="m6.25 3.75 4.25 4.25-4.25 4.25" />,
  check: <path d="m3.5 8.25 3 3 6-6.5" />,
};

/** A line icon by name: a toolbar command (bold, align:left…), a chevron or a check. */
export function Glyph({ name, className }: { name: string; className?: string }) {
  return <Icon className={className}>{PATHS[name]}</Icon>;
}

/** The app's mark: a black tile with four cells (a light one on a dark window). */
export function AppMark({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 18 18" aria-hidden="true">
      <rect width="18" height="18" rx="1.6" style={TILE} />
      <rect x="4.5" y="4.5" width="3.9" height="3.9" rx="0.6" style={CELL} />
      <rect x="9.6" y="4.5" width="3.9" height="3.9" rx="0.6" style={CELL} />
      <rect x="4.5" y="9.6" width="3.9" height="3.9" rx="0.6" style={CELL} />
      <rect x="9.6" y="9.6" width="3.9" height="3.9" rx="0.6" style={CELL} opacity="0.45" />
    </svg>
  );
}

/** The status chip's face: all is saved. */
export function Smiley({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">
      <circle cx="5.6" cy="6.4" r="1" fill="currentColor" />
      <circle cx="10.4" cy="6.4" r="1" fill="currentColor" />
      <path
        d="M5 9.4c.8 1.15 1.85 1.7 3 1.7s2.2-.55 3-1.7"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.35"
        strokeLinecap="round"
      />
    </svg>
  );
}
```

### sheet-menu/demo-pointer.tsx

```tsx
'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait,
 * glide the pointer onto a target, press, calling the window's own handlers as a hand would.
 * 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 out.
 *
 * The pointer moves by animations the compositor plays, so the glides cost no layout, and it is
 * an HTML box around a still drawing: a change of style inside an SVG lays the SVG out again.
 */

export type Point = { x: number; y: number };

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

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

/** Where the arrow's tip is, in its 20 × 20 drawing. */
const TIP: Point = { x: 3, y: 2 };

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

/** Plays `script` with the 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>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.92 };

  /** 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);
      }),
    );

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    // Screen px to the stage's own: the stage may sit in a scaled container.
    const frame = stage.getBoundingClientRect();
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    const to = {
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    };
    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();
    pointer.style.opacity = '1';
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(900, Math.max(320, 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) => {
    // The arrow dips towards its tip.
    const dip = pointer.firstElementChild?.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 (dip) await step(dip.finished);
  };

  const hide = () => {
    pointer.style.opacity = '0';
  };

  pointer.style.transform = place(at);
  script({ wait, moveTo, press, hide }).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's box: it moves, on a small layer of its own. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 20,
  display: 'block',
  width: 22,
  height: 22,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.25))',
  willChange: 'transform',
};

/** Inside it, what dips towards the tip when the pointer presses. */
const PRESS: CSSProperties = {
  display: 'block',
  width: 22,
  height: 22,
  transform: 'scale(1)',
  transformOrigin: `${TIP.x * 1.1}px ${TIP.y * 1.1}px`,
};

const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };

/** The arrow the demo moves around. It is decorative: the demo stays silent. */
export function DemoPointer({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} style={POINTER} aria-hidden="true">
      <span style={PRESS}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            fill="#111"
            stroke="#fff"
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
    </span>
  );
}
```

### sheet-menu/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 };
}
```