# Stock grid

> An editable sheet of a shop’s category mix: stage orders in one click, drag-fill a row, type in place, swap suppliers in the cell, sort by the biggest gap, undo anything.

A shop’s stock mix as a grid: each category a column, with its share of the stock (target over actual, tinted green, amber or red by how far it drifts), supplier, target, tolerance, minimum order, case cost, value and the order it needs. A side panel picks the season plan, counts the categories by stage and refreshes the costs one by one. Select a cell and drag its handle along the row to copy it, type a new number in place, open a supplier cell for its menu, re-sort the columns by the largest gap and watch them slide over. Changed cells get a corner mark, every change a toast with Undo, and a share opens a drawer with the category’s plans, cost log and history.

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

Category: Data.

## Anatomy

1. **Header**: The title with an unsent count while anything changed, and the store, the stock’s value and the version of the orders.
2. **Toolbar**: Columns (sort and show), Stage Orders with the count of suggested orders, Revert, which asks first, and Submit Orders.
3. **Side panel**: The season plan and what orders aim at (target or tolerance), the stage counts, and the costs: Update Costs and Copy to All.
4. **Stages**: A pill per stage with how many categories are in it: on plan, watch, off plan, unpriced, on hold. Zero counts fade.
5. **Grid**: A column per category plus a total; the share row tints target over actual by the gap, then supplier, target, tolerance, minimum order, case cost, value and order.
6. **Selection**: One border over the selected cell or range, with the fill handle at its corner; changed cells keep a small corner mark until submitted.
7. **Toast**: What a change did, with Undo back to the state before it. A new one drops in front; up to two tuck in behind it.
8. **Footer**: The budget, and what is left after the suggested orders and their fees; Export CSV downloads the sheet.

## When to use

- A planner’s working sheet: a handful of items (categories, regions, channels) as columns and the same few numbers for each, edited in place and sent as one batch.
- Targets against actuals where the drift decides the action: the grid tints each share by its gap and suggests the order that closes it.
- Bulk edits along a row: copy one tolerance or supplier into the next three cells with the fill handle, or into every category with Copy to All.
- Changes that have to be reviewed before they go out: corner marks on what changed, an unsent count, Undo on every toast, Revert to the last submission.

## When not to use

- Many rows of records (orders, products, people). Use a data table with sorting, paging and a row per record; this grid has a column per item and stops reading past about ten.
- Free-form spreadsheets with formulas. The rows and their rules are fixed in code; give people a real spreadsheet instead.
- Read-only reporting. A grid of editable cells invites changes; use a dashboard of charts and figures.
- A single number to change. Use a form field or a stepper beside its label, with no selection model to learn.
- Phones as the main place it is used. Under 760 px the grid scrolls sideways; a list of category cards with one edit sheet each reads better.

## Installation

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

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

Or add the registry to components.json once, as a namespace that coding agents and the shadcn MCP server search by name:

```json
{
  "registries": {
    "@brunodelorence": "https://brunodelorence.com/r/{name}.json"
  }
}
```

```sh
npx shadcn@latest add @brunodelorence/stock-grid
```

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 { StockGrid } from './stock-grid';

