# Records table

> A CRM grid of companies with coloured category tags, sortable columns and an agent that fills a new column row by row.

A grid of sixty companies that scrolls both ways under a sticky header, company column and footer of calculations. Rows tick and tint, two columns sort, every column’s edge drags, and tags that no longer fit fold into a +N chip. Each column’s header opens how an agent fills it — type, tool, inputs and a prompt with chips — and the + adds a Competitors column that the agent fills a row every 120 ms.

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

Category: AI.

## Anatomy

1. **Card**: A 480 px window, 440 px tall, onto one scroller that moves both ways. The header, the company column and the footer stick to its edges.
2. **Select all**: Ticks every row, or clears them. A dash when only some are ticked.
3. **Column header**: The type’s icon and the column’s name. It opens the column’s settings; its right edge drags from 120 to 520 px.
4. **Row checkbox**: Takes the row number’s place under the pointer, in focus or once ticked. A ticked row is tinted with the accent.
5. **Company**: An initial, the name and the city, linking to the site. Sticks to the left with a faint shadow off its edge.
6. **Category tag**: Each category’s colour mixed into the card, so both schemes keep their tint.
7. **+N chip**: The tags that no longer fit whole fold into it, measured as the column narrows.
8. **Footer**: A calculation per column: the count, unique tags, the average strength, how many links.

## When to use

- A list of records with several properties each, read across a row: companies, people, deals.
- Columns filled by an agent or a rule, where each header says how and can fill its column again.
- Picking a few records to act on together: ticked rows, a header box that selects them all.
- Reading the same list by different orders, recent or strong first, without losing the original one.
- Wide data in a small card or side panel: one scroller both ways, the company column and the header kept in view.

## When not to use

- A handful of records with two or three facts each. Use a list or cards.
- Editing cell by cell like a spreadsheet: this table reads and calculates, it has no cell editor.
- Hundreds of rows or more: every row is drawn. Page it, or use a virtualised grid.
- Comparing numbers at a glance. Use a chart, and keep the table for the detail.
- A table on a phone as the main view: a column at a time fits better as a list of cards.

## Installation

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

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

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

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

## Usage

```tsx
import { RecordsTable, type CompanyRecord } from './records-table';

const companies: CompanyRecord[] = [
  {
    name: 'Harbor Freeze',
    city: 'Lisbon',
    site: 'harbor-freeze.example.com',
    categories: ['Gelato', 'Wholesale'],
    last: [3, '3 days ago'],
    strength: 'very-strong',
  },
  {
    name: 'Pine Cone Dairy',
    city: 'Oslo',
    categories: ['Local'],
    strength: 'none',
  },
];

export function Accounts({ onPick }: { onPick: (ids: string[]) => void }) {
  return (
    <RecordsTable
      records={companies}
      categoryColors={{ Gelato: 'oklch(0.62 0.18 293)', Wholesale: 'oklch(0.76 0.13 70)', Local: 'oklch(0.7 0.13 162)' }}
      onSelectionChange={onPick}
      autoplay={false}
    />
  );
}
```

## States

- **Book order**: Nothing ticked, rows in the order they were given, numbered.
- **Some ticked**: Ticked rows keep their box and the accent tint; the header box shows a dash.
- **All ticked**: From the header box: every row tinted, the header box ticked.
- **Most recent first**: First press on Last interaction’s arrow. Rows with no contact stay last.
- **Oldest first**: The second press turns the arrow over (160 ms); a third restores the book’s order.
- **Compact columns**: From the options menu: narrower columns, the tags folding into +N chips.
- **Calculating**: An agent filling a column a row every 120 ms, held here at row 4. The dot keeps breathing.
- **New property menu**: The + opens the types; picking one adds the column and opens its settings.
- **Column settings**: Type, tool, inputs and prompt, and Go calculate. The column is tinted while it is open.

## How it works

- One scroller holds a fixed-layout table. Its header sticks to the top, the footer of calculations to the bottom and the company column to the left, each painted opaque, with a faint 5 px shadow off the company column’s edge; the corner cells sit above both.
- A row’s number and its checkbox share one 24 px grid cell: the number gives way to the box under the pointer, in focus or once ticked. Ticked rows mix 7 % of the accent into the card in srgb; the header’s box selects all and shows a dash for some (and is indeterminate for screen readers).
- Tags take one base colour per category and mix it into the card in srgb: 34 % at night with its words 86 % of it on white, 18 % by day with a 32 % edge and words darkened by the ink. Each tag and +N chip is measured once, hidden (again when the fonts arrive), so a cell shows as many tags as fit whole, with a chip for the rest, without measuring 60 rows.
- Every column’s right edge is a separator: drag it (with pointer capture) or focus it and press the arrows, from 120 to 520 px, and a 2 px accent bar shows while it is in use. Table options compact the columns, so the tags fold, or put them back.
- Last interaction and Connection strength sort: the arrow fades in on the header’s hover and stays while it sorts, turns over in 160 ms for the other way, and a third press restores the book’s order. Rows without a value stay last either way.
- A header opens its column’s settings under it, tinting the column with 8 % of the accent and an accent line along its top. Type, Tool and Inputs open small menus beside the panel (or over it when the component is too narrow); the prompt is a contentEditable line whose mentioned columns stand as chips, read back as the reader types.
- Go calculate fills the column again: each waiting cell says “Calculating…” beside a dot breathing between 35 % and full (1.1 s), and one row fills every 120 ms in the order shown. The + adds a Competitors column, of the type picked, and opens its settings at once; it can be pinned first or hidden again.
- Menus pop in from their button’s corner, 160 ms from 95 %, and are placed inside the component (clamped to its edges, following the grid as it scrolls). A soft highlight glides between their rows by top and height (220 ms).
- While on screen and untouched, a demo uses the table: it ticks two rows, sorts by the last interaction both ways, adds and calculates the Competitors column, compacts the columns so the tags fold, resets them and tidies up. Its actions never reach the callbacks and are not announced; any real input hands the table to the reader.
- Every colour is a light-dark() token on the root, so the table follows the page’s color-scheme or the colorScheme prop. On a card under 440 px the company column starts at half its width, so the others still show.

## API reference

- **records** (`CompanyRecord[]`, default `sixty sample companies`): The companies, in the book’s order: { id?, name, city?, site?, categories, last?: [days, label], strength }. strength is very-strong, strong, weak, very-weak or none.
- **categoryColors** (`Record<string, string>`, default `twelve sample categories`): Each category’s base colour, any CSS colour; the tags mix it with the table.
- **label** (`string`, default `'Companies'`): Names the table and its scroller for screen readers.
- **defaultState** (`RecordsState`): Opens in a state, with the demo off: selected rows, sort, compact widths, competitors (or 'pinned'), a column held calculating, unique counts, scroll (px or 'end') and an open menu or column panel, held until a press inside the table.
- **onSelectionChange** (`(ids: string[]) => void`): Called with the ids (or names) of the ticked rows when the reader changes them.
- **onSortChange** (`(sort: RecordsSort) => void`): Called when the reader sorts: { key: last | strength, direction }, or null for the book’s order.
- **onCalculate** (`(column: string) => void`): Called when the reader presses Go calculate, with the column’s name.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo selects, sorts, adds and calculates a column, and folds the tags by itself.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root.

## Accessibility

- A real table with a caption, column headers and the company as each row’s header, inside a focusable, labelled scroll region that arrow keys scroll both ways.
- Every row has a labelled checkbox (“Select Alpine Churn — Zürich”), reachable with Tab even while the row number covers it; the header’s box is indeterminate when some rows are ticked. A polite live region says how many are selected.
- Sortable headers carry aria-sort, and the sort is a separate button (“Sort by Last interaction, now most recent first”); the order is announced. Column edges are separators with their width as a value: arrows resize by 10 px (Shift by 40), Home and End go to the limits.
- Headers are buttons that open their column’s settings as a labelled dialog; the + and the options open menus with menuitem, menuitemradio and menuitemcheckbox roles. Arrow keys, Home and End move between rows, Escape closes the innermost menu and gives the focus back to its button, and a press outside closes them.
- Under reduced motion the menus appear at once, the calculating dot holds still and the demo never plays. Focus rings are visible on every control.

## Source

### records-table/records-table.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import {
  ColumnPanel,
  PROPERTY_TYPES,
  typeIcon,
  type ColumnSettings,
  type PropertyType,
} from './column-panel';
import { CATEGORY_COLORS, COMPANIES, COMPETITORS, type CompanyRecord, type Strength } from './data';
import { GlideMenu, type MenuItem } from './glide-menu';
import { Icon } from './icons';
import { useAutoplay } from './use-autoplay';
import styles from './records-table.module.css';

/*
 * Records table: a CRM grid of companies, with their categories as coloured tags, when you last
 * heard from them, how strong the relationship is and their site. Every column but the company is
 * filled by an agent, and its header opens how: type, tool, inputs and prompt, and Go calculate.
 *
 * - One scroller holds the table, both ways. The header sticks to its top, the footer of
 *   calculations to its bottom and the company column to its left, each painted opaque, with a
 *   faint shadow off the company column's edge.
 * - A row's number gives way to its checkbox under the pointer, in focus or once ticked; ticked
 *   rows are tinted with the accent. The header's box selects all, and shows a dash for some.
 * - Last interaction and Connection strength sort: an arrow appears on the header's hover and
 *   stays while it sorts; it turns over (160 ms) for the other way, and a third press restores
 *   the book's order. Rows without a value stay last either way.
 * - Every column's right edge drags (or takes the arrow keys) from 120 to 520 px. Tags that no
 *   longer fit fold into a +N chip: each tag and chip is measured once, hidden, and a cell shows
 *   as many as fit whole, with the chip for the rest.
 * - The + adds a property: a Competitors column that opens its settings at once. Go calculate
 *   fills a column again, a row every 120 ms in the order shown, each waiting row saying
 *   "Calculating…" by a breathing dot.
 * - Menus pop in from their button's corner (160 ms, from 95 %), and a soft highlight glides
 *   between their rows.
 * - While on screen and untouched, a demo uses the table: it ticks two rows, sorts by the last
 *   interaction both ways, adds and calculates the Competitors column, compacts the columns to
 *   fold the tags and resets them, then tidies up. Any real input hands it to the reader.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type ColumnKey = 'categories' | 'last' | 'strength' | 'links' | 'competitors';
type ColumnId = 'company' | ColumnKey;
type SortKey = 'last' | 'strength';
export type RecordsSort = { key: SortKey; direction: 'ascending' | 'descending' } | null;

/**
 * Where the table starts: a state to open in, for docs and tests. Giving one turns the demo off.
 * An open menu or panel stays open until the reader presses inside the table.
 */
export type RecordsState = {
  /** The ids (or names) of the ticked rows. */
  selected?: string[];
  sort?: RecordsSort;
  /** The compact widths, so the tags fold into +N chips. */
  compact?: boolean;
  /** The Competitors column is there; 'pinned' puts it first, after the company. */
  competitors?: boolean | 'pinned';
  /** A column caught calculating, `done` rows in, held still (it doesn't go on). */
  calculating?: { column: ColumnKey; done: number };
  /** The categories' footer shows its count of unique tags. */
  unique?: boolean;
  /** The grid scrolled this many px across, or to its end. */
  scroll?: number | 'end';
  /** A menu ('add', 'options') or a column's settings, open. */
  open?: 'add' | 'options' | ColumnKey;
};

const NAMES: Record<ColumnId, string> = {
  company: 'Company',
  categories: 'Categories',
  last: 'Last interaction',
  strength: 'Connection strength',
  links: 'Links',
  competitors: 'Competitors',
};
const BOOK_ORDER: ColumnKey[] = ['categories', 'last', 'strength', 'links', 'competitors'];
const WIDTHS: Record<ColumnId, number> = {
  company: 270,
  categories: 275,
  last: 190,
  strength: 210,
  links: 175,
  competitors: 200,
};
/** Compact columns: narrower, so the tags fold. */
const COMPACT: Record<ColumnId, number> = {
  company: 220,
  categories: 220,
  last: 155,
  strength: 180,
  links: 160,
  competitors: 200,
};
/** The header's last cell, which holds the + and the options. */
const TAIL = 100;
const MIN = 120;
const MAX = 520;
/** A calculation fills a row every STEP ms. */
const STEP = 120;
/** Tags are 4 px apart, and a cell keeps 8 px either side and its 1 px line. */
const GAP = 4;
const CELL_INSET = 17;
const TAG_MAX = 115;

const STRENGTH: Record<Strength, { label: string; tone: string; rank: number; score: number }> = {
  'very-strong': { label: 'Very strong', tone: 'green', rank: 4, score: 100 },
  strong: { label: 'Strong', tone: 'green', rank: 3, score: 80 },
  weak: { label: 'Weak', tone: 'orange', rank: 2, score: 60 },
  'very-weak': { label: 'Very weak', tone: 'red', rank: 1, score: 30 },
  none: { label: 'No communication', tone: 'grey', rank: 0, score: 15 },
};

const SETTINGS: Record<ColumnKey, ColumnSettings> = {
  categories: settings(
    'Multi select',
    'Sprinkles 5',
    ['company'],
    ['Tag each ', { mention: 'company' }, ' with its market categories.'],
  ),
  last: settings('Date', 'User input', [], []),
  strength: settings(
    'Single select',
    'Sprinkles 5',
    ['last'],
    ['Score the relationship from ', { mention: 'last' }, '.'],
  ),
  links: settings(
    'URL',
    'Web search',
    ['company'],
    ['Find the website for ', { mention: 'company' }, '.'],
  ),
  competitors: settings(
    'Text',
    'Web search',
    ['company'],
    ['Find competitors for ', { mention: 'company' }],
  ),
};

function settings(
  type: PropertyType,
  tool: ColumnSettings['tool'],
  inputs: string[],
  prompt: ColumnSettings['prompt'],
): ColumnSettings {
  return {
    type,
    tool,
    grounding: false,
    inputs,
    prompt,
    required: false,
    allowEmpty: true,
    confidence: false,
  };
}

type Open = { kind: 'panel'; key: ColumnKey } | { kind: 'add' } | { kind: 'options' } | null;

type Row = CompanyRecord & { key: string; index: number };