export function Page() {
  return <StockGrid />;
}
```

## States

- **Ready**: As it opens: the Autumn plan, Drinks over and Home under their target, so Stage Orders counts 2.
- **Cell selected**: A press on a cell: the border and the fill handle at its corner. Typing a digit, Enter or F2 edits it.
- **Range**: Shift with a press or the arrows, or a drag along the row: the cells between are tinted under one border.
- **Filling**: The handle dragged along the row: a dashed outline over the cells that will take the first value on release.
- **Editing**: A number edited in place in a labelled input; Enter or Tab keeps it, Escape drops it.
- **Supplier menu**: A second press on a selected supplier cell opens a listbox of the three supplier kinds under it.
- **Changed**: Three edits: each cell keeps a corner mark, the title counts them unsent, and their toasts stack, the newest in front.
- **Orders staged**: Stage Orders applied both suggestions: the shares move back on plan and Submit Orders is ready.
- **Targets off 100**: Drinks’ target typed down to 14, leaving the plan at 99%: the totals turn red and Submit Orders waits until they add up to 100.
- **On hold**: Home put on hold from its drawer: no order for it, its share says so, and the stage count moves.
- **Another plan**: The Summer plan: every target changes, the shares retint and the suggested orders follow.
- **Updating costs**: Update Costs reprices one category after another, a spinner in the share it is on; the panel counts the steps.
- **Columns menu**: Sorted by the largest gap, with Care hidden: the order as radio items, each category as a checkbox.
- **Plan list**: The plan select open: four season plans, the current one checked.
- **Drawer**: A share opens its category over the blurred grid: the current plan, every plan side by side, and the stock facts.
- **Cost log**: The drawer’s second tab: six days of case costs and their change, with Update Again.
- **History**: The third tab, newest first: here the order just staged for Home on top of the day’s earlier changes.
- **Revert dialog**: An alertdialog says how many changes go back and to which version; Cancel takes the focus.
- **Submitting**: Submit Orders on its spinner while the orders go out; then the version moves on and the marks clear.
- **Dark**: colorScheme="dark", or a dark page: every colour is a light-dark() token on the root.
- **Narrow panel**: Under 1080 px of its own width the side panel, its selects and the row names narrow; every column stays on screen.
- **Stacked**: Under 760 px the panel moves above the grid, which scrolls sideways with its row names held in place.

## How it works

- The model is plain data: the editable part (plan, what orders aim for, and per category its targets for each plan, tolerance, minimum order, supplier, share, cost and staged orders), the same as last submitted, the stock’s value and the cash left. Stages, gaps, totals and suggested orders are worked out from it on every render.
- A category inside half its tolerance is on plan, inside its tolerance on watch, beyond it off plan, and gets a suggested order back to its target (or just inside the tolerance). Buys that would spend more than the budget are scaled down to fit, and the footer says so.
- Every change goes through one commit that keeps the state before it, so each toast’s Undo, and Ctrl/Cmd+Z and Shift+Ctrl/Cmd+Z, step through the history. Changed cells are found by comparing with the last submission, which also counts the unsent changes.
- The grid is a CSS grid of cells, the rows display: contents. One element draws the selection over it, placed by measuring the first and last cell of the range after each render and written to its style; dragging its handle shows a dashed outline over the target range and copies the first value into it on release.
- Typing a number in a selected cell starts an edit in place (so do Enter, F2 and a double click); Enter or Tab keeps it, Escape drops it. A selected supplier cell shows a chevron and opens a menu of the three supplier kinds.
- Sorting by the largest gap is a FLIP: each column’s left edge is measured before the change, and after it every cell of a column slides from where it was by a transform.
- Toasts stack at the top of the card: a new one drops in front while the one before tucks behind it, a little smaller; the check in its icon draws itself.
- Update Costs reprices the categories one after the other, a spinner in each share as it goes; a share opens a drawer over the blurred grid with an overview against every plan, the cost log and the history, Hold and Stage Order.
- While on screen and untouched, a demo pointer stages the orders, fills a row, types two targets, swaps a supplier, reprices, looks through a drawer, sorts, switches plan, considers reverting and submits, then starts again; any input of the reader’s stops it for good.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop. Under 760 px the side panel moves above the grid, and the grid scrolls sideways inside the card with its row names held in place.

## API reference

- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer goes through staging, filling, editing, sorting and submitting. Any input of the reader’s stops it.
- **colorScheme** (`'light' | 'dark' | 'system'`, default `'system'`): Light or dark; by default it follows the page’s color-scheme.
- **className** (`string`): Extra classes for the root.
- **defaultState** (`StockGridState`): Opens in a given state, with the demo off: changes applied first, each with its toast (plan, orderTo, edits, hold, staged; toasts: false drops them), then sort and hidden, then select (a cell or a range), fill, edit, menu, drawer, dialog, or Update Costs (pricing) and Submit Orders (submitting) held mid-way.

## Accessibility

- The grid is role="grid" with row and column headers; it takes the focus as one stop and names the selected cell with aria-activedescendant. Arrow keys move the selection, Shift+arrows stretch it along the row, Home and End jump to the ends, Escape clears it.
- Typing a digit, Enter or F2 edits a number in place in a labelled input; Enter or Tab keeps it, Escape drops it. On a supplier cell Enter opens a listbox; on a share it opens the drawer; on a suggested order it stages it.
- Menus and lists take the focus on their checked item, move with the arrow keys, Home and End, and give the focus back to their trigger on Escape. The plan selects are buttons with aria-haspopup="listbox" and aria-expanded.
- The drawer is a modal dialog (the card behind it is inert): its tabs are a tablist with arrow keys, Tab stays inside, Escape or the close button gives the focus back. Revert asks first in an alertdialog whose Cancel takes the focus.
- Toasts are announced from a polite live region, as are the costs’ progress and the budget line; toasts behind the front one are hidden from assistive tech and inert.
- Stages are written in words, not only colours; the demo pointer is aria-hidden, and under reduced motion there is no demo and no animation.

## Source

### stock-grid/stock-grid.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
  type RefObject,
} from 'react';
import {
  CATEGORIES,
  FIELD_NAMES,
  PLANS,
  PRICE_LIST,
  START,
  STORE,
  SUPPLIERS,
  categoryOf,
  cellText,
  changesOf,
  dirty,
  dollars,
  fmt1,
  gapOf,
  initialDoc,
  initialHistory,
  money,
  round1,
  signed,
  spoken,
  stageOf,
  suggest,
  supplierName,
  targetOf,
  type Doc,
  type Entry,
  type Field,
  type OrderTo,
  type Plan,
  type Stage,
  type Supplier,
} from './data';
import { DemoPointer, runDemo, type Demo, type Point } from './demo-pointer';
import { Drawer, type Tab } from './drawer';
import { Icon, type IconName } from './icons';
import { useAutoplay } from './use-autoplay';
import styles from './stock-grid.module.css';

/*
 * Stock grid: a shop's category mix as an editable sheet. Each column is a category, each row one
 * of its numbers: its share of the stock (target over actual), supplier, target, tolerance,
 * minimum order, case cost, value and the order the grid suggests. A side panel picks the season
 * plan and what orders aim for, counts the categories by stage and refreshes the costs.
 *
 * - The model is plain data (data.ts): a Doc of what can be edited, the Doc as last submitted
 *   (the base), the stock's value and the cash left. Stages, gaps, suggested orders and totals
 *   are computed from it on every render. Every change goes through `commit`, which keeps the
 *   state before it, so any change can be undone (its toast's Undo, or Ctrl/Cmd+Z).
 * - The grid is a CSS grid of cells in rows (role="grid"). One element draws the selection over
 *   it: a border around a cell or a range of cells in one row, with a fill handle at its corner.
 *   It is placed by measuring the first and last cell of the range after each render, written
 *   straight to its style, so moving it never renders the grid again. Dragging the handle along
 *   the row shows a dashed outline; letting go copies the first cell's value into the range.
 * - Typing a number in a selected cell (or Enter, F2, a double click) edits it in place; Enter
 *   or Tab keeps it, Escape drops it. A selected supplier cell opens a menu of the three supplier
 *   kinds. Arrow keys move the selection, Shift+arrows stretch it along the row.
 * - Changed cells carry a small corner mark until the orders are submitted. Stage Orders applies
 *   every suggested order at once; Revert puts back the last submission (after a confirmation);
 *   Submit Orders sends them and starts the next version.
 * - Re-sorting the columns by the biggest gap plays a FLIP: each column's cells start where they
 *   were and slide to their new place, by transform only.
 * - Clicking a share opens a drawer for that category: an overview against every season plan, its
 *   cost log and its history, with Hold and Stage Order. Arrows in it step through categories.
 * - Toasts stack at the top: a new one slides down in front, the one before tucks in behind it.
 * - While on screen and untouched, a demo pointer goes through it all, then starts over. Any
 *   input of the reader's stops it for good, and leaves the grid as the demo left it.
 */

type Model = {
  doc: Doc;
  base: Doc;
  stock: number;
  budget: number;
  version: number;
  pricedToday: number;
  history: Record<string, Entry[]>;
};

type RowKey = 'share' | Field;
type Sel = { row: RowKey; anchor: string; head: string };
type Edit = { row: Field; col: string; text: string };
type ToastItem = { id: number; title: string; text: string; undo?: number; leaving?: boolean };
type Popover =
  { kind: 'columns' } | { kind: 'plan' } | { kind: 'orderTo' } | { kind: 'supplier'; col: string };

type RowDef = {
  key: Field;
  label: string;
  unit?: string;
  icon: IconName;
  edit: 'number' | 'supplier' | false;
  costs?: boolean;
};

const ROWS: RowDef[] = [
  { key: 'supplier', label: 'Supplier', icon: 'layers', edit: 'supplier' },
  { key: 'target', label: 'Target', unit: '%', icon: 'target', edit: 'number' },
  { key: 'tol', label: 'Tolerance', unit: 'pp', icon: 'tolerance', edit: 'number' },
  { key: 'min', label: 'Min order', unit: '$k', icon: 'note', edit: 'number' },
  { key: 'cost', label: 'Case cost', unit: '$', icon: 'tag', edit: false, costs: true },
  { key: 'value', label: 'Value', unit: '$k', icon: 'wallet', edit: false, costs: true },
  { key: 'order', label: 'Order', unit: '$k', icon: 'order', edit: false, costs: true },
];
const ROW_KEYS: RowKey[] = ['share', ...ROWS.map((r) => r.key)];
const rowDef = (key: RowKey) => ROWS.find((r) => r.key === key);

const STAGES: { id: Stage; label: string }[] = [
  { id: 'on', label: 'On plan' },
  { id: 'watch', label: 'Watch' },
  { id: 'off', label: 'Off plan' },
  { id: 'unpriced', label: 'Unpriced' },
  { id: 'hold', label: 'On hold' },
];

const ORDER_TO: { id: OrderTo; label: string }[] = [
  { id: 'Target', label: 'Target' },
  { id: 'Edge', label: 'Tolerance' },
];

/** How long one category takes to reprice, a submission to go through, a toast to stay. */
const PRICE_STEP = 420;
const SUBMIT_TIME = 1100;
const TOAST_LIFE = 9000;

const initialModel = (): Model => ({
  doc: initialDoc(),
  base: initialDoc(),
  stock: START.stock,
  budget: START.budget,
  version: START.version,
  pricedToday: START.pricedToday,
  history: initialHistory(),
});

const clock = () => {
  const d = new Date();
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
/** A list in words: "A, B and 1 more". */
const listed = (items: string[], max = 2) =>
  items.length <= max
    ? items.join(' and ')
    : `${items.slice(0, max).join(', ')} and ${items.length - max} more`;

function withCat(doc: Doc, id: string, patch: Partial<Doc['cats'][string]>): Doc {
  return { ...doc, cats: { ...doc.cats, [id]: { ...doc.cats[id], ...patch } } };
}
function logged(history: Model['history'], id: string, entry: Omit<Entry, 'time'>) {
  return { ...history, [id]: [{ ...entry, time: clock() }, ...history[id]] };
}

/**
 * A state to open in (it also keeps the demo off). The changes come first, in this order, each
 * through the same commit as a click, so each leaves its corner marks and a toast with Undo (kept
 * on screen); then the sort and the columns; then what is selected or open.
 */
export type StockGridState = {
  plan?: Plan;
  orderTo?: OrderTo;
  /** Cells to change, as if typed (a supplier as its code: DST, WHL or DIR). */
  edits?: { row: 'supplier' | 'target' | 'tol' | 'min'; col: string; value: string }[];
  /** Categories to put on hold. */
  hold?: string[];
  /** Applies every suggested order, as Stage Orders does. */
  staged?: boolean;
  /** false drops the toasts the changes above left. */
  toasts?: boolean;
  sort?: 'aisle' | 'gap';
  /** Categories whose columns are hidden. */
  hidden?: string[];
  /** A cell, or a range along one row from `col` to `to`. */
  select?: { row: 'share' | Field; col: string; to?: string };
  /** With `select`: the fill handle dragged as far as this column, the dashed outline shown. */
  fill?: string;
  /** A number cell open for editing, with this text in it. */
  edit?: { row: 'target' | 'tol' | 'min'; col: string; text?: string };
  /** A menu open: the supplier one opens on the selected supplier cell (or Drinks'). */
  menu?: 'columns' | 'plan' | 'orderTo' | 'supplier';
  drawer?: { id: string; tab?: Tab };
  /** The revert confirmation. */
  dialog?: boolean;
  /** Update Costs held at this step (0 to 7): its category's share spins. */
  pricing?: number;
  /** Submit Orders held on its spinner. */
  submitting?: boolean;
};

export type StockGridProps = {
  className?: string;
  /** While on screen and untouched, a demo pointer stages, fills, edits, sorts and submits. */
  autoplay?: boolean;
  /** Light or dark; by default it follows the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens in this state, with the demo off. */
  defaultState?: StockGridState;
};

export function StockGrid({
  className,
  autoplay = true,
  colorScheme,
  defaultState,
}: StockGridProps) {
  const uid = useId().replace(/:/g, '');
  const { ref: root, take, playing } = useAutoplay<HTMLDivElement>(null);

  // ----- The model, its undo history, and the toasts that announce changes -----
  const [model, setModelState] = useState<Model>(initialModel);
  const modelRef = useRef(model);
  const past = useRef<Model[]>([]);
  const future = useRef<Model[]>([]);
  const setModel = useCallback((next: Model) => {
    modelRef.current = next;
    setModelState(next);
  }, []);

  const [toasts, setToasts] = useState<ToastItem[]>([]);
  const toastId = useRef(0);
  const toastTimers = useRef(new Map<number, number>());
  /** True while the changes of `defaultState` are applied: their toasts stay. */
  const opening = useRef(false);
  const dropToast = useCallback((id: number) => {
    setToasts((list) => list.map((t) => (t.id === id ? { ...t, leaving: true } : t)));
    window.setTimeout(() => setToasts((list) => list.filter((t) => t.id !== id)), 220);
  }, []);
  const toast = useCallback(
    (title: string, text: string, undo?: number) => {
      const id = ++toastId.current;
      setToasts((list) => [...list.filter((t) => !t.leaving).slice(-2), { id, title, text, undo }]);
      if (opening.current) return;
      toastTimers.current.set(
        id,
        window.setTimeout(() => dropToast(id), TOAST_LIFE),
      );
    },
    [dropToast],
  );
  useEffect(() => {
    const timers = toastTimers.current;
    return () => timers.forEach((t) => window.clearTimeout(t));
  }, []);

  /** Applies a change, keeping the state before it; its toast's Undo puts that state back. */
  const commit = useCallback(
    (next: Model, title?: string, text?: string) => {
      past.current.push(modelRef.current);
      future.current = [];
      setModel(next);
      if (title) toast(title, text ?? '', past.current.length - 1);
    },
    [setModel, toast],
  );
  const undoTo = useCallback(
    (index: number) => {
      const target = past.current[index];
      if (!target) return;
      future.current = [modelRef.current, ...past.current.slice(index + 1).reverse()];
      past.current = past.current.slice(0, index);
      setModel(target);
    },
    [setModel],
  );
  const undo = useCallback(() => {
    const prev = past.current.pop();
    if (!prev) return;
    future.current.unshift(modelRef.current);
    setModel(prev);
  }, [setModel]);
  const redo = useCallback(() => {
    const next = future.current.shift();
    if (!next) return;
    past.current.push(modelRef.current);
    setModel(next);
  }, [setModel]);

  // ----- What the grid shows -----
  const [order, setOrder] = useState<string[]>(() => CATEGORIES.map((c) => c.id));
  const [sort, setSort] = useState<'aisle' | 'gap'>('aisle');
  const [hidden, setHidden] = useState<string[]>([]);
  const [focus, setFocus] = useState('drinks');
  const cols = useMemo(() => order.filter((id) => !hidden.includes(id)), [order, hidden]);

  const { doc, base, stock, budget } = model;
  const stages = useMemo(() => {
    const out: Record<string, Stage> = {};
    for (const c of CATEGORIES) out[c.id] = stageOf(doc, c.id);
    return out;
  }, [doc]);
  const orders = useMemo(() => suggest(doc, stock, budget), [doc, stock, budget]);
  const orderOf = useMemo(() => new Map(orders.list.map((s) => [s.id, s])), [orders]);
  const changes = changesOf(doc, base);
  const totalTarget = round1(CATEGORIES.reduce((a, c) => a + targetOf(doc, c.id), 0));
  const totalActual = round1(CATEGORIES.reduce((a, c) => a + doc.cats[c.id].actual, 0));
  const totalValue = Math.round((totalActual * stock) / 100);
  const counts = useMemo(() => {
    const out: Record<Stage, number> = { on: 0, watch: 0, off: 0, unpriced: 0, hold: 0 };
    for (const c of CATEGORIES) out[stages[c.id]]++;
    return out;
  }, [stages]);

  // ----- Busy states: repricing, submitting -----
  /** A state opened on holds Update Costs at its step, and Submit Orders on its spinner. */
  const heldPricing = defaultState?.pricing !== undefined;
  const [pricing, setPricing] = useState<{ step: number; undo: number } | null>(() =>
    defaultState?.pricing !== undefined
      ? { step: clamp(Math.round(defaultState.pricing), 0, CATEGORIES.length - 1), undo: -1 }
      : null,
  );
  const [submitting, setSubmitting] = useState(!!defaultState?.submitting);

  // ----- Selection, editing, filling -----
  const [sel, setSel] = useState<Sel | null>(null);
  const [edit, setEdit] = useState<Edit | null>(null);
  const [fill, setFill] = useState<string | null>(null);
  const [popover, setPopover] = useState<Popover | null>(null);
  const [leavingPopover, setLeavingPopover] = useState<Popover | null>(null);
  const [drawer, setDrawer] = useState<{ id: string; tab: Tab } | null>(null);
  const [drawerLeaving, setDrawerLeaving] = useState(false);
  /** A drawer the grid opens in doesn't scroll the page to itself. */
  const [drawerStill, setDrawerStill] = useState(!!defaultState?.drawer);
  const [dialog, setDialog] = useState<'open' | 'leaving' | null>(null);

  const grid = useRef<HTMLDivElement>(null);
  const scroller = useRef<HTMLDivElement>(null);
  const selBox = useRef<HTMLDivElement>(null);
  const fillBox = useRef<HTMLDivElement>(null);
  const returnFocus = useRef<HTMLElement | null>(null);

  const cellId = (row: RowKey, col: string) => `${uid}-${row}-${col}`;
  const findCell = useCallback(
    (row: RowKey, col: string) =>
      grid.current?.querySelector<HTMLElement>(`[data-cell="${row}:${col}"]`) ?? null,
    [],
  );

  const range = useMemo(() => {
    if (!sel) return [] as string[];
    const a = cols.indexOf(sel.anchor);
    const b = cols.indexOf(sel.head);
    if (a < 0 || b < 0) return [];
    return cols.slice(Math.min(a, b), Math.max(a, b) + 1);
  }, [sel, cols]);
  const fillRange = useMemo(() => {
    if (!sel || fill === null || !range.length) return [] as string[];
    const lo = cols.indexOf(range[0]);
    const hi = cols.indexOf(range[range.length - 1]);
    const f = cols.indexOf(fill);
    return cols.slice(Math.min(lo, f), Math.max(hi, f) + 1);
  }, [sel, fill, range, cols]);

  /** Places the selection's border and the fill's dashed outline over their cells. */
  const place = useCallback(() => {
    const box = selBox.current;
    const dash = fillBox.current;
    if (!box || !dash) return;
    const rect = (row: RowKey, ids: string[]) => {
      const first = ids.length ? findCell(row, ids[0]) : null;
      const last = ids.length ? findCell(row, ids[ids.length - 1]) : null;
      if (!first || !last) return null;
      return {
        x: first.offsetLeft,
        y: first.offsetTop,
        w: last.offsetLeft + last.offsetWidth - first.offsetLeft,
        h: first.offsetHeight,
      };
    };
    const show = (el: HTMLElement, r: ReturnType<typeof rect>) => {
      if (!r) {
        el.style.visibility = 'hidden';
        return;
      }
      el.style.visibility = 'visible';
      el.style.transform = `translate(${r.x}px, ${r.y}px)`;
      el.style.width = `${r.w}px`;
      el.style.height = `${r.h}px`;
    };
    show(box, sel && sel.row !== 'share' && fill === null ? rect(sel.row, range) : null);
    show(dash, sel && fill !== null ? rect(sel.row, fillRange) : null);
  }, [sel, fill, range, fillRange, findCell]);
  useLayoutEffect(() => {
    place();
  });
  useEffect(() => {
    const el = grid.current;
    if (!el) return;
    const observer = new ResizeObserver(() => place());
    observer.observe(el);
    return () => observer.disconnect();
  }, [place]);

  // ----- Column moves (FLIP) -----
  const flip = useRef<Map<string, number> | null>(null);
  const reduced = useRef(false);
  useEffect(() => {
    const media = window.matchMedia('(prefers-reduced-motion: reduce)');
    const read = () => (reduced.current = media.matches);
    read();
    media.addEventListener('change', read);
    return () => media.removeEventListener('change', read);
  }, []);
  const remember = () => {
    const map = new Map<string, number>();
    grid.current
      ?.querySelectorAll<HTMLElement>('[data-head]')
      .forEach((el) => map.set(el.dataset.head!, el.getBoundingClientRect().left));
    flip.current = map;
  };
  useLayoutEffect(() => {
    const before = flip.current;
    if (!before || !grid.current) return;
    flip.current = null;
    if (reduced.current) return;
    grid.current.querySelectorAll<HTMLElement>('[data-head]').forEach((head) => {
      const id = head.dataset.head!;
      const was = before.get(id);
      if (was === undefined) return;
      const dx = was - head.getBoundingClientRect().left;
      if (Math.abs(dx) < 1) return;
      grid.current!.querySelectorAll<HTMLElement>(`[data-col="${id}"]`).forEach((cell) =>
        cell.animate([{ transform: `translateX(${dx}px)` }, { transform: 'none' }], {
          duration: 340,
          easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
        }),
      );
    });
  }, [order, hidden]);

  // ----- Changes -----
  const setCell = (row: Field, col: string, raw: string | Supplier): boolean => {
    const m = modelRef.current;
    const cat = m.doc.cats[col];
    const name = categoryOf(col).name;
    let doc = m.doc;
    let before: string;
    let after: string;
    if (row === 'supplier') {
      if (cat.supplier === raw) return false;
      before = cat.supplier;
      after = raw;
      doc = withCat(doc, col, { supplier: raw as Supplier });
    } else {
      const n = Number(String(raw).replace(/[^0-9.\-]/g, ''));
      if (!Number.isFinite(n) || String(raw).trim() === '') return false;
      if (row === 'target') {
        const v = round1(clamp(n, 0, 100));
        if (v === cat.targets[doc.plan]) return false;
        before = spoken('target', cat.targets[doc.plan]);
        after = spoken('target', v);
        doc = withCat(doc, col, { targets: { ...cat.targets, [doc.plan]: v } });
      } else if (row === 'tol') {
        const v = round1(clamp(n, 0, 10));
        if (v === cat.tol) return false;
        before = spoken('tol', cat.tol);
        after = spoken('tol', v);
        doc = withCat(doc, col, { tol: v });
      } else if (row === 'min') {
        const v = Math.round(clamp(n, 0, 20));
        if (v === cat.min) return false;
        before = spoken('min', cat.min);
        after = spoken('min', v);
        doc = withCat(doc, col, { min: v });
      } else return false;
    }
    const title = `${FIELD_NAMES[row]} changed`;
    const what =
      row === 'target' ? `${doc.plan} plan, ${before} to ${after}` : `${before} to ${after}`;
    commit(
      {
        ...m,
        doc,
        history: logged(m.history, col, {
          icon: row === 'supplier' ? 'swap' : 'pencil',
          title,
          text: what,
        }),
      },
      title,
      `${name} ${before} to ${after}.`,
    );
    return true;
  };

  const fillCells = (row: Field, from: string, ids: string[]) => {
    const m = modelRef.current;
    let doc = m.doc;
    let history = m.history;
    const said: string[] = [];
    const src = m.doc.cats[from];
    for (const id of ids) {
      if (id === from) continue;
      const cat = doc.cats[id];
      const name = categoryOf(id).name;
      if (row === 'supplier' && cat.supplier !== src.supplier) {
        said.push(`${name} ${cat.supplier} to ${src.supplier}`);
        doc = withCat(doc, id, { supplier: src.supplier });
      } else if (row === 'target' && cat.targets[doc.plan] !== src.targets[doc.plan]) {
        said.push(
          `${name} ${spoken('target', cat.targets[doc.plan])} to ${spoken('target', src.targets[doc.plan])}`,
        );
        doc = withCat(doc, id, { targets: { ...cat.targets, [doc.plan]: src.targets[doc.plan] } });
      } else if (row === 'tol' && cat.tol !== src.tol) {
        said.push(`${name} ${spoken('tol', cat.tol)} to ${spoken('tol', src.tol)}`);
        doc = withCat(doc, id, { tol: src.tol });
      } else if (row === 'min' && cat.min !== src.min) {
        said.push(`${name} ${spoken('min', cat.min)} to ${spoken('min', src.min)}`);
        doc = withCat(doc, id, { min: src.min });
      } else continue;
      history = logged(history, id, {
        icon: 'pencil',
        title: `${FIELD_NAMES[row]} changed`,
        text: said[said.length - 1].slice(name.length + 1),
      });
    }
    if (!said.length) return 0;
    commit(
      { ...m, doc, history },
      `${said.length} ${said.length === 1 ? 'cell' : 'cells'} updated`,
      `Filled: ${listed(said)}.`,
    );
    return said.length;
  };

  const stageOrders = (only?: string) => {
    const m = modelRef.current;
    const o = suggest(m.doc, m.stock, m.budget);
    const list = only ? o.list.filter((s) => s.id === only) : o.list;
    if (!list.length) return;
    let doc = m.doc;
    let history = m.history;
    for (const s of list) {
      const cat = doc.cats[s.id];
      doc = withCat(doc, s.id, { actual: round1(cat.actual + s.pp), ordered: cat.ordered + s.pp });
      history = logged(history, s.id, {
        icon: 'stage',
        title: s.amount < 0 ? 'Return staged' : 'Order staged',
        text: `${Math.round(Math.abs((s.amount * 1000) / cat.cost))} cases of ${categoryOf(s.id).name} at $${cat.cost.toFixed(2)}, ${doc.plan} plan`,
      });
    }
    const gross = list.reduce((a, s) => a + Math.abs(s.amount), 0);
    const net = list.reduce((a, s) => a + s.amount, 0);
    const fees = gross * 0.01;
    const n = list.length;
    commit(
      { ...m, doc, history, budget: m.budget - net - fees },
      n === 1 ? 'Order staged' : `${n} orders staged`,
      n === 1
        ? `${categoryOf(list[0].id).name} ${signed(list[0].amount, 0)}k, ${dollars(fees)} est. fees.`
        : `$${Math.round(gross)}k across ${n} categories, ${dollars(fees)} est. fees.`,
    );
  };

  const choosePlan = (plan: Plan) => {
    const m = modelRef.current;
    if (plan === m.doc.plan) return;
    commit(
      { ...m, doc: { ...m.doc, plan } },
      `${plan} plan`,
      `${m.doc.plan} to ${plan}, ships with the next submission.`,
    );
  };
  const chooseOrderTo = (to: OrderTo) => {
    const m = modelRef.current;
    if (to === m.doc.orderTo) return;
    commit(
      { ...m, doc: { ...m.doc, orderTo: to } },
      to === 'Target' ? 'Orders aim at the target' : 'Orders aim at the tolerance',
      to === 'Target'
        ? 'Suggested orders bring each category back to its target.'
        : 'Suggested orders bring each category just inside its tolerance.',
    );
  };

  const startPricing = () => {
    if (pricing) return;
    past.current.push(modelRef.current);
    future.current = [];
    setPricing({ step: 0, undo: past.current.length - 1 });
  };
  useEffect(() => {
    if (!pricing || heldPricing) return;
    const timer = window.setTimeout(() => {
      const m = modelRef.current;
      const id = cols[pricing.step] ?? order[pricing.step];
      if (id) {
        const cat = m.doc.cats[id];
        const list = PRICE_LIST[id];
        const fresh = Math.abs(cat.cost - list.cost) > 1e-9;
        const cost = fresh ? list.cost : Math.round(cat.cost * 1.002 * 100) / 100;
        const shift = fresh ? list.shift : 0;
        setModel({
          ...m,
          doc: withCat(m.doc, id, {
            cost,
            priced: true,
            actual: round1(cat.actual + shift),
          }),
          history: logged(m.history, id, {
            icon: 'tag',
            title: 'Cost updated',
            text: `${categoryOf(id).name} from the supplier price list`,
          }),
        });
      }
      const step = pricing.step + 1;
      if (step >= CATEGORIES.length) {
        setModel({ ...modelRef.current, pricedToday: CATEGORIES.length });
        setPricing(null);
        toast(
          'Costs updated',
          `${CATEGORIES.length} categories updated at ${clock()}.`,
          pricing.undo,
        );
      } else setPricing({ ...pricing, step });
    }, PRICE_STEP);
    return () => window.clearTimeout(timer);
  }, [pricing, heldPricing, cols, order, setModel, toast]);
  const pricingId = pricing ? (cols[pricing.step] ?? order[pricing.step]) : null;

  const copyToAll = () => {
    const row = (sel && rowDef(sel.row)?.edit ? sel.row : 'tol') as Field;
    const from = sel && rowDef(sel.row)?.edit ? sel.anchor : focus;
    const n = fillCells(
      row,
      from,
      CATEGORIES.map((c) => c.id),
    );
    if (!n)
      toast(
        'Nothing to copy',
        `Every category already has this ${FIELD_NAMES[row].toLowerCase()}.`,
      );
  };

  const revert = () => {
    const m = modelRef.current;
    const n = changesOf(m.doc, m.base);
    const cats: Doc['cats'] = {};
    for (const c of CATEGORIES) {
      const now = m.doc.cats[c.id];
      cats[c.id] = {
        ...m.base.cats[c.id],
        cost: now.cost,
        priced: now.priced,
        actual: round1(now.actual - now.ordered),
        ordered: 0,
      };
    }
    commit(
      { ...m, doc: { ...m.base, cats }, budget: START.budget },
      'Changes reverted',
      `${n} ${n === 1 ? 'change goes' : 'changes go'} back to orders v${m.version}.`,
    );
  };

  const submit = () => {
    if (submitting) return;
    setSubmitting(true);
    window.setTimeout(() => {
      const m = modelRef.current;
      const n = changesOf(m.doc, m.base);
      const net = CATEGORIES.reduce((a, c) => a + (m.doc.cats[c.id].ordered * m.stock) / 100, 0);
      const cats: Doc['cats'] = {};
      for (const c of CATEGORIES) cats[c.id] = { ...m.doc.cats[c.id], ordered: 0 };
      const doc = { ...m.doc, cats };
      setSubmitting(false);
      commit(
        { ...m, doc, base: doc, version: m.version + 1, stock: Math.round(m.stock + net) },
        `Orders v${m.version + 1} submitted`,
        `${n} ${n === 1 ? 'change' : 'changes'} sent for ${STORE.code}.`,
      );
    }, SUBMIT_TIME);
  };

  const exportCsv = () => {
    const m = modelRef.current;
    const head = [
      'Category',
      'Group',
      'Supplier',
      'Target %',
      'Actual %',
      'Tolerance pp',
      'Min order $k',
      'Case cost $',
      'Value $k',
      'Order $k',
    ];
    const lines = [head.join(',')];
    for (const c of CATEGORIES) {
      const cat = m.doc.cats[c.id];
      const s = orderOf.get(c.id);
      lines.push(
        [
          c.title,
          c.group,
          cat.supplier,
          fmt1(targetOf(m.doc, c.id)),
          fmt1(cat.actual),
          fmt1(cat.tol),
          cat.min,
          cat.cost.toFixed(2),
          cellText(m.doc, c.id, 'value', m.stock),
          s ? Math.round(s.amount) : 0,
        ]
          .map((v) => (/[,"]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : v))
          .join(','),
      );
    }
    const name = `stock-mix-v${m.version}.csv`;
    const url = URL.createObjectURL(new Blob([lines.join('\n')], { type: 'text/csv' }));
    const a = document.createElement('a');
    a.href = url;
    a.download = name;
    a.click();
    window.setTimeout(() => URL.revokeObjectURL(url), 1000);
    toast('CSV exported', `${name}, ${CATEGORIES.length} categories.`);
  };

  const toggleHold = (id: string) => {
    const m = modelRef.current;
    const hold = !m.doc.cats[id].hold;
    const name = categoryOf(id).name;
    commit(
      {
        ...m,
        doc: withCat(m.doc, id, { hold }),
        history: logged(m.history, id, {
          icon: 'lock',
          title: hold ? 'Put on hold' : 'Released',
          text: hold ? 'No orders until it is released' : 'Orders resume',
        }),
      },
      hold ? `${name} on hold` : `${name} released`,
      hold ? 'No orders for it until you release it.' : 'It gets orders again.',
    );
  };

  // ----- Sorting and columns -----
  const sortBy = (how: 'aisle' | 'gap') => {
    remember();
    setSort(how);
    if (how === 'aisle') setOrder(CATEGORIES.map((c) => c.id));
    else {
      const d = modelRef.current.doc;
      setOrder(
        [...CATEGORIES.map((c) => c.id)].sort(
          (a, b) => Math.abs(gapOf(d, b)) - Math.abs(gapOf(d, a)),
        ),
      );
    }
  };
  const toggleColumn = (id: string) => {
    remember();
    setHidden((h) =>
      h.includes(id)
        ? h.filter((x) => x !== id)
        : h.length >= CATEGORIES.length - 1
          ? h
          : [...h, id],
    );
  };
  const showAll = () => {
    remember();
    setHidden([]);
  };

  // ----- Popovers -----
  const openPopover = (p: Popover, from?: HTMLElement | null) => {
    returnFocus.current = from ?? (document.activeElement as HTMLElement | null);
    setLeavingPopover(null);
    setPopover(p);
  };
  const closePopover = useCallback((refocus = true) => {
    setPopover((p) => {
      if (p) setLeavingPopover(p);
      return null;
    });
    if (refocus) returnFocus.current?.focus({ preventScroll: true });
  }, []);
  useEffect(() => {
    if (!leavingPopover) return;
    const timer = window.setTimeout(() => setLeavingPopover(null), reduced.current ? 0 : 120);
    return () => window.clearTimeout(timer);
  }, [leavingPopover]);
  // A click anywhere else closes the open popover.
  useEffect(() => {
    if (!popover) return;
    const away = (event: PointerEvent) => {
      const target = event.target as HTMLElement;
      if (target.closest('[data-popover]') || target.closest('[data-popover-trigger]')) return;
      closePopover(false);
    };
    document.addEventListener('pointerdown', away, true);
    return () => document.removeEventListener('pointerdown', away, true);
  }, [popover, closePopover]);

  // ----- Drawer and dialog -----
  const openDrawer = (id: string, tab: Tab = 'overview') => {
    returnFocus.current = document.activeElement as HTMLElement | null;
    setDrawerLeaving(false);
    setDrawerStill(false);
    setFocus(id);
    setDrawer({ id, tab });
  };
  const closeDrawer = () => {
    setDrawerLeaving(true);
    window.setTimeout(
      () => {
        setDrawer(null);
        setDrawerLeaving(false);
        (returnFocus.current ?? grid.current)?.focus({ preventScroll: true });
      },
      reduced.current ? 0 : 200,
    );
  };
  const stepDrawer = (by: number) => {
    if (!drawer) return;
    const i = cols.indexOf(drawer.id);
    const id = cols[(i + by + cols.length) % cols.length];
    setFocus(id);
    setSel({ row: 'share', anchor: id, head: id });
    setDrawer({ ...drawer, id });
  };
  const openDialog = () => {
    returnFocus.current = document.activeElement as HTMLElement | null;
    setDialog('open');
  };
  const closeDialog = (confirm: boolean) => {
    setDialog('leaving');
    if (confirm) revert();
    window.setTimeout(
      () => {
        setDialog(null);
        returnFocus.current?.focus({ preventScroll: true });
      },
      reduced.current ? 0 : 160,
    );
  };

  // ----- Selection and editing -----
  const select = (row: RowKey, col: string, extend = false) => {
    changeEdit(null);
    if (extend && sel && sel.row === row) setSel({ ...sel, head: col });
    else setSel({ row, anchor: col, head: col });
  };
  const startEdit = (row: Field, col: string, text?: string) => {
    if (rowDef(row)?.edit !== 'number') return;
    const m = modelRef.current;
    setSel({ row, anchor: col, head: col });
    changeEdit({ row, col, text: text ?? cellText(m.doc, col, row, m.stock) });
  };
  /** Keeps the edit in a ref too: a blur that follows Enter or Escape must not commit twice. */
  const editRef = useRef<Edit | null>(null);
  const changeEdit = (next: Edit | null) => {
    editRef.current = next;
    setEdit(next);
  };
  const commitEdit = (move?: 'down' | 'right' | 'left') => {
    const e = editRef.current;
    if (!e) return;
    changeEdit(null);
    setCell(e.row, e.col, e.text);
    if (move === 'right' || move === 'left') {
      const i = cols.indexOf(e.col) + (move === 'right' ? 1 : -1);
      if (cols[i]) setSel({ row: e.row, anchor: cols[i], head: cols[i] });
    }
    grid.current?.focus({ preventScroll: true });
  };
  const cancelEdit = () => {
    changeEdit(null);
    grid.current?.focus({ preventScroll: true });
  };

  const activate = (row: RowKey, col: string, from?: HTMLElement | null) => {
    if (row === 'share') openDrawer(col);
    else if (row === 'supplier') {
      setSel({ row, anchor: col, head: col });
      if (popover?.kind === 'supplier' && popover.col === col) closePopover();
      else openPopover({ kind: 'supplier', col }, from ?? grid.current);
    } else if (row === 'order') {
      if (orderOf.has(col)) stageOrders(col);
    } else startEdit(row, col);
  };

  const onGridKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    if (edit || event.target !== event.currentTarget) return;
    const mod = event.metaKey || event.ctrlKey;
    if (mod && event.key.toLowerCase() === 'z') {
      event.preventDefault();
      if (event.shiftKey) redo();
      else undo();
      return;
    }
    const s = sel ?? { row: 'share' as RowKey, anchor: cols[0], head: cols[0] };
    const r = ROW_KEYS.indexOf(s.row);
    const c = cols.indexOf(s.head);
    const move = (dr: number, dc: number, extend = false) => {
      event.preventDefault();
      if (!sel) {
        setSel(s);
        return;
      }
      const row = ROW_KEYS[clamp(r + dr, 0, ROW_KEYS.length - 1)];
      const col = cols[clamp(c + dc, 0, cols.length - 1)];
      if (extend && dr === 0) setSel({ ...s, head: col });
      else setSel({ row, anchor: col, head: col });
      findCell(row, col)?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
    };
    switch (event.key) {
      case 'ArrowUp':
        return move(-1, 0);
      case 'ArrowDown':
        return move(1, 0);
      case 'ArrowLeft':
        return move(0, -1, event.shiftKey);
      case 'ArrowRight':
        return move(0, 1, event.shiftKey);
      case 'Tab':
        if (!sel) return;
        if ((event.shiftKey && c === 0) || (!event.shiftKey && c === cols.length - 1)) return;
        return move(0, event.shiftKey ? -1 : 1);
      case 'Home':
        return move(0, -c);
      case 'End':
        return move(0, cols.length - 1 - c);
      case 'Escape':
        if (sel) {
          event.preventDefault();
          setSel(null);
        }
        return;
      case 'Enter':
      case 'F2':
      case ' ':
        if (!sel) return move(0, 0);
        event.preventDefault();
        return activate(s.row, s.anchor);
      default:
        if (sel && !mod && /^[0-9.]$/.test(event.key) && rowDef(sel.row)?.edit === 'number') {
          event.preventDefault();
          startEdit(sel.row as Field, sel.anchor, event.key);
        }
    }
  };

  // Pointer: press a cell to select it, drag along the row to stretch, press again to act.
  const dragging = useRef<{ row: RowKey; mode: 'range' | 'fill' } | null>(null);
  const colAt = (x: number) => {
    const heads = grid.current?.querySelectorAll<HTMLElement>('[data-head]');
    if (!heads?.length) return null;
    let best: string | null = null;
    heads.forEach((h) => {
      const r = h.getBoundingClientRect();
      if (x >= r.left) best = h.dataset.head!;
    });
    return best ?? heads[0].dataset.head!;
  };
  const onCellDown = (event: ReactPointerEvent<HTMLElement>, row: RowKey, col: string) => {
    if (event.button !== 0 || row === 'share') return;
    const again = sel?.row === row && sel.anchor === col && sel.head === col && !event.shiftKey;
    if (edit && !(edit.row === row && edit.col === col)) commitEdit();
    if (again) {
      if (row === 'supplier') activate(row, col, event.currentTarget);
      return;
    }
    select(row, col, event.shiftKey);
    grid.current?.focus({ preventScroll: true });
    dragging.current = { row, mode: 'range' };
    event.currentTarget.setPointerCapture?.(event.pointerId);
  };
  const onGridMove = (event: ReactPointerEvent) => {
    const d = dragging.current;
    if (!d) return;
    const col = colAt(event.clientX);
    if (!col) return;
    if (d.mode === 'range') setSel((s) => (s && s.head !== col ? { ...s, head: col } : s));
    else setFill(col);
  };
  const onGridUp = () => {
    const d = dragging.current;
    dragging.current = null;
    if (d?.mode === 'fill') finishFill();
  };
  const startFill = (event: ReactPointerEvent<HTMLElement>) => {
    if (!sel) return;
    event.preventDefault();
    event.stopPropagation();
    dragging.current = { row: sel.row, mode: 'fill' };
    setFill(sel.head);
    event.currentTarget.setPointerCapture?.(event.pointerId);
  };
  const finishFill = () => {
    if (!sel || fill === null) return setFill(null);
    const ids = fillRange;
    setFill(null);
    if (rowDef(sel.row)?.edit) fillCells(sel.row as Field, range[0], ids);
    setSel({ row: sel.row, anchor: ids[0], head: ids[ids.length - 1] });
  };

  // Global keys while it is open: Escape closes the top layer.
  const onRootKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    if (event.key !== 'Escape') return;
    if (popover) {
      event.preventDefault();
      event.stopPropagation();
      closePopover();
    } else if (dialog === 'open') {
      event.preventDefault();
      closeDialog(false);
    } else if (drawer && !drawerLeaving) {
      event.preventDefault();
      closeDrawer();
    }
  };

  // ----- The demo -----
  const pointer = useRef<HTMLSpanElement>(null);
  const actions = useRef({
    stageOrders,
    select,
    startEdit,
    setEdit: changeEdit,
    commitEdit,
    setFill,
    finishFill,
    setSel,
    openPopover,
    closePopover,
    setCell,
    startPricing,
    openDrawer,
    closeDrawer,
    setDrawer,
    stepDrawer,
    sortBy,
    choosePlan,
    chooseOrderTo,
    toggleHold,
    openDialog,
    closeDialog,
    submit,
    reset: () => {},
  });
  actions.current = {
    stageOrders,
    select,
    startEdit,
    setEdit: changeEdit,
    commitEdit,
    setFill,
    finishFill,
    setSel,
    openPopover,
    closePopover,
    setCell,
    startPricing,
    openDrawer,
    closeDrawer,
    setDrawer,
    stepDrawer,
    sortBy,
    choosePlan,
    chooseOrderTo,
    toggleHold,
    openDialog,
    closeDialog,
    submit,
    reset: () => {
      past.current = [];
      future.current = [];
      setModel(initialModel());
      setOrder(CATEGORIES.map((c) => c.id));
      setSort('aisle');
      setHidden([]);
      setFocus('drinks');
      setSel(null);
      changeEdit(null);
      setFill(null);
      setPopover(null);
      setDrawer(null);
      setDialog(null);
    },
  };
  const drawerRef = useRef(drawer);
  drawerRef.current = drawer;

  // Open in the state asked for: its changes first, then the sort, then what is selected or open.
  const opensIn = useRef(defaultState);
  useLayoutEffect(() => {
    const state = opensIn.current;
    if (!state) return;
    const a = actions.current;
    opening.current = true;
    if (state.plan) a.choosePlan(state.plan);
    if (state.orderTo) a.chooseOrderTo(state.orderTo);
    for (const e of state.edits ?? []) a.setCell(e.row, e.col, e.value);
    // Each step leaves things as asked when it runs again (Strict Mode runs it twice).
    for (const id of state.hold ?? []) if (!modelRef.current.doc.cats[id]?.hold) a.toggleHold(id);
    if (state.staged) a.stageOrders();
    opening.current = false;
    if (state.toasts === false) setToasts([]);
    if (state.sort === 'gap') {
      const d = modelRef.current.doc;
      setSort('gap');
      setOrder(
        CATEGORIES.map((c) => c.id).sort((x, y) => Math.abs(gapOf(d, y)) - Math.abs(gapOf(d, x))),
      );
    }
    if (state.hidden) setHidden(state.hidden.slice(0, CATEGORIES.length - 1));
    const supplierCol =
      state.select?.row === 'supplier'
        ? state.select.col
        : state.menu === 'supplier'
          ? 'drinks'
          : null;
    if (state.select) {
      const { row, col, to } = state.select;
      setSel({ row, anchor: col, head: to ?? col });
    } else if (supplierCol) setSel({ row: 'supplier', anchor: supplierCol, head: supplierCol });
    if (state.select && state.fill) setFill(state.fill);
    if (state.edit) {
      const { row, col, text } = state.edit;
      const m = modelRef.current;
      setSel({ row, anchor: col, head: col });
      a.setEdit({ row, col, text: text ?? cellText(m.doc, col, row, m.stock) });
    }
    if (state.menu)
      setPopover(
        state.menu === 'supplier' ? { kind: 'supplier', col: supplierCol! } : { kind: state.menu },
      );
    if (state.drawer) {
      const { id, tab = 'overview' } = state.drawer;
      setFocus(id);
      setSel({ row: 'share', anchor: id, head: id });
      setDrawer({ id, tab });
    }
    if (state.dialog) setDialog('open');
  }, []);

  const running = autoplay && playing && !defaultState;
  useEffect(() => {
    const stage = root.current;
    const glyph = pointer.current;
    if (!running || !stage || !glyph) return;
    const a = () => actions.current;
    const at = (selector: string, dx = 0.5, dy = 0.5): Point => {
      const el = stage.querySelector<HTMLElement>(selector);
      const box = stage.getBoundingClientRect();
      if (!el) return { x: stage.offsetWidth / 2, y: stage.offsetHeight / 2 };
      // In the stage's own pixels, should it be drawn scaled.
      const k = box.width / stage.offsetWidth || 1;
      const r = el.getBoundingClientRect();
      return {
        x: (r.left - box.left + r.width * dx) / k,
        y: (r.top - box.top + r.height * dy) / k,
      };
    };
    const press = async (demo: Demo) => {
      glyph.dataset.press = 'true';
      await demo.wait(140);
      glyph.dataset.press = 'false';
    };
    const go = async (demo: Demo, selector: string, ms = 700, dx = 0.5, dy = 0.5) => {
      await demo.glide(at(selector, dx, dy), ms);
    };
    const cell = (row: string, col: string) => `[data-cell="${row}:${col}"]`;
    const script = async (demo: Demo) => {
      for (;;) {
        a().reset();
        await demo.wait(900);
        // Stage the suggested orders.
        await go(demo, '[data-demo="stage"]', 900);
        await demo.wait(250);
        await press(demo);
        a().stageOrders();
        await demo.wait(1600);
        // Select a tolerance and drag its fill handle over three more categories.
        await go(demo, cell('tol', 'drinks'), 900, 0.55, 0.6);
        await press(demo);
        a().select('tol', 'drinks');
        await demo.wait(700);
        await go(demo, cell('tol', 'drinks'), 400, 0.99, 0.97);
        a().setFill('drinks');
        for (const id of ['dairy', 'bakery', 'produce']) {
          await go(demo, cell('tol', id), 330, 0.6, 0.7);
          a().setFill(id);
        }
        await demo.wait(350);
        a().finishFill();
        await demo.wait(1500);
        // Type a new target for Drinks, then for Home.
        await go(demo, cell('target', 'drinks'), 700, 0.55, 0.6);
        await press(demo);
        a().select('target', 'drinks');
        await demo.wait(500);
        a().startEdit('target', 'drinks', '1');
        await demo.wait(220);
        a().setEdit({ row: 'target', col: 'drinks', text: '14' });
        await demo.wait(500);
        a().setEdit(null);
        a().setCell('target', 'drinks', '14');
        await demo.wait(1300);
        await go(demo, cell('target', 'home'), 900, 0.55, 0.6);
        await press(demo);
        a().select('target', 'home');
        await demo.wait(450);
        a().startEdit('target', 'home', '1');
        await demo.wait(220);
        a().setEdit({ row: 'target', col: 'home', text: '18' });
        await demo.wait(500);
        a().setEdit(null);
        a().setCell('target', 'home', '18');
        await demo.wait(1300);
        // Swap Dairy's supplier in its cell.
        await go(demo, cell('supplier', 'dairy'), 800, 0.5, 0.6);
        await press(demo);
        a().select('supplier', 'dairy');
        await demo.wait(600);
        await press(demo);
        a().openPopover({ kind: 'supplier', col: 'dairy' }, null);
        await demo.wait(700);
        await go(demo, '[data-option="WHL"]', 500, 0.4, 0.5);
        await demo.wait(300);
        await press(demo);
        a().closePopover(false);
        a().setCell('supplier', 'dairy', 'WHL');
        await demo.wait(1300);
        // Reprice everything.
        await go(demo, '[data-demo="costs"]', 1000);
        await press(demo);
        a().startPricing();
        await demo.wait(PRICE_STEP * 8 + 1200);
        // Open Drinks, look through its tabs, step to the next category.
        await go(demo, '[data-share="drinks"]', 900, 0.5, 0.72);
        await press(demo);
        a().openDrawer('drinks');
        await demo.wait(1800);
        await go(demo, '[data-tab="costs"]', 700);
        await press(demo);
        a().setDrawer({ id: drawerRef.current?.id ?? 'drinks', tab: 'costs' });
        await demo.wait(1500);
        await go(demo, '[data-tab="history"]', 500);
        await press(demo);
        a().setDrawer({ id: drawerRef.current?.id ?? 'drinks', tab: 'history' });
        await demo.wait(1500);
        await go(demo, '[data-demo="next"]', 700);
        await press(demo);
        a().stepDrawer(1);
        await demo.wait(1400);
        await go(demo, '[data-demo="close"]', 500);
        await press(demo);
        a().closeDrawer();
        await demo.wait(1000);
        // Sort the columns by their gap.
        await go(demo, '[data-demo="columns"]', 900);
        await press(demo);
        a().openPopover({ kind: 'columns' }, null);
        await demo.wait(900);
        await go(demo, '[data-option="gap"]', 500, 0.3, 0.5);
        await demo.wait(250);
        await press(demo);
        a().closePopover(false);
        a().sortBy('gap');
        await demo.wait(1700);
        // Switch to the summer plan, and stage what it asks for.
        await go(demo, '[data-demo="plan"]', 1000);
        await press(demo);
        a().openPopover({ kind: 'plan' }, null);
        await demo.wait(800);
        await go(demo, '[data-option="Summer"]', 450, 0.3, 0.5);
        await demo.wait(250);
        await press(demo);
        a().closePopover(false);
        a().choosePlan('Summer');
        await demo.wait(1800);
        await go(demo, '[data-demo="stage"]', 1000);
        await press(demo);
        a().stageOrders();
        await demo.wait(1500);
        // Think about reverting, then submit.
        await go(demo, '[data-demo="revert"]', 600);
        await press(demo);
        a().openDialog();
        await demo.wait(1700);
        await go(demo, '[data-demo="cancel"]', 700);
        await press(demo);
        a().closeDialog(false);
        await demo.wait(900);
        await go(demo, '[data-demo="submit"]', 700);
        await press(demo);
        a().submit();
        await demo.wait(SUBMIT_TIME + 3600);
        await demo.glide({ x: stage.offsetWidth * 0.62, y: stage.offsetHeight * 0.96 }, 900);
        await demo.wait(2500);
      }
    };
    return runDemo(stage, glyph, script);
  }, [running, root]);

  // Any real input hands the grid to the reader for good.
  const takeOver = () => {
    if (running) take();
  };

  // ----- Rendering -----
  const range0 = new Set(range);
  const fillSet = new Set(fill !== null ? fillRange : []);
  const drawerCat = drawer ? drawer.id : null;
  const footer = orders.list.length ? (
    <>
      Budget <b>{money(budget)}</b>, <b>{money(budget - orders.net - orders.fees)}</b> after
      suggested orders and <b>{dollars(orders.fees)}</b> fees
      {orders.scaled ? ', orders scaled to fit' : ''}
    </>
  ) : (
    <>
      Budget <b>{money(budget)}</b>
    </>
  );
  const canSubmit = changes > 0 && totalTarget === 100 && !submitting;
  const lastPopover = popover ?? leavingPopover;

  const rootStyle = { '--cols': cols.length } as CSSProperties;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className ?? ''}`}
      data-part="root"
      data-color-scheme={colorScheme}
      data-demo-on={running ? 'true' : undefined}
      style={rootStyle}
      onPointerDownCapture={(event) => {
        if (event.isTrusted) takeOver();
      }}
      onKeyDownCapture={(event) => {
        if (event.isTrusted) takeOver();
      }}
      onWheelCapture={takeOver}
      onKeyDown={onRootKey}
    >
      <section
        className={styles.card}
        data-part="card"
        aria-label="Stock mix"
        inert={drawer || dialog ? true : undefined}
      >
        <header className={styles.header} data-part="header">
          <div className={styles.titles}>
            <h3 className={styles.title}>
              Stock Mix
              {changes > 0 && (
                <span className={styles.unsent} data-part="unsent">
                  {changes} unsent
                </span>
              )}
            </h3>
            <p className={styles.subtitle}>
              {STORE.name}, ${stock}k, <span>orders</span> v{model.version}
            </p>
          </div>
          <div className={styles.actions} data-part="toolbar" role="toolbar" aria-label="Orders">
            <button
              type="button"
              className={styles.button}
              data-demo="columns"
              data-popover-trigger
              aria-haspopup="menu"
              aria-expanded={popover?.kind === 'columns'}
              data-open={popover?.kind === 'columns' || undefined}
              onClick={(e) =>
                popover?.kind === 'columns'
                  ? closePopover()
                  : openPopover({ kind: 'columns' }, e.currentTarget)
              }
            >
              <Icon name="columns" className={styles.icon} />
              <span className={styles.label}>Columns</span>
            </button>
            <button
              type="button"
              className={styles.button}
              data-demo="stage"
              disabled={!orders.list.length}
              onClick={() => stageOrders()}
            >
              <Icon name="stage" className={styles.icon} />
              Stage Orders
              {orders.list.length > 0 && (
                <span className={styles.count} aria-label={`${orders.list.length} suggested`}>
                  {orders.list.length}
                </span>
              )}
            </button>
            <button
              type="button"
              className={styles.button}
              data-demo="revert"
              disabled={changes === 0}
              onClick={openDialog}
            >
              <Icon name="revert" className={styles.icon} />
              <span className={styles.label}>Revert</span>
            </button>
            <button
              type="button"
              className={`${styles.button} ${styles.primary}`}
              data-demo="submit"
              disabled={!canSubmit}
              aria-busy={submitting || undefined}
              onClick={submit}
            >
              {submitting ? (
                <span className={styles.spin} aria-hidden="true" />
              ) : (
                <Icon name="send" className={styles.icon} />
              )}
              {submitting ? 'Submitting' : 'Submit Orders'}
            </button>
          </div>
        </header>

        <div className={styles.body}>
          <aside className={styles.panel} data-part="panel" aria-label="Plan and stages">
            <div className={styles.panelTop}>
              <div className={styles.field}>
                <span className={styles.fieldLabel} id={`${uid}-plan`}>
                  Plan
                </span>
                <button
                  type="button"
                  className={styles.select}
                  data-demo="plan"
                  data-popover-trigger
                  aria-haspopup="listbox"
                  aria-expanded={popover?.kind === 'plan'}
                  aria-labelledby={`${uid}-plan ${uid}-plan-value`}
                  onClick={(e) =>
                    popover?.kind === 'plan'
                      ? closePopover()
                      : openPopover({ kind: 'plan' }, e.currentTarget)
                  }
                >
                  <span id={`${uid}-plan-value`}>{doc.plan}</span>
                  <Icon name="chevron" className={styles.chev} />
                </button>
              </div>
              <div className={styles.field}>
                <span className={styles.fieldLabel} id={`${uid}-to`}>
                  Order to
                </span>
                <button
                  type="button"
                  className={styles.select}
                  data-demo="orderTo"
                  data-popover-trigger
                  aria-haspopup="listbox"
                  aria-expanded={popover?.kind === 'orderTo'}
                  aria-labelledby={`${uid}-to ${uid}-to-value`}
                  onClick={(e) =>
                    popover?.kind === 'orderTo'
                      ? closePopover()
                      : openPopover({ kind: 'orderTo' }, e.currentTarget)
                  }
                >
                  <span id={`${uid}-to-value`}>
                    {ORDER_TO.find((o) => o.id === doc.orderTo)!.label}
                  </span>
                  <Icon name="chevron" className={styles.chev} />
                </button>
              </div>
              <div className={styles.stages} data-part="stages">
                <span className={styles.stagesTitle} id={`${uid}-stages`}>
                  Stages
                </span>
                <ul aria-labelledby={`${uid}-stages`}>
                  {STAGES.map((s) => (
                    <li key={s.id}>
                      <span className={styles.pill} data-stage={s.id}>
                        {s.label}
                      </span>
                      <span
                        className={styles.stageCount}
                        data-zero={counts[s.id] === 0 || undefined}
                      >
                        {counts[s.id]}
                      </span>
                    </li>
                  ))}
                </ul>
              </div>
            </div>
            <div className={styles.costs} data-part="costs">
              <div className={styles.costsTitle}>Costs</div>
              <div className={styles.costsNote} aria-live="polite">
                {pricing
                  ? `Updating ${pricing.step + 1} of ${CATEGORIES.length}`
                  : `${model.pricedToday} of ${CATEGORIES.length} updated today`}
              </div>
              <button
                type="button"
                className={`${styles.wide} ${styles.dark}`}
                data-demo="costs"
                disabled={!!pricing}
                aria-busy={!!pricing || undefined}
                onClick={startPricing}
              >
                {pricing ? (
                  <span className={styles.spin} aria-hidden="true" />
                ) : (
                  <Icon name="refresh" className={styles.icon} />
                )}
                {pricing ? 'Updating' : 'Update Costs'}
              </button>
              <button
                type="button"
                className={styles.wide}
                disabled={!!pricing}
                onClick={copyToAll}
              >
                <Icon name="copy" className={styles.icon} />
                Copy to All
              </button>
            </div>
          </aside>

          <div
            className={styles.scroller}
            ref={scroller}
            onScroll={() => popover?.kind === 'supplier' && closePopover(false)}
          >
            {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
            <div
              ref={grid}
              className={styles.grid}
              role="grid"
              aria-label="Category mix"
              aria-rowcount={ROW_KEYS.length + 1}
              aria-colcount={cols.length + 2}
              aria-activedescendant={sel ? cellId(sel.row, sel.head) : undefined}
              tabIndex={0}
              data-part="grid"
              data-editing={edit ? 'true' : undefined}
              onKeyDown={onGridKey}
              onPointerMove={onGridMove}
              onPointerUp={onGridUp}
              onPointerCancel={onGridUp}
            >
              <div role="row" className={`${styles.row} ${styles.headRow}`}>
                <div role="columnheader" className={`${styles.cell} ${styles.corner}`}>
                  {sort === 'gap' ? 'Largest gap first' : 'Aisle order'}
                </div>
                {cols.map((id) => {
                  const c = categoryOf(id);
                  return (
                    <div
                      key={id}
                      role="columnheader"
                      className={`${styles.cell} ${styles.colHead}`}
                      data-head={id}
                      data-col={id}
                      data-focus={id === focus || undefined}
                    >
                      <span className={styles.colName}>{c.name}</span>
                      <span className={styles.colGroup}>{c.group}</span>
                    </div>
                  );
                })}
                <div
                  role="columnheader"
                  className={`${styles.cell} ${styles.colHead} ${styles.total}`}
                >
                  <span className={styles.colName}>Total</span>
                  <span className={styles.colGroup}>Categories</span>
                </div>
              </div>

              <div role="row" className={`${styles.row} ${styles.shareRow}`}>
                <RowHead icon="share" label="Share" unit="%" note="Target / actual" />
                {cols.map((id) => {
                  const cat = doc.cats[id];
                  const stage = stages[id];
                  const busy = pricingId === id;
                  const gap = gapOf(doc, id);
                  const isSel = range0.has(id) && sel?.row === 'share';
                  return (
                    <div
                      key={id}
                      role="gridcell"
                      id={cellId('share', id)}
                      className={`${styles.cell} ${styles.shareCell}`}
                      data-cell={`share:${id}`}
                      data-col={id}
                      data-focus={id === focus || undefined}
                      data-selected={isSel || undefined}
                      aria-selected={isSel}
                    >
                      <button
                        type="button"
                        tabIndex={-1}
                        className={styles.shareBox}
                        data-part="share"
                        data-share={id}
                        data-stage={busy ? 'busy' : stage === 'hold' ? 'unpriced' : stage}
                        aria-label={`${categoryOf(id).name}: target ${fmt1(targetOf(doc, id))}%, actual ${fmt1(cat.actual)}%. Open details`}
                        onClick={() => {
                          takeOver();
                          setSel({ row: 'share', anchor: id, head: id });
                          openDrawer(id);
                        }}
                      >
                        <span className={styles.shareTarget}>{fmt1(targetOf(doc, id))}</span>
                        <span className={styles.shareActual}>
                          {busy ? (
                            <span className={styles.spin} aria-hidden="true" />
                          ) : (
                            fmt1(cat.actual)
                          )}
                        </span>
                      </button>
                      <span className={styles.delta} data-stage={busy ? 'busy' : stage}>
                        {busy
                          ? 'Updating'
                          : stage === 'unpriced'
                            ? 'Unpriced'
                            : stage === 'hold'
                              ? 'On hold'
                              : signed(gap)}
                      </span>
                    </div>
                  );
                })}
                <div
                  role="gridcell"
                  className={`${styles.cell} ${styles.shareCell} ${styles.total}`}
                >
                  <span className={`${styles.shareBox} ${styles.shareTotal}`}>
                    <span
                      className={styles.shareTarget}
                      data-bad={totalTarget !== 100 || undefined}
                    >
                      {fmt1(totalTarget)}
                    </span>
                    <span className={styles.shareActual}>{fmt1(totalActual)}</span>
                  </span>
                  <span className={styles.delta}>
                    Budget {fmt1(Math.max(0, 100 - totalActual))}
                  </span>
                </div>
              </div>

              {ROWS.map((row) => (
                <div
                  role="row"
                  key={row.key}
                  className={`${styles.row} ${row.costs ? styles.costRow : ''} ${row.key === 'order' ? styles.orderRow : ''}`}
                >
                  <RowHead icon={row.icon} label={row.label} unit={row.unit} />
                  {cols.map((id) => {
                    const isSel = sel?.row === row.key && range0.has(id);
                    const inFill = sel?.row === row.key && fillSet.has(id);
                    const editing = edit?.row === row.key && edit.col === id;
                    const s = row.key === 'order' ? orderOf.get(id) : undefined;
                    let content: ReactNode;
                    if (editing) {
                      content = (
                        <input
                          className={styles.input}
                          value={edit.text}
                          aria-label={`${FIELD_NAMES[row.key]}, ${categoryOf(id).name}`}
                          inputMode="decimal"
                          // The edit starts from a key or a double click on this very cell.
                          // eslint-disable-next-line jsx-a11y/no-autofocus
                          autoFocus
                          onFocus={(e) => {
                            const el = e.currentTarget;
                            const end = el.value.length;
                            el.setSelectionRange(edit.text.length === 1 ? end : 0, end);
                          }}
                          onChange={(e) => changeEdit({ ...edit, text: e.target.value })}
                          onBlur={() => commitEdit()}
                          onKeyDown={(e) => {
                            if (e.key === 'Enter') {
                              e.preventDefault();
                              commitEdit('down');
                            } else if (e.key === 'Escape') {
                              e.preventDefault();
                              e.stopPropagation();
                              cancelEdit();
                            } else if (e.key === 'Tab') {
                              e.preventDefault();
                              commitEdit(e.shiftKey ? 'left' : 'right');
                            }
                          }}
                        />
                      );
                    } else if (row.key === 'order') {
                      content = s ? (
                        <span className={styles.suggest}>{signed(Math.round(s.amount), 0)}</span>
                      ) : (
                        <span className={styles.dot} aria-label="None" />
                      );
                    } else if (row.key === 'supplier') {
                      content = (
                        <>
                          <span>{doc.cats[id].supplier}</span>
                          {isSel && range.length === 1 && (
                            <Icon name="chevron" className={styles.cellChev} />
                          )}
                        </>
                      );
                    } else content = cellText(doc, id, row.key, stock);
                    return (
                      <div
                        key={id}
                        role="gridcell"
                        id={cellId(row.key, id)}
                        className={styles.cell}
                        data-cell={`${row.key}:${id}`}
                        data-col={id}
                        data-focus={id === focus || undefined}
                        data-edit={row.edit || undefined}
                        data-selected={(isSel && range.length > 1) || inFill || undefined}
                        data-dirty={dirty(doc, base, id, row.key) || undefined}
                        data-muted={(row.key === 'cost' && !doc.cats[id].priced) || undefined}
                        data-popover-trigger={row.key === 'supplier' || undefined}
                        data-editing={editing || undefined}
                        aria-selected={isSel}
                        aria-readonly={!row.edit || undefined}
                        onPointerDown={(e) => onCellDown(e, row.key, id)}
                        onDoubleClick={() => row.edit === 'number' && startEdit(row.key, id)}
                      >
                        {content}
                      </div>
                    );
                  })}
                  <div role="gridcell" className={`${styles.cell} ${styles.total}`}>
                    {row.key === 'target' ? (
                      <b data-bad={totalTarget !== 100 || undefined}>{fmt1(totalTarget)}</b>
                    ) : row.key === 'value' ? (
                      <b>{totalValue}</b>
                    ) : row.key === 'order' ? (
                      <b>
                        {orders.list.length ? (
                          signed(Math.round(orders.net), 0)
                        ) : (
                          <span className={styles.dot} aria-label="None" />
                        )}
                      </b>
                    ) : (
                      <span className={styles.dot} aria-label="None" />
                    )}
                  </div>
                </div>
              ))}

              <div
                ref={selBox}
                className={styles.selection}
                aria-hidden="true"
                data-part="selection"
              >
                {sel && rowDef(sel.row)?.edit && !edit && (
                  <span
                    className={styles.handle}
                    onPointerDown={startFill}
                    data-demo="handle"
                    data-part="handle"
                  />
                )}
              </div>
              <div
                ref={fillBox}
                className={styles.fillOutline}
                aria-hidden="true"
                data-part="fill"
              />
            </div>
          </div>
        </div>

        <footer className={styles.footer} data-part="footer">
          <p aria-live="polite">{footer}</p>
          <button type="button" className={styles.button} onClick={exportCsv}>
            <Icon name="download" className={styles.icon} />
            Export CSV
          </button>
        </footer>
      </section>

      {/* Toasts, newest in front. */}
      <div className={styles.toasts} role="status" aria-live="polite" data-part="toasts">
        {toasts.map((t, i) => {
          const depth = toasts.length - 1 - i;
          return (
            <div
              key={t.id}
              className={styles.toast}
              data-part="toast"
              data-depth={Math.min(depth, 2)}
              data-leaving={t.leaving || undefined}
              aria-hidden={depth > 0 || undefined}
              inert={depth > 0 ? true : undefined}
            >
              <span className={styles.toastIcon} aria-hidden="true">
                <svg viewBox="0 0 16 16">
                  <path d="M5.4 8.2 7.2 10l3.4-3.6" />
                </svg>
              </span>
              <span className={styles.toastText}>
                <b>{t.title}</b>
                <span>{t.text}</span>
              </span>
              {t.undo !== undefined && (
                <button
                  type="button"
                  className={styles.undo}
                  onClick={() => {
                    undoTo(t.undo!);
                    dropToast(t.id);
                  }}
                >
                  Undo
                </button>
              )}
            </div>
          );
        })}
      </div>

      {/* Popovers. */}
      {lastPopover && (
        <PopoverLayer
          key={lastPopover.kind + ('col' in lastPopover ? lastPopover.col : '')}
          root={root}
          popover={lastPopover}
          leaving={!popover}
          anchor={
            lastPopover.kind === 'supplier'
              ? `[data-cell="supplier:${lastPopover.col}"]`
              : `[data-demo="${lastPopover.kind}"]`
          }
          onClose={() => closePopover()}
        >
          {lastPopover.kind === 'columns' && (
            <div role="menu" aria-label="Columns" className={styles.menu} data-wide>
              <div className={styles.menuLabel}>Order</div>
              {(
                [
                  ['aisle', 'Aisle order'],
                  ['gap', 'Largest gap first'],
                ] as const
              ).map(([id, label]) => (
                <button
                  key={id}
                  type="button"
                  role="menuitemradio"
                  aria-checked={sort === id}
                  className={styles.item}
                  data-option={id}
                  onClick={() => {
                    closePopover();
                    sortBy(id);
                  }}
                >
                  <span>{label}</span>
                  {sort === id && <Icon name="check" className={styles.check} />}
                </button>
              ))}
              <div className={styles.menuRule} />
              <div className={styles.menuLabel}>Categories</div>
              {CATEGORIES.map((c) => (
                <button
                  key={c.id}
                  type="button"
                  role="menuitemcheckbox"
                  aria-checked={!hidden.includes(c.id)}
                  className={styles.item}
                  onClick={() => toggleColumn(c.id)}
                >
                  <span>{c.title}</span>
                  <span className={styles.itemNote}>{c.name}</span>
                  {hidden.includes(c.id) ? (
                    <span className={styles.check} />
                  ) : (
                    <Icon name="check" className={styles.check} />
                  )}
                </button>
              ))}
              <div className={styles.menuRule} />
              <button
                type="button"
                role="menuitem"
                className={`${styles.item} ${styles.itemQuiet}`}
                aria-disabled={hidden.length === 0}
                onClick={() => {
                  if (hidden.length) showAll();
                }}
              >
                <Icon name="revert" className={styles.itemIcon} />
                <span>Show All Categories</span>
              </button>
            </div>
          )}
          {lastPopover.kind === 'plan' && (
            <div role="listbox" aria-label="Plan" className={styles.menu}>
              {PLANS.map((p) => (
                <button
                  key={p}
                  type="button"
                  role="option"
                  aria-selected={doc.plan === p}
                  className={styles.item}
                  data-option={p}
                  onClick={() => {
                    closePopover();
                    choosePlan(p);
                  }}
                >
                  <span>{p}</span>
                  {doc.plan === p && <Icon name="check" className={styles.check} />}
                </button>
              ))}
            </div>
          )}
          {lastPopover.kind === 'orderTo' && (
            <div role="listbox" aria-label="Order to" className={styles.menu}>
              {ORDER_TO.map((o) => (
                <button
                  key={o.id}
                  type="button"
                  role="option"
                  aria-selected={doc.orderTo === o.id}
                  className={styles.item}
                  data-option={o.id}
                  onClick={() => {
                    closePopover();
                    chooseOrderTo(o.id);
                  }}
                >
                  <span>{o.label}</span>
                  {doc.orderTo === o.id && <Icon name="check" className={styles.check} />}
                </button>
              ))}
            </div>
          )}
          {lastPopover.kind === 'supplier' && (
            <div role="listbox" aria-label="Supplier" className={styles.menu}>
              {SUPPLIERS.map((s) => (
                <button
                  key={s.id}
                  type="button"
                  role="option"
                  aria-selected={doc.cats[lastPopover.col].supplier === s.id}
                  className={styles.item}
                  data-option={s.id}
                  onClick={() => {
                    closePopover();
                    setCell('supplier', lastPopover.col, s.id);
                  }}
                >
                  <span>{s.name}</span>
                  {doc.cats[lastPopover.col].supplier === s.id && (
                    <Icon name="check" className={styles.check} />
                  )}
                </button>
              ))}
            </div>
          )}
        </PopoverLayer>
      )}

      {drawer && drawerCat && (
        <Drawer
          key="drawer"
          id={drawerCat}
          tab={drawer.tab}
          leaving={drawerLeaving}
          doc={doc}
          stock={stock}
          stage={stages[drawerCat]}
          suggestion={orderOf.get(drawerCat)}
          history={model.history[drawerCat]}
          onTab={(tab) => setDrawer({ ...drawer, tab })}
          onStep={stepDrawer}
          onClose={closeDrawer}
          onHold={() => toggleHold(drawerCat)}
          onStage={() => stageOrders(drawerCat)}
          onReprice={startPricing}
          pricing={!!pricing}
          scroll={!drawerStill}
        />
      )}

      {dialog && (
        <div className={styles.scrim} data-leaving={dialog === 'leaving' || undefined}>
          <div
            className={styles.dialog}
            data-part="dialog"
            role="alertdialog"
            aria-modal="true"
            aria-labelledby={`${uid}-dialog-title`}
            aria-describedby={`${uid}-dialog-text`}
          >
            <h4 id={`${uid}-dialog-title`}>Revert Changes?</h4>
            <p id={`${uid}-dialog-text`}>
              <b>
                {changes} {changes === 1 ? 'change' : 'changes'}
              </b>{' '}
              go back to orders v{model.version}, working in the store now. Today&apos;s costs stay.
              You can undo it straight after.
            </p>
            <div className={styles.dialogActions}>
              <button
                type="button"
                className={styles.dialogButton}
                data-demo="cancel"
                // The safe choice takes the focus when the dialog opens.
                // eslint-disable-next-line jsx-a11y/no-autofocus
                autoFocus
                onClick={() => closeDialog(false)}
              >
                Cancel
              </button>
              <button
                type="button"
                className={`${styles.dialogButton} ${styles.danger}`}
                onClick={() => closeDialog(true)}
              >
                Revert
              </button>
            </div>
          </div>
        </div>
      )}

      <span className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.demoPointer}
          arrowClass={styles.glyphArrow}
          handClass={styles.glyphHand}
        />
      </span>
    </div>
  );
}