export function RecordsTable({
  records = COMPANIES,
  categoryColors = CATEGORY_COLORS,
  label = 'Companies',
  onSelectionChange,
  onSortChange,
  onCalculate,
  autoplay = true,
  defaultState,
  colorScheme,
  className = '',
}: {
  /** The companies, in the book's order. */
  records?: CompanyRecord[];
  /** Each category's base colour (any CSS colour); the tags mix it with the table. */
  categoryColors?: Record<string, string>;
  /** Names the table for screen readers. */
  label?: string;
  /** Called with the ids (or names) of the ticked rows when the reader changes them. */
  onSelectionChange?: (ids: string[]) => void;
  /** Called when the reader sorts, or restores the book's order (null). */
  onSortChange?: (sort: RecordsSort) => void;
  /** Called when the reader presses Go calculate, with the column's name. */
  onCalculate?: (column: string) => void;
  /** While on screen and untouched, a demo selects, sorts, adds and calculates by itself. */
  autoplay?: boolean;
  /** Opens in this state, with the demo off (see RecordsState). */
  defaultState?: RecordsState;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(8000);
  const demo = autoplay && !defaultState && playing;
  const scroller = useRef<HTMLDivElement>(null);
  const measurer = useRef<HTMLDivElement>(null);
  const popover = useRef<HTMLDivElement>(null);
  /** The button or header a popover opened from, to place it and give the focus back. */
  const anchor = useRef<HTMLElement | null>(null);

  const rows = useMemo<Row[]>(
    () => records.map((record, index) => ({ ...record, key: record.id ?? record.name, index })),
    [records],
  );

  const [sort, setSort] = useState<RecordsSort>(defaultState?.sort ?? null);
  const [selected, setSelected] = useState<ReadonlySet<string>>(
    () => new Set(defaultState?.selected),
  );
  const [widths, setWidths] = useState<Partial<Record<ColumnId, number>>>(() =>
    defaultState?.compact ? { ...COMPACT } : {},
  );
  const [resizing, setResizing] = useState<ColumnId | null>(null);
  const [shellWidth, setShellWidth] = useState(478);
  const [config, setConfig] = useState(SETTINGS);
  const [added, setAdded] = useState(
    !!defaultState?.competitors || defaultState?.calculating?.column === 'competitors',
  );
  const [pinned, setPinned] = useState<ColumnKey | null>(
    defaultState?.competitors === 'pinned' ? 'competitors' : null,
  );
  /** Columns whose agent has filled them (the book's own columns come filled). */
  const [filled, setFilled] = useState<ReadonlySet<ColumnKey>>(() => {
    const set = new Set<ColumnKey>(['categories', 'last', 'strength', 'links']);
    if (defaultState?.competitors) set.add('competitors');
    if (defaultState?.calculating) set.delete(defaultState.calculating.column);
    return set;
  });
  // A run below 0 is a calculation held still (defaultState.calculating).
  const [calc, setCalc] = useState<{ key: ColumnKey; done: number; run: number } | null>(() =>
    defaultState?.calculating
      ? { key: defaultState.calculating.column, done: defaultState.calculating.done, run: -1 }
      : null,
  );
  const [uniqueShown, setUniqueShown] = useState(!!defaultState?.unique);
  const [open, setOpen] = useState<Open>(null);
  const [place, setPlace] = useState<{
    x: number;
    y: number;
    maxHeight: number;
    origin: string;
    rootWidth: number;
  } | null>(null);
  /** The popover was opened from the keyboard or by a reader: its first control takes focus. */
  const [focusIn, setFocusIn] = useState(false);
  const [tagWidths, setTagWidths] = useState<Map<string, number> | null>(null);
  const [message, setMessage] = useState('');
  // What the demo points at: a row, a header, a menu row, a pressed button.
  const [demoRow, setDemoRow] = useState<string | null>(null);
  const [demoHead, setDemoHead] = useState<ColumnId | null>(null);
  const [demoTool, setDemoTool] = useState<'add' | 'options' | null>(null);
  const [demoMenu, setDemoMenu] = useState<number | null>(null);
  const [demoPress, setDemoPress] = useState<'calculate' | null>(null);
  /** A popover opened by defaultState: it stays open, unfocused, until a press in the table. */
  const [held, setHeld] = useState(false);

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

  const columns = useMemo<ColumnKey[]>(() => {
    const shown = BOOK_ORDER.filter((key) => key !== 'competitors' || added);
    return pinned && shown.includes(pinned)
      ? [pinned, ...shown.filter((key) => key !== pinned)]
      : shown;
  }, [added, pinned]);

  // On a narrow card the company column starts narrower, so the others still show.
  const companyDefault =
    shellWidth >= 440 ? WIDTHS.company : Math.max(150, Math.round(shellWidth * 0.5));
  const widthOf = (id: ColumnId) => widths[id] ?? (id === 'company' ? companyDefault : WIDTHS[id]);
  const tableWidth =
    widthOf('company') + columns.reduce((sum, key) => sum + widthOf(key), 0) + TAIL;

  const shown = useMemo(() => {
    if (!sort) return rows;
    const score = (row: Row) =>
      sort.key === 'last' ? (row.last ? -row.last[0] : null) : STRENGTH[row.strength].rank;
    const flip = sort.direction === 'descending' ? -1 : 1;
    return [...rows].sort((a, b) => {
      const x = score(a);
      const y = score(b);
      if (x === null || y === null) return x === y ? a.index - b.index : x === null ? 1 : -1;
      return (x - y) * flip || a.index - b.index;
    });
  }, [rows, sort]);

  const links = rows.filter((row) => row.site).length;
  const average = Math.round(
    rows.reduce((sum, row) => sum + STRENGTH[row.strength].score, 0) / Math.max(1, rows.length),
  );
  const averageTone = average >= 70 ? 'green' : average >= 40 ? 'orange' : 'red';
  const categories = useMemo(() => [...new Set(rows.flatMap((row) => row.categories))], [rows]);

  // The card's width, for the narrow-card company column.
  useLayoutEffect(() => {
    const element = scroller.current;
    if (!element) return;
    const observer = new ResizeObserver(() => setShellWidth(element.clientWidth));
    observer.observe(element);
    return () => observer.disconnect();
  }, []);

  // Every tag and +N chip measured once (again when the fonts arrive), to fold the tags of a cell.
  useLayoutEffect(() => {
    const box = measurer.current;
    if (!box) return;
    let alive = true;
    const measure = () => {
      if (!alive) return;
      const map = new Map<string, number>();
      box.querySelectorAll<HTMLElement>('[data-measure]').forEach((element) => {
        map.set(element.dataset.measure!, element.getBoundingClientRect().width / scaleOf(box));
      });
      setTagWidths(map);
    };
    measure();
    document.fonts?.ready.then(measure);
    return () => {
      alive = false;
    };
  }, [categories]);

  // A calculation fills one row every STEP ms, in the order the rows are shown.
  useEffect(() => {
    if (!calc || calc.run < 0) return;
    const timer = window.setInterval(() => {
      setCalc((now) => {
        if (!now) return now;
        if (now.done + 1 < rows.length) return { ...now, done: now.done + 1 };
        setFilled((set) => new Set(set).add(now.key));
        return null;
      });
    }, STEP);
    return () => window.clearInterval(timer);
    // One timer per run.
  }, [calc?.run, rows.length]);

  // Places an open popover under its button, inside the component, and again as the grid scrolls.
  const reposition = useCallback(() => {
    const stage = root.current;
    const from = anchor.current;
    if (!stage || !from || !open) return setPlace(null);
    const scale = scaleOf(stage);
    const frame = stage.getBoundingClientRect();
    const rect = from.getBoundingClientRect();
    const width = open.kind === 'panel' ? 320 : open.kind === 'add' ? 260 : 220;
    const rootWidth = stage.offsetWidth;
    const left = (rect.left - frame.left) / scale;
    const right = (rect.right - frame.left) / scale;
    const top = (rect.bottom - frame.top) / scale + (open.kind === 'panel' ? 2 : 6);
    const wanted = open.kind === 'options' ? right - width : left;
    const x = Math.max(8, Math.min(wanted, rootWidth - Math.min(width, rootWidth - 16) - 8));
    setPlace({
      x,
      y: top,
      maxHeight: stage.offsetHeight - top - 8,
      origin: open.kind === 'options' ? 'right top' : 'left top',
      rootWidth,
    });
  }, [open, root]);

  useLayoutEffect(() => {
    reposition();
    if (!open) return;
    const grid = scroller.current;
    grid?.addEventListener('scroll', reposition, { passive: true });
    window.addEventListener('resize', reposition);
    return () => {
      grid?.removeEventListener('scroll', reposition);
      window.removeEventListener('resize', reposition);
    };
  }, [open, reposition, columns, widths]);

  // A press outside the popover and its button closes it.
  useEffect(() => {
    if (!open) return;
    const close = (event: PointerEvent) => {
      const target = event.target as Node;
      if (popover.current?.contains(target) || anchor.current?.contains(target)) return;
      if (held && !root.current?.contains(target)) return;
      setOpen(null);
    };
    document.addEventListener('pointerdown', close);
    return () => document.removeEventListener('pointerdown', close);
  }, [open, held, root]);

  const closePopover = (refocus: boolean) => {
    setOpen(null);
    // A column's panel hangs from its header cell: the focus goes back to the cell's button.
    const from = anchor.current;
    if (refocus) (from?.matches('th') ? from.querySelector('button') : from)?.focus();
  };

  const toggle = (next: NonNullable<Open>, from: HTMLElement, keyboard: boolean) => {
    const same =
      open?.kind === next.kind && (next.kind !== 'panel' || (open as typeof next).key === next.key);
    if (same) return closePopover(false);
    anchor.current = from;
    setFocusIn(keyboard);
    setOpen(next);
  };

  // Selection.
  const commitSelection = (next: Set<string>) => {
    setSelected(next);
    onSelectionChange?.([...next]);
    announce(
      next.size === 0
        ? 'No companies selected'
        : `${next.size} ${next.size === 1 ? 'company' : 'companies'} selected`,
    );
  };
  const toggleRow = (key: string) => {
    const next = new Set(selected);
    if (next.has(key)) next.delete(key);
    else next.add(key);
    commitSelection(next);
  };
  const toggleAll = () =>
    commitSelection(
      selected.size === rows.length ? new Set() : new Set(rows.map((row) => row.key)),
    );

  // Sorting: most recent or strongest first, then the other way, then the book's order.
  const nextSort = (key: SortKey): RecordsSort =>
    sort?.key !== key
      ? { key, direction: 'descending' }
      : sort.direction === 'descending'
        ? { key, direction: 'ascending' }
        : null;
  const sortBy = (key: SortKey) => {
    const next = nextSort(key);
    setSort(next);
    onSortChange?.(next);
    announce(next ? `Sorted by ${NAMES[key]}, ${sortWords(next)}` : 'Sorted in the original order');
  };

  const calculate = (key: ColumnKey, reader: boolean) => {
    setCalc((now) => ({ key, done: 0, run: (now?.run ?? 0) + 1 + Math.random() }));
    setFilled((set) => {
      const next = new Set(set);
      next.delete(key);
      return next;
    });
    setOpen(null);
    if (reader) {
      onCalculate?.(NAMES[key]);
      announce(`Calculating ${NAMES[key]} for ${rows.length} companies`);
      anchor.current?.focus();
    }
  };

  const addColumn = (type: PropertyType) => {
    setAdded(true);
    setConfig((now) => ({ ...now, competitors: { ...now.competitors, type } }));
    return 'competitors' as const;
  };

  // Opens a column's settings once its header is drawn and in view.
  const openPanelFor = (key: ColumnKey, keyboard: boolean) => {
    requestAnimationFrame(() => {
      const grid = scroller.current;
      const head = grid?.querySelector<HTMLElement>(`[data-column="${key}"]`);
      if (!grid || !head) return;
      const sticky = widthOf('company');
      const left = head.offsetLeft;
      const right = left + head.offsetWidth;
      if (right > grid.scrollLeft + grid.clientWidth) grid.scrollLeft = right - grid.clientWidth;
      else if (left - sticky < grid.scrollLeft) grid.scrollLeft = left - sticky;
      anchor.current = head;
      setFocusIn(keyboard);
      setOpen({ kind: 'panel', key });
    });
  };

  const hide = (key: ColumnKey) => {
    if (key !== 'competitors') return;
    setAdded(false);
    setOpen(null);
    if (pinned === key) setPinned(null);
    setFilled((set) => {
      const next = new Set(set);
      next.delete(key);
      return next;
    });
    if (calc?.key === key) setCalc(null);
  };

  // Opens in defaultState: the grid scrolled, and a menu or a column's settings open (held).
  const begin = useEffectEvent(() => {
    const state = defaultState;
    const grid = scroller.current;
    if (!state || !grid) return;
    const menu = state.open === 'add' || state.open === 'options' ? state.open : null;
    const scroll = state.scroll ?? (menu ? 'end' : undefined);
    if (scroll !== undefined)
      grid.scrollLeft = scroll === 'end' ? grid.scrollWidth - grid.clientWidth : scroll;
    if (!state.open) return;
    setHeld(true);
    setFocusIn(false);
    if (menu) {
      const from = root.current?.querySelector<HTMLElement>(`[data-tool="${menu}"]`);
      if (!from) return;
      anchor.current = from;
      setOpen({ kind: menu });
    } else openPanelFor(state.open as ColumnKey, false);
  });
  useLayoutEffect(() => begin(), []);

  const resetWidths = () => setWidths({});
  const compact = () =>
    setWidths(
      Object.fromEntries(
        (Object.keys(COMPACT) as ColumnId[]).map((id) => [
          id,
          id === 'company' ? Math.min(COMPACT.company, companyDefault) : COMPACT[id],
        ]),
      ),
    );

  // Resizing: a drag from the column's edge, or the arrow keys on it.
  const setWidth = (id: ColumnId, width: number) =>
    setWidths((now) => ({ ...now, [id]: Math.round(Math.max(MIN, Math.min(MAX, width))) }));
  const startResize = (id: ColumnId, event: ReactPointerEvent<HTMLElement>) => {
    if (event.button !== 0) return;
    event.preventDefault();
    const handle = event.currentTarget;
    handle.setPointerCapture(event.pointerId);
    const start = event.clientX;
    const from = widthOf(id);
    const scale = scaleOf(handle);
    setResizing(id);
    const move = (moved: PointerEvent) => setWidth(id, from + (moved.clientX - start) / scale);
    const end = () => {
      setResizing(null);
      handle.removeEventListener('pointermove', move);
      handle.removeEventListener('pointerup', end);
      handle.removeEventListener('pointercancel', end);
    };
    handle.addEventListener('pointermove', move);
    handle.addEventListener('pointerup', end);
    handle.addEventListener('pointercancel', end);
  };
  const keyResize = (id: ColumnId, event: ReactKeyboardEvent) => {
    const step = event.shiftKey ? 40 : 10;
    const to =
      event.key === 'ArrowLeft'
        ? widthOf(id) - step
        : event.key === 'ArrowRight'
          ? widthOf(id) + step
          : event.key === 'Home'
            ? MIN
            : event.key === 'End'
              ? MAX
              : null;
    if (to === null) return;
    event.preventDefault();
    setWidth(id, to);
  };

  const resizeHandle = (id: ColumnId) => (
    <span
      role="separator"
      aria-orientation="vertical"
      aria-label={`Resize ${NAMES[id]} column`}
      aria-valuenow={widthOf(id)}
      aria-valuemin={MIN}
      aria-valuemax={MAX}
      tabIndex={0}
      className={styles.resize}
      data-part="resize"
      data-active={resizing === id}
      onPointerDown={(event) => startResize(id, event)}
      onKeyDown={(event) => keyResize(id, event)}
    />
  );

  // The tags of a cell that fit whole, and how many fold into the +N chip.
  const fit = (tags: string[], width: number) => {
    if (!tagWidths) return tags.length;
    const room = width - CELL_INSET;
    const sizes = tags.map((tag) => Math.min(TAG_MAX, tagWidths.get(tag) ?? TAG_MAX));
    const total = sizes.reduce((sum, size) => sum + size, 0) + GAP * (tags.length - 1);
    if (total <= room) return tags.length;
    for (let count = tags.length - 1; count > 0; count--) {
      const used = sizes.slice(0, count).reduce((sum, size) => sum + size, 0);
      const chip = tagWidths.get(`+${tags.length - count}`) ?? 30;
      if (used + GAP * count + chip <= room) return count;
    }
    return 0;
  };

  const cell = (key: ColumnKey, row: Row, order: number) => {
    if (calc?.key === key && order >= calc.done)
      return (
        <span className={styles.calc} data-part="calculating">
          <span className={styles.muted}>Calculating…</span>
          <span className={styles.pulse} aria-hidden="true" />
        </span>
      );
    if (!filled.has(key) && calc?.key !== key) return <span className={styles.muted}>—</span>;
    switch (key) {
      case 'categories': {
        const count = fit(row.categories, widthOf('categories'));
        const rest = row.categories.length - count;
        return (
          <div
            className={styles.tags}
            title={row.categories.join(', ')}
            aria-label={`Categories: ${row.categories.join(', ')}`}
          >
            {row.categories.slice(0, count).map((tag) => (
              <span
                key={tag}
                className={styles.tag}
                data-part="tag"
                style={tagStyle(categoryColors[tag])}
              >
                {tag}
              </span>
            ))}
            {rest > 0 && (
              <span className={styles.more} data-part="more">
                +{rest}
              </span>
            )}
          </div>
        );
      }
      case 'last':
        return row.last ? row.last[1] : <span className={styles.muted}>No contact</span>;
      case 'strength': {
        const strength = STRENGTH[row.strength];
        return (
          <span className={styles.strength} data-part="strength">
            <span className={styles.dot} data-tone={strength.tone} aria-hidden="true" />
            {strength.label}
          </span>
        );
      }
      case 'links':
        return row.site ? (
          <a
            className={styles.link}
            href={`https://${row.site}`}
            title={row.site}
            target="_blank"
            rel="noreferrer"
          >
            <span>{row.site}</span>
            <Icon name="external" size={12} />
          </a>
        ) : (
          <span className={styles.muted}>—</span>
        );
      case 'competitors':
        return COMPETITORS[row.index % COMPETITORS.length];
    }
  };

  const footer = (key: ColumnKey) => {
    switch (key) {
      case 'categories':
        return (
          <button
            type="button"
            className={styles.addCalc}
            aria-pressed={uniqueShown}
            onClick={() => setUniqueShown(!uniqueShown)}
          >
            {uniqueShown ? (
              <>
                <span className={styles.footNum}>{categories.length}</span>unique
              </>
            ) : (
              <>
                <Icon name="plus" />
                Add calculation
              </>
            )}
          </button>
        );
      case 'strength':
        return (
          <span className={styles.foot}>
            <span className={styles.dot} data-tone={averageTone} aria-hidden="true" />
            {average}% average
          </span>
        );
      case 'links':
        return <span className={`${styles.foot} ${styles.muted}`}>{links} links</span>;
      default:
        return <span className={`${styles.foot} ${styles.muted}`}>—</span>;
    }
  };

  // The menus.
  const addItems: MenuItem[] = PROPERTY_TYPES.map(({ type, icon }) => ({
    key: type,
    label: type,
    icon: <Icon name={icon} stroke={type === 'Text' ? 2 : 1.8} />,
    onSelect: () => {
      const key = addColumn(type);
      setOpen(null);
      announce(`${NAMES[key]} column added`);
      openPanelFor(key, focusIn);
    },
  }));
  const optionItems: MenuItem[] = [
    {
      key: 'add',
      label: 'Add property',
      icon: <Icon name="plus" stroke={2} />,
      onSelect: () => {
        const from = root.current?.querySelector<HTMLElement>('[data-tool="add"]');
        if (from) toggle({ kind: 'add' }, from, focusIn);
      },
    },
    {
      key: 'compact',
      label: 'Compact columns',
      icon: <Icon name="compact" />,
      onSelect: () => {
        compact();
        closePopover(true);
        announce('Columns compacted');
      },
    },
    {
      key: 'reset',
      label: 'Reset column widths',
      icon: <Icon name="reset" />,
      onSelect: () => {
        resetWidths();
        closePopover(true);
        announce('Column widths reset');
      },
    },
    {
      key: 'clear',
      label: 'Clear selection',
      icon: <Icon name="close" />,
      separator: true,
      onSelect: () => {
        commitSelection(new Set());
        closePopover(true);
      },
    },
  ];

  // The demo.
  const act = useEffectEvent((step: DemoStep) => {
    const grid = scroller.current;
    switch (step.do) {
      case 'reset':
        setOpen(null);
        setSort(null);
        setSelected(new Set());
        setWidths({});
        setAdded(false);
        setPinned(null);
        setCalc(null);
        setConfig(SETTINGS);
        setFilled(new Set(['categories', 'last', 'strength', 'links']));
        if (grid) grid.scrollLeft = 0;
        return;
      case 'hover':
        setDemoRow(step.row === null ? null : (shown[step.row]?.key ?? null));
        return;
      case 'tick': {
        const key = shown[step.row]?.key;
        if (key) setSelected((now) => new Set(now).add(key));
        return;
      }
      case 'head':
        setDemoHead(step.column);
        return;
      case 'sort': {
        setSort(nextSort(step.column));
        return;
      }
      case 'tool': {
        setDemoTool(step.tool);
        const from = root.current?.querySelector<HTMLElement>(`[data-tool="${step.tool}"]`);
        if (step.tool && from) {
          anchor.current = from;
          setFocusIn(false);
          setOpen({ kind: step.tool });
        }
        return;
      }
      case 'menu':
        setDemoMenu(step.index);
        return;
      case 'add':
        setDemoMenu(null);
        setDemoTool(null);
        setOpen(null);
        openPanelFor(addColumn('Text'), false);
        return;
      case 'press':
        setDemoPress(step.press);
        return;
      case 'calculate':
        setDemoPress(null);
        calculate('competitors', false);
        return;
      case 'compact':
        setDemoMenu(null);
        setDemoTool(null);
        setOpen(null);
        compact();
        return;
      case 'widths':
        setDemoMenu(null);
        setDemoTool(null);
        setOpen(null);
        resetWidths();
        return;
      case 'clear':
        setDemoMenu(null);
        setDemoTool(null);
        setOpen(null);
        setSelected(new Set());
        return;
      case 'panel':
        openPanelFor('competitors', false);
        return;
      case 'hide':
        hide('competitors');
        return;
    }
  });
  const scrollGrid = useEffectEvent((to: 'start' | 'end' | number) => {
    const grid = scroller.current;
    if (!grid) return { from: 0, to: 0, grid: null };
    const max = grid.scrollWidth - grid.clientWidth;
    return {
      grid,
      from: grid.scrollLeft,
      to: to === 'start' ? 0 : to === 'end' ? max : Math.min(max, to),
    };
  });

  useEffect(() => {
    if (!demo) return;
    let alive = true;
    let frame = 0;
    const timers = new Set<number>();
    const wait = (ms: number) =>
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          if (alive) resolve();
        }, ms);
        timers.add(timer);
      });
    const glideTo = (to: 'start' | 'end' | number, ms: number) =>
      new Promise<void>((resolve) => {
        const { grid, from, to: end } = scrollGrid(to);
        if (!grid) return resolve();
        const start = performance.now();
        const tick = (now: number) => {
          if (!alive) return;
          const t = Math.min(1, (now - start) / ms);
          grid.scrollLeft = from + (end - from) * easeInOut(t);
          if (t < 1) frame = requestAnimationFrame(tick);
          else resolve();
        };
        frame = requestAnimationFrame(tick);
      });

    (async () => {
      for (;;) {
        act({ do: 'reset' });
        await wait(1000);
        // Glance down the rows and tick two.
        for (const row of [0, 1, 2, 1]) {
          act({ do: 'hover', row });
          await wait(row === 1 ? 420 : 260);
        }
        act({ do: 'tick', row: 1 });
        await wait(560);
        for (const row of [2, 3, 4]) {
          act({ do: 'hover', row });
          await wait(240);
        }
        act({ do: 'tick', row: 4 });
        await wait(700);
        act({ do: 'hover', row: null });
        // Sort by the last interaction, both ways, then back to the book's order.
        await glideTo(WIDTHS.categories - 20, 700);
        await wait(300);
        act({ do: 'head', column: 'last' });
        await wait(600);
        act({ do: 'sort', column: 'last' });
        await wait(1600);
        act({ do: 'sort', column: 'last' });
        await wait(1400);
        act({ do: 'sort', column: 'last' });
        await wait(500);
        act({ do: 'head', column: null });
        // Add a Competitors column and let the agent fill it.
        await glideTo('end', 800);
        await wait(400);
        act({ do: 'tool', tool: 'add' });
        await wait(500);
        for (const index of [1, 2, 0]) {
          act({ do: 'menu', index });
          await wait(index === 0 ? 500 : 300);
        }
        act({ do: 'add' });
        await wait(1500);
        act({ do: 'press', press: 'calculate' });
        await wait(160);
        act({ do: 'calculate' });
        await wait(2600);
        // Compact the columns so the tags fold, then put them back.
        await glideTo('start', 800);
        await wait(500);
        act({ do: 'tool', tool: 'options' });
        await wait(500);
        act({ do: 'menu', index: 1 });
        await wait(500);
        act({ do: 'compact' });
        await wait(2400);
        act({ do: 'tool', tool: 'options' });
        await wait(500);
        act({ do: 'menu', index: 2 });
        await wait(500);
        act({ do: 'widths' });
        await wait(900);
        act({ do: 'tool', tool: 'options' });
        await wait(500);
        act({ do: 'menu', index: 3 });
        await wait(450);
        act({ do: 'clear' });
        await wait(700);
        // Hide the added column again.
        await glideTo('end', 800);
        act({ do: 'panel' });
        await wait(1200);
        act({ do: 'hide' });
        await wait(400);
        await glideTo('start', 800);
        await wait(1600);
      }
    })();

    return () => {
      alive = false;
      cancelAnimationFrame(frame);
      timers.forEach((timer) => window.clearTimeout(timer));
      setDemoRow(null);
      setDemoHead(null);
      setDemoTool(null);
      setDemoMenu(null);
      setDemoPress(null);
    };
  }, [demo]);

  const allState = selected.size === 0 ? 'none' : selected.size === rows.length ? 'all' : 'some';
  const panelKey = open?.kind === 'panel' ? open.key : null;
  const inputColumns = (
    ['company', ...columns.filter((key) => key !== 'competitors')] as ColumnId[]
  )
    .filter((id) => id !== panelKey)
    .map((id) => ({ id, label: NAMES[id] }));

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={() => {
        take();
        setHeld(false);
      }}
      onKeyDownCapture={take}
      onWheel={take}
      onPointerMove={(event) => {
        // The reader's own pointer in the stage takes over from the demo.
        if (event.pointerType !== 'touch') take();
      }}
      onKeyDown={(event) => {
        if (event.key === 'Escape' && open) {
          event.preventDefault();
          closePopover(true);
        }
      }}
    >
      <div className={styles.shell} data-part="card">
        <div className={styles.measure} ref={measurer} aria-hidden="true">
          {categories.map((tag) => (
            <span key={tag} data-measure={tag} className={styles.tag}>
              {tag}
            </span>
          ))}
          {Array.from({ length: 9 }, (_, i) => (
            <span key={i} data-measure={`+${i + 1}`} className={styles.more}>
              +{i + 1}
            </span>
          ))}
        </div>
        <div
          ref={scroller}
          className={styles.scroll}
          tabIndex={0}
          role="region"
          aria-label={`${label} table. Scroll horizontally and vertically to view all columns and records.`}
        >
          <table className={styles.table} style={{ width: tableWidth }}>
            <caption className={styles.hidden}>{label}</caption>
            <colgroup>
              <col style={{ width: widthOf('company') }} />
              {columns.map((key) => (
                <col key={key} style={{ width: widthOf(key) }} />
              ))}
              <col style={{ width: TAIL }} />
            </colgroup>
            <thead>
              <tr>
                <th scope="col" className={`${styles.th} ${styles.sticky}`}>
                  <div className={styles.companyHead}>
                    <label className={styles.check} title="Select all companies">
                      <input
                        type="checkbox"
                        aria-label="Select all companies"
                        checked={allState === 'all'}
                        ref={(input) => {
                          if (input) input.indeterminate = allState === 'some';
                        }}
                        onChange={toggleAll}
                      />
                      <span
                        className={styles.box}
                        data-part="select-all"
                        data-on={allState !== 'none'}
                        aria-hidden="true"
                      >
                        {allState === 'some' ? (
                          <span className={styles.dash} />
                        ) : (
                          <Icon name="check" size={12} stroke={2} />
                        )}
                      </span>
                    </label>
                    <span>Company</span>
                  </div>
                  {resizeHandle('company')}
                </th>
                {columns.map((key) => {
                  const sortable = key === 'last' || key === 'strength';
                  const direction = sort?.key === key ? sort.direction : undefined;
                  return (
                    <th
                      key={key}
                      scope="col"
                      className={styles.th}
                      data-column={key}
                      data-selected={panelKey === key}
                      data-hover={demoHead === key}
                      aria-sort={sortable ? (direction ?? 'none') : undefined}
                    >
                      <div className={styles.head}>
                        <button
                          type="button"
                          className={styles.headButton}
                          data-part="column-head"
                          aria-haspopup="dialog"
                          aria-expanded={panelKey === key}
                          onClick={(event) =>
                            toggle(
                              { kind: 'panel', key },
                              event.currentTarget.closest('th')!,
                              event.detail === 0,
                            )
                          }
                        >
                          <span className={styles.headIcon}>
                            <Icon name={typeIcon(config[key].type)} />
                          </span>
                          <span className={styles.headLabel}>{NAMES[key]}</span>
                        </button>
                        {sortable && (
                          <button
                            type="button"
                            className={styles.sort}
                            data-part="sort"
                            data-dir={direction}
                            aria-label={`Sort by ${NAMES[key]}${direction ? `, now ${sortWords(sort)}` : ''}`}
                            onClick={() => sortBy(key)}
                          >
                            <Icon name="down" size={12} />
                          </button>
                        )}
                      </div>
                      {resizeHandle(key)}
                    </th>
                  );
                })}
                <th scope="col" className={styles.th}>
                  <div className={styles.tools} data-part="tools">
                    <button
                      type="button"
                      className={styles.tool}
                      data-tool="add"
                      data-demo={demoTool === 'add'}
                      aria-label="New property"
                      aria-haspopup="menu"
                      aria-expanded={open?.kind === 'add'}
                      onClick={(event) =>
                        toggle({ kind: 'add' }, event.currentTarget, event.detail === 0)
                      }
                    >
                      <Icon name="plus" stroke={2} />
                    </button>
                    <button
                      type="button"
                      className={styles.tool}
                      data-tool="options"
                      data-demo={demoTool === 'options'}
                      aria-label="Table options"
                      aria-haspopup="menu"
                      aria-expanded={open?.kind === 'options'}
                      onClick={(event) =>
                        toggle({ kind: 'options' }, event.currentTarget, event.detail === 0)
                      }
                    >
                      <Icon name="dots" />
                    </button>
                  </div>
                </th>
              </tr>
            </thead>
            <tbody>
              {shown.map((row, order) => {
                const ticked = selected.has(row.key);
                const title = row.city ? `${row.name} — ${row.city}` : row.name;
                return (
                  <tr
                    key={row.key}
                    className={styles.row}
                    data-selected={ticked}
                    data-hover={demoRow === row.key}
                  >
                    <th scope="row" className={styles.sticky}>
                      <div className={styles.company} data-part="company">
                        <span className={styles.slot}>
                          <span className={styles.rownum} aria-hidden="true">
                            {order + 1}
                          </span>
                          <label className={styles.check} title={`Select ${title}`}>
                            <input
                              type="checkbox"
                              aria-label={`Select ${title}`}
                              checked={ticked}
                              onChange={() => toggleRow(row.key)}
                            />
                            <span
                              className={styles.box}
                              data-part="row-check"
                              data-on={ticked}
                              aria-hidden="true"
                            >
                              <Icon name="check" size={12} stroke={2} />
                            </span>
                          </label>
                        </span>
                        <span className={styles.mark} aria-hidden="true">
                          {row.name.charAt(0).toUpperCase()}
                        </span>
                        {row.site ? (
                          <a
                            className={styles.name}
                            href={`https://${row.site}`}
                            title={title}
                            target="_blank"
                            rel="noreferrer"
                          >
                            {title}
                          </a>
                        ) : (
                          <span className={styles.name} title={title}>
                            {title}
                          </span>
                        )}
                      </div>
                    </th>
                    {columns.map((key) => (
                      <td key={key} data-selected={panelKey === key}>
                        {cell(key, row, order)}
                      </td>
                    ))}
                    <td />
                  </tr>
                );
              })}
            </tbody>
            <tfoot>
              <tr data-part="footer">
                <td className={styles.sticky}>
                  <span className={styles.foot} data-part="count">
                    <span className={styles.footNum}>{rows.length}</span>count
                  </span>
                </td>
                {columns.map((key) => (
                  <td key={key} data-selected={panelKey === key}>
                    {footer(key)}
                  </td>
                ))}
                <td />
              </tr>
            </tfoot>
          </table>
        </div>
      </div>

      {open && place && (
        <div
          ref={popover}
          className={styles.popover}
          data-part="popover"
          data-kind={open.kind === 'panel' ? 'panel' : 'menu'}
          role={open.kind === 'panel' ? 'dialog' : undefined}
          aria-label={open.kind === 'panel' ? `${NAMES[open.key]} settings` : undefined}
          style={
            {
              left: place.x,
              top: place.y,
              width: Math.min(
                open.kind === 'panel' ? 320 : open.kind === 'add' ? 260 : 220,
                place.rootWidth - 16,
              ),
              transformOrigin: place.origin,
              '--max-h': `${Math.max(160, place.maxHeight)}px`,
            } as CSSProperties
          }
        >
          {open.kind === 'panel' ? (
            <ColumnPanel
              key={open.key}
              name={NAMES[open.key]}
              settings={config[open.key]}
              columns={inputColumns}
              room={{ left: place.x - 8, right: place.rootWidth - place.x - 320 - 8 }}
              canHide={open.key === 'competitors'}
              pinned={pinned === open.key}
              demoPress={demoPress}
              autoFocus={focusIn}
              onChange={(patch) =>
                setConfig((now) => ({ ...now, [open.key]: { ...now[open.key], ...patch } }))
              }
              onCalculate={() => calculate(open.key, true)}
              onPin={() => {
                const key = open.key;
                setPinned(pinned === key ? null : key);
                announce(pinned === key ? `${NAMES[key]} unpinned` : `${NAMES[key]} pinned first`);
              }}
              onHide={() => {
                hide(open.key);
                announce(`${NAMES[open.key]} column hidden`);
                root.current?.querySelector<HTMLElement>('[data-tool="add"]')?.focus();
              }}
            />
          ) : (
            <GlideMenu
              label={open.kind === 'add' ? 'New property' : 'Table options'}
              items={open.kind === 'add' ? addItems : optionItems}
              demoIndex={demoMenu}
              autoFocus={!demo && !held}
            />
          )}
        </div>
      )}

      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