/** A row's name: its icon, its label with a unit, and a note under it. */
function RowHead({
  icon,
  label,
  unit,
  note,
}: {
  icon: IconName;
  label: string;
  unit?: string;
  note?: string;
}) {
  return (
    <div role="rowheader" className={`${styles.cell} ${styles.rowHead}`}>
      <span className={styles.rowIcon} aria-hidden="true">
        <Icon name={icon} />
      </span>
      <span className={styles.rowText}>
        <span className={styles.rowLabel}>
          {label}
          {unit && <span className={styles.unit}>{unit}</span>}
        </span>
        {note && <span className={styles.rowNote}>{note}</span>}
      </span>
    </div>
  );
}

/**
 * A menu or a list under its trigger, inside the component: placed from the trigger's box,
 * kept inside the stage, focused on open, and closed by Escape or a press elsewhere.
 */
function PopoverLayer({
  root,
  popover,
  anchor,
  leaving,
  onClose,
  children,
}: {
  root: RefObject<HTMLDivElement | null>;
  popover: Popover;
  anchor: string;
  leaving: boolean;
  onClose: () => void;
  children: ReactNode;
}) {
  const layer = useRef<HTMLDivElement>(null);
  useLayoutEffect(() => {
    const stage = root.current;
    const el = layer.current;
    const trigger = stage?.querySelector<HTMLElement>(anchor);
    if (!stage || !el || !trigger) return;
    const s = stage.getBoundingClientRect();
    const t = trigger.getBoundingClientRect();
    // Boxes on screen, in the stage's own pixels: it may be drawn scaled (a preview, a thumbnail).
    const k = s.width / stage.offsetWidth || 1;
    const w = el.offsetWidth;
    const right = popover.kind === 'columns';
    let x = (right ? t.right - s.left : t.left - s.left) / k - (right ? w : 0);
    x = clamp(x, 8, stage.offsetWidth - w - 8);
    const y = (t.bottom - s.top) / k + (popover.kind === 'supplier' ? 1 : 6);
    el.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
    if (popover.kind === 'plan' || popover.kind === 'orderTo')
      el.style.minWidth = `${Math.round(t.width / k + 22)}px`;
    if (!leaving) {
      const first =
        el.querySelector<HTMLElement>('[aria-checked="true"], [aria-selected="true"]') ??
        el.querySelector<HTMLElement>('button');
      first?.focus({ preventScroll: true });
    }
  }, [root, anchor, popover, leaving]);
  const onKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    const items = [...(layer.current?.querySelectorAll<HTMLElement>('button') ?? [])];
    const i = items.indexOf(document.activeElement as HTMLElement);
    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      event.preventDefault();
      const next = items[(i + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length];
      next?.focus();
    } else if (event.key === 'Home' || event.key === 'End') {
      event.preventDefault();
      items[event.key === 'Home' ? 0 : items.length - 1]?.focus();
    } else if (event.key === 'Escape' || event.key === 'Tab') {
      event.preventDefault();
      event.stopPropagation();
      onClose();
    }
  };
  return (
    // eslint-disable-next-line jsx-a11y/no-static-element-interactions
    <div
      ref={layer}
      className={styles.popover}
      data-part="popover"
      data-popover
      data-kind={popover.kind}
      data-leaving={leaving || undefined}
      inert={leaving ? true : undefined}
      onKeyDown={onKey}
      onPointerOver={(event) => {
        const item = (event.target as HTMLElement).closest('button');
        if (item && item !== document.activeElement) item.focus({ preventScroll: true });
      }}
    >
      {children}
    </div>
  );
}
```

### stock-grid/data.ts

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

/*
 * The stock grid's model: an invented corner shop's category mix, and the rules that read it.
 *
 * - A category holds a share of the stock (its actual, in %), a target share for each season plan,
 *   a tolerance in percentage points, a minimum order, a supplier and a case cost.
 * - Its stage follows from the gap between actual and target: inside half the tolerance it is on
 *   plan, inside the tolerance it is on watch, beyond it off plan. A category with no price yet is
 *   unpriced, one put on hold is on hold; neither gets an order.
 * - An off-plan category gets a suggested order, in $k, that brings it back to its target (or just
 *   inside its tolerance). Buys that would spend more than the budget are scaled down to fit.
 * - Everything here is a pure function of a Doc, the editable part of the state; the component
 *   keeps the Doc as of the last submission (the base) to count and mark what changed.
 */

export type Supplier = 'DST' | 'WHL' | 'DIR';
export type Plan = 'Winter' | 'Spring' | 'Summer' | 'Autumn';
export type OrderTo = 'Target' | 'Edge';
export type Stage = 'on' | 'watch' | 'off' | 'unpriced' | 'hold';
export type Field = 'supplier' | 'target' | 'tol' | 'min' | 'cost' | 'value' | 'order';

export const PLANS: Plan[] = ['Winter', 'Spring', 'Summer', 'Autumn'];
export const SUPPLIERS: { id: Supplier; name: string }[] = [
  { id: 'DST', name: 'Distributor' },
  { id: 'WHL', name: 'Wholesaler' },
  { id: 'DIR', name: 'Direct' },
];
export const supplierName = (id: Supplier) => SUPPLIERS.find((s) => s.id === id)?.name ?? id;

export type Category = {
  id: string;
  /** The column's name and the line under it. */
  name: string;
  group: string;
  /** Its full name, in the drawer and the columns menu. */
  title: string;
};

export const CATEGORIES: Category[] = [
  { id: 'drinks', name: 'Drinks', group: 'Pantry', title: 'Soft Drinks' },
  { id: 'dairy', name: 'Dairy', group: 'Chilled', title: 'Milk & Cheese' },
  { id: 'bakery', name: 'Bakery', group: 'Fresh', title: 'Bread & Pastry' },
  { id: 'produce', name: 'Produce', group: 'Fresh', title: 'Fruit & Veg' },
  { id: 'frozen', name: 'Frozen', group: 'Chilled', title: 'Frozen Meals' },
  { id: 'snacks', name: 'Snacks', group: 'Pantry', title: 'Crisps & Sweets' },
  { id: 'home', name: 'Home', group: 'Non-food', title: 'Cleaning & Paper' },
  { id: 'care', name: 'Care', group: 'Non-food', title: 'Health & Beauty' },
];
export const categoryOf = (id: string) => CATEGORIES.find((c) => c.id === id)!;

export type Cat = {
  targets: Record<Plan, number>;
  tol: number;
  min: number;
  supplier: Supplier;
  /** Share of the stock, %. */
  actual: number;
  /** Case cost, $; `priced` is false until a price list gave one. */
  cost: number;
  priced: boolean;
  /** Share moved by staged orders since the last submission, pp. */
  ordered: number;
  hold: boolean;
};

export type Doc = { plan: Plan; orderTo: OrderTo; cats: Record<string, Cat> };

const t = (winter: number, spring: number, summer: number, autumn: number) => ({
  Winter: winter,
  Spring: spring,
  Summer: summer,
  Autumn: autumn,
});

export const STORE = { name: 'Linden Row Grocer', code: 'LRG-021' };
/** The stock's value as of the last submission, $k, and the cash left for orders. */
export const START = { stock: 240, budget: 6, version: 7, pricedToday: 7 };

export function initialDoc(): Doc {
  const cat = (c: Omit<Cat, 'ordered' | 'hold' | 'priced'> & { priced?: boolean }): Cat => ({
    priced: true,
    ordered: 0,
    hold: false,
    ...c,
  });
  return {
    plan: 'Autumn',
    orderTo: 'Target',
    cats: {
      drinks: cat({
        targets: t(12, 15, 21, 15),
        tol: 2,
        min: 1,
        supplier: 'DST',
        actual: 17.4,
        cost: 13.6,
      }),
      dairy: cat({
        targets: t(14, 13, 11, 12),
        tol: 1,
        min: 1,
        supplier: 'DIR',
        actual: 12.7,
        cost: 22.4,
      }),
      bakery: cat({
        targets: t(8, 7, 6, 7),
        tol: 1,
        min: 1,
        supplier: 'DIR',
        actual: 6.8,
        cost: 17.85,
      }),
      produce: cat({
        targets: t(9, 13, 13, 11),
        tol: 1,
        min: 1,
        supplier: 'WHL',
        actual: 10.6,
        cost: 25.9,
        priced: false,
      }),
      frozen: cat({
        targets: t(12, 9, 14, 9),
        tol: 2,
        min: 2,
        supplier: 'DST',
        actual: 8.3,
        cost: 31.2,
      }),
      snacks: cat({
        targets: t(17, 14, 12, 15),
        tol: 2,
        min: 1,
        supplier: 'DST',
        actual: 13.8,
        cost: 18.7,
      }),
      home: cat({
        targets: t(15, 16, 12, 17),
        tol: 2,
        min: 1,
        supplier: 'WHL',
        actual: 14.6,
        cost: 26.8,
      }),
      care: cat({
        targets: t(13, 13, 11, 14),
        tol: 2,
        min: 1,
        supplier: 'DST',
        actual: 14.2,
        cost: 35.4,
      }),
    },
  };
}

/** What the next price list says, category by category: the new case cost, and how the share moves. */
export const PRICE_LIST: Record<string, { cost: number; shift: number }> = {
  drinks: { cost: 13.66, shift: -0.1 },
  dairy: { cost: 22.34, shift: 0 },
  bakery: { cost: 17.96, shift: 0 },
  produce: { cost: 25.71, shift: -0.1 },
  frozen: { cost: 31.27, shift: 0 },
  snacks: { cost: 18.68, shift: 0 },
  home: { cost: 26.83, shift: 0 },
  care: { cost: 35.58, shift: 0.1 },
};

export const round1 = (n: number) => Math.round(n * 10) / 10;
export const fmt1 = (n: number) => round1(n).toFixed(1);
/** A signed gap, "+0.6", "-1.3", "0.0". */
export const signed = (n: number, digits = 1) => {
  const r = Number(n.toFixed(digits));
  if (r === 0) return (0).toFixed(digits);
  return (r > 0 ? '+' : '-') + Math.abs(r).toFixed(digits);
};

export const targetOf = (doc: Doc, id: string) => doc.cats[id].targets[doc.plan];
export const gapOf = (doc: Doc, id: string) => round1(doc.cats[id].actual - targetOf(doc, id));

export function stageOf(doc: Doc, id: string): Stage {
  const cat = doc.cats[id];
  if (cat.hold) return 'hold';
  if (!cat.priced) return 'unpriced';
  const gap = Math.abs(gapOf(doc, id));
  if (gap > cat.tol + 1e-9) return 'off';
  if (gap > cat.tol / 2 + 1e-9) return 'watch';
  return 'on';
}

export type Suggestion = { id: string; pp: number; amount: number };
export type Orders = {
  list: Suggestion[];
  /** Net spend, gross volume and fees, $k. */
  net: number;
  gross: number;
  fees: number;
  scaled: boolean;
};

/** The orders the grid suggests, in $k, scaled so the buys fit in the budget. */
export function suggest(doc: Doc, stock: number, budget: number): Orders {
  const raw: Suggestion[] = [];
  for (const c of CATEGORIES) {
    if (stageOf(doc, c.id) !== 'off') continue;
    const cat = doc.cats[c.id];
    const target = targetOf(doc, c.id);
    const gap = cat.actual - target;
    const aim = doc.orderTo === 'Target' ? target : target + Math.sign(gap) * cat.tol;
    const pp = aim - cat.actual;
    raw.push({ id: c.id, pp, amount: (pp * stock) / 100 });
  }
  const buys = raw.filter((s) => s.amount > 0).reduce((a, s) => a + s.amount, 0);
  const sells = raw.filter((s) => s.amount < 0).reduce((a, s) => a + s.amount, 0);
  const feesOf = (gross: number) => gross * 0.01;
  let factor = 1;
  if (buys > 0 && budget - (buys + sells) - feesOf(buys - sells) < 0) {
    // Leave a few dollars over: scale the buys until what is left is just above nothing.
    factor = Math.max(0, (budget - 0.02 - sells - feesOf(-sells)) / (buys * 1.01));
    factor = Math.min(1, factor);
  }
  const list = raw.map((s) =>
    s.amount > 0 ? { ...s, pp: s.pp * factor, amount: s.amount * factor } : s,
  );
  const net = list.reduce((a, s) => a + s.amount, 0);
  const gross = list.reduce((a, s) => a + Math.abs(s.amount), 0);
  return { list, net, gross, fees: feesOf(gross), scaled: factor < 1 };
}

/** $k as the footer says it: "$4k" from a thousand up, "$18" under. */
export function money(k: number) {
  if (Math.abs(k) >= 1) return `$${Math.floor(k)}k`;
  return `$${Math.max(0, Math.round(k * 1000))}`;
}
export const dollars = (k: number) => `$${Math.round(k * 1000).toLocaleString('en-US')}`;

/** A cell's value as the grid shows it, and as a toast names it. */
export function cellText(doc: Doc, id: string, field: Field, stock: number): string {
  const cat = doc.cats[id];
  switch (field) {
    case 'supplier':
      return cat.supplier;
    case 'target':
      return fmt1(targetOf(doc, id));
    case 'tol':
      return fmt1(cat.tol);
    case 'min':
      return String(cat.min);
    case 'cost':
      return cat.cost.toFixed(2);
    case 'value':
      return String(Math.round((cat.actual * stock) / 100));
    default:
      return '';
  }
}
export function spoken(field: Field, value: number | string) {
  if (field === 'target') return `${fmt1(Number(value))}%`;
  if (field === 'tol') return `±${fmt1(Number(value))} pp`;
  if (field === 'min') return `$${value}k`;
  return String(value);
}
export const FIELD_NAMES: Record<Field, string> = {
  supplier: 'Supplier',
  target: 'Target',
  tol: 'Tolerance',
  min: 'Min order',
  cost: 'Case cost',
  value: 'Value',
  order: 'Order',
};

/** How many things differ from the last submission. */
export function changesOf(doc: Doc, base: Doc) {
  let n = 0;
  if (doc.plan !== base.plan) n++;
  if (doc.orderTo !== base.orderTo) n++;
  for (const c of CATEGORIES) {
    const a = doc.cats[c.id];
    const b = base.cats[c.id];
    for (const p of PLANS) if (a.targets[p] !== b.targets[p]) n++;
    if (a.tol !== b.tol) n++;
    if (a.min !== b.min) n++;
    if (a.supplier !== b.supplier) n++;
    if (Math.abs(a.ordered) > 1e-9) n++;
    if (a.hold !== b.hold) n++;
  }
  return n;
}

export function dirty(doc: Doc, base: Doc, id: string, field: Field) {
  const a = doc.cats[id];
  const b = base.cats[id];
  switch (field) {
    case 'supplier':
      return a.supplier !== b.supplier;
    case 'target':
      return a.targets[doc.plan] !== b.targets[doc.plan];
    case 'tol':
      return a.tol !== b.tol;
    case 'min':
      return a.min !== b.min;
    case 'order':
      return Math.abs(a.ordered) > 1e-9;
    default:
      return false;
  }
}

/** The six days of a category's cost log, newest first, ending at today's cost. */
export function costLog(cost: number, seed: number) {
  const days = ['Oct 15', 'Oct 14', 'Oct 13', 'Oct 12', 'Oct 11', 'Oct 10'];
  const steps = [0.07, -0.28, 0.28, -0.21, 0.35];
  const rows: { day: number; date: string; cost: number; change: number | null }[] = [];
  let c = cost;
  for (let i = 0; i < 6; i++) {
    const change = i < 5 ? steps[(i + seed) % 5] : null;
    rows.push({ day: 6 - i, date: days[i], cost: c, change });
    if (change !== null) c = c / (1 + change / 100);
  }
  return rows;
}

export type Entry = {
  icon: 'tag' | 'pencil' | 'stage' | 'send' | 'swap' | 'lock';
  title: string;
  text: string;
  time: string;
};

export function initialHistory(): Record<string, Entry[]> {
  const doc = initialDoc();
  const out: Record<string, Entry[]> = {};
  for (const c of CATEGORIES) {
    const cat = doc.cats[c.id];
    out[c.id] = [
      {
        icon: 'tag',
        title: cat.priced ? 'Cost updated' : 'Cost missing',
        text: cat.priced
          ? `${c.name} at $${cat.cost.toFixed(2)} a case`
          : `${c.name} has no price on the last list`,
        time: '10:00',
      },
      {
        icon: 'send',
        title: `Orders v${START.version} working`,
        text: `${STORE.code}, ${STORE.name}`,
        time: '08:10',
      },
    ];
  }
  return out;
}
```

### stock-grid/drawer.tsx

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

'use client';

import { useEffect, useId, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import {
  PLANS,
  categoryOf,
  costLog,
  fmt1,
  signed,
  supplierName,
  type Doc,
  type Entry,
  type Stage,
  type Suggestion,
} from './data';
import { Icon, type IconName } from './icons';
import styles from './stock-grid.module.css';

/*
 * The drawer of one category, over the blurred grid: its stage, its numbers against the current
 * plan and every other plan, its cost log and its history, with Hold and Stage Order at the foot.
 * It is a modal dialog: it takes the focus, keeps Tab inside, and Escape or the close button
 * gives the focus back to what opened it. The arrows step to the previous or next category.
 */

export type Tab = 'overview' | 'costs' | 'history';
const TABS: { id: Tab; label: string }[] = [
  { id: 'overview', label: 'Overview' },
  { id: 'costs', label: 'Cost Log' },
  { id: 'history', label: 'History' },
];
const STAGE_LABEL: Record<Stage, string> = {
  on: 'On plan',
  watch: 'Watch',
  off: 'Off plan',
  unpriced: 'Unpriced',
  hold: 'On hold',
};
const ENTRY_ICON: Record<Entry['icon'], IconName> = {
  tag: 'tag',
  pencil: 'pencil',
  stage: 'stage',
  send: 'send',
  swap: 'layers',
  lock: 'lock',
};

export function Drawer({
  id,
  tab,
  leaving,
  doc,
  stock,
  stage,
  suggestion,
  history,
  pricing,
  scroll = true,
  onTab,
  onStep,
  onClose,
  onHold,
  onStage,
  onReprice,
}: {
  id: string;
  tab: Tab;
  leaving: boolean;
  doc: Doc;
  stock: number;
  stage: Stage;
  suggestion?: Suggestion;
  history: Entry[];
  pricing: boolean;
  /** Brings the sheet on screen when it opens. */
  scroll?: boolean;
  onTab: (tab: Tab) => void;
  onStep: (by: number) => void;
  onClose: () => void;
  onHold: () => void;
  onStage: () => void;
  onReprice: () => void;
}) {
  const uid = useId().replace(/:/g, '');
  const sheet = useRef<HTMLDivElement>(null);
  const c = categoryOf(id);
  const cat = doc.cats[id];
  const target = cat.targets[doc.plan];
  const gap = cat.actual - target;
  const log = costLog(cat.cost, c.id.length);

  // Take the focus on open, and bring the sheet on screen if the stage is taller than it.
  useEffect(() => {
    if (scroll) sheet.current?.scrollIntoView({ block: 'nearest' });
    sheet.current?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')?.focus({
      preventScroll: true,
    });
    // Only on open.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Keep Tab inside the sheet.
  const onKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    if (event.key !== 'Tab') return;
    const items = [
      ...(sheet.current?.querySelectorAll<HTMLElement>(
        'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]',
      ) ?? []),
    ];
    if (!items.length) return;
    const first = items[0];
    const last = items[items.length - 1];
    if (event.shiftKey && document.activeElement === first) {
      event.preventDefault();
      last.focus();
    } else if (!event.shiftKey && document.activeElement === last) {
      event.preventDefault();
      first.focus();
    }
  };
  const onTabsKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    const i = TABS.findIndex((t) => t.id === tab);
    let next = -1;
    if (event.key === 'ArrowRight') next = (i + 1) % TABS.length;
    if (event.key === 'ArrowLeft') next = (i - 1 + TABS.length) % TABS.length;
    if (event.key === 'Home') next = 0;
    if (event.key === 'End') next = TABS.length - 1;
    if (next < 0) return;
    event.preventDefault();
    onTab(TABS[next].id);
    sheet.current?.querySelector<HTMLElement>(`[data-tab="${TABS[next].id}"]`)?.focus();
  };

  const orderWord = suggestion
    ? `${signed(Math.round(suggestion.amount), 0).replace(/^([+-])/, '$1$')}k`
    : 'None';

  return (
    <div className={styles.scrim} data-leaving={leaving || undefined} data-part="drawer">
      {/* A press on the blurred grid closes the drawer; the keyboard has Escape and the close button. */}
      {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
      <div className={styles.scrimHit} onClick={onClose} />
      {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
      <div
        ref={sheet}
        className={styles.sheet}
        data-part="sheet"
        role="dialog"
        aria-modal="true"
        aria-labelledby={`${uid}-title`}
        onKeyDown={onKey}
      >
        <div className={styles.sheetHead}>
          <div>
            <h4 className={styles.sheetTitle} id={`${uid}-title`}>
              {c.title}
              <span className={styles.pill} data-stage={stage}>
                {STAGE_LABEL[stage]}
              </span>
            </h4>
            <p className={styles.sheetSub}>
              {c.name}, {c.group} via {supplierName(cat.supplier)}
            </p>
          </div>
          <div className={styles.sheetNav}>
            <span className={styles.stepper}>
              <button type="button" aria-label="Previous category" onClick={() => onStep(-1)}>
                <Icon name="left" />
              </button>
              <button
                type="button"
                aria-label="Next category"
                data-demo="next"
                onClick={() => onStep(1)}
              >
                <Icon name="right" />
              </button>
            </span>
            <button
              type="button"
              className={styles.close}
              aria-label="Close"
              data-demo="close"
              onClick={onClose}
            >
              <Icon name="close" />
            </button>
          </div>
        </div>

        <div className={styles.tabs} role="tablist" aria-label="Details" onKeyDown={onTabsKey}>
          {TABS.map((t) => (
            <button
              key={t.id}
              type="button"
              role="tab"
              id={`${uid}-tab-${t.id}`}
              aria-selected={tab === t.id}
              aria-controls={`${uid}-panel`}
              tabIndex={tab === t.id ? 0 : -1}
              className={styles.tab}
              data-tab={t.id}
              onClick={() => onTab(t.id)}
            >
              {t.label}
            </button>
          ))}
        </div>

        <div
          className={styles.sheetBody}
          role="tabpanel"
          id={`${uid}-panel`}
          aria-labelledby={`${uid}-tab-${tab}`}
          // Scrollable content: the keyboard can reach it.
          // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
          tabIndex={0}
        >
          {tab === 'overview' && (
            <>
              <section className={styles.panelCard}>
                <header>
                  <h5>{doc.plan} Plan</h5>
                  <p>
                    Tolerance ±{fmt1(cat.tol)} pp, orders back to{' '}
                    {doc.orderTo === 'Target' ? 'target' : 'tolerance'}
                  </p>
                </header>
                <div className={styles.stats}>
                  <div>
                    <span>Target</span>
                    <b>{fmt1(target)}%</b>
                    <small>${Math.round((target * stock) / 100)}k of the stock</small>
                  </div>
                  <div>
                    <span>Actual</span>
                    <b>{fmt1(cat.actual)}%</b>
                    <small>{signed(gap)} pp</small>
                  </div>
                  <div>
                    <span>Order</span>
                    <b>{orderWord}</b>
                    <small>
                      {suggestion
                        ? 'Back inside its tolerance'
                        : stage === 'hold'
                          ? 'On hold'
                          : stage === 'unpriced'
                            ? 'Needs a price first'
                            : 'Inside its tolerance'}
                    </small>
                  </div>
                </div>
              </section>
              <section className={styles.panelCard}>
                <header>
                  <h5>By Plan</h5>
                  <p>What each season plan asks of this category</p>
                </header>
                <table className={styles.table}>
                  <thead>
                    <tr>
                      <th scope="col">Plan</th>
                      <th scope="col">Target %</th>
                      <th scope="col">Gap</th>
                      <th scope="col">Order $k</th>
                    </tr>
                  </thead>
                  <tbody>
                    {PLANS.map((p) => {
                      const g = cat.actual - cat.targets[p];
                      const off = Math.abs(g) > cat.tol + 1e-9;
                      return (
                        <tr key={p}>
                          <th scope="row">{p}</th>
                          <td>{fmt1(cat.targets[p])}</td>
                          <td data-tone={off ? 'off' : undefined} data-quiet>
                            {signed(g)}
                          </td>
                          <td>
                            {off ? (
                              signed(Math.round((-g * stock) / 100), 0)
                            ) : (
                              <span className={styles.dot} aria-label="None" />
                            )}
                          </td>
                        </tr>
                      );
                    })}
                  </tbody>
                </table>
              </section>
              <section className={styles.facts}>
                <h5>Stock</h5>
                <dl>
                  <dt>Group</dt>
                  <dd>{c.group}</dd>
                  <dt>Supplier</dt>
                  <dd>{supplierName(cat.supplier)}</dd>
                  <dt>Min order</dt>
                  <dd>${cat.min}k</dd>
                  <dt>Case cost</dt>
                  <dd>{cat.priced ? `$${cat.cost.toFixed(2)}` : 'Not priced yet'}</dd>
                </dl>
              </section>
            </>
          )}
          {tab === 'costs' && (
            <section className={styles.panelCard}>
              <header className={styles.withAction}>
                <div>
                  <h5>Cost History</h5>
                  <p>Last six days, today at 10:36</p>
                </div>
                <button
                  type="button"
                  className={styles.button}
                  disabled={pricing}
                  onClick={onReprice}
                >
                  <Icon name="refresh" className={styles.icon} />
                  Update Again
                </button>
              </header>
              <table className={styles.table}>
                <colgroup>
                  <col data-w="day" />
                  <col data-w="date" />
                  <col data-w="cost" />
                  <col />
                </colgroup>
                <thead>
                  <tr>
                    <th scope="col">Day</th>
                    <th scope="col" className={styles.left}>
                      Date
                    </th>
                    <th scope="col">Cost $</th>
                    <th scope="col">Change</th>
                  </tr>
                </thead>
                <tbody>
                  {log.map((r) => (
                    <tr key={r.day}>
                      <th scope="row">{r.day}</th>
                      <td className={styles.left}>{r.date}</td>
                      <td>{r.cost.toFixed(2)}</td>
                      <td data-quiet>
                        {r.change === null ? (
                          <span className={styles.dot} aria-label="None" />
                        ) : (
                          `${signed(r.change, 2)}%`
                        )}
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </section>
          )}
          {tab === 'history' && (
            <section className={styles.panelCard}>
              <header>
                <h5>History</h5>
                <p>Today, newest first</p>
              </header>
              <ol className={styles.history}>
                {history.slice(0, 7).map((e, i) => (
                  <li key={`${e.time}-${i}`}>
                    <span className={styles.rowIcon} aria-hidden="true">
                      <Icon name={ENTRY_ICON[e.icon]} />
                    </span>
                    <span>
                      <b>{e.title}</b>
                      <span>{e.text}</span>
                    </span>
                    <time>{e.time}</time>
                  </li>
                ))}
              </ol>
            </section>
          )}
        </div>

        <div className={styles.sheetFoot}>
          <button type="button" className={styles.button} aria-pressed={cat.hold} onClick={onHold}>
            <Icon name={cat.hold ? 'unlock' : 'lock'} className={styles.icon} />
            {cat.hold ? 'Release' : 'Hold'}
          </button>
          <button
            type="button"
            className={`${styles.button} ${styles.primary}`}
            disabled={!suggestion}
            onClick={onStage}
          >
            <Icon name="stage" className={styles.icon} />
            Stage Order
          </button>
        </div>
      </div>
    </div>
  );
}
```

### stock-grid/icons.tsx

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

/*
 * The stock grid's icons: thin line drawings on a 16-unit grid, stroked in the current colour.
 * They are decorative (the words next to them, or the button's label, say what they mean); the
 * stylesheet sizes them.
 */

export type IconName =
  | 'columns'
  | 'stage'
  | 'revert'
  | 'send'
  | 'chevron'
  | 'left'
  | 'right'
  | 'check'
  | 'close'
  | 'share'
  | 'layers'
  | 'target'
  | 'tolerance'
  | 'note'
  | 'tag'
  | 'wallet'
  | 'order'
  | 'refresh'
  | 'copy'
  | 'download'
  | 'done'
  | 'lock'
  | 'unlock'
  | 'pencil'
  | 'swap';

const PATHS: Record<IconName, string> = {
  columns: 'M2.5 3.5h11v9h-11zM6.2 3.5v9M9.8 3.5v9',
  stage: 'M3 5.5h9.5M10.5 3.2l2.3 2.3-2.3 2.3M13 10.5H3.5M5.5 8.2l-2.3 2.3 2.3 2.3',
  revert: 'M3.2 3.4v3h3M3.6 6.2A5 5 0 1 1 3.5 10',
  send: 'M14 2 7.2 8.8M14 2l-4.3 12-2.5-5.2L2 6.3z',
  chevron: 'M3.6 6.1 8 10.5l4.4-4.4',
  left: 'M9.8 4.2 6 8l3.8 3.8',
  right: 'M6.2 4.2 10 8l-3.8 3.8',
  check: 'M3.5 8.4 6.6 11.3 12.5 4.8',
  close: 'M4 4l8 8M12 4l-8 8',
  share: 'M8 2.5v5.5h5.5M13.1 9.7A5.5 5.5 0 1 1 6.3 2.8M8 2.5a5.5 5.5 0 0 1 5.5 5.5',
  layers: 'M8 2.5 13.5 5 8 7.5 2.5 5zM2.5 8 8 10.5 13.5 8M2.5 11 8 13.5 13.5 11',
  target:
    'M8 2.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11zM8 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM8 7.3a.7.7 0 1 0 0 1.4.7.7 0 0 0 0-1.4z',
  tolerance: 'M2.8 5.5h9.6M10.4 3.4l2.1 2.1-2.1 2.1M13.2 10.5H3.6M5.6 8.4l-2.1 2.1 2.1 2.1',
  note: 'M2.5 4.5h11v7h-11zM8 6.6a1.4 1.4 0 1 0 0 2.8 1.4 1.4 0 0 0 0-2.8zM4.6 8h.1M11.3 8h.1',
  tag: 'M2.5 2.5h5.2l5.8 5.8-5.2 5.2-5.8-5.8zM5.4 4.8a.6.6 0 1 0 0 1.2.6.6 0 0 0 0-1.2z',
  wallet: 'M3 3.5h8.5v2.5M3 3.5v9h10V6H3.5M10.5 9.2h.1',
  order:
    'M12.5 6.2A4.7 4.7 0 0 0 4 4.6M3.5 9.8A4.7 4.7 0 0 0 12 11.4M4 2.6v2.2h2.2M12 13.4v-2.2H9.8',
  refresh: 'M13 3.2v3h-3M12.7 6A5 5 0 0 0 3.3 6.4M3 12.8v-3h3M3.3 10a5 5 0 0 0 9.4-.4',
  copy: 'M5.5 5.5h8v8h-8zM10.5 5.5v-3h-8v8h3M9.5 7.8v3.4M7.8 9.5h3.4',
  download: 'M8 2.5v7.5M5 7.2 8 10.2l3-3M2.8 10.5v2.7h10.4v-2.7',
  done: 'M8 2.2a5.8 5.8 0 1 0 0 11.6A5.8 5.8 0 0 0 8 2.2zM5.6 8.2l1.7 1.7 3.2-3.5',
  lock: 'M4 7.2h8v6.3H4zM5.6 7.2V5.4a2.4 2.4 0 0 1 4.8 0v1.8',
  unlock: 'M4 7.2h8v6.3H4zM5.6 7.2V5.4a2.4 2.4 0 0 1 4.6-1',
  pencil: 'M10.6 2.9l2.5 2.5-7.6 7.6-3 .5.5-3zM9.3 4.2l2.5 2.5',
  swap: 'M3 5.5h9.5M10.5 3.2l2.3 2.3-2.3 2.3M13 10.5H3.5M5.5 8.2l-2.3 2.3 2.3 2.3',
};

export function Icon({ name, className }: { name: IconName; className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.3"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
    >
      <path d={PATHS[name]} />
    </svg>
  );
}
```

### stock-grid/demo-pointer.tsx

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

'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, or
 * glide the pointer somewhere. 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.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it (the eye of the
 * badge, which plays its own animation on the same clock). The arrow and the hand are HTML boxes
 * that swap by their visibility: a change of style inside an SVG would lay the SVG out again.
 */

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

/** A glide's path and timing, for an animation that plays along with it. */
export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`; `along` gets the flight. */
  glide: (to: Point, ms: number, along?: (flight: Flight) => void) => Promise<void>;
  /** Shows the arrow, or the hand a link shows. */
  glyph: (name: 'arrow' | 'hand') => void;
};

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

/** Where the arrow's tip is, as shares of its box: the point the pointer stands on. */
const TIP: Point = { x: 0.15, y: 0.1 };

/** How many points a flight is drawn through. */
const SAMPLES = 24;

/** 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;
  // The stylesheet sizes the pointer from the stage's width; its tip is where it stands.
  const tip = { x: pointer.offsetWidth * TIP.x, y: pointer.offsetHeight * TIP.y };
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  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.9 };

  /** 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 glide: Demo['glide'] = (to, ms, along) => {
    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.18, y: (from.y + to.y) / 2 + dx * 0.18 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        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,
      });
    }
    const timing: KeyframeAnimationOptions = {
      duration: ms,
      easing: 'cubic-bezier(0.45, 0, 0.25, 1)',
    };
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      timing,
    );
    along?.({ points, timing });
    return step(flight.finished);
  };

  const glyph: Demo['glyph'] = (name) => {
    pointer.dataset.glyph = name;
  };

  pointer.dataset.glyph = 'arrow';
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, glide, glyph }).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 layer of its own. Its size comes from its class, its colours
 * from the stage's tokens (nda-badge.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** One glyph over the other, each filling the box. */
const GLYPH: CSSProperties = { position: 'absolute', inset: 0, display: 'block' };
const DRAWING: CSSProperties = {
  display: 'block',
  width: '100%',
  height: '100%',
  overflow: 'visible',
};
/** The arrow is dark on a white edge, the hand white on a dark one, as the system draws them. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a pointing hand over the badge. It is
 * decorative: what it does shows on the badge itself. The stylesheet shows one glyph at a time,
 * from the box's data-glyph.
 */
export function DemoPointer({
  ref,
  className,
  arrowClass,
  handClass,
}: {
  ref: Ref<HTMLSpanElement>;
  className?: string;
  arrowClass?: string;
  handClass?: string;
}) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span className={arrowClass} style={GLYPH}>
        <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"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      <span className={handClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          {/* The tip of the finger sits where the arrow's tip is. */}
          <path
            d="M6.4 3.3c0-.8.6-1.4 1.3-1.4S9 2.5 9 3.3v5.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.7c.2-.4.6-.7 1.1-.7.7 0 1.2.5 1.2 1.2v3.5c0 2.8-1.9 4.9-4.6 4.9h-1.2c-1.8 0-3.1-.8-4.1-2.3l-2.6-3.9c-.4-.6-.2-1.3.4-1.7.6-.3 1.3-.2 1.7.4l.6.9z"
            style={HAND}
            strokeWidth="1.1"
            strokeLinejoin="round"
            transform="translate(-4.7 0.1)"
          />
        </svg>
      </span>
    </span>
  );
}
```

### stock-grid/use-autoplay.ts

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

'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, or never when `idle` is null.
 */
export function useAutoplay<T extends HTMLElement>(idle: number | null = 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);
    if (idle !== null) timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

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

### stock-grid/stock-grid.module.css

```css
/* Stock grid (stock-grid.tsx): a card on a plain stage. The card has a title bar, a body (the
   side panel and the grid) and a footer; toasts, menus, the drawer and the dialog sit over it on
   the stage.

   Sizes are px of a 13 px interface. The grid's rows have fixed heights, so the side panel's Costs
   block lines up with the three cost rows; its columns share the width that is left, down to a
   minimum, under which the grid scrolls sideways inside the card (the row names stay put).

   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it
   inherits from the page, or the one its colorScheme prop sets (data-color-scheme).

   Motion is transforms and opacity: the selection moves by a transform, columns slide (FLIP),
   toasts drop in and stack, menus, the drawer and the dialog fade and rise. Under reduced motion
   all of it is instant. */

.root {
  position: relative;
  container-type: inline-size;
  width: 100%;
  box-sizing: border-box;
  padding: 30px 16px 16px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-size: 13px;
  font-weight: 400;
  line-height: 1.35;
  font-variant-numeric: tabular-nums;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  color: var(--ink);
  background: var(--stage);
  box-shadow: inset 0 0 0 1px var(--stage-edge);
  /* Sizes. */
  --panel-w: 215px;
  --label-w: 168px;
  --col-min: 60px;
  --head-h: 47px;
  --share-h: 95px;
  --row-h: 50px;
  --order-h: 38px;
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
}
@supports (color: light-dark(#000, #fff)) {
  .root {
    /* The stage, the card, its panels and lines. */
    --stage: light-dark(#ffffff, #0c0c0d);
    --stage-edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --card: light-dark(#fafafa, #151516);
    --panel: light-dark(#ffffff, #1b1b1d);
    --sunk: light-dark(#fcfcfc, #18181a);
    --edge: light-dark(#e4e4e4, #2d2d30);
    --line: light-dark(#e8e8e8, #2a2a2d);
    --focus-col: light-dark(#f8f8f8, #222225);
    --hover: light-dark(#fafafa, #202023);
    --range: light-dark(#f2f2f2, #26262a);
    --target: light-dark(#f8f8f8, #222225);
    /* Words. */
    --ink: light-dark(#111111, #f2f2f3);
    --ink-2: light-dark(#666666, #a3a3a8);
    --ink-3: light-dark(#8c8c8c, #74747a);
    --blue: light-dark(#2d62d9, #86a8ff);
    /* Stages: on plan, watch, off plan. */
    --green-bg: light-dark(#e5faf1, #11291e);
    --green-line: light-dark(#c9eedc, #1d4532);
    --green-ink: light-dark(#0f5e3e, #7fdcae);
    --amber-bg: light-dark(#fbf7e1, #2c2410);
    --amber-line: light-dark(#f0e5b6, #4a3c17);
    --amber-ink: light-dark(#664813, #f0cf7b);
    --amber-delta: light-dark(#d2a817, #e3bd4a);
    --red-bg: light-dark(#f7dfe3, #351820);
    --red-line: light-dark(#efc6cf, #5a2632);
    --red-ink: light-dark(#a0122f, #ff9db0);
    --purple-bg: light-dark(#f3e8fe, #2a1b42);
    --purple-ink: light-dark(#6a24b4, #cfaeff);
    --dirty: #f5a31a;
    /* The dark buttons, and the same switched off. */
    --dark: light-dark(#171717, #f2f2f3);
    --dark-ink: light-dark(#ffffff, #111111);
    --dark-off: light-dark(#878787, #5b5b60);
    --danger-bg: light-dark(#fbe3e7, #3a1a21);
    --danger-ink: light-dark(#b0102f, #ff9db0);
    --danger-hover: light-dark(#f6d3da, #48202a);
    --box-hover: light-dark(#c9c9c9, #4a4a4f);
    --box-ring: light-dark(#dcdcdc, #3a3a3e);
    --dot: light-dark(#cdcdcd, #4c4c50);
    --scrim: light-dark(rgb(30 30 30 / 0.08), rgb(0 0 0 / 0.35));
    --shadow: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.5));
    --focus: #387bff;
    --pointer: #1c1c1e;
    --pointer-edge: #fff;
    --pointer-shadow: rgb(0 0 0 / 0.25);
  }
  .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 button {
  font: inherit;
  color: inherit;
  margin: 0;
}
.root svg {
  display: block;
  flex: none;
}
.root :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

/* ----- The card ----- */

.card {
  position: relative;
  border: 1px solid var(--edge);
  border-radius: 12px;
  background: var(--card);
}
.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px 16px;
  min-height: 50px;
  padding: 0 15px 0 16px;
}
.titles {
  min-width: 0;
  padding: 5px 0 4px;
}
.title {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0;
  font-size: 14px;
  font-weight: 600;
  line-height: 18px;
  letter-spacing: -0.005em;
}
.unsent {
  padding: 0 5px;
  border-radius: 5px;
  font-size: 12px;
  font-weight: 500;
  line-height: 18px;
  letter-spacing: 0;
  color: var(--purple-ink);
  background: var(--purple-bg);
}
.subtitle {
  margin: 0;
  font-size: 12.5px;
  line-height: 17px;
  white-space: nowrap;
}
.subtitle span {
  color: var(--ink-2);
}
.actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 8px;
}

/* Buttons: white and outlined, or dark. */
.button,
.wide {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  height: 29px;
  padding: 0 10px;
  border: 1px solid var(--edge);
  border-radius: 8px;
  font-size: 13px !important;
  font-weight: 500 !important;
  white-space: nowrap;
  color: var(--ink);
  background: var(--panel);
  cursor: pointer;
  transition:
    background-color 150ms ease,
    color 150ms ease,
    border-color 150ms ease,
    transform 120ms ease;
}
.button:hover:not(:disabled),
.button[data-open],
.wide:hover:not(:disabled) {
  background: var(--range);
}
.button:active:not(:disabled),
.wide:active:not(:disabled) {
  transform: scale(0.98);
}
.button:disabled,
.wide:disabled {
  cursor: default;
  color: var(--ink-3);
  background: var(--sunk);
}
.icon {
  width: 15px;
  height: 15px;
}
.count {
  display: grid;
  place-items: center;
  min-width: 18px;
  height: 18px;
  margin-left: 2px;
  padding: 0 4px;
  box-sizing: border-box;
  border: 1px solid var(--edge);
  border-radius: 9px;
  font-size: 11.5px;
  font-weight: 500;
}
.primary,
.dark {
  border-color: var(--dark);
  color: var(--dark-ink) !important;
  background: var(--dark) !important;
}
.primary:hover:not(:disabled),
.dark:hover:not(:disabled) {
  opacity: 0.9;
}
.primary:disabled,
.dark:disabled {
  border-color: var(--dark-off);
  background: var(--dark-off) !important;
  color: var(--dark-ink) !important;
}
.wide {
  width: 100%;
  height: 28px;
}

/* A spinner: a little square that turns into a broken ring as it starts. */
.spin {
  display: inline-block;
  flex: none;
  width: 11px;
  height: 11px;
  box-sizing: border-box;
  border: 1.4px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation:
    spin 0.75s linear infinite,
    morph 0.35s ease-out;
}
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}
@keyframes morph {
  from {
    border-radius: 2px;
    border-top-color: currentColor;
  }
}

/* ----- Body: the side panel and the grid ----- */

.body {
  display: grid;
  grid-template-columns: var(--panel-w) minmax(0, 1fr);
  margin: 0 -1px;
  border: 1px solid var(--edge);
  border-radius: 12px 12px 0 0;
  background: var(--panel);
  overflow: hidden;
  overflow: clip;
}
.panel {
  display: flex;
  flex-direction: column;
  border-right: 1px solid var(--edge);
}
.panelTop {
  flex: 1;
  padding: 17px 16px 12px;
}
.field {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  height: 29px;
  margin-bottom: 12.5px;
}
.fieldLabel {
  color: var(--ink-2);
}
.select {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 112px;
  height: 29px;
  padding: 0 9px 0 10px;
  box-sizing: border-box;
  border: 1px solid var(--edge);
  border-radius: 8px;
  background: var(--panel);
  cursor: pointer;
  transition: background-color 150ms ease;
}
.select:hover,
.select[aria-expanded='true'] {
  background: var(--range);
}
.chev {
  width: 16px;
  height: 16px;
  color: var(--ink-2);
}
.stages {
  margin-top: 4px;
  padding-top: 9px;
  border-top: 1px solid var(--line);
}
.stagesTitle {
  display: block;
  font-size: 12px;
  color: var(--ink-2);
}
.stages ul {
  margin: 0;
  padding: 0;
  list-style: none;
}
.stages li {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 30.4px;
}
.stageCount {
  font-size: 13px;
}
.stageCount[data-zero] {
  color: var(--ink-2);
}
.pill {
  display: inline-flex;
  align-items: center;
  height: 18px;
  padding: 0 5px;
  box-sizing: border-box;
  border: 1px solid var(--edge);
  border-radius: 5px;
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0;
  white-space: nowrap;
  color: var(--ink);
  background: var(--panel);
}
.pill[data-stage='on'] {
  color: var(--green-ink);
  background: var(--green-bg);
  border-color: var(--green-line);
}
.pill[data-stage='watch'] {
  color: var(--amber-ink);
  background: var(--amber-bg);
  border-color: var(--amber-line);
}
.pill[data-stage='off'] {
  color: var(--red-ink);
  background: var(--red-bg);
  border-color: var(--red-line);
}
.costs {
  box-sizing: border-box;
  height: calc(2 * var(--row-h) + var(--order-h));
  padding: 13px 16px 0;
  border-top: 1px solid var(--edge);
  background: var(--sunk);
}
.costsTitle {
  font-weight: 500;
  line-height: 16px;
}
.costsNote {
  margin-bottom: 11px;
  font-size: 12.5px;
  line-height: 17px;
  color: var(--ink-2);
}
.costs .wide + .wide {
  margin-top: 10px;
}

/* ----- The grid ----- */

.scroller {
  min-width: 0;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: thin;
}
.grid {
  position: relative;
  display: grid;
  grid-template-columns:
    var(--label-w)
    repeat(var(--cols), minmax(var(--col-min), 1fr))
    minmax(calc(var(--col-min) + 6px), 1.1fr);
  grid-template-rows: var(--head-h) var(--share-h) repeat(6, var(--row-h)) var(--order-h);
  min-width: min-content;
  outline: none;
  user-select: none;
  -webkit-user-select: none;
  touch-action: pan-x pan-y;
}
.grid:focus-visible {
  box-shadow: inset 0 0 0 2px var(--focus);
}
.row {
  display: contents;
}
.cell {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  min-width: 0;
  box-sizing: border-box;
  border-right: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  background: var(--panel);
  font-size: 13px;
  white-space: nowrap;
  cursor: default;
  transition: background-color 120ms ease;
}
.row > .cell:last-child {
  border-right: 0;
}
.costRow > .cell {
  background: var(--sunk);
}
.costRow:first-of-type > .cell {
  border-top: 1px solid var(--edge);
}
.orderRow > .cell {
  border-bottom: 0;
}
.cell[data-focus] {
  background: var(--focus-col);
}
.row:not(.headRow):hover > .cell {
  background: var(--hover);
}
.row:not(.headRow):hover > .cell[data-focus] {
  background: var(--focus-col);
}
.cell[data-edit] {
  cursor: cell;
}
.cell[data-focus][data-edit] {
  color: var(--blue);
}
.cell[data-muted] {
  color: var(--ink-3);
}
.cell[data-selected] {
  background: var(--range) !important;
}
/* A changed cell's corner. */
.cell[data-dirty]::after {
  content: '';
  position: absolute;
  top: 0;
  right: -1px;
  width: 5px;
  height: 5px;
  background: var(--dirty);
  clip-path: polygon(0 0, 100% 0, 100% 100%);
}

/* The heads: the corner, the categories, the rows. */
.corner {
  justify-content: flex-start;
  padding-left: 16px;
  color: var(--ink-2);
}
.colHead {
  flex-direction: column;
  gap: 0;
  line-height: 17px;
}
.colName {
  font-size: 13.5px;
  color: var(--ink-2);
}
.colGroup {
  font-size: 13px;
  color: var(--ink-2);
}
.colHead[data-focus] .colName,
.colHead.total .colName {
  font-weight: 500;
  color: var(--ink);
}
.total {
  font-weight: 600;
}
.total .colGroup {
  font-weight: 400;
}
.total b {
  font-weight: 600;
}
.total [data-bad] {
  color: var(--red-ink);
}
.rowHead {
  justify-content: flex-start;
  gap: 7.5px;
  padding-left: 15.5px;
  position: sticky;
  left: 0;
  z-index: 2;
}
.shareRow .rowHead {
  align-items: flex-start;
  padding-top: 5px;
}
.shareRow .rowIcon {
  margin-top: 6px;
}
.rowIcon {
  display: grid;
  place-items: center;
  width: 23px;
  height: 23px;
  flex: none;
  box-sizing: border-box;
  border: 1px solid var(--edge);
  border-radius: 6px;
  color: var(--ink-2);
  background: var(--panel);
  box-shadow: 0 1px 1px rgb(0 0 0 / 0.03);
}
.rowIcon svg {
  width: 15px;
  height: 15px;
}
.rowText {
  display: flex;
  flex-direction: column;
  min-width: 0;
}
.rowLabel {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 13.5px;
  font-weight: 500;
  line-height: 19px;
}
.unit {
  display: inline-grid;
  place-items: center;
  height: 18px;
  min-width: 18px;
  padding: 0 4px;
  box-sizing: border-box;
  border: 1px solid var(--edge);
  border-radius: 5px;
  font-size: 11.5px;
  font-weight: 500;
  line-height: 1;
  background: var(--panel);
}
.rowNote {
  font-size: 12.5px;
  line-height: 20px;
  color: var(--ink-2);
}

/* The share: target over actual, tinted by stage, and the gap under it. */
.shareCell {
  flex-direction: column;
  justify-content: flex-start;
  gap: 0;
  padding: 5px 5px 0;
}
.shareBox {
  display: grid;
  grid-template-rows: 1fr 1fr;
  width: 100%;
  height: 70px;
  padding: 0;
  box-sizing: border-box;
  overflow: hidden;
  border: 1px solid var(--edge);
  border-radius: 8px;
  background: var(--panel);
  cursor: pointer;
  transition:
    border-color 150ms ease,
    box-shadow 150ms ease;
}
button.shareBox:hover,
.cell[data-selected] .shareBox {
  border-color: var(--box-hover);
}
.cell[data-selected] .shareBox {
  box-shadow: 0 0 0 1px var(--box-ring);
}
.shareTarget,
.shareActual {
  display: grid;
  place-items: center;
  font-size: 13px;
  transition:
    background-color 200ms ease,
    color 200ms ease;
}
.shareTarget {
  font-weight: 600;
  background: var(--target);
  border-bottom: 1px solid var(--edge);
}
.shareTarget[data-bad] {
  color: var(--red-ink);
}
.shareBox[data-stage='on'] .shareActual {
  color: var(--green-ink);
  background: var(--green-bg);
}
.shareBox[data-stage='watch'] .shareActual {
  color: var(--amber-ink);
  background: var(--amber-bg);
}
.shareBox[data-stage='off'] .shareActual {
  color: var(--red-ink);
  background: var(--red-bg);
}
.shareBox[data-stage='unpriced'] .shareActual {
  color: var(--ink-2);
}
.shareBox[data-stage='busy'] .shareActual {
  color: var(--ink-2);
}
.shareTotal {
  cursor: default;
}
.shareTotal .shareActual {
  font-weight: 400;
}
.delta {
  margin-top: 3px;
  font-size: 12px;
  font-weight: 400;
  line-height: 15px;
  color: var(--ink-2);
}
.delta[data-stage='watch'] {
  color: var(--amber-delta);
}
.delta[data-stage='off'] {
  color: var(--red-ink);
}

/* Cells' contents. */
.input {
  width: 100%;
  height: 100%;
  padding: 0;
  border: 0;
  outline: none;
  font: inherit;
  text-align: center;
  color: var(--ink);
  background: var(--panel);
  caret-color: var(--ink);
}
.cell[data-editing] {
  padding: 1px;
}
.cellChev {
  position: absolute;
  right: 10px;
  width: 13px;
  height: 13px;
  color: var(--ink-2);
}
.suggest {
  display: grid;
  place-items: center;
  width: calc(100% - 10px);
  height: 28px;
  box-sizing: border-box;
  border: 1px solid var(--edge);
  border-radius: 7px;
  font-weight: 500;
  color: var(--ink);
  background: var(--panel);
}
.dot {
  display: inline-block;
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--dot);
}

/* The selection: one border over its cells, the fill handle at its corner. */
.selection,
.fillOutline {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 3;
  box-sizing: border-box;
  visibility: hidden;
  pointer-events: none;
}
.selection {
  border: 1px solid var(--ink);
}
.fillOutline {
  border: 1px dashed var(--ink);
}
.handle {
  position: absolute;
  right: -1px;
  bottom: -1px;
  width: 6px;
  height: 6px;
  background: var(--ink);
  pointer-events: auto;
  cursor: crosshair;
  touch-action: none;
}
.handle::before {
  content: '';
  position: absolute;
  inset: -7px;
}
.grid[data-editing] .selection {
  border-width: 1px;
}

/* ----- Footer ----- */

.footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  min-height: 46px;
  padding: 0 15px 0 16px;
}
.footer p {
  margin: 0;
  font-size: 12.5px;
  color: var(--ink-2);
}
.footer b {
  font-weight: 500;
  color: var(--ink);
}

/* ----- Toasts ----- */

.toasts {
  position: absolute;
  top: 7px;
  left: 50%;
  z-index: 8;
  width: min(338px, calc(100% - 24px));
  transform: translateX(-50%);
  pointer-events: none;
}
.toast {
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  align-items: center;
  gap: 9px;
  width: 100%;
  min-height: 53px;
  box-sizing: border-box;
  padding: 9px 12px 9px 12px;
  font-variant-numeric: normal;
  border: 1px solid var(--edge);
  border-radius: 10px;
  background: var(--panel);
  box-shadow:
    0 6px 20px var(--shadow),
    0 1px 3px var(--shadow);
  pointer-events: auto;
  transform-origin: 50% 100%;
  transition:
    transform 320ms var(--ease),
    opacity 320ms var(--ease);
  animation: toast-in 380ms var(--ease);
}
.toast[data-depth='1'] {
  z-index: -1;
  transform: translateY(7px) scale(0.95);
}
.toast[data-depth='2'] {
  z-index: -2;
  opacity: 0;
  transform: translateY(14px) scale(0.9);
}
.toast[data-leaving] {
  opacity: 0;
  transform: translateY(-10px) scale(0.98);
}
@keyframes toast-in {
  from {
    opacity: 0;
    transform: translateY(-26px);
  }
}
.toastIcon {
  display: block;
  flex: none;
  width: 13px;
  height: 13px;
  box-sizing: border-box;
  border: 1.4px solid #34b37e;
  border-radius: 50%;
  animation: toast-icon 420ms var(--ease);
}
.toastIcon svg {
  width: 100%;
  height: 100%;
  overflow: visible;
  transform: scale(1.35);
}
.toastIcon path {
  fill: none;
  stroke: #34b37e;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 9;
  animation: toast-check 420ms 160ms var(--ease) backwards;
}
@keyframes toast-icon {
  from {
    border-radius: 2px;
  }
}
@keyframes toast-check {
  from {
    stroke-dashoffset: 9;
  }
}
.toastText {
  display: flex;
  flex: 1;
  flex-direction: column;
  min-width: 0;
}
.toastText b {
  font-size: 13.5px;
  font-weight: 500;
  line-height: 19px;
}
.toastText span {
  font-size: 12px;
  line-height: 15.5px;
  color: var(--ink-2);
}
.undo {
  flex: none;
  height: 23px;
  padding: 0 7px;
  border: 0;
  border-radius: 5px;
  font-size: 12.5px !important;
  font-weight: 500;
  color: var(--dark-ink) !important;
  background: var(--dark);
  cursor: pointer;
}
.undo:hover {
  opacity: 0.88;
}

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

.popover {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 7;
  animation: pop-in 140ms ease-out;
  transition: opacity 110ms ease;
}
.popover[data-leaving] {
  opacity: 0;
  pointer-events: none;
}
@keyframes pop-in {
  from {
    opacity: 0;
    translate: 0 -3px;
  }
}
.menu {
  display: flex;
  flex-direction: column;
  min-width: 128px;
  padding: 4px;
  box-sizing: border-box;
  border: 1px solid var(--edge);
  border-radius: 10px;
  background: var(--panel);
  box-shadow:
    0 8px 24px var(--shadow),
    0 1px 2px var(--shadow);
}
.menu[data-wide] {
  width: 236px;
}
.menuLabel {
  padding: 6px 8px 2px;
  font-size: 12px;
  color: var(--ink-2);
}
.menuRule {
  height: 1px;
  margin: 4px -4px;
  background: var(--line);
}
.item {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 29px;
  padding: 0 8px;
  border: 0;
  border-radius: 6px;
  font-size: 13.5px !important;
  text-align: left;
  white-space: nowrap;
  background: transparent;
  cursor: pointer;
}
.item > span:first-of-type {
  flex: 1;
}
.item:focus,
.item:focus-visible {
  outline: none;
  background: var(--range);
}
.itemNote {
  flex: none !important;
  font-size: 12px;
  color: var(--ink-2);
}
.check {
  display: block;
  width: 14px;
  height: 14px;
  flex: none;
}
.itemQuiet {
  color: var(--ink-2);
  font-size: 12.5px !important;
}
.itemQuiet[aria-disabled='true'] {
  cursor: default;
  opacity: 0.6;
}
.itemQuiet[aria-disabled='true']:hover {
  background: transparent;
}
.itemIcon {
  width: 13px;
  height: 13px;
}

/* ----- The drawer and the dialog ----- */

.scrim {
  position: absolute;
  inset: 0;
  z-index: 6;
  display: grid;
  place-items: center;
  background: var(--scrim);
  -webkit-backdrop-filter: blur(5px);
  backdrop-filter: blur(5px);
  animation: fade-in 220ms ease-out;
  transition: opacity 180ms ease;
}
.scrim[data-leaving] {
  opacity: 0;
}
@keyframes fade-in {
  from {
    opacity: 0;
  }
}
.scrimHit {
  position: absolute;
  inset: 0;
}
.sheet {
  position: absolute;
  top: 15px;
  right: 15px;
  bottom: 15px;
  display: flex;
  flex-direction: column;
  width: min(543px, calc(100% - 30px));
  overflow: hidden;
  border: 1px solid var(--edge);
  border-radius: 12px;
  background: var(--panel);
  box-shadow:
    0 12px 40px var(--shadow),
    0 2px 6px var(--shadow);
  animation: sheet-in 300ms var(--ease);
}
.scrim[data-leaving] .sheet {
  transform: translateX(16px);
  transition: transform 180ms ease-in;
}
@keyframes sheet-in {
  from {
    opacity: 0;
    transform: translateX(24px);
  }
}
.sheetHead {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  padding: 12px 15px 0 19px;
  height: 70px;
  box-sizing: border-box;
  border-bottom: 1px solid var(--line);
}
.sheetTitle {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 1px 0 0;
  font-size: 17.5px;
  font-weight: 500;
  line-height: 22px;
  letter-spacing: -0.01em;
}
.sheetSub {
  margin: 3px 0 0;
  font-size: 13px;
  color: var(--ink-2);
}
.sheetNav {
  display: flex;
  align-items: flex-start;
  gap: 10px;
}
.stepper {
  display: flex;
  border: 1px solid var(--edge);
  border-radius: 8px;
  overflow: hidden;
}
.stepper button,
.close {
  display: grid;
  place-items: center;
  width: 26px;
  height: 24px;
  padding: 0;
  border: 0;
  background: var(--panel);
  cursor: pointer;
}
.stepper button + button {
  border-left: 1px solid var(--edge);
}
.stepper button:hover,
.close:hover {
  background: var(--range);
}
.stepper svg,
.close svg {
  width: 14px;
  height: 14px;
}
.close {
  border-radius: 6px;
}
.close svg {
  width: 15px;
  height: 15px;
}
.tabs {
  display: flex;
  gap: 21px;
  height: 38px;
  padding: 0 19px;
  border-bottom: 1px solid var(--line);
}
.tab {
  position: relative;
  padding: 0 1px;
  border: 0;
  font-size: 13.5px !important;
  letter-spacing: -0.005em;
  color: var(--ink-2) !important;
  background: transparent;
  cursor: pointer;
  transition: color 150ms ease;
}
.tab:hover,
.tab[aria-selected='true'] {
  color: var(--ink) !important;
}
.tab::after {
  content: '';
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  height: 2px;
  border-radius: 1px;
  background: var(--ink);
  opacity: 0;
  transition: opacity 200ms ease;
}
.tab[aria-selected='true']::after {
  opacity: 1;
}
.sheetBody {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 19px;
  outline: none;
}
.sheetBody:focus-visible {
  box-shadow: inset 0 0 0 2px var(--focus);
}
.panelCard {
  margin: 0 0 14px;
  border: 1px solid var(--edge);
  border-radius: 12px;
  background: var(--card);
  overflow: hidden;
}
.panelCard header {
  padding: 10px 12px 9px;
}
.panelCard h5,
.facts h5 {
  margin: 0;
  font-size: 13px;
  font-weight: 600;
  line-height: 17px;
}
.panelCard header p {
  margin: 1px 0 0;
  font-size: 12px;
  color: var(--ink-2);
}
.withAction {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.withAction {
  padding-top: 7px !important;
  padding-bottom: 5px !important;
}
.withAction .button {
  height: 26px;
  padding: 0 9px;
}
.stats {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  margin: 0 -1px -1px;
  padding: 13px 12px 14px;
  border: 1px solid var(--edge);
  border-radius: 12px;
  background: var(--panel);
}
.stats div {
  display: flex;
  flex-direction: column;
}
.stats span,
.stats small {
  font-size: 12.5px;
  color: var(--ink-2);
}
.stats b {
  margin: 5px 0 3px;
  font-size: 20px;
  font-weight: 600;
  line-height: 24px;
  letter-spacing: -0.01em;
}
.table {
  width: calc(100% + 2px);
  margin: 0 -1px -1px;
  border: 1px solid var(--edge);
  border-radius: 12px;
  border-collapse: separate;
  border-spacing: 0;
  background: var(--panel);
  font-size: 13px;
}
.table th,
.table td {
  height: 35px;
  padding: 0 12px;
  border-bottom: 1px solid var(--line);
  text-align: right;
  font-weight: 400;
}
.table thead th {
  height: 38px;
  font-weight: 500;
}
.table tr > :first-child {
  text-align: left;
}
.table tbody th {
  font-weight: 500;
}
.table tbody tr:last-child > * {
  border-bottom: 0;
}
.table td[data-tone='off'] {
  color: var(--red-ink);
}
.table [data-quiet] {
  color: var(--ink-2);
}
.table td[data-tone='off'] {
  color: var(--red-ink);
}
.table col[data-w='day'] {
  width: 19%;
}
.table col[data-w='date'] {
  width: 33.5%;
}
.table col[data-w='cost'] {
  width: 17.7%;
}
.table .left {
  text-align: left;
}
.table td.left {
  color: var(--ink-2);
}
.facts {
  padding: 2px 0 6px;
}
.facts h5 {
  font-weight: 400;
  color: var(--ink-2);
}
.facts dl {
  display: grid;
  grid-template-columns: 163px 1fr;
  row-gap: 12px;
  margin: 14px 0 0;
}
.facts dt {
  color: var(--ink-3);
}
.facts dd {
  margin: 0;
}
.history {
  margin: 0 -1px -1px;
  padding: 6px 0;
  list-style: none;
  border: 1px solid var(--edge);
  border-radius: 12px;
  background: var(--panel);
}
.history li {
  display: flex;
  align-items: center;
  gap: 11px;
  padding: 7px 12px;
}
.history li > span:nth-child(2) {
  display: flex;
  flex: 1;
  flex-direction: column;
  min-width: 0;
}
.history b {
  font-weight: 500;
}
.history li > span:nth-child(2) span {
  font-size: 12px;
  color: var(--ink-2);
}
.history time {
  font-size: 12px;
  color: var(--ink-2);
}
.history .rowIcon {
  width: 20px;
  height: 20px;
}
.sheetFoot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  height: 53px;
  padding: 0 19px;
  box-sizing: border-box;
  border-top: 1px solid var(--line);
  background: var(--card);
}

.dialog {
  position: relative;
  width: min(300px, calc(100% - 32px));
  overflow: hidden;
  border: 1px solid var(--edge);
  border-radius: 12px;
  background: var(--panel);
  box-shadow:
    0 12px 40px var(--shadow),
    0 2px 6px var(--shadow);
  text-align: center;
  animation: dialog-in 220ms var(--ease);
}
.scrim[data-leaving] .dialog {
  transform: scale(0.97);
  transition: transform 160ms ease-in;
}
@keyframes dialog-in {
  from {
    opacity: 0;
    transform: scale(0.96);
  }
}
.dialog h4 {
  margin: 16px 16px 6px;
  font-size: 15px;
  font-weight: 500;
}
.dialog p {
  margin: 0 18px 15px;
  font-size: 13px;
  line-height: 18px;
  color: var(--ink-2);
}
.dialog p b {
  font-weight: 500;
  color: var(--ink);
}
.dialogActions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 8px;
  padding: 12px 14px 14px;
  border-top: 1px solid var(--line);
  background: var(--card);
}
.dialogButton {
  height: 30px;
  border: 1px solid var(--edge);
  border-radius: 8px;
  font-size: 13.5px !important;
  font-weight: 500;
  background: var(--panel);
  cursor: pointer;
}
.dialogButton:hover {
  background: var(--range);
}
.danger {
  border-color: var(--danger-bg);
  color: var(--danger-ink) !important;
  background: var(--danger-bg);
}
.danger:hover {
  background: var(--danger-hover);
}

/* ----- The demo pointer ----- */

.pointerLayer {
  position: absolute;
  inset: 0;
  z-index: 9;
  pointer-events: none;
}
.demoPointer {
  width: 20px;
  height: 20px;
}
.glyphArrow {
  transition: transform 120ms ease;
}
.demoPointer[data-glyph='arrow'] .glyphHand,
.demoPointer[data-glyph='hand'] .glyphArrow {
  visibility: hidden;
}
.demoPointer[data-press='true'] .glyphArrow {
  transform: scale(0.86);
}

/* ----- Narrower ----- */

@container (max-width: 1080px) {
  .card {
    --panel-w: 186px;
    --label-w: 142px;
    --col-min: 58px;
  }
  .select {
    width: 98px;
  }
  .fieldLabel {
    white-space: nowrap;
  }
}

@container (max-width: 760px) {
  .card {
    --label-w: 128px;
    --col-min: 64px;
  }
  .header {
    flex-wrap: wrap;
    padding: 10px 12px;
  }
  .actions {
    justify-content: flex-start;
  }
  .body {
    grid-template-columns: minmax(0, 1fr);
  }
  .panel {
    display: flex;
    flex-direction: column;
    min-width: 0;
    border-right: 0;
    border-bottom: 1px solid var(--edge);
  }
  .panelTop {
    display: flex;
    flex-wrap: wrap;
    gap: 10px 20px;
    padding: 12px 12px 0;
  }
  .panelTop > .field {
    flex: 1 1 200px;
    margin: 0;
  }
  .field .select {
    flex: 1;
    max-width: 150px;
  }
  .stages {
    flex: 1 1 100%;
    margin: 0;
    padding: 10px 0 12px;
  }
  .stages ul {
    display: flex;
    flex-wrap: wrap;
    gap: 6px 14px;
    margin-top: 6px;
  }
  .stages li {
    height: auto;
    gap: 6px;
  }
  .costs {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 0 8px;
    height: auto;
    padding: 10px 12px 12px;
  }
  .costsTitle,
  .costsNote {
    grid-column: 1 / -1;
  }
  .costs .wide + .wide {
    margin-top: 0;
  }
  .footer {
    flex-wrap: wrap;
    padding: 10px 12px;
  }
  .toasts {
    top: 10px;
  }
}

@container (max-width: 480px) {
  .card {
    --label-w: 112px;
  }
  .rowHead {
    padding-left: 10px;
  }
  .rowHead .rowIcon {
    display: none;
  }
  .rowLabel {
    font-size: 13px;
    gap: 4px;
  }
  .button .label {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
  }
  .actions {
    gap: 6px;
  }
  .sheet {
    top: 8px;
    right: 8px;
    bottom: auto;
    width: calc(100% - 16px);
    height: min(calc(100% - 16px), 640px);
  }
  .sheetHead {
    height: auto;
    padding-bottom: 10px;
  }
  .sheetTitle {
    flex-wrap: wrap;
    font-size: 16px;
  }
  .tabs {
    padding: 0 16px;
  }
  .sheetBody {
    padding: 14px;
  }
  .stats b {
    font-size: 17px;
  }
  .facts dl {
    grid-template-columns: 110px 1fr;
  }
}

@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    animation: none !important;
    transition: none !important;
  }
  .spin {
    animation: spin 1.6s linear infinite !important;
  }
}
```