type DemoStep =
  | { do: 'reset' }
  | { do: 'hover'; row: number | null }
  | { do: 'tick'; row: number }
  | { do: 'head'; column: ColumnId | null }
  | { do: 'sort'; column: SortKey }
  | { do: 'tool'; tool: 'add' | 'options' | null }
  | { do: 'menu'; index: number | null }
  | { do: 'add' }
  | { do: 'press'; press: 'calculate' | null }
  | { do: 'calculate' }
  | { do: 'compact' }
  | { do: 'widths' }
  | { do: 'clear' }
  | { do: 'panel' }
  | { do: 'hide' };

function sortWords(sort: RecordsSort) {
  if (!sort) return '';
  const first = sort.direction === 'descending';
  return sort.key === 'last'
    ? first
      ? 'most recent first'
      : 'oldest first'
    : first
      ? 'strongest first'
      : 'weakest first';
}

/** How much larger than its own px an element is drawn (a scaled gallery card, say). */
function scaleOf(element: HTMLElement) {
  return element.getBoundingClientRect().width / element.offsetWidth || 1;
}

function tagStyle(color: string | undefined) {
  return { '--tag': color ?? 'oklch(0.7 0.02 260)' } as CSSProperties;
}

function easeInOut(t: number) {
  return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
}
```

### records-table/column-panel.tsx

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

'use client';

import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { GlideMenu, type MenuItem } from './glide-menu';
import { Icon, type IconName } from './icons';
import styles from './records-table.module.css';

/*
 * A column's settings: how an agent fills it. Its type, the tool or model it uses, whether it checks
 * its answers against sources, which other columns it reads, and the prompt, where those columns
 * stand as small chips. Go calculate fills the column again; Pin moves it next to the company; a
 * column added from the table can be hidden again.
 *
 * - Type, Tool and Inputs open small menus beside the panel (or over it when there is no room),
 *   which pop in from their corner in 140 ms.
 * - The prompt is a contentEditable line: typed text and the chips of the columns it mentions.
 *   What the reader types is read back from it as they type; it is drawn afresh only when another
 *   column's settings come in.
 */

export const PROPERTY_TYPES: { type: PropertyType; icon: IconName }[] = [
  { type: 'Text', icon: 'text' },
  { type: 'File', icon: 'file' },
  { type: 'Collection', icon: 'collection' },
  { type: 'Single select', icon: 'single' },
  { type: 'Multi select', icon: 'multi' },
  { type: 'URL', icon: 'url' },
  { type: 'Reference', icon: 'reference' },
  { type: 'JSON', icon: 'json' },
  { type: 'File splitter', icon: 'splitter' },
];

export type PropertyType =
  | 'Text'
  | 'File'
  | 'Collection'
  | 'Single select'
  | 'Multi select'
  | 'URL'
  | 'Reference'
  | 'JSON'
  | 'File splitter'
  | 'Date';

/** The icon of a column of this type, in its header and in the panel. */
export const typeIcon = (type: PropertyType): IconName =>
  type === 'Date' ? 'date' : (PROPERTY_TYPES.find((entry) => entry.type === type)?.icon ?? 'text');

export const TOOLS = ['Web search', 'User input'] as const;
export const MODELS = ['Sprinkles 5', 'Sprinkles 4.2', 'Sprinkles Mini'] as const;
export type Tool = (typeof TOOLS)[number] | (typeof MODELS)[number];

/** A prompt: words, and the columns it mentions (by id). */
export type PromptPart = string | { mention: string };

export type ColumnSettings = {
  type: PropertyType;
  tool: Tool;
  grounding: boolean;
  /** The columns whose values it reads, by id. */
  inputs: string[];
  prompt: PromptPart[];
  required: boolean;
  allowEmpty: boolean;
  confidence: boolean;
};

type Sub = 'type' | 'tool' | 'inputs' | null;

export function ColumnPanel({
  name,
  settings,
  columns,
  room,
  canHide,
  pinned,
  demoPress = null,
  autoFocus,
  onChange,
  onCalculate,
  onPin,
  onHide,
}: {
  name: string;
  settings: ColumnSettings;
  /** Every column it could read: id and name. */
  columns: { id: string; label: string }[];
  /** Px free on each side of the panel within the component, for the small menus. */
  room: { left: number; right: number };
  canHide: boolean;
  pinned: boolean;
  /** The control the demo presses, drawn pressed. */
  demoPress?: 'calculate' | null;
  autoFocus: boolean;
  onChange: (patch: Partial<ColumnSettings>) => void;
  onCalculate: () => void;
  onPin: () => void;
  onHide: () => void;
}) {
  const [sub, setSub] = useState<Sub>(null);
  const [subTop, setSubTop] = useState(0);
  const [tip, setTip] = useState(false);
  const [more, setMore] = useState(false);
  const body = useRef<HTMLDivElement>(null);
  const prompt = useRef<HTMLDivElement>(null);
  const tipId = useId();
  const labelOf = (id: string) => columns.find((column) => column.id === id)?.label ?? id;

  // The prompt is drawn from the settings when a column's panel opens (the panel is keyed by
  // column), then left to the reader's typing.
  useEffect(() => {
    const box = prompt.current;
    if (!box) return;
    box.replaceChildren(
      ...settings.prompt.map((part) => {
        if (typeof part === 'string') return document.createTextNode(part);
        const chip = document.createElement('span');
        chip.contentEditable = 'false';
        chip.dataset.mention = part.mention;
        chip.className = styles.promptChip;
        chip.textContent = labelOf(part.mention);
        return chip;
      }),
    );
    box.dataset.empty = String(!box.textContent);
    // Drawn once per panel; the settings it reads are what the panel opened with.
  }, []);

  useEffect(() => {
    if (autoFocus) body.current?.querySelector<HTMLElement>('button')?.focus();
  }, [autoFocus]);

  const readPrompt = () => {
    const box = prompt.current;
    if (!box) return;
    const parts: PromptPart[] = [];
    box.childNodes.forEach((node) => {
      if (node instanceof HTMLElement && node.dataset.mention)
        parts.push({ mention: node.dataset.mention });
      else if (node.textContent) parts.push(node.textContent);
    });
    box.dataset.empty = String(!box.textContent);
    onChange({ prompt: parts });
  };

  const open = (which: Exclude<Sub, null>, row: HTMLElement) => {
    if (sub === which) return setSub(null);
    setSubTop(row.offsetTop - (body.current?.scrollTop ?? 0));
    setSub(which);
  };

  // Beside the panel when there is room (as wide as the panel's own gap), else over its right side.
  const subWidth = sub === 'inputs' ? 220 : 210;
  const subStyle: CSSProperties =
    room.right >= subWidth + 8
      ? { left: 'calc(100% + 8px)', top: subTop, transformOrigin: 'left top' }
      : room.left >= subWidth + 8
        ? { right: 'calc(100% + 8px)', top: subTop, transformOrigin: 'right top' }
        : { right: 12, top: subTop + 34, transformOrigin: 'right top' };

  const typeItems: MenuItem[] = PROPERTY_TYPES.map(({ type, icon }) => ({
    key: type,
    label: type,
    icon: <Icon name={icon} size={14} />,
    role: 'menuitemradio',
    checked: settings.type === type,
    onSelect: () => {
      onChange({ type });
      setSub(null);
    },
  }));
  const toolItems: MenuItem[] = [...MODELS, ...TOOLS].map((tool, index) => ({
    key: tool,
    label: tool,
    icon: <Icon name={toolIcon(tool)} size={14} />,
    role: 'menuitemradio',
    checked: settings.tool === tool,
    separator: index === MODELS.length,
    onSelect: () => {
      onChange({ tool });
      setSub(null);
    },
  }));
  const inputItems: MenuItem[] = columns.map((column) => ({
    key: column.id,
    label: column.label,
    role: 'menuitemcheckbox',
    checked: settings.inputs.includes(column.id),
    onSelect: () =>
      onChange({
        inputs: settings.inputs.includes(column.id)
          ? settings.inputs.filter((id) => id !== column.id)
          : columns
              .map((c) => c.id)
              .filter((id) => id === column.id || settings.inputs.includes(id)),
      }),
  }));

  return (
    <>
      <div
        ref={body}
        className={styles.panelBody}
        data-part="panel"
        onKeyDown={(event) => {
          if (event.key === 'Escape' && (sub || tip)) {
            event.stopPropagation();
            setSub(null);
            setTip(false);
          }
        }}
      >
        <div className={styles.panelTitle}>{name}</div>
        <div className={styles.field}>
          <span className={styles.fieldLabel}>Type</span>
          <button
            type="button"
            className={styles.fieldValue}
            aria-haspopup="menu"
            aria-expanded={sub === 'type'}
            aria-label={`Type: ${settings.type}`}
            onClick={(event) => open('type', event.currentTarget.parentElement!)}
          >
            <span className={styles.fieldIcon}>
              <Icon name={typeIcon(settings.type)} size={14} />
            </span>
            {settings.type}
            <Icon name="chevron" size={12} stroke={2.2} className={styles.fieldChevron} />
          </button>
        </div>
        <div className={styles.field}>
          <span className={styles.fieldLabel}>Tool</span>
          <button
            type="button"
            className={styles.fieldValue}
            aria-haspopup="menu"
            aria-expanded={sub === 'tool'}
            aria-label={`Tool: ${settings.tool}`}
            onClick={(event) => open('tool', event.currentTarget.parentElement!)}
          >
            <span className={styles.fieldIcon} data-model={isModel(settings.tool)}>
              <Icon name={toolIcon(settings.tool)} size={14} />
            </span>
            {settings.tool}
            <Icon name="chevron" size={12} stroke={2.2} className={styles.fieldChevron} />
          </button>
        </div>
        <div className={styles.field}>
          <span className={styles.fieldLabel} id={`${tipId}-g`}>
            Grounding
          </span>
          <span className={styles.fieldEnd}>
            <Switch
              label="Grounding"
              on={settings.grounding}
              onToggle={() => onChange({ grounding: !settings.grounding })}
            />
            <button
              type="button"
              className={styles.infoButton}
              aria-label="About grounding"
              aria-expanded={tip}
              aria-controls={tipId}
              onClick={() => setTip(!tip)}
            >
              <Icon name="info" size={13} />
            </button>
          </span>
          {tip && (
            <div id={tipId} role="status" className={styles.tip}>
              Grounding lets the model verify generated values against connected sources.
            </div>
          )}
        </div>
        <div className={styles.field}>
          <span className={styles.fieldLabel}>Inputs</span>
          <button
            type="button"
            className={`${styles.fieldValue} ${styles.fieldInputs}`}
            aria-haspopup="menu"
            aria-expanded={sub === 'inputs'}
            aria-label={`Inputs: ${settings.inputs.map(labelOf).join(', ') || 'none'}`}
            onClick={(event) => open('inputs', event.currentTarget.parentElement!)}
          >
            <span className={styles.inputChips}>
              {settings.inputs.length ? (
                settings.inputs.slice(0, 2).map((id) => (
                  <span key={id} className={styles.inputChip}>
                    {labelOf(id)}
                  </span>
                ))
              ) : (
                <span>Select inputs</span>
              )}
              {settings.inputs.length > 2 && (
                <span className={styles.inputMore}>+{settings.inputs.length - 2}</span>
              )}
            </span>
            <Icon name="chevron" size={12} stroke={2.2} className={styles.fieldChevron} />
          </button>
        </div>
        <div
          ref={prompt}
          className={styles.prompt}
          data-part="prompt"
          contentEditable
          suppressContentEditableWarning
          role="textbox"
          aria-multiline="true"
          aria-label={`${name} calculation prompt`}
          spellCheck
          data-placeholder="Set a prompt (press @ to mention an input)"
          onInput={readPrompt}
        />
        <button
          type="button"
          className={styles.calculate}
          data-part="calculate"
          data-pressed={demoPress === 'calculate'}
          onClick={onCalculate}
        >
          <Icon name="refresh" size={14} />
          Go calculate
        </button>
        <GlideRows>
          <button type="button" className={styles.panelRow} aria-pressed={pinned} onClick={onPin}>
            <span className={styles.panelRowIcon}>
              <Icon name="pin" />
            </span>
            {pinned ? 'Unpin' : 'Pin'}
          </button>
          <button
            type="button"
            className={styles.panelRow}
            aria-expanded={more}
            onClick={() => setMore(!more)}
          >
            <span className={styles.panelRowIcon}>
              <Icon name="gear" />
            </span>
            <span className={styles.panelRowGrow}>More settings</span>
            <Icon name="chevron" size={12} stroke={2.2} className={styles.moreChevron} />
          </button>
          {canHide && (
            <button type="button" className={styles.panelRow} onClick={onHide}>
              <span className={styles.panelRowIcon}>
                <Icon name="hide" />
              </span>
              Hide from view
            </button>
          )}
        </GlideRows>
        {more && (
          <div className={styles.behavior}>
            <div className={styles.behaviorHeading}>Behavior</div>
            {(
              [
                ['required', 'Required value'],
                ['allowEmpty', 'Allow empty results'],
                ['confidence', 'Show confidence'],
              ] as const
            ).map(([key, label]) => (
              <div key={key} className={styles.field}>
                <span className={styles.fieldLabel}>{label}</span>
                <Switch
                  label={label}
                  on={settings[key]}
                  onToggle={() => onChange({ [key]: !settings[key] } as Partial<ColumnSettings>)}
                />
              </div>
            ))}
          </div>
        )}
      </div>
      {sub && (
        <GlideMenu
          key={sub}
          className={styles.subMenu}
          style={{ ...subStyle, width: subWidth }}
          size="small"
          autoFocus
          label={sub === 'type' ? 'Property type' : sub === 'tool' ? 'Tool' : 'Use values from'}
          items={sub === 'type' ? typeItems : sub === 'tool' ? toolItems : inputItems}
        />
      )}
    </>
  );
}

const isModel = (tool: Tool) => (MODELS as readonly string[]).includes(tool);
const toolIcon = (tool: Tool): IconName =>
  isModel(tool) ? 'sparkle' : tool === 'Web search' ? 'globe' : 'cursor';

function Switch({ label, on, onToggle }: { label: string; on: boolean; onToggle: () => void }) {
  return (
    <button
      type="button"
      role="switch"
      aria-checked={on}
      aria-label={label}
      className={styles.switch}
      onClick={onToggle}
    >
      <span className={styles.knob} />
    </button>
  );
}

/** The panel's last rows, under one gliding highlight. */
function GlideRows({ children }: { children: ReactNode }) {
  const [mark, setMark] = useState<{ top: number; height: number } | null>(null);
  const list = useRef<HTMLDivElement>(null);
  const glide = (target: EventTarget) => {
    const row = (target as HTMLElement).closest('button');
    setMark(row ? { top: row.offsetTop, height: row.offsetHeight } : null);
  };
  return (
    <div
      ref={list}
      className={styles.panelRows}
      onPointerOver={(event) => glide(event.target)}
      onFocus={(event) => glide(event.target)}
      onPointerLeave={() => {
        if (!list.current?.contains(document.activeElement)) setMark(null);
      }}
      onBlur={(event) => {
        if (!list.current?.contains(event.relatedTarget as Node | null)) setMark(null);
      }}
    >
      <span
        className={styles.menuMark}
        aria-hidden="true"
        style={mark ? { top: mark.top, height: mark.height, opacity: 1 } : undefined}
      />
      {children}
    </div>
  );
}
```

### records-table/glide-menu.tsx

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

'use client';

import {
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
} from 'react';
import { Icon } from './icons';
import styles from './records-table.module.css';

/*
 * A short menu under a small heading. A soft highlight sits under the row the pointer or the focus
 * is on and glides to the next by its top and height (220 ms), fading out when both leave. Up and
 * down arrows, Home and End move the focus between the rows; Escape is the opener's to handle.
 */

export type MenuItem = {
  key: string;
  label: string;
  icon?: ReactNode;
  /** A plain command, one of a set (a tick marks it) or a box of its own. */
  role?: 'menuitem' | 'menuitemradio' | 'menuitemcheckbox';
  checked?: boolean;
  /** A thin rule above the row. */
  separator?: boolean;
  onSelect: () => void;
};

export function GlideMenu({
  label,
  items,
  size = 'large',
  demoIndex = null,
  autoFocus = false,
  className = '',
  style,
}: {
  /** The heading, which also names the menu. */
  label: string;
  items: MenuItem[];
  /** Large: the table's own menus. Small: the lists that open beside the column settings. */
  size?: 'large' | 'small';
  /** The row the demo points at, if it is playing. */
  demoIndex?: number | null;
  /** Puts the focus on the first row (the menu was opened from the keyboard, or for a reader). */
  autoFocus?: boolean;
  className?: string;
  style?: CSSProperties;
}) {
  const list = useRef<HTMLDivElement>(null);
  const [mark, setMark] = useState<{ top: number; height: number } | null>(null);

  const glide = (row: HTMLElement | null) =>
    setMark(row ? { top: row.offsetTop, height: row.offsetHeight } : null);

  useEffect(() => {
    if (!autoFocus) return;
    list.current?.querySelector<HTMLElement>('[role^="menuitem"]')?.focus();
  }, [autoFocus]);

  useEffect(() => {
    if (demoIndex === null) return;
    const rows = list.current?.querySelectorAll<HTMLElement>('[role^="menuitem"]');
    glide(rows?.[demoIndex] ?? null);
    return () => setMark(null);
  }, [demoIndex]);

  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    const rows = [...(list.current?.querySelectorAll<HTMLElement>('[role^="menuitem"]') ?? [])];
    const at = rows.indexOf(document.activeElement as HTMLElement);
    const go = (index: number) => {
      event.preventDefault();
      rows[(index + rows.length) % rows.length]?.focus();
    };
    if (event.key === 'ArrowDown') go(at + 1);
    else if (event.key === 'ArrowUp') go(at < 0 ? rows.length - 1 : at - 1);
    else if (event.key === 'Home') go(0);
    else if (event.key === 'End') go(rows.length - 1);
  };

  return (
    <div
      role="menu"
      aria-label={label}
      className={`${styles.menu} ${className}`}
      data-part="menu"
      data-size={size}
      style={style}
      onKeyDown={onKeyDown}
    >
      <div className={styles.menuHeading} aria-hidden="true">
        {label}
      </div>
      <div
        ref={list}
        className={styles.menuList}
        onPointerLeave={() => {
          if (!list.current?.contains(document.activeElement)) glide(null);
        }}
        onBlur={(event) => {
          if (!list.current?.contains(event.relatedTarget as Node | null)) glide(null);
        }}
      >
        <span
          className={styles.menuMark}
          aria-hidden="true"
          style={mark ? { top: mark.top, height: mark.height, opacity: 1 } : undefined}
        />
        {items.map((item) => (
          <div key={item.key} className={styles.menuItemWrap}>
            {item.separator && <div className={styles.menuRule} role="separator" />}
            <button
              type="button"
              role={item.role ?? 'menuitem'}
              aria-checked={item.role && item.role !== 'menuitem' ? !!item.checked : undefined}
              tabIndex={-1}
              className={styles.menuItem}
              data-part="menu-item"
              onPointerEnter={(event) => glide(event.currentTarget)}
              onFocus={(event) => glide(event.currentTarget)}
              onClick={item.onSelect}
            >
              {item.role === 'menuitemcheckbox' ? (
                <span className={styles.menuBox} data-on={!!item.checked} aria-hidden="true">
                  <Icon name="check" size={11} stroke={2.4} />
                </span>
              ) : (
                item.icon && (
                  <span className={styles.menuIcon} aria-hidden="true">
                    {item.icon}
                  </span>
                )
              )}
              <span className={styles.menuLabel}>{item.label}</span>
              {item.role === 'menuitemradio' && (
                <span className={styles.menuTick} data-on={!!item.checked} aria-hidden="true">
                  <Icon name="check" size={13} stroke={2} />
                </span>
              )}
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}
```

### records-table/icons.tsx

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

import type { ReactNode } from 'react';

/*
 * The table's line icons, drawn on a 24-unit grid with round ends, in the colour of the text around
 * them. Every one is decorative: the control it sits in carries the words.
 */

export type IconName =
  | 'text'
  | 'file'
  | 'collection'
  | 'single'
  | 'multi'
  | 'url'
  | 'reference'
  | 'json'
  | 'splitter'
  | 'date'
  | 'globe'
  | 'cursor'
  | 'sparkle'
  | 'plus'
  | 'dots'
  | 'compact'
  | 'reset'
  | 'close'
  | 'refresh'
  | 'pin'
  | 'gear'
  | 'hide'
  | 'info'
  | 'chevron'
  | 'down'
  | 'check'
  | 'external';

const PATHS: Record<IconName, ReactNode> = {
  text: <path d="M4.5 6.5h15M4.5 12h10.5M4.5 17.5h6.5" />,
  file: (
    <>
      <path d="M13.5 3H7a2.5 2.5 0 0 0-2.5 2.5v13A2.5 2.5 0 0 0 7 21h10a2.5 2.5 0 0 0 2.5-2.5V9z" />
      <path d="M13.5 3.2V9h5.8" />
    </>
  ),
  collection: (
    <>
      <ellipse cx="12" cy="5.5" rx="7.5" ry="2.8" />
      <path d="M4.5 5.5v13c0 1.55 3.36 2.8 7.5 2.8s7.5-1.25 7.5-2.8v-13" />
      <path d="M4.5 12c0 1.55 3.36 2.8 7.5 2.8s7.5-1.25 7.5-2.8" />
    </>
  ),
  single: (
    <>
      <circle cx="12" cy="12" r="8.75" />
      <path d="M8.6 12.2l2.3 2.3 4.5-4.8" />
    </>
  ),
  multi: (
    <>
      <path d="M11.5 6.5h8.5M11.5 12h8.5M11.5 17.5h8.5" />
      <path d="M4 6.6l1.4 1.4L8 5.4M4 12.1l1.4 1.4L8 10.9M4 17.6l1.4 1.4L8 16.4" />
    </>
  ),
  url: (
    <>
      <path d="M10.2 13.8a4.6 4.6 0 0 0 6.6 0l2.3-2.3a4.7 4.7 0 0 0-6.6-6.6l-1.2 1.2" />
      <path d="M13.8 10.2a4.6 4.6 0 0 0-6.6 0l-2.3 2.3a4.7 4.7 0 0 0 6.6 6.6l1.2-1.2" />
    </>
  ),
  reference: <path d="M7 17L17 7M8.5 7H17v8.5" />,
  json: (
    <>
      <path d="M8.5 4.5C6.6 4.5 6.5 6 6.5 7.3S7 10 4.5 10.2C7 10.4 6.5 12 6.5 13.3s.1 3.2 2 3.2" />
      <path d="M15.5 4.5c1.9 0 2 1.5 2 2.8s-.5 2.7 2 2.9c-2.5.2-2 1.8-2 3.1s-.1 3.2-2 3.2" />
    </>
  ),
  splitter: (
    <>
      <rect x="8.5" y="8.5" width="11.5" height="11.5" rx="2.2" />
      <path d="M15.5 8.5V6.2A2.2 2.2 0 0 0 13.3 4H6.2A2.2 2.2 0 0 0 4 6.2v7.1a2.2 2.2 0 0 0 2.2 2.2h2.3" />
    </>
  ),
  date: (
    <>
      <rect x="3.5" y="5.5" width="17" height="15" rx="2.5" />
      <path d="M8 3.5v4M16 3.5v4M3.5 10.5h17" />
    </>
  ),
  globe: (
    <>
      <circle cx="12" cy="12" r="8.75" />
      <path d="M3.5 12h17M12 3.3c2.3 2.4 3.4 5.3 3.4 8.7s-1.1 6.3-3.4 8.7c-2.3-2.4-3.4-5.3-3.4-8.7S9.7 5.7 12 3.3z" />
    </>
  ),
  cursor: <path d="M9 4.5h6M9 19.5h6M12 4.5v15" />,
  sparkle: (
    <path d="M12 3.5c.6 4.6 2.6 6.9 7.5 8.5-4.9 1.6-6.9 3.9-7.5 8.5-.6-4.6-2.6-6.9-7.5-8.5 4.9-1.6 6.9-3.9 7.5-8.5z" />
  ),
  plus: <path d="M12 5.5v13M5.5 12h13" />,
  dots: (
    <g fill="currentColor" stroke="none">
      <circle cx="5.5" cy="12" r="1.6" />
      <circle cx="12" cy="12" r="1.6" />
      <circle cx="18.5" cy="12" r="1.6" />
    </g>
  ),
  compact: <path d="M4.5 8.5h15M7.5 5.2L4.3 8.5l3.2 3.3M16.5 12.2l3.2 3.3-3.2 3.3M4.5 15.5h15" />,
  reset: <path d="M4 12.5a8 8 0 1 0 2.6-6.3M4 4.5V10h5.5" />,
  close: <path d="M6 6l12 12M18 6L6 18" />,
  refresh: <path d="M19.5 12a7.5 7.5 0 1 1-2.4-5.5M19.5 4.5V9H15" />,
  pin: <path d="M9 3.5h6M10 3.5v6l-3 4h10l-3-4v-6M12 13.5v7" />,
  gear: (
    <>
      <circle cx="12" cy="12" r="2.6" />
      <path d="M12 3.5v2.2M12 18.3v2.2M3.5 12h2.2M18.3 12h2.2M6 6l1.6 1.6M16.4 16.4L18 18M6 18l1.6-1.6M16.4 7.6L18 6" />
    </>
  ),
  hide: (
    <>
      <path d="M10 5.7a9.6 9.6 0 0 1 2-.2c5 0 8 4.3 9 6.5a12 12 0 0 1-2.3 3.3M6.4 7.1C4.6 8.4 3.6 10.3 3 12c1 2.2 4 6.5 9 6.5 1.7 0 3.1-.5 4.3-1.1" />
      <path d="M9.9 9.9a3 3 0 0 0 4.2 4.2M3.5 3.5l17 17" />
    </>
  ),
  info: (
    <>
      <circle cx="12" cy="12" r="8.75" />
      <path d="M12 11v5.5M12 7.6v.1" />
    </>
  ),
  chevron: <path d="M9.5 6l6 6-6 6" />,
  down: <path d="M12 5v14M5.5 12.5L12 19l6.5-6.5" />,
  check: <path d="M5.5 12.5l4 4 9-9.5" />,
  external: <path d="M13.5 5.5h5v5M18.5 5.5l-8 8" />,
};

export function Icon({
  name,
  size = 15,
  stroke = 1.8,
  className,
}: {
  name: IconName;
  size?: number;
  stroke?: number;
  className?: string;
}) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={stroke}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
      className={className}
    >
      {PATHS[name]}
    </svg>
  );
}
```

### records-table/data.ts

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

/*
 * The sample book: sixty ice-cream makers, sellers and cafés (invented, with example.com sites), the
 * colour of each category, and what the demo's "Competitors" column finds for them.
 */

/** How strong a relationship is, from the last interaction. */
export type Strength = 'very-strong' | 'strong' | 'weak' | 'very-weak' | 'none';

export type CompanyRecord = {
  /** Names the row in selections; the name is used when absent. */
  id?: string;
  name: string;
  /** Shown after the name: “Alpine Churn — Zürich”. */
  city?: string;
  /** The company's website, without the scheme: “alpine-churn.example.com”. */
  site?: string;
  categories: string[];
  /** When they last heard from you: days ago (for sorting) and how it reads. */
  last?: [days: number, label: string];
  strength: Strength;
};

/** Each category's colour, as one base the tags mix with the table to fill and edge them. */
export const CATEGORY_COLORS: Record<string, string> = {
  B2B: 'oklch(0.76 0.13 70)',
  B2C: 'oklch(0.77 0.16 122)',
  Cafe: 'oklch(0.64 0.19 27)',
  Catering: 'oklch(0.66 0.21 323)',
  'Dairy-free': 'oklch(0.72 0.1 221)',
  Gelato: 'oklch(0.62 0.18 293)',
  Imports: 'oklch(0.71 0.16 48)',
  Local: 'oklch(0.7 0.13 162)',
  Seasonal: 'oklch(0.8 0.15 101)',
  Sorbet: 'oklch(0.67 0.19 3)',
  Vegan: 'oklch(0.77 0.16 122)',
  Wholesale: 'oklch(0.76 0.13 70)',
};

/** What the demo's Competitors column finds, row after row. */
export const COMPETITORS = [
  'Frost & Ladle, Cirrus Scoops',
  'Polar Pint Co., Golden Churn',
  'Meltwater Creamery, Velvet Freeze',
  'Cirrus Scoops, North Cone Collective',
  'Golden Churn, Sundae Syndicate',
  'Velvet Freeze, Frost & Ladle',
  'North Cone Collective, Polar Pint Co.',
  'Sundae Syndicate, Meltwater Creamery',
];

export const COMPANIES: CompanyRecord[] = [
  {
    name: 'Alpine Churn',
    city: 'Zürich',
    site: 'alpine-churn.example.com',
    categories: ['B2B', 'Gelato', 'Wholesale'],
    last: [4, '4 days ago'],
    strength: 'very-strong',
  },
  {
    name: 'Amber Scoop',
    city: 'Prague',
    categories: ['Gelato', 'B2B'],
    last: [450, 'over 1 year ago'],
    strength: 'none',
  },
  {
    name: 'Andes Snow Creamery',
    city: 'Quito',
    categories: ['Gelato', 'Catering'],
    last: [700, 'almost 2 years ago'],
    strength: 'very-weak',
  },
  {
    name: 'Apricot Atlas',
    city: 'Algiers',
    site: 'apricot-atlas.example.com',
    categories: ['Sorbet', 'Imports'],
    last: [333, '11 months ago'],
    strength: 'very-weak',
  },
  {
    name: 'Aurora Scoops',
    city: 'Reykjavík',
    site: 'aurora-scoops.example.com',
    categories: ['Gelato', 'Seasonal'],
    last: [9, '9 days ago'],
    strength: 'very-strong',
  },
  {
    name: 'Baltic Berry',
    city: 'Tallinn',
    site: 'baltic-berry.example.com',
    categories: ['Dairy-free', 'Seasonal', 'B2C'],
    last: [35, '5 weeks ago'],
    strength: 'weak',
  },
  {
    name: 'Black Sesame Social',
    city: 'Bandung',
    site: 'black-sesame.example.com',
    categories: ['Vegan', 'Cafe', 'B2C'],
    last: [9, '9 days ago'],
    strength: 'very-strong',
  },
  {
    name: 'Blue Fig Gelato',
    city: 'Florence',
    site: 'blue-fig.example.com',
    categories: ['Gelato', 'Cafe'],
    last: [450, 'over 1 year ago'],
    strength: 'very-weak',
  },
  {
    name: 'Boreal Batch',
    city: 'Yellowknife',
    site: 'boreal-batch.example.com',
    categories: ['Dairy-free', 'Local', 'Seasonal'],
    last: [8, '8 days ago'],
    strength: 'very-strong',
  },
  {
    name: 'Cacao Norte',
    city: 'Oaxaca',
    site: 'cacao-norte.example.com',
    categories: ['B2B', 'Local', 'Wholesale'],
    last: [735, 'about 2 years ago'],
    strength: 'none',
  },
  {
    name: 'Cape Vanilla Co.',
    city: 'Cape Town',
    site: 'cape-vanilla.example.com',
    categories: ['Wholesale', 'Imports'],
    last: [450, 'over 1 year ago'],
    strength: 'very-weak',
  },
  {
    name: 'Cedar Spoon',
    city: 'Beirut',
    site: 'cedar-spoon.example.com',
    categories: ['Cafe', 'Local', 'Seasonal'],
    last: [6, '6 days ago'],
    strength: 'very-strong',
  },
  {
    name: 'Cloudberry Cone',
    city: 'Helsinki',
    site: 'cloudberry-cone.example.com',
    categories: ['Dairy-free', 'Seasonal'],
    strength: 'none',
  },
  {
    name: 'Coconut Commons',
    city: 'Manila',
    site: 'coconut-commons.example.com',
    categories: ['Vegan', 'B2C', 'Cafe'],
    last: [24, '24 days ago'],
    strength: 'weak',
  },
  {
    name: 'Copper Cone',
    city: 'Melbourne',
    site: 'copper-cone.example.com',
    categories: ['Gelato', 'Cafe', 'B2C'],
    last: [32, 'about 1 month ago'],
    strength: 'weak',
  },
  {
    name: 'Coral Coast Sorbet',
    city: 'Honolulu',
    site: 'coral-coast.example.com',
    categories: ['Sorbet', 'Local'],
    last: [9, '9 days ago'],
    strength: 'very-strong',
  },
  {
    name: 'Crimson Clover',
    city: 'Brussels',
    site: 'crimson-clover.example.com',
    categories: ['Gelato', 'Wholesale', 'Catering'],
    last: [63, '2 months ago'],
    strength: 'weak',
  },
  {
    name: 'Delta Dairy Works',
    city: 'New Orleans',
    site: 'delta-dairy.example.com',
    categories: ['B2B', 'Wholesale', 'Local'],
    last: [2, '2 days ago'],
    strength: 'very-strong',
  },
  {
    name: 'Dolomite Dairy',
    city: 'Bolzano',
    site: 'dolomite-dairy.example.com',
    categories: ['Gelato', 'Wholesale'],
    last: [3, '3 days ago'],
    strength: 'very-strong',
  },
  {
    name: 'Dragonfruit Dock',
    city: 'Shenzhen',
    categories: ['Sorbet', 'B2B', 'Wholesale'],
    strength: 'none',
  },
  {
    name: 'Ember Cone Company',
    city: 'Seoul',
    site: 'ember-cone.example.com',
    categories: ['B2C', 'Vegan'],
    last: [15, '15 days ago'],
    strength: 'weak',
  },
  {
    name: 'Equator Cream',
    city: 'Kampala',
    site: 'equator-cream.example.com',
    categories: ['B2B', 'Catering', 'Local'],
    last: [303, '10 months ago'],
    strength: 'very-weak',
  },
  {
    name: 'Fjord Fizz Ice',
    city: 'Oslo',
    categories: ['Dairy-free', 'Seasonal'],
    strength: 'none',
  },
  {
    name: 'Glacier Grove',
    city: 'Anchorage',
    site: 'glacier-grove.example.com',
    categories: ['Seasonal', 'Local', 'Catering'],
    last: [49, '7 weeks ago'],
    strength: 'weak',
  },
  {
    name: 'Hibiscus House',
    city: 'Accra',
    site: 'hibiscus-house.example.com',
    categories: ['Sorbet', 'Cafe'],
    last: [42, '6 weeks ago'],
    strength: 'weak',
  },
  {
    name: 'Juniper & Cream',
    city: 'Vancouver',
    categories: ['Dairy-free', 'Catering'],
    strength: 'none',
  },
  {
    name: 'Kumo Creamery',
    city: 'Tokyo',
    site: 'kumo-creamery.example.com',
    categories: ['B2C', 'Cafe', 'Vegan'],
    last: [21, '3 weeks ago'],
    strength: 'very-strong',
  },
  {
    name: 'Lagoon Ladle',
    city: 'Venice',
    site: 'lagoon-ladle.example.com',
    categories: ['Gelato', 'Seasonal', 'Catering'],
    last: [7, '7 days ago'],
    strength: 'very-strong',
  },
  {
    name: 'Lotus Leaf Scoops',
    city: 'Hanoi',
    categories: ['Vegan', 'Cafe'],
    last: [15, '15 days ago'],
    strength: 'weak',
  },
  {
    name: 'Lumen Soft Serve',
    city: 'Copenhagen',
    site: 'lumen-soft-serve.example.com',
    categories: ['Dairy-free', 'Cafe'],
    last: [243, '8 months ago'],
    strength: 'weak',
  },
  {
    name: 'Mango Moon Gelato',
    city: 'Nairobi',
    site: 'mango-moon.example.com',
    categories: ['Sorbet', 'Vegan'],
    last: [700, 'almost 2 years ago'],
    strength: 'very-weak',
  },
  {
    name: 'Maple Orbit',
    city: 'Montréal',
    site: 'maple-orbit.example.com',
    categories: ['B2B', 'Wholesale', 'Seasonal'],
    last: [15, '15 days ago'],
    strength: 'weak',
  },
  {
    name: 'Midnight Milk',
    city: 'Tromsø',
    categories: ['Dairy-free', 'Vegan', 'Wholesale'],
    strength: 'none',
  },
  {
    name: 'Mint Medina',
    city: 'Tunis',
    categories: ['Dairy-free', 'Vegan', 'Local'],
    strength: 'none',
  },
  {
    name: 'Monsoon Mango',
    city: 'Mumbai',
    site: 'monsoon-mango.example.com',
    categories: ['Sorbet', 'Vegan', 'Catering'],
    last: [18, '18 days ago'],
    strength: 'weak',
  },
  {
    name: 'Mooncake Ice Cream',
    city: 'Singapore',
    site: 'mooncake-ice-cream.example.com',
    categories: ['B2B', 'Wholesale'],
    last: [32, 'about 1 month ago'],
    strength: 'very-weak',
  },
  {
    name: 'Nomad Nougat',
    city: 'Ulaanbaatar',
    site: 'nomad-nougat.example.com',
    categories: ['Imports', 'B2B'],
    last: [700, 'almost 2 years ago'],
    strength: 'none',
  },
  {
    name: 'Olive Snow',
    city: 'Athens',
    site: 'olive-snow.example.com',
    categories: ['Gelato', 'Cafe', 'Local'],
    last: [4, '4 days ago'],
    strength: 'very-strong',
  },
  {
    name: 'Orchard Cloud',
    city: 'Lyon',
    site: 'orchard-cloud.example.com',
    categories: ['Gelato', 'Seasonal', 'Cafe'],
    last: [5, '5 days ago'],
    strength: 'very-strong',
  },
  {
    name: 'Pacific Pear',
    city: 'Valparaíso',
    site: 'pacific-pear.example.com',
    categories: ['Sorbet', 'Seasonal'],
    last: [63, '2 months ago'],
    strength: 'weak',
  },
  {
    name: 'Palm Sugar Creamery',
    city: 'Bangkok',
    site: 'palm-sugar.example.com',
    categories: ['B2C', 'Vegan'],
    last: [93, '3 months ago'],
    strength: 'very-weak',
  },
  {
    name: 'Pampa Creamery',
    city: 'Córdoba',
    site: 'pampa-creamery.example.com',
    categories: ['B2C', 'Local'],
    last: [363, '12 months ago'],
    strength: 'very-weak',
  },
  {
    name: 'Pine & Pistachio',
    city: 'Istanbul',
    categories: ['Gelato', 'Catering'],
    last: [32, 'about 1 month ago'],
    strength: 'very-weak',
  },
  {
    name: 'Quartz Cone',
    city: 'Denver',
    site: 'quartz-cone.example.com',
    categories: ['B2C', 'Wholesale'],
    last: [10, '10 days ago'],
    strength: 'very-strong',
  },
  {
    name: 'Red Lantern Creamery',
    city: 'Taipei',
    site: 'red-lantern.example.com',
    categories: ['Cafe', 'Vegan'],
    last: [32, 'about 1 month ago'],
    strength: 'weak',
  },
  {
    name: 'Rosewater Kulfi',
    city: 'Jaipur',
    categories: ['B2C', 'Seasonal'],
    last: [63, '2 months ago'],
    strength: 'very-weak',
  },
  {
    name: 'Saffron Sky Kulfi',
    city: 'Dubai',
    site: 'saffron-sky.example.com',
    categories: ['Imports', 'Catering'],
    last: [700, 'almost 2 years ago'],
    strength: 'very-weak',
  },
  {
    name: 'Sahara Swirl',
    city: 'Marrakech',
    categories: ['Sorbet', 'Local'],
    last: [153, '5 months ago'],
    strength: 'very-weak',
  },
  {
    name: 'Salt & Silk',
    city: 'Muscat',
    site: 'salt-and-silk.example.com',
    categories: ['Imports', 'Catering', 'Gelato'],
    last: [243, '8 months ago'],
    strength: 'very-weak',
  },
  {
    name: 'Silk Road Sorbet',
    city: 'Tbilisi',
    site: 'silk-road.example.com',
    categories: ['Sorbet', 'Imports'],
    last: [32, 'about 1 month ago'],
    strength: 'weak',
  },
  {
    name: 'Sol y Nieve',
    city: 'Buenos Aires',
    site: 'sol-y-nieve.example.com',
    categories: ['Gelato', 'Local'],
    last: [63, '2 months ago'],
    strength: 'weak',
  },
  {
    name: 'Sunbird Gelateria',
    city: 'Lisbon',
    site: 'sunbird.example.com',
    categories: ['Gelato', 'Cafe'],
    last: [800, 'over 2 years ago'],
    strength: 'none',
  },
  {
    name: 'Tamarind Tide',
    city: 'Chennai',
    site: 'tamarind-tide.example.com',
    categories: ['Vegan', 'Sorbet', 'B2C'],
    last: [273, '9 months ago'],
    strength: 'very-weak',
  },
  {
    name: 'Tasman Sea Gelato',
    city: 'Hobart',
    site: 'tasman-sea.example.com',
    categories: ['Gelato', 'Local'],
    last: [63, '2 months ago'],
    strength: 'weak',
  },
  {
    name: 'Tropic Churn',
    city: 'San Juan',
    site: 'tropic-churn.example.com',
    categories: ['Sorbet', 'Local', 'B2C'],
    last: [6, '6 days ago'],
    strength: 'very-strong',
  },
  {
    name: 'Umber Cream',
    city: 'Warsaw',
    site: 'umber-cream.example.com',
    categories: ['B2B', 'Wholesale', 'Cafe'],
    last: [35, '5 weeks ago'],
    strength: 'weak',
  },
  {
    name: 'Vanilla Vale',
    city: 'Antananarivo',
    categories: ['Imports', 'Local'],
    strength: 'none',
  },
  {
    name: 'Willow Whip',
    city: 'Portland',
    site: 'willow-whip.example.com',
    categories: ['Dairy-free', 'Vegan', 'Cafe'],
    last: [3, '3 days ago'],
    strength: 'very-strong',
  },
  {
    name: 'Yuzu Yard',
    city: 'Kyoto',
    site: 'yuzu-yard.example.com',
    categories: ['Sorbet', 'Cafe', 'Seasonal'],
    last: [11, '11 days ago'],
    strength: 'very-strong',
  },
  {
    name: 'Zenith Gelato',
    city: 'Auckland',
    site: 'zenith-gelato.example.com',
    categories: ['Gelato', 'Seasonal'],
    last: [21, '3 weeks ago'],
    strength: 'weak',
  },
];
```

### records-table/records-table.module.css

```css
/* Records table (records-table.tsx): a soft grey stage with a 480 px card in its middle, the grid
   of companies scrolling inside it both ways, or at night a graphite stage with the card a step
   lighter.

   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it
   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop
   sets (data-color-scheme). Tags, the selected rows and the selected column mix their colour into
   the card's in srgb, so each scheme keeps its own strength of tint.

   Memory. The grid is one scroller: the header, the company column and the footer stick to its
   edges, painted opaque so nothing shows through. Motion is opacity and transforms (the menus'
   pop-in, the sort arrow, the calculating dot) plus the menus' highlight gliding by top and height
   within one short list. */

.root {
  /* The stage, the card, the hover and the footer's and prompt's inset. */
  --canvas: oklch(96.1% 0.002 247.84);
  --surface: oklch(100% 0 0);
  --hover: oklch(97% 0.002 247.839);
  --inset: oklch(97.9% 0.002 247.839);
  --mark: oklch(96.1% 0.001 286.375);
  /* Words: strong, quiet and faint. */
  --ink: oklch(24.7% 0.006 258.361);
  --ink-2: oklch(50.6% 0.01 264.477);
  --ink-3: oklch(69.5% 0.009 264.505);
  /* The card's edge, the grid's lines, and the stronger edges (the +N chip, the off switch). */
  --line: oklch(94.6% 0.003 264.542);
  --grid: rgb(236 237 239 / 0.78);
  --line-strong: oklch(91.2% 0.005 258.326);
  /* Selection, links and the chips of the inputs. */
  --accent: oklch(62.6% 0.205 254.947);
  --accent-ink: oklch(55.6% 0.187 255.617);
  --accent-tint: oklch(96% 0.019 252.878);
  /* Relationship strength. */
  --green: oklch(60.3% 0.155 150.883);
  --orange: oklch(68.9% 0.179 49.902);
  --red: oklch(62.1% 0.192 23.042);
  /* The row checkboxes: empty, and under the pointer. */
  --box: oklch(100% 0 0);
  --box-edge: oklch(84.5% 0.011 247.953);
  --box-hover: oklch(97% 0.002 247.839);
  --box-hover-edge: oklch(75% 0.012 252);
  /* The menus: their ring, and the day's long soft lift or the night's short dark one. */
  --overlay-ring: oklch(94.6% 0.003 264.542);
  --overlay-night: transparent;
  --day-5: rgb(0 0 0 / 0.05);
  --day-4: rgb(0 0 0 / 0.04);
  --day-3: rgb(0 0 0 / 0.03);
  --day-2: rgb(0 0 0 / 0.02);
  --btn-ring: oklch(91.2% 0.005 258.326);
  --btn-night: transparent;
  --tip: oklch(100% 0 0);
  /* The same on both schemes: the focus ring. */
  --focus: oklch(62.6% 0.205 254.947);
  --ease: cubic-bezier(0.23, 1, 0.32, 1);
  position: relative;
  container-type: inline-size;
  display: grid;
  place-items: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 464px;
  padding: 12px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--canvas);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  font-size: 14px;
  line-height: 1.5;
  letter-spacing: -0.14px;
  font-feature-settings: normal;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));
    --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
    --hover: light-dark(oklch(97% 0.002 247.839), oklch(28.9% 0.006 271.22));
    --inset: light-dark(oklch(97.9% 0.002 247.839), oklch(24.3% 0.004 264.492));
    --mark: light-dark(oklch(96.1% 0.001 286.375), oklch(29.3% 0.006 271.223));
    --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));
    --ink-2: light-dark(oklch(50.6% 0.01 264.477), oklch(73.1% 0.008 260.731));
    --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));
    --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
    --grid: light-dark(rgb(236 237 239 / 0.78), rgb(46 48 51 / 0.78));
    --line-strong: light-dark(oklch(91.2% 0.005 258.326), oklch(35.6% 0.007 264.474));
    --accent: light-dark(oklch(62.6% 0.205 254.947), oklch(68% 0.173 253.301));
    --accent-ink: light-dark(oklch(55.6% 0.187 255.617), oklch(78.8% 0.113 248.33));
    --accent-tint: light-dark(oklch(96% 0.019 252.878), oklch(68% 0.173 253.301 / 0.16));
    --green: light-dark(oklch(60.3% 0.155 150.883), oklch(70.5% 0.154 153.814));
    --orange: light-dark(oklch(68.9% 0.179 49.902), oklch(74.6% 0.156 55.642));
    --red: light-dark(oklch(62.1% 0.192 23.042), oklch(66.6% 0.18 21.433));
    --box: light-dark(oklch(100% 0 0), oklch(34.6% 0.015 252.294));
    --box-edge: light-dark(oklch(84.5% 0.011 247.953), oklch(45% 0.017 254.711));
    --box-hover: light-dark(oklch(97% 0.002 247.839), oklch(39.1% 0.016 251.761));
    --box-hover-edge: light-dark(oklch(75% 0.012 252), oklch(52.1% 0.019 254));
    --overlay-ring: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.15));
    --overlay-night: light-dark(transparent, oklch(0% 0 0 / 0.34));
    --day-5: light-dark(rgb(0 0 0 / 0.05), transparent);
    --day-4: light-dark(rgb(0 0 0 / 0.04), transparent);
    --day-3: light-dark(rgb(0 0 0 / 0.03), transparent);
    --day-2: light-dark(rgb(0 0 0 / 0.02), transparent);
    --btn-ring: light-dark(oklch(91.2% 0.005 258.326), oklch(100% 0 0 / 0.1));
    --btn-night: light-dark(transparent, oklch(0% 0 0 / 0.3));
    --tip: light-dark(oklch(100% 0 0), oklch(18.2% 0.004 264.459));
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

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

.root svg {
  display: block;
  flex: none;
}

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

/* The card: a 480 px window onto the grid, 440 px tall. */
.shell {
  position: relative;
  box-sizing: border-box;
  width: 100%;
  max-width: 480px;
  height: 440px;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--surface);
  box-shadow: 0 1px 2px oklch(0% 0 0 / 0.06);
}
.scroll {
  width: 100%;
  height: 100%;
  overflow: auto;
  overscroll-behavior: contain;
  scrollbar-color: var(--line-strong) transparent;
}
.root .scroll:focus-visible {
  outline-offset: -2px;
}

/* Hidden copies of every tag and +N chip, measured once to know which tags fit a cell. */
.measure {
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  gap: 4px;
  visibility: hidden;
  pointer-events: none;
}

.table {
  table-layout: fixed;
  border-collapse: separate;
  border-spacing: 0;
  font-size: 13px;
  font-weight: 500;
  line-height: 1.5;
}

/* Every cell: a right and a bottom line, on the card. */
.table :is(th, td) {
  box-sizing: border-box;
  padding: 0 8px;
  overflow: hidden;
  border-right: 1px solid var(--grid);
  border-bottom: 1px solid var(--grid);
  text-align: left;
  text-overflow: ellipsis;
  white-space: nowrap;
  vertical-align: middle;
  transition:
    background-color 0.12s ease-out,
    color 0.12s ease-out;
}

/* The header sticks to the top, the footer to the bottom, the company column to the left. The
   corners sit above both. */
.table thead th {
  position: sticky;
  top: 0;
  z-index: 5;
  height: 36px;
  padding: 0;
  background: var(--surface);
  color: var(--ink-2);
  font-size: 12.5px;
  font-weight: 500;
}
.table .sticky {
  position: sticky;
  left: 0;
  z-index: 2;
  background: var(--surface);
  box-shadow: 5px 0 8px -10px oklch(0% 0 0 / 0.4);
}
.table thead .sticky {
  z-index: 7;
}
.table tfoot td {
  position: sticky;
  bottom: 0;
  z-index: 4;
  height: 36px;
  background: var(--inset);
  color: var(--ink-2);
  font-size: 14px;
  line-height: 1;
}
.table tfoot .sticky {
  z-index: 6;
  background: var(--inset);
}

/* A header: the column's settings button and, on sortable columns, the sort arrow. */
.head {
  display: flex;
  align-items: center;
  height: 35px;
  transition:
    background-color 0.12s ease-out,
    color 0.12s ease-out;
}
.headButton {
  display: flex;
  flex: 1;
  align-items: center;
  gap: 8px;
  min-width: 0;
  height: 100%;
  padding: 0 12px;
  text-align: left;
}
.head:has(.sort) .headButton {
  padding-right: 6px;
}
.headIcon {
  display: flex;
  flex: none;
  color: var(--ink-3);
}
.headLabel {
  overflow: hidden;
  text-overflow: ellipsis;
}
@media (hover: hover) {
  .th:hover .head {
    background: var(--hover);
    color: var(--ink);
  }
}
.th[data-hover='true'] .head {
  background: var(--hover);
  color: var(--ink);
}
/* The column whose settings are open: tinted, with an accent line along its top. */
.table .th[data-selected='true'] {
  background: color-mix(in srgb, var(--accent) 8%, var(--surface));
  box-shadow: inset 0 2px 0 var(--accent);
  color: var(--ink);
}
.table td[data-selected='true'] {
  background: color-mix(in srgb, var(--accent) 4%, var(--surface));
}

.sort {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  margin-right: 8px;
  border-radius: 5px;
  color: var(--ink);
  opacity: 0;
  transition:
    opacity 0.12s ease-out,
    transform 0.16s var(--ease);
}
.sort[data-dir='ascending'] {
  transform: rotate(180deg);
}
.th:is(:hover, :focus-within, [data-hover='true']) .sort,
.sort[data-dir] {
  opacity: 1;
}

/* A column's right edge: drag it, or focus it and press the arrows. A thin accent bar shows. */
.resize {
  position: absolute;
  top: 0;
  right: -6px;
  z-index: 12;
  width: 12px;
  height: 35px;
  cursor: col-resize;
  touch-action: none;
}
.resize::after {
  position: absolute;
  inset: 7px 5px;
  border-radius: 99px;
  background: var(--accent);
  opacity: 0;
  transition: opacity 0.12s ease-out;
  content: '';
}
.resize:is(:hover, :focus-visible, [data-active='true'])::after {
  opacity: 1;
}
.root .resize:focus-visible {
  outline: none;
}
.th {
  position: relative;
}
/* The header's last cell keeps its handle inside the card. */
.table thead th:last-child .resize {
  display: none;
}

/* The company header: the select-all box, then the word. */
.companyHead {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 35px;
  padding: 0 12px 0 6px;
}

/* The tail of the header: new property and the table's options. */
.tools {
  display: flex;
  align-items: center;
  gap: 4px;
  height: 35px;
  padding: 0 8px;
}
.tool {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  border-radius: 7px;
  color: var(--ink-2);
  transition:
    background-color 0.1s,
    color 0.1s;
}
.tool + .tool {
  color: var(--ink-3);
}
.tool:is(:hover, [aria-expanded='true'], [data-demo='true']) {
  background: var(--hover);
  color: var(--ink);
}

/* Rows: the hover and the selection tint every cell, the sticky one included. */
.row {
  height: 35px;
}
.row:is(:hover, [data-hover='true']) > * {
  background: var(--hover);
}
.row[data-selected='true'] > * {
  background: color-mix(in srgb, var(--accent) 7%, var(--surface));
}
.table .row > .sticky {
  padding: 0;
  font-weight: 500;
}

.company {
  display: flex;
  align-items: center;
  gap: 4px;
  height: 34px;
  padding: 0 8px 0 6px;
}
/* The row's number, which gives way to its checkbox under the pointer, once ticked or in focus. */
.slot {
  display: grid;
  flex: none;
  place-items: center;
  width: 24px;
  height: 24px;
}
.slot > * {
  grid-area: 1 / 1;
}
.rownum {
  color: var(--ink-3);
  font-size: 11.5px;
  font-variant-numeric: tabular-nums;
}
.slot .check {
  opacity: 0;
}
.row:is(:hover, [data-hover='true'], [data-selected='true']) .rownum,
.slot:focus-within .rownum {
  opacity: 0;
}
.row:is(:hover, [data-hover='true'], [data-selected='true']) .check,
.slot:focus-within .check {
  opacity: 1;
}

.check {
  position: relative;
  display: flex;
  flex: none;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  border-radius: 6px;
  cursor: pointer;
}
.check input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  cursor: pointer;
  appearance: none;
}
.box {
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  width: 18px;
  height: 18px;
  border: 1px solid var(--box-edge);
  border-radius: 6px;
  background: var(--box);
  color: #fff;
  transition:
    border-color 0.14s ease-out,
    background-color 0.14s ease-out,
    transform 0.14s var(--ease);
}
.box svg {
  opacity: 0;
}
@media (hover: hover) {
  .check:hover .box[data-on='false'] {
    border-color: var(--box-hover-edge);
    background: var(--box-hover);
  }
}
.check:active .box {
  transform: scale(0.92);
}
.box[data-on='true'] {
  border-color: var(--accent);
  background: var(--accent);
}
.box[data-on='true'] svg {
  opacity: 1;
}
.check input:focus-visible + .box {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.dash {
  width: 8px;
  height: 1.5px;
  border-radius: 99px;
  background: currentColor;
}

/* The company's initial, and its name: a link to its site when it has one. */
.mark {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  border-radius: 6px;
  background: var(--mark);
  color: var(--ink-2);
  font-size: 10px;
  line-height: 1;
}
.name {
  overflow: hidden;
  color: var(--ink);
  text-decoration: none;
  text-overflow: ellipsis;
  white-space: nowrap;
}
a.name:hover {
  color: var(--accent-ink);
  text-decoration: underline;
  text-decoration-color: var(--accent-ink);
  text-underline-offset: 3px;
}

/* Tags: each category's colour mixed into the card, its words a deeper or lighter shade of it. */
.tags {
  display: flex;
  align-items: center;
  gap: 4px;
  overflow: hidden;
}
.tag {
  display: flex;
  flex: none;
  align-items: center;
  box-sizing: border-box;
  max-width: 115px;
  height: 23px;
  padding: 0 7px;
  overflow: hidden;
  border: 1px solid;
  border-radius: 8px;
  border-color: light-dark(
    color-mix(in srgb, var(--tag) 32%, var(--surface)),
    color-mix(in srgb, var(--tag) 34%, var(--surface))
  );
  background: light-dark(
    color-mix(in srgb, var(--tag) 18%, var(--surface)),
    color-mix(in srgb, var(--tag) 34%, var(--surface))
  );
  color: light-dark(
    color-mix(in srgb, var(--tag) 92%, var(--ink)),
    color-mix(in srgb, var(--tag) 86%, #fff)
  );
  font-size: 13px;
  font-weight: 500;
  line-height: 1.5;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.more {
  display: flex;
  flex: none;
  align-items: center;
  box-sizing: border-box;
  height: 23px;
  padding: 0 7px;
  border: 1px solid var(--line-strong);
  border-radius: 6px;
  background: var(--inset);
  color: var(--ink-3);
  font-size: 11px;
  font-weight: 500;
  white-space: nowrap;
}

.muted {
  color: var(--ink-3);
}
.strength {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  color: var(--ink-2);
}
.dot {
  flex: none;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--ink-3);
}
.dot[data-tone='green'] {
  background: var(--green);
}
.dot[data-tone='orange'] {
  background: var(--orange);
}
.dot[data-tone='red'] {
  background: var(--red);
}
.link {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  max-width: 100%;
  overflow: hidden;
  color: var(--accent-ink);
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--accent-ink) 35%, transparent);
  text-underline-offset: 3px;
  transition:
    color 0.12s ease-out,
    text-decoration-color 0.12s ease-out;
}
.link:hover {
  text-decoration-color: var(--accent-ink);
}
.link > span {
  overflow: hidden;
  text-overflow: ellipsis;
}
.link > svg {
  flex: none;
}

/* A cell being calculated: the words, and a dot breathing at its end. */
.calc {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
.pulse {
  flex: none;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--accent);
  animation: pulse 1.1s ease-in-out infinite;
}
@keyframes pulse {
  0%,
  100% {
    opacity: 0.35;
    transform: scale(0.8);
  }
  50% {
    opacity: 1;
    transform: scale(1);
  }
}

/* The footer's calculations. */
.foot {
  display: inline-flex;
  align-items: center;
  height: 35px;
}
.footNum {
  margin-right: 3px;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.foot .dot {
  margin-right: 7px;
}
.addCalc {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 35px;
  color: var(--ink-3);
  transition:
    color 0.12s ease-out,
    transform 0.14s var(--ease);
}
.addCalc:hover {
  color: var(--ink);
}
.addCalc:active {
  transform: scale(0.97);
}

/* Menus and the column settings, over the stage: they pop in from the corner nearest their
   button. */
.popover {
  position: absolute;
  z-index: 30;
  box-sizing: border-box;
  border-radius: 14px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--overlay-ring),
    0 8px 28px var(--overlay-night),
    0 25px 50px var(--day-5),
    0 12px 24px var(--day-4),
    0 6px 12px var(--day-3),
    0 3px 6px var(--day-2),
    0 1.5px 3px var(--day-2);
  color: var(--ink);
  animation: pop-in 160ms var(--ease) both;
}
.popover[data-kind='menu'] {
  padding: 6px;
}
@keyframes pop-in {
  from {
    opacity: 0;
    transform: scale(0.95);
  }
}

.menu {
  position: relative;
}
.menuHeading {
  padding: 4px 8px;
  color: var(--ink-3);
  font-size: 12px;
  font-weight: 500;
  line-height: 1.5;
}
.menuList {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 1px;
}
.menuItemWrap {
  display: contents;
}
.menuMark {
  position: absolute;
  inset-inline: 0;
  top: 0;
  height: 0;
  border-radius: 8px;
  background: var(--hover);
  opacity: 0;
  pointer-events: none;
  transition:
    top 220ms var(--ease),
    height 220ms var(--ease),
    opacity 150ms;
}
.menuItem {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  height: 36px;
  padding: 0 8px;
  border-radius: 8px;
  color: var(--ink);
  font-size: 13px;
  text-align: left;
}
.root .menuItem:focus-visible {
  outline-offset: -2px;
}
.menuIcon {
  display: flex;
  flex: none;
  color: var(--ink-2);
}
.menuLabel {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.menuRule {
  height: 1px;
  margin: 4px 0;
  background: var(--line);
}
/* The small menus beside the column settings. */
.menu[data-size='small'] .menuHeading {
  padding: 2px 8px 4px;
  font-size: 11.5px;
}
.menu[data-size='small'] .menuItem {
  gap: 6px;
  height: 32px;
  padding: 0 6px;
  font-weight: 500;
}
.menu[data-size='small'] .menuIcon {
  justify-content: center;
  width: 16px;
}
.menuTick {
  display: flex;
  color: var(--ink);
  visibility: hidden;
}
.menuTick[data-on='true'] {
  visibility: visible;
}
.menuBox {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  width: 16px;
  height: 16px;
  border: 1px solid var(--line-strong);
  border-radius: 5px;
  color: transparent;
}
.menuBox[data-on='true'] {
  border-color: var(--accent);
  background: var(--accent);
  color: #fff;
}
.subMenu {
  position: absolute;
  z-index: 3;
  box-sizing: border-box;
  padding: 6px;
  border-radius: 12px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--overlay-ring),
    0 8px 28px var(--overlay-night),
    0 25px 50px var(--day-5),
    0 12px 24px var(--day-4),
    0 6px 12px var(--day-3),
    0 3px 6px var(--day-2),
    0 1.5px 3px var(--day-2);
  animation: pop-in 140ms var(--ease) both;
}

/* The column settings. */
.panelBody {
  position: relative;
  box-sizing: border-box;
  max-height: var(--max-h, none);
  padding: 12px 12px 6px;
  overflow: auto;
  overscroll-behavior: contain;
  border-radius: inherit;
  scrollbar-width: thin;
}
.panelTitle {
  padding-bottom: 8px;
  font-size: 13.5px;
  font-weight: 500;
}
.field {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 32px;
}
.fieldLabel {
  color: var(--ink-3);
  font-size: 13px;
}
.fieldValue {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 6px;
  border-radius: 6px;
  font-size: 13px;
  font-weight: 500;
  transition: background-color 0.1s;
}
.fieldValue:is(:hover, [aria-expanded='true']) {
  background: var(--hover);
}
.fieldIcon {
  display: flex;
  color: var(--ink-2);
}
.fieldIcon[data-model='true'] {
  color: var(--accent);
}
.fieldChevron {
  color: var(--ink-3);
}
.fieldInputs {
  max-width: 220px;
  color: var(--ink-2);
  font-weight: 400;
}
.inputChips {
  display: flex;
  align-items: center;
  gap: 4px;
  min-width: 0;
}
.inputChip {
  max-width: 92px;
  padding: 2px 6px;
  overflow: hidden;
  border-radius: 5px;
  background: var(--accent-tint);
  color: var(--accent-ink);
  font-size: 12px;
  font-weight: 500;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.inputMore {
  color: var(--ink-3);
  font-size: 12px;
}
.fieldEnd {
  display: flex;
  align-items: center;
  gap: 8px;
}
.infoButton {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  border-radius: 6px;
  color: var(--ink-3);
  transition:
    background-color 0.1s,
    color 0.1s;
}
.infoButton:is(:hover, [aria-expanded='true']) {
  background: var(--hover);
  color: var(--ink);
}
.tip {
  position: absolute;
  top: 30px;
  right: 0;
  z-index: 3;
  width: 230px;
  box-sizing: border-box;
  padding: 10px 12px;
  border-radius: 10px;
  background: var(--tip);
  box-shadow:
    0 0 0 1px var(--overlay-ring),
    0 8px 28px var(--overlay-night),
    0 12px 24px var(--day-4),
    0 3px 6px var(--day-2);
  font-size: 12px;
  line-height: 1.625;
  animation: pop-in 140ms var(--ease) both;
  transform-origin: right top;
}

.switch {
  position: relative;
  flex: none;
  width: 30px;
  height: 18px;
  border-radius: 99px;
  background: var(--line-strong);
  transition: background-color 0.15s;
}
.switch[aria-checked='true'] {
  background: var(--accent);
}
.knob {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: #fff;
  box-shadow:
    0 0 0 1px light-dark(oklch(91.2% 0.005 258.326), oklch(100% 0 0 / 0.1)),
    0 1px 2px light-dark(rgb(0 0 0 / 0.08), oklch(0% 0 0 / 0.3));
  transition: transform 0.15s var(--ease);
}
.switch[aria-checked='true'] .knob {
  transform: translateX(12px);
}

/* The prompt: words, and chips for the columns it reads. */
.prompt {
  box-sizing: border-box;
  min-height: 88px;
  margin-top: 8px;
  padding: 12px;
  border-radius: 10px;
  background: var(--inset);
  box-shadow: 0 0 0 1px var(--line);
  font-size: 13px;
  line-height: 1.625;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  cursor: text;
  transition: box-shadow 0.15s;
}
.root .prompt:focus-visible,
.prompt:focus {
  outline: none;
  box-shadow: 0 0 0 2px var(--accent);
}
.prompt[data-empty='true']::before {
  color: var(--ink-3);
  content: attr(data-placeholder);
  pointer-events: none;
}
.promptChip {
  padding: 2px 6px;
  border-radius: 5px;
  background: var(--accent-tint);
  color: var(--accent-ink);
  font-size: 12px;
  font-weight: 500;
}
.calculate {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: 100%;
  height: 36px;
  margin-top: 10px;
  border-radius: 9px;
  box-shadow:
    0 0 0 1px var(--btn-ring),
    0 0 4px var(--day-4),
    0 1px 2px var(--btn-night);
  font-size: 12.5px;
  font-weight: 500;
  transition:
    background-color 0.15s,
    transform 0.15s;
}
.calculate:hover {
  background: var(--hover);
}
.calculate:active,
.calculate[data-pressed='true'] {
  background: var(--hover);
  transform: scale(0.98);
}
.panelRows {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin-top: 12px;
  padding-top: 8px;
  border-top: 1px solid var(--line);
}
.panelRows > .menuMark {
  inset-inline: -6px;
}
.panelRow {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 10px;
  height: 32px;
  margin: 0 -6px;
  padding: 0 6px;
  border-radius: 8px;
  font-size: 13px;
  line-height: 1;
  text-align: left;
  transition: transform 0.15s;
}
.panelRow:active {
  transform: scale(0.96);
}
.root .panelRow:focus-visible {
  outline-offset: -2px;
}
.panelRowIcon {
  display: flex;
  color: var(--ink-2);
}
.panelRow[aria-pressed='true'] .panelRowIcon {
  color: var(--accent);
}
.panelRowGrow {
  flex: 1;
}
.moreChevron {
  color: var(--ink-3);
  transition: transform 0.15s;
}
.panelRow[aria-expanded='true'] .moreChevron {
  transform: rotate(90deg);
}
.behavior {
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid var(--line);
}
.behaviorHeading {
  padding-bottom: 4px;
  color: var(--ink-3);
  font-size: 11.5px;
  font-weight: 500;
}

@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    animation-duration: 1ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 1ms !important;
  }
  .pulse {
    opacity: 0.7;
  }
}
```

### records-table/use-autoplay.ts

```tsx
'use client';

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

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

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

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

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