# Skeuo tasks

> A to-do app whose Inbox and Today are trays you can watch fill up, slip by slip.

A to-do app with a sidebar and a list. Its two main lists are drawn as trays: a glossy blue one holding paper slips for the Inbox, and a white one (grey plastic at night) with a growing stack of sheets for Today. Add a task and a slip drops in; move tasks to Today and they fly across; tick one off and its slip is flung away.

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

Category: Motion.

## Anatomy

1. **Banner**: A task waiting from another app (“1 task from Mail”). A click brings it into the Inbox, once; then it reads “No new tasks”.
2. **Inbox tray**: A glossy blue tray of paper slips, the last three tasks written on them. A new slip drops in and the pile rises as it grows.
3. **Today tray**: A white tray with a stack of sheets, one more for every three tasks (up to four), its first tasks written on the top one.
4. **List header**: The list’s glyph and name, a line on what it holds, and the Filter under them.
5. **Filter**: All Tasks or Added Today, in a menu under it. Tinted while a filter is on.
6. **List options**: Pins the list, and opens Select Tasks or New Task.
7. **Task row**: A circle to tick it off and its title. Rows are placed by index, so when one leaves the others slide up, 450 ms on a spring.
8. **New task**: Opens the card for a new task over the top of the window; while it is open, the same button adds the task.

## When to use

- A personal to-do app or a showcase of one, where seeing a list fill up is part of the pleasure.
- Two lists that matter most (a catch-all Inbox and a pick for Today) drawn as trays whose papers show how full each is.
- Moving tasks in batches between lists: select mode, a count of the picked ones, and one menu of where they go.
- A landing page or a portfolio that needs a product to feel tactile in a few seconds: the demo adds, moves and ticks tasks on its own.

## When not to use

- A work tracker with owners, dates and statuses in columns. Use a table or a board.
- A list that runs to hundreds of rows: the rows are placed by index, all drawn, with no virtualisation.
- Somewhere quiet and dense, like a settings pane: the trays, slips and flights are the point, and they take room.
- A single checklist inside another screen. Use plain task rows with a checkbox.
- Anything that needs a task’s notes, dates or tags edited: the new task’s card draws them but only takes a title.

## Installation

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

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

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

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

## Usage

```tsx
import { SkeuoTasks } from './skeuo-tasks';
import type { Task } from './data';

export function TasksPanel({ onSave }: { onSave: (tasks: Task[]) => void }) {
  return (
    <SkeuoTasks source="Mail" incoming="Reply to Sam’s invite" onChange={onSave} autoplay={false} />
  );
}
```

## States

- **Inbox**: Where it starts: four tasks, the blue tray ringed as the list on show.
- **Today**: Today’s list, its tray ringed and its count beside its name.
- **New task**: The card is open over the window with a title typed; Enter or the + adds it, Escape closes.
- **Select mode**: Rows turn into toggles, blue when picked; the bar counts them. Escape or Cancel leaves.
- **Actions menu**: Every list but the one on show, then Mark as Done and Delete, in red.
- **Ticked off**: The box fills; in use the row leaves 480 ms later and its slip is flung out of the tray.
- **Filtered**: Added Today on, after the task from Mail was brought in: the banner reads “No new tasks”.
- **Empty list**: A list with nothing in it says so in one quiet line.
- **Narrow**: Up to 520 px wide, the trays sit above the list and the other lists are left out.

## How it works

- The trays are HTML and CSS: a dark back, a glossy front and paper boxes between them. Each paper is placed by its depth in the pile through a transform, so a new one drops in from above while the others move back and rise.
- The round + opens a card for the new task over the top of the window; adding it closes the card and drops its slip into the tray.
- A paper that leaves stays a moment as a ghost to play its exit: lifted out when its task moves, flung up and away when it is done. Moving tasks between the trays sends slips across on an arc, a little over 100 ms apart, and each appears in its new tray and count only once it lands.
- Rows are placed by their index through a transform, so when one leaves the others slide up with no measuring. Select mode turns them into toggles, and a button counts them and opens what to do with them.
- Every part that changes during the demo paints on a small layer of its own. Closed panels are only faded out, never hidden by visibility, so their drawings are never restyled and the page is never laid out again.
- Every colour is a light-dark() token on the root, the trays, their fronts and backs and the paper included, so at night it is the same desk in dim light: darker plastic and dimmed paper rather than grey boxes. It follows the page’s color-scheme, or the colorScheme prop.

## API reference

- **source** (`string`, default `'Mail'`): Where the waiting task comes from, in the banner: “1 task from …”.
- **incoming** (`string`, default `'Reply to Sam’s invite'`): The title of the task waiting to be brought in.
- **defaultState** (`SkeuoTasksState`): Opens in this state instead of the start: list (the list on show), filter ('all' | 'fresh'), selecting and selected (select mode, with these task ids picked), ticked (tasks shown ticked), composing and draft (the new task’s card open, with this title), menu ('list' | 'filter' | 'actions') and imported (the waiting task already in the Inbox). Turns the demo off.
- **onChange** (`(tasks: Task[]) => void`): Called with every task whenever one is added, moved, ticked off or deleted.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer adds, selects and moves tasks.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root.

## Accessibility

- The sidebar is a navigation landmark whose list buttons mark the one on show with aria-current. Each tray is a button named with its count (“Today, 4 tasks”).
- The + opens the new task’s card (aria-expanded), and focus goes to its title field. Enter adds the task and Escape closes the card; both hand focus back to the +.
- Each row has a real Complete button; in select mode each row is a toggle button (aria-pressed). The menus follow the menu pattern, keys included, and Escape leaves select mode.
- Adding, moving, ticking off and deleting are announced in a polite live region; the demo stays silent. Under reduced motion papers and rows appear and leave at once, nothing flies, and the demo never plays.

## Source

### skeuo-tasks/skeuo-tasks.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from 'react';
import { INCOMING, LISTS, TASKS, type ListId, type Task } from './data';
import { DemoPointer, runDemo, type Demo } from './demo-pointer';
import {
  ArrowDownIcon,
  BellIcon,
  BookIcon,
  CalendarIcon,
  CheckCircleIcon,
  ChevronDown,
  ChevronRight,
  DeadlineIcon,
  HouseIcon,
  InfinityIcon,
  MoonIcon,
  MoreIcon,
  NotesIcon,
  PlayCircleIcon,
  PlusIcon,
  RepeatIcon,
  SidebarIcon,
  StarFilled,
  StarIcon,
  TagIcon,
  TodayGlyph,
  TrashIcon,
  TrayGlyph,
  TrayIcon,
} from './icons';
import { Menu, type Item } from './menu';
import { InboxTray, TodayTray, type Exit } from './trays';
import { useAutoplay } from './use-autoplay';
import styles from './skeuo-tasks.module.css';

/*
 * Skeuo tasks: a to-do app whose two main lists are trays you can see filling up. The Inbox is a
 * glossy blue tray of paper slips, Today a white tray with a stack of sheets (trays.tsx).
 *
 * - The round + opens a card for a new task over the top of the window; add it and a slip drops
 *   into the Inbox tray, the others rising behind it. Select tasks and move them to Today: their
 *   rows slide out, slips fly from one tray to the other, and Today's stack grows as they land.
 *   Tick a task off and its slip is flung away.
 * - The rows are placed by their index through a transform, so when one leaves the others slide
 *   up into place by a transition: no measuring. A row that leaves stays a moment to play out.
 * - Select mode turns the rows into toggles (blue when picked), and a button counts them and
 *   opens what to do with them. Menus follow the menu pattern, keys included.
 * - Everything moves by transforms and opacity, on boxes the size of a slip or a row; the menus,
 *   the new task's card and the bottom bars are in the page from the start, only shown.
 * - While on screen and untouched, a demo pointer adds a task, selects three and moves them to
 *   Today; then brings them back and ticks the new one off, and again.
 * - 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. At night the trays and papers are the
 *   same materials in dim light.
 */

/** The lists with a tray in the sidebar. */
const TRAYS: ListId[] = ['inbox', 'today'];
/** A row's height and the gap under it, in px: the stylesheet's --pitch. */
const PITCH = 38.5;
/** How long a row takes to leave, and a slip to fly from one tray to the other, in ms. */
const LEAVE = 380;
const FLIGHT = 640;
/** Between two slips flying, in ms. */
const STAGGER = 110;
/** Up to this width, in px, the trays sit above the list (the stylesheet says the same). */
const NARROW = 520;
/** How long a ticked task stays ticked before it leaves, in ms. */
const TICKED = 480;

type MenuName = 'list' | 'filter' | 'actions';
type Filter = 'all' | 'fresh';
type Leaving = { task: Task; index: number; from: ListId; how: 'moved' | 'done' };
type Flight = { id: number; title: string; to: 'inbox' | 'today'; delay: number };
type Destination = Exclude<ListId, 'log' | 'goals' | 'projects' | 'studio' | 'personal'>;

const DESTINATIONS: { id: Destination; label: string; icon: ReactNode }[] = [
  { id: 'inbox', label: 'Move to Inbox', icon: <TrayIcon className={styles.itemIcon} /> },
  { id: 'today', label: 'Move to Today', icon: <StarIcon className={styles.itemIcon} /> },
  {
    id: 'upcoming',
    label: 'Move to Upcoming',
    icon: <CalendarIcon className={styles.itemIcon} />,
  },
  { id: 'someday', label: 'Move to Someday', icon: <MoonIcon className={styles.itemIcon} /> },
];

const plural = (n: number) => `${n} task${n === 1 ? '' : 's'}`;

/** What the demo does to the app, one step at a time. */
type DemoAct =
  | { type: 'compose' }
  | { type: 'type'; text: string }
  | { type: 'add' }
  | { type: 'menu'; menu: MenuName }
  | { type: 'select' }
  | { type: 'toggle'; id: string }
  | { type: 'move'; to: Destination }
  | { type: 'list'; list: ListId }
  | { type: 'complete'; id: string };

/** The tasks the demo adds, in turn. */
const DEMO_TASKS = ['Pick up film rolls', 'Order a birthday cake', 'Sharpen the kitchen knives'];

/** A state to open in, for a still picture of the app (the docs' states): any of it, or none. */
export type SkeuoTasksState = {
  /** The list on show. */
  list?: ListId;
  filter?: Filter;
  /** Select mode, with these tasks picked (by id). */
  selecting?: boolean;
  selected?: string[];
  /** Tasks shown ticked, their boxes filled, without leaving. */
  ticked?: string[];
  /** The new task's card open, with this title typed. */
  composing?: boolean;
  draft?: string;
  /** A menu open. */
  menu?: MenuName;
  /** The waiting task already brought in to the Inbox. */
  imported?: boolean;
};

export function SkeuoTasks({
  source = INCOMING.from,
  incoming = INCOMING.title,
  onChange,
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** Where the waiting task comes from, in the banner: "1 task from …". */
  source?: string;
  /** The title of the task waiting to be brought in. */
  incoming?: string;
  /** Called with every task, whenever one is added, moved, ticked off or deleted. */
  onChange?: (tasks: Task[]) => void;
  /** While on screen and untouched, a demo pointer adds, selects and moves tasks. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens in this state instead of the start, and turns the demo off. */
  defaultState?: SkeuoTasksState;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(8000);
  // The layout follows the stage's width. Until this has measured it, a container query picks the
  // same layout (the stylesheet says why it is then taken off).
  const [layout, setLayout] = useState<'narrow' | 'wide'>();
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    const observer = new ResizeObserver(([entry]) =>
      setLayout(entry.contentRect.width <= NARROW ? 'narrow' : 'wide'),
    );
    observer.observe(element);
    return () => observer.disconnect();
  }, [root]);
  const [tasks, setTasks] = useState<Task[]>(() =>
    defaultState?.imported
      ? [...TASKS, { id: 'incoming', title: incoming, list: 'inbox', fresh: true }]
      : TASKS,
  );
  const [list, setList] = useState<ListId>(defaultState?.list ?? 'inbox');
  const [filter, setFilter] = useState<Filter>(defaultState?.filter ?? 'all');
  const [selecting, setSelecting] = useState(defaultState?.selecting ?? false);
  const [selected, setSelected] = useState<string[]>(defaultState?.selected ?? []);
  const [ticked, setTicked] = useState<string[]>(defaultState?.ticked ?? []);
  const [leaving, setLeaving] = useState<Leaving[]>([]);
  const [landing, setLanding] = useState<string[]>([]);
  const [flights, setFlights] = useState<Flight[]>([]);
  const [exit, setExit] = useState<Exit>('lift');
  const [menu, setMenu] = useState<MenuName | null>(defaultState?.menu ?? null);
  const [menuFocus, setMenuFocus] = useState(false);
  const [draft, setDraft] = useState(defaultState?.draft ?? '');
  /** The new task's card is open. */
  const [composing, setComposing] = useState(defaultState?.composing ?? false);
  const [imported, setImported] = useState(defaultState?.imported ?? false);
  const [pinned, setPinned] = useState(false);
  const [message, setMessage] = useState('');
  const uid = useId();
  const stageRef = useRef<HTMLDivElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const rowsRef = useRef<HTMLUListElement>(null);
  /** The rows in the page at the last render, in order: their tasks and their elements' keys. */
  const drawn = useRef<{ list: ListId; ids: string[]; slots: number[] }>({
    list: 'inbox',
    ids: [],
    slots: [],
  });
  const input = useRef<HTMLInputElement>(null);
  const composer = useRef<HTMLFormElement>(null);
  const fab = useRef<HTMLButtonElement>(null);
  /** The card was opened by the reader, so focus goes into it. */
  const composeFocus = useRef(false);
  /** The card was opened by the demo, which closes it when it stops. */
  const composeByDemo = useRef(false);
  const menus = useRef<Record<MenuName, HTMLDivElement | null>>({
    list: null,
    filter: null,
    actions: null,
  });
  /** The open menu was opened by the demo, which closes it when it stops. */
  const menuByDemo = useRef(false);
  const made = useRef(0);
  const flown = useRef(0);
  const timers = useRef(new Set<number>());
  /** The tasks as they are now, for what happens a moment after a click. */
  const latest = useRef(tasks);
  useEffect(() => {
    latest.current = tasks;
  }, [tasks]);

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

  /** Runs `action` after `ms`, unless the app has gone. */
  const later = (action: () => void, ms: number) => {
    const timer = window.setTimeout(() => {
      timers.current.delete(timer);
      action();
    }, ms);
    timers.current.add(timer);
  };
  useEffect(() => {
    const all = timers.current;
    return () => all.forEach((timer) => window.clearTimeout(timer));
  }, []);

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

  const commit = (next: Task[]) => {
    setTasks(next);
    onChange?.(next);
  };

  // A click anywhere but the open menu or its button closes it.
  useEffect(() => {
    if (!menu) return;
    const close = (event: PointerEvent) => {
      if (!menus.current[menu]?.contains(event.target as Node)) setMenu(null);
    };
    document.addEventListener('pointerdown', close);
    return () => document.removeEventListener('pointerdown', close);
  }, [menu]);

  // The new task's card: focus goes in as the reader opens it, and a click elsewhere closes it.
  useEffect(() => {
    if (!composing) return;
    if (composeFocus.current) input.current?.focus();
    composeFocus.current = false;
    const close = (event: PointerEvent) => {
      const target = event.target as Node;
      if (!composer.current?.contains(target) && !fab.current?.contains(target)) {
        setComposing(false);
      }
    };
    document.addEventListener('pointerdown', close);
    return () => document.removeEventListener('pointerdown', close);
  }, [composing]);

  const openComposer = (byReader: boolean) => {
    setMenu(null);
    composeFocus.current = byReader;
    composeByDemo.current = !byReader;
    setComposing(true);
    // The demo's card, taken over by the reader, is open already: focus goes in at once.
    if (byReader && composing) input.current?.focus();
  };
  const closeComposer = (refocus: boolean) => {
    setComposing(false);
    if (refocus) fab.current?.focus();
  };

  const openMenu = (name: MenuName, byReader: boolean) => {
    menuByDemo.current = !byReader;
    setMenuFocus(byReader);
    setMenu((now) => (now === name ? null : name));
  };

  const closeMenu = (refocus: boolean) => {
    const was = menu;
    setMenu(null);
    if (refocus && was) {
      menus.current[was]?.querySelector<HTMLButtonElement>('[aria-haspopup="menu"]')?.focus();
    }
  };

  const shown = tasks.filter(
    (task) => task.list === list && (filter === 'all' || task.fresh) && !landing.includes(task.id),
  );
  // A row that joins the list on show slides in; switching lists brings no animation.
  const shownKey = shown.map((task) => task.id).join();
  const seen = useRef<{ list: ListId; ids: string[] } | null>(null);
  useLayoutEffect(() => {
    const ids = shownKey ? shownKey.split(',') : [];
    const before = seen.current;
    seen.current = { list, ids };
    if (!before || before.list !== list || reduced()) return;
    ids.forEach((id, index) => {
      if (before.ids.includes(id)) return;
      rowsRef.current
        ?.querySelector(`[data-task="${id}"]`)
        ?.animate([{ opacity: 0, translate: `0 ${index * PITCH - 10}px`, offset: 0 }], {
          duration: 360,
          easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)',
        });
    });
  }, [shownKey, list]);

  const inTray = (id: ListId) =>
    tasks.filter((task) => task.list === id && !landing.includes(task.id));
  const countOf = (id: ListId) => inTray(id).length;

  /** Adds the task on the card to the list on show, and closes the card. */
  const add = (byReader: boolean) => {
    const text = draft.trim();
    if (text) {
      made.current += 1;
      commit([...tasks, { id: `new-${made.current}`, title: text, list, fresh: true }]);
      setDraft('');
      if (byReader) announce(`Added “${text}” to ${LISTS[list].name}.`);
    }
    closeComposer(byReader);
  };

  const bringIn = () => {
    if (imported) return;
    made.current += 1;
    commit([...tasks, { id: `new-${made.current}`, title: incoming, list: 'inbox', fresh: true }]);
    setImported(true);
    announce(`Brought “${incoming}” in from ${source} to the Inbox.`);
  };

  /** Rows that leave the list on show play out a moment in their place. */
  const leave = (ids: string[], how: Leaving['how']) => {
    if (reduced()) return;
    const going = shown.flatMap((task, index) =>
      ids.includes(task.id) ? [{ task, index, from: list, how }] : [],
    );
    setLeaving((now) => [...now, ...going]);
    later(() => setLeaving((now) => now.filter((row) => !going.includes(row))), LEAVE);
  };

  /** Moves tasks to another list, at its end. Between the trays, slips fly across. */
  const move = (ids: string[], to: Destination, byReader: boolean) => {
    const moving = tasks.filter((task) => ids.includes(task.id) && task.list !== to);
    if (!moving.length) return;
    const from = moving[0].list;
    leave(
      moving.map((task) => task.id),
      'moved',
    );
    setExit('lift');
    commit([
      ...tasks.filter((task) => !moving.includes(task)),
      ...moving.map((task) => ({ ...task, list: to })),
    ]);
    if (TRAYS.includes(to) && !reduced()) {
      // The slips land one after another, and only then show in their new tray.
      const across = TRAYS.includes(from);
      const ready = moving.map((task, index) => ({
        task,
        at: (across ? FLIGHT : 220) + index * STAGGER,
      }));
      setLanding((now) => [...now, ...moving.map((task) => task.id)]);
      for (const { task, at } of ready) {
        later(() => setLanding((now) => now.filter((id) => id !== task.id)), at);
      }
      if (across) {
        const sent = moving.slice(0, 3).map((task, index) => {
          flown.current += 1;
          return {
            id: flown.current,
            title: task.title,
            to: to as Flight['to'],
            delay: index * STAGGER,
          };
        });
        setFlights((now) => [...now, ...sent]);
        later(
          () => setFlights((now) => now.filter((flight) => !sent.includes(flight))),
          FLIGHT + STAGGER * sent.length + 80,
        );
      }
    }
    if (byReader) announce(`${plural(moving.length)} moved to ${LISTS[to].name}.`);
  };

  /** Ticks tasks off: their boxes fill, then they leave and their slips are flung away. */
  const complete = (ids: string[], byReader: boolean) => {
    setTicked((now) => [...now, ...ids]);
    const finish = () => {
      leave(ids, 'done');
      setExit('fly');
      setTicked((now) => now.filter((id) => !ids.includes(id)));
      commit(latest.current.filter((task) => !ids.includes(task.id)));
    };
    if (reduced()) finish();
    else later(finish, TICKED);
    if (byReader) {
      const titles = tasks.filter((task) => ids.includes(task.id)).map((task) => task.title);
      announce(
        titles.length === 1 ? `Completed “${titles[0]}”.` : `Completed ${plural(titles.length)}.`,
      );
    }
  };

  const remove = (ids: string[]) => {
    leave(ids, 'moved');
    setExit('lift');
    commit(tasks.filter((task) => !ids.includes(task.id)));
    announce(`Deleted ${plural(ids.length)}.`);
  };

  const startSelecting = () => {
    setComposing(false);
    setSelecting(true);
    setSelected([]);
  };
  const stopSelecting = () => {
    setSelecting(false);
    setSelected([]);
  };
  const toggle = (id: string) =>
    setSelected((now) => (now.includes(id) ? now.filter((one) => one !== id) : [...now, id]));

  const act = (action: string, byReader: boolean) => {
    const ids = selected;
    setMenu(null);
    stopSelecting();
    if (action === 'done') complete(ids, byReader);
    else if (action === 'delete') remove(ids);
    else move(ids, action as Destination, byReader);
  };

  const showList = (next: ListId) => {
    setComposing(false);
    setList(next);
    setFilter('all');
    stopSelecting();
    setMenu(null);
  };

  // The demo: add a task, select three and send them to Today; then bring them back, tick the
  // new one off, and again.
  const demoAct = useEffectEvent((step: DemoAct) => {
    if (step.type === 'compose') openComposer(false);
    else if (step.type === 'type') setDraft(step.text);
    else if (step.type === 'add') add(false);
    else if (step.type === 'menu') openMenu(step.menu, false);
    else if (step.type === 'select') {
      setMenu(null);
      startSelecting();
    } else if (step.type === 'toggle') toggle(step.id);
    else if (step.type === 'move') act(step.to, false);
    else if (step.type === 'list') showList(step.list);
    else complete([step.id], false);
  });
  const readState = useEffectEvent(() => ({ tasks, list, shown }));
  const demo = autoplay && !defaultState && playing;
  useEffect(() => {
    const stage = stageRef.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const find = (selector: string) => stage.querySelector(selector);
    const row = (id: string) => find(`[data-task="${id}"]`);

    /** Selects tasks and moves them, from the list on show. */
    const send = async ({ wait, moveTo, press }: Demo, ids: string[], to: Destination) => {
      await moveTo(find('[data-menu="list"]'));
      await press(() => demoAct({ type: 'menu', menu: 'list' }));
      await wait(550);
      await moveTo(find('[data-item="select"]'), { x: 0.3, y: 0.5 });
      await press(() => demoAct({ type: 'select' }));
      await wait(500);
      for (const id of ids) {
        await moveTo(row(id), { x: 0.4, y: 0.5 });
        await press(() => demoAct({ type: 'toggle', id }));
        await wait(220);
      }
      await wait(300);
      await moveTo(find('[data-menu="actions"]'));
      await press(() => demoAct({ type: 'menu', menu: 'actions' }));
      await wait(600);
      await moveTo(find(`[data-item="${to}"]`), { x: 0.3, y: 0.5 });
      await press(() => demoAct({ type: 'move', to }));
    };

    const stop = runDemo(stage, arrow, async (steps) => {
      const { wait, moveTo, press } = steps;
      for (let round = 0; ; round += 1) {
        await wait(900);
        if (readState().list !== 'inbox') {
          await moveTo(find('[data-list="inbox"]'));
          await press(() => demoAct({ type: 'list', list: 'inbox' }));
          await wait(600);
        }
        // A new task: its card opened with the +, its title typed, then added with the +.
        const title = DEMO_TASKS[round % DEMO_TASKS.length];
        await moveTo(fab.current);
        await press(() => demoAct({ type: 'compose' }));
        await wait(450);
        for (let length = 1; length <= title.length; length += 1) {
          demoAct({ type: 'type', text: title.slice(0, length) });
          await wait(55);
        }
        await wait(400);
        await press(() => demoAct({ type: 'add' }));
        await wait(1000);
        // Three of the others, off to Today.
        const picked = readState()
          .shown.filter((task) => !task.fresh)
          .slice(0, 3)
          .map((task) => task.id);
        await send(steps, picked, 'today');
        await wait(1800);
        // And back, from Today's list.
        await moveTo(find('[data-list="today"]'));
        await press(() => demoAct({ type: 'list', list: 'today' }));
        await wait(900);
        await send(steps, picked, 'inbox');
        await wait(1300);
        // The new task, ticked off in the Inbox.
        await moveTo(find('[data-list="inbox"]'));
        await press(() => demoAct({ type: 'list', list: 'inbox' }));
        await wait(800);
        const fresh = readState().tasks.find(
          (task) => task.list === 'inbox' && task.fresh && task.title === title,
        );
        if (fresh) {
          await moveTo(find(`[data-task="${fresh.id}"] [data-check]`));
          await press(() => demoAct({ type: 'complete', id: fresh.id }));
        }
        await wait(1500);
      }
    });
    return () => {
      stop();
      if (menuByDemo.current) setMenu(null);
      menuByDemo.current = false;
      // The demo's card closes; it stays the demo's until the reader opens one, so a press on
      // the + that stopped the demo opens a fresh card rather than adding the demo's draft.
      if (composeByDemo.current) setComposing(false);
      setDraft('');
    };
  }, [demo]);

  const current = LISTS[list];
  // Each row is a layer, and a new element is a new layer with new buffers. So a row that leaves
  // plays out on the element it had, which keeps its place among the others (an element moved in
  // the page is made again), and rows that join go in their place in the list. Another list's
  // rows take over the elements in place, in order (their keys are these elements, the slots).
  const before = drawn.current;
  const switched = before.list !== list;
  const live = [
    ...shown.map((task, index) => ({ task, index, how: null })),
    ...leaving.filter((row) => row.from === list && !shown.some((task) => task.id === row.task.id)),
  ];
  const byId = new Map(live.map((row) => [row.task.id, row]));
  const order = switched ? [] : before.ids.filter((id) => byId.has(id));
  shown.forEach((task, index) => {
    if (order.includes(task.id)) return;
    order.splice(index ? order.indexOf(shown[index - 1].id) + 1 : 0, 0, task.id);
  });
  for (const row of live) if (!order.includes(row.task.id)) order.push(row.task.id);
  const free = switched ? [...before.slots] : [];
  let fresh = Math.max(-1, ...before.slots) + 1;
  const slots = order.map((id) => {
    const at = before.ids.indexOf(id);
    if (!switched && at >= 0) return before.slots[at];
    return free.shift() ?? fresh++;
  });
  const rows = order.flatMap((id, at) => {
    const row = byId.get(id);
    return row ? [{ ...row, slot: slots[at] }] : [];
  });
  useLayoutEffect(() => {
    drawn.current = { list, ids: order, slots };
    // The elements taken over show their new rows at once, as new elements would.
    const all = switched ? rowsRef.current?.querySelectorAll<HTMLElement>('li, li *') : null;
    if (!all?.length) return;
    all.forEach((element) => (element.style.transition = 'none'));
    void rowsRef.current?.offsetWidth;
    all.forEach((element) => (element.style.transition = ''));
  });
  const selectedCount = selected.length;

  const listButton = (id: ListId, icon: ReactNode, extra?: ReactNode) => (
    <button
      type="button"
      className={styles.listButton}
      data-list={id}
      aria-current={list === id ? 'page' : undefined}
      onClick={() => showList(id)}
    >
      {icon}
      <span className={styles.listName}>{LISTS[id].name}</span>
      {extra}
    </button>
  );

  const actionItems: Item[] = [
    // Where the tasks can go: every list but the one they are in.
    ...DESTINATIONS.filter(({ id }) => id !== list),
    { id: 'done', label: 'Mark as Done', icon: <CheckCircleIcon className={styles.itemIcon} /> },
    {
      id: 'delete',
      label: 'Delete',
      icon: <TrashIcon className={styles.itemIcon} />,
      danger: true,
    },
  ];

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-layout={layout}
      data-color-scheme={colorScheme}
      onPointerDownCapture={(event) => {
        // A press in the demo's card keeps it open for the reader.
        if (composer.current?.contains(event.target as Node)) composeByDemo.current = false;
        take();
      }}
      onKeyDownCapture={take}
      onKeyDown={(event) => {
        if (event.key === 'Escape' && !menu && selecting) stopSelecting();
      }}
    >
      <div ref={stageRef} className={styles.stage}>
        <div className={styles.window}>
          <nav className={styles.sidebar} aria-label="Lists" data-part="sidebar">
            <div className={styles.chrome} aria-hidden="true">
              <span className={styles.lights}>
                <span />
                <span />
                <span />
              </span>
              <span className={styles.chromePill}>
                <SidebarIcon />
                <PlayCircleIcon />
              </span>
              <span className={styles.chromeCircle}>
                <MoreIcon />
              </span>
            </div>

            <button
              type="button"
              className={styles.banner}
              data-part="banner"
              aria-disabled={imported || undefined}
              onClick={bringIn}
            >
              <span className={styles.bannerIcon} aria-hidden="true">
                <ArrowDownIcon />
              </span>
              <span className={styles.bannerText}>
                {imported ? `No new tasks from ${source}` : `1 task from ${source}`}
              </span>
              <span className={styles.bannerGo} aria-hidden="true">
                <ChevronRight />
              </span>
            </button>

            <div className={styles.tiles} data-part="trays">
              <button
                type="button"
                className={styles.tile}
                data-list="inbox"
                aria-current={list === 'inbox' ? 'page' : undefined}
                aria-label={`Inbox, ${plural(countOf('inbox'))}`}
                onClick={() => showList('inbox')}
              >
                <InboxTray tasks={inTray('inbox')} exit={exit} />
                <span className={styles.tileLabel}>Inbox</span>
              </button>
              <button
                type="button"
                className={styles.tile}
                data-list="today"
                aria-current={list === 'today' ? 'page' : undefined}
                aria-label={`Today, ${plural(countOf('today'))}`}
                onClick={() => showList('today')}
              >
                <TodayTray tasks={inTray('today')} exit={exit} />
                <span className={styles.tileLabel}>
                  Today <span className={styles.count}>{countOf('today')}</span>
                </span>
              </button>
              <span className={styles.flights} aria-hidden="true">
                {flights.map((flight) => (
                  <span
                    key={flight.id}
                    className={styles.flight}
                    data-to={flight.to}
                    style={{ animationDelay: `${flight.delay}ms` } as CSSProperties}
                  >
                    <span className={styles.slipBox} />
                    <span className={styles.slipText}>{flight.title}</span>
                  </span>
                ))}
              </span>
            </div>

            <div className={styles.group}>
              <h3 className={styles.groupTitle}>Collections</h3>
              {listButton('someday', <MoonIcon className={styles.listIcon} data-tone="purple" />)}
              {listButton(
                'upcoming',
                <CalendarIcon className={styles.listIcon} data-tone="orange" />,
              )}
              {listButton('log', <BookIcon className={styles.listIcon} data-tone="indigo" />)}
            </div>
            <div className={styles.group}>
              <h3 className={styles.groupTitle}>Spaces &amp; Lists</h3>
              {listButton(
                'goals',
                <span className={styles.emoji} aria-hidden="true">
                  😊
                </span>,
                <span className={styles.chip}>290 days</span>,
              )}
              {listButton(
                'projects',
                <span className={styles.emoji} aria-hidden="true">
                  ⚒️
                </span>,
                <InfinityIcon className={styles.trail} />,
              )}
              <button
                type="button"
                className={styles.space}
                data-list="studio"
                aria-current={list === 'studio' ? 'page' : undefined}
                onClick={() => showList('studio')}
              >
                <HouseIcon className={styles.listIcon} />
                <span className={styles.listName}>Studio</span>
                <span className={styles.spaceGo} aria-hidden="true">
                  <ChevronRight />
                </span>
              </button>
              <button
                type="button"
                className={styles.space}
                data-list="personal"
                aria-current={list === 'personal' ? 'page' : undefined}
                onClick={() => showList('personal')}
              >
                <HouseIcon className={styles.listIcon} />
                <span className={styles.listName}>Personal</span>
                <span className={styles.spaceGo} aria-hidden="true">
                  <ChevronDown />
                </span>
              </button>
            </div>
          </nav>

          <section
            className={styles.main}
            data-selecting={selecting || undefined}
            aria-label={current.name}
          >
            <div className={styles.corner} data-part="corner">
              <button
                type="button"
                className={styles.round}
                aria-label="Pin this list"
                aria-pressed={pinned}
                onClick={() => {
                  setPinned(!pinned);
                  announce(pinned ? `${current.name} unpinned.` : `${current.name} pinned.`);
                }}
              >
                <StarFilled className={styles.pin} />
              </button>
              <div
                ref={(element) => {
                  menus.current.list = element;
                }}
                className={styles.anchor}
              >
                <button
                  type="button"
                  className={styles.round}
                  data-menu="list"
                  aria-label="List options"
                  aria-haspopup="menu"
                  aria-expanded={menu === 'list'}
                  aria-controls={`${uid}list`}
                  onClick={() => openMenu('list', true)}
                >
                  <MoreIcon />
                </button>
                <Menu
                  id={`${uid}list`}
                  label="List options"
                  open={menu === 'list'}
                  focusOnOpen={menuFocus}
                  place="below-end"
                  items={[
                    {
                      id: 'select',
                      label: 'Select Tasks',
                      icon: <CheckCircleIcon className={styles.itemIcon} />,
                    },
                    {
                      id: 'new',
                      label: 'New Task',
                      icon: <PlusIcon className={styles.itemIcon} />,
                    },
                  ]}
                  onPick={(id) => {
                    if (id === 'select') {
                      // Focus goes back to the button, where Escape leaves select mode.
                      closeMenu(true);
                      startSelecting();
                    } else openComposer(true);
                  }}
                  onClose={closeMenu}
                />
              </div>
            </div>

            <header className={styles.head} data-part="head">
              <h2 className={styles.title} data-part="title">
                <span className={styles.glyph} data-glyph={list} aria-hidden="true">
                  <TrayGlyph data-for="inbox" />
                  <TodayGlyph data-for="today" />
                  <span data-for="other" className={styles.glyphOther} />
                </span>
                {current.name}
              </h2>
              <span className={styles.titleMore} aria-hidden="true">
                <MoreIcon />
              </span>
              <p className={styles.about}>{current.about}</p>
              <div
                ref={(element) => {
                  menus.current.filter = element;
                }}
                className={styles.anchor}
              >
                <button
                  type="button"
                  className={styles.filter}
                  data-part="filter"
                  data-active={filter !== 'all' || undefined}
                  aria-haspopup="menu"
                  aria-expanded={menu === 'filter'}
                  aria-controls={`${uid}filter`}
                  onClick={() => openMenu('filter', true)}
                >
                  Filter
                  <ChevronDown className={styles.filterChevron} />
                </button>
                <Menu
                  id={`${uid}filter`}
                  label="Filter"
                  open={menu === 'filter'}
                  focusOnOpen={menuFocus}
                  place="below-start"
                  items={[
                    { id: 'all', label: 'All Tasks', checked: filter === 'all' },
                    { id: 'fresh', label: 'Added Today', checked: filter === 'fresh' },
                  ]}
                  onPick={(id) => {
                    setFilter(id as Filter);
                    closeMenu(true);
                    announce(id === 'all' ? 'Showing all tasks.' : 'Showing tasks added today.');
                  }}
                  onClose={closeMenu}
                />
              </div>
            </header>

            <ul
              ref={rowsRef}
              className={styles.rows}
              data-part="rows"
              aria-label={`${current.name} tasks`}
            >
              {rows.map(({ task, index, how, slot }) => {
                const picked = selected.includes(task.id);
                return (
                  <li
                    key={slot}
                    className={styles.row}
                    data-part="row"
                    data-task={how ? undefined : task.id}
                    data-picked={picked || undefined}
                    data-ticked={ticked.includes(task.id) || how === 'done' || undefined}
                    data-leaving={how ?? undefined}
                    aria-hidden={how ? true : undefined}
                    inert={Boolean(how)}
                    style={{ '--i': index } as CSSProperties}
                  >
                    <span className={styles.rowShade} />
                    <span className={styles.rowFill}>
                      <span />
                    </span>
                    <button
                      type="button"
                      className={styles.check}
                      data-check
                      data-part="check"
                      aria-label={`Complete “${task.title}”`}
                      inert={selecting}
                      onClick={() => complete([task.id], true)}
                    >
                      <span className={styles.tick} />
                    </button>
                    <span className={styles.rowTitle}>{task.title}</span>
                    <button
                      type="button"
                      className={styles.pick}
                      data-part="pick"
                      aria-pressed={picked}
                      aria-label={task.title}
                      inert={!selecting}
                      onClick={() => toggle(task.id)}
                    >
                      <span className={styles.pickDot} />
                    </button>
                  </li>
                );
              })}
            </ul>
            {!shown.length && (
              <p className={styles.empty}>
                {filter === 'all' ? 'Nothing here yet.' : 'Nothing added today.'}
              </p>
            )}

            <button
              ref={fab}
              type="button"
              className={styles.fab}
              data-add
              data-part="add"
              aria-label={composing ? 'Add task' : 'New task'}
              aria-expanded={composing}
              aria-controls={`${uid}new`}
              inert={selecting}
              onClick={() => (composing && !composeByDemo.current ? add(true) : openComposer(true))}
            >
              <PlusIcon />
            </button>
            <div className={styles.bottom} data-part="bottom">
              <button
                type="button"
                className={styles.cancel}
                inert={!selecting}
                onClick={() => {
                  stopSelecting();
                  announce('Selection cancelled.');
                }}
              >
                Cancel
              </button>
              <div
                ref={(element) => {
                  menus.current.actions = element;
                }}
                className={styles.anchor}
              >
                <button
                  type="button"
                  className={styles.actions}
                  data-part="actions"
                  data-menu="actions"
                  aria-haspopup="menu"
                  aria-expanded={menu === 'actions'}
                  aria-controls={`${uid}actions`}
                  aria-disabled={!selectedCount || undefined}
                  inert={!selecting}
                  onClick={() => {
                    if (selectedCount) openMenu('actions', true);
                  }}
                >
                  {selectedCount ? plural(selectedCount) : 'Select tasks'}
                  <ChevronDown className={styles.actionsChevron} />
                </button>
                <Menu
                  id={`${uid}actions`}
                  label={`Do with ${plural(selectedCount)}`}
                  open={menu === 'actions'}
                  focusOnOpen={menuFocus}
                  place="above-end"
                  items={actionItems}
                  onPick={(id) => act(id, true)}
                  onClose={closeMenu}
                />
              </div>
            </div>
          </section>

          <form
            id={`${uid}new`}
            ref={composer}
            className={styles.composer}
            data-part="composer"
            data-open={composing || undefined}
            aria-label={`New task in ${current.name}`}
            inert={!composing}
            onSubmit={(event) => {
              event.preventDefault();
              add(true);
            }}
            onKeyDown={(event) => {
              if (event.key !== 'Escape') return;
              event.stopPropagation();
              closeComposer(true);
            }}
          >
            {/* The card, its hairline and its white, drawn as plain colours. */}
            <span className={styles.card} aria-hidden="true">
              <span />
              <span />
            </span>
            <div className={styles.composeTitle}>
              <span className={styles.composeCheck} aria-hidden="true" />
              <input
                ref={input}
                className={styles.input}
                value={draft}
                placeholder="New Task"
                aria-label="Title"
                enterKeyHint="done"
                autoComplete="off"
                onChange={(event) => setDraft(event.target.value)}
              />
            </div>
            {/* What a task can carry besides its title; drawn, not taken in here. */}
            <div className={styles.composeExtras} aria-hidden="true">
              <span className={styles.composeNotes}>
                <NotesIcon className={styles.composeIcon} />
                Notes
              </span>
              <span className={styles.composeChips}>
                <span>
                  <CalendarIcon className={styles.chipIcon} />
                  Plan
                </span>
                <span>
                  <DeadlineIcon className={styles.chipIcon} />
                  Deadline
                </span>
                <span>
                  <RepeatIcon className={styles.chipIcon} />
                  Repeat
                </span>
                <span>
                  <BellIcon className={styles.chipIcon} />
                  Reminders
                </span>
                <span>
                  <TagIcon className={styles.chipIcon} />
                  Tags
                </span>
              </span>
            </div>
            <p className={styles.composeList}>
              <span className={styles.glyph} data-glyph={list} aria-hidden="true">
                <TrayGlyph data-for="inbox" />
                <TodayGlyph data-for="today" />
                <span data-for="other" className={styles.glyphOther} />
              </span>
              {current.name}
            </p>
          </form>
        </div>

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

### skeuo-tasks/skeuo-tasks.module.css

```css
/* Skeuo tasks (skeuo-tasks.tsx): an app window on a misty grey screen, the sidebar on the left
   with the two trays (trays.tsx), the list on the right. On a narrow stage the sidebar keeps only
   its trays, above the list.

   Memory. A phone keeps the page's pixels in tiles the width of the screen, and a tile painted
   again takes more buffers. So every part that changes while the demo plays paints on a small
   layer of its own (will-change): the trays, the slips, the list's title, each row's title and
   box, the round + and the select bar, the new task's lines, the menus; and each of these layers
   keeps one size, as a layer that grows or shrinks is drawn again in new buffers. A layer painted
   again holds two buffers, and one painted at every frame (a colour fading) many more, so:
   - Safari paints a layer again whenever its opacity or transform changes, and all the layers in
     it; not when its translate, scale or rotate change. What moves (rows, slips, sheets, the
     card) moves by those.
   - A plain colour clipped to rounded corners holds no pixels, and its colour fades without
     painting: the rows' backgrounds and the new task's card are drawn so, and their shadows by the
     compositor (filter: drop-shadow, as a box-shadow of half the blur), but for the rows' shadow,
     too small for Safari to draw so.
   - New elements are new layers: rows keep their elements as they leave, and another list's rows
     take them over.
   Above all, the whole page must never be laid out, as that paints all its tiles again. The stage
   is one layout boundary, its height set from its width; its two panes are placed absolutely,
   not by a grid, so that a change in one never lays the other out (and paints it again); and no
   query container is left around it once the script has measured it (see the end).

   Colours are the tokens on .root, light-dark(light, dark) where the two differ: 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). At night it is the same desk in dim light:
   the screen unlit, the window dark, the trays the same plastic and the papers the same paper,
   only darker. The coloured list glyphs keep their colours. */

.root {
  /* The screen behind the window: a misty grey, a haze above and darker hills below. */
  --screen-top: #d8d8da;
  --screen-middle: #c6c6c9;
  --screen-bottom: #a2a2a6;
  --screen-haze: rgb(255 255 255 / 0.75);
  --screen-hill: rgb(52 52 56 / 0.55);
  --screen-far-hill: rgb(40 40 44 / 0.5);
  --screen-edge: rgb(0 0 0 / 0.06);
  /* The window (the sidebar's grey), its edge and shadow, the list's pane, and their rules. */
  --surface: #f1f0f3;
  --window-edge: rgb(0 0 0 / 0.14);
  --shadow: rgb(0 0 0 / 0.38);
  --pane: #f1f0f3;
  --line: #e2e1e6;
  --pane-edge: #e6e5e9;
  --narrow-edge: #dcdbe1;
  --group-edge: #e3e2e7;
  --head-edge: #e9e8ec;
  /* Raised controls: the window's buttons, the round ones and Cancel; their icons, their shadows. */
  --surface-2: #fff;
  --control-ink: #3a3a3c;
  --control-shadow: rgb(0 0 0 / 0.08);
  --round-shadow: rgb(0 0 0 / 0.1);
  --cancel-edge: rgb(0 0 0 / 0.06);
  --cancel-shadow: rgb(0 0 0 / 0.08);
  /* The words: the titles; quiet ones; faint ones (groups' titles, the empty list); the trays'
     names; the list's description. */
  --ink: #1c1c1e;
  --ink-muted: #8e8e93;
  --faint: #a3a2a8;
  --tile-ink: #a1a1a6;
  --about: #b9b8bd;
  /* A wash under the current list or tray, a count, a chip; the spaces' fill. */
  --wash: #e4e3e9;
  --space: #e8e7ec;
  --space-current: rgb(47 124 246 / 0.4);
  --space-go: #d6d5dc;
  --space-go-ink: #fff;
  --trail: #b4b3b9;
  /* The banner: its fill and words, its arrow's disc (grey once used), its chevron's disc. */
  --banner: #e5e3ee;
  --banner-ink: #626169;
  --banner-used: #b9b7c3;
  --banner-go: #d4d2dc;
  --banner-go-ink: #6e6c77;
  /* The window's three lights, the same on both schemes. */
  --light-red: #f0857e;
  --light-yellow: #f2c76a;
  --light-green: #8fd18a;
  /* The lists' icons. */
  --purple: #8e6cf0;
  --orange: #f0703a;
  --indigo: #6f6be8;
  /* The heading: the other lists' glyph, its ... button, Filter (and on). */
  --glyph-top: #a7a6ad;
  --glyph-bottom: #86858c;
  --title-more: #e7e6eb;
  --title-more-ink: #b4b3b9;
  --filter: #e4e3e8;
  --filter-ink: #8a898f;
  --filter-on: #dce8fd;
  /* The blue of the app: fills under white (the banner's arrow, ticked boxes, picked rows, the
     round + and the actions), their glow, and blue words and marks on the window. */
  --accent: #0a73f5;
  --accent-ink: #fff;
  --accent-glow: rgb(10 115 245 / 0.35);
  --accent-text: #0a73f5;
  --focus: #0a73f5;
  /* A pinned list's star. */
  --pin: #f5b301;
  /* The rows: a box's edge (in select mode too), a row's card and shadow in select mode, the
     picked dot's edge. */
  --check-edge: #c5c5ca;
  --check-edge-selecting: #d1d1d6;
  --pick-edge: #c7c7cc;
  --row-card: #fff;
  --row-shadow: rgb(0 0 0 / 0.08);
  --picked-edge: rgb(255 255 255 / 0.85);
  /* The new task's card: its fill, hairline and shadow; its placeholder and notes; its chips. */
  --card: #fff;
  --card-edge: rgb(0 0 0 / 0.08);
  --card-shadow: rgb(0 0 0 / 0.14);
  --placeholder: #c4c3c8;
  --chips: #f4f4f6;
  --chips-ink: #a9a8ae;
  /* The menus: their fill, edge and shadow, a lit item, and Delete. */
  --menu: rgb(250 250 252 / 0.98);
  --menu-edge: rgb(0 0 0 / 0.12);
  --menu-shadow: rgb(0 0 0 / 0.2);
  --menu-lit: #e9e8ee;
  --danger: #ff3b30;
  /* The Inbox tray: blue plastic, its rim, shadow and ring when current; its back; its front,
     the front's highlight, shadow and glyph. At night the same plastic, unlit. */
  --inbox-top: #a5d4fa;
  --inbox-bottom: #4a86de;
  --inbox-rim: rgb(255 255 255 / 0.55);
  --inbox-shadow: rgb(36 84 180 / 0.28);
  --inbox-ring: rgb(10 115 245 / 0.18);
  --inbox-back-top: #0f3756;
  --inbox-back-bottom: #24506f;
  --inbox-lip-top: #7fc0f1;
  --inbox-lip-middle: #5893d8;
  --inbox-lip-bottom: #437bd8;
  --inbox-lip-shine: rgb(255 255 255 / 0.85);
  --inbox-lip-shadow: rgb(10 30 80 / 0.3);
  --inbox-lip-ink: rgb(20 60 150 / 0.45);
  /* The Today tray: white plastic, then the same, its back and its front. At night, grey. */
  --today-top: #fbfbfc;
  --today-bottom: #d3d3d8;
  --today-rim: rgb(255 255 255 / 0.8);
  --today-shadow: rgb(0 0 0 / 0.16);
  --today-ring: rgb(0 0 0 / 0.08);
  --today-back-top: #262c2c;
  --today-back-bottom: #3c4242;
  --today-lip-top: #ffffff;
  --today-lip-middle: #e2e1e5;
  --today-lip-bottom: #b2b3bc;
  --today-lip-shine: #fff;
  --today-lip-shadow: rgb(0 0 0 / 0.22);
  --today-lip-ink: #a9a9af;
  /* The trays' back's shade, and the papers: paper, its shadow (a flying slip's too), a line's
     box and words. At night, the same paper in dim light. */
  --back-shade: rgb(0 0 0 / 0.45);
  --paper-top: #fff;
  --paper-bottom: #eff0f3;
  --paper-shadow: rgb(0 0 0 / 0.35);
  --flight-shadow: rgb(0 0 0 / 0.25);
  --paper-box: #a9afba;
  --paper-ink: #3a3a3e;
  /* The demo's pointer, the same on both schemes: it looks like the system's. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --screen-top: light-dark(#d8d8da, #28282c);
    --screen-middle: light-dark(#c6c6c9, #19191c);
    --screen-bottom: light-dark(#a2a2a6, #0c0c0e);
    --screen-haze: light-dark(rgb(255 255 255 / 0.75), rgb(255 255 255 / 0.1));
    --screen-hill: light-dark(rgb(52 52 56 / 0.55), rgb(0 0 0 / 0.5));
    --screen-far-hill: light-dark(rgb(40 40 44 / 0.5), rgb(0 0 0 / 0.45));
    --screen-edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --surface: light-dark(#f1f0f3, #232326);
    --window-edge: light-dark(rgb(0 0 0 / 0.14), rgb(255 255 255 / 0.14));
    --shadow: light-dark(rgb(0 0 0 / 0.38), rgb(0 0 0 / 0.6));
    --pane: light-dark(#f1f0f3, #232326);
    --line: light-dark(#e2e1e6, #333337);
    --pane-edge: light-dark(#e6e5e9, #333336);
    --narrow-edge: light-dark(#dcdbe1, #333336);
    --group-edge: light-dark(#e3e2e7, #333337);
    --head-edge: light-dark(#e9e8ec, #313134);
    --surface-2: light-dark(#fff, #38383b);
    --control-ink: light-dark(#3a3a3c, #e5e5ea);
    --control-shadow: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.3));
    --round-shadow: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.35));
    --cancel-edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.08));
    --cancel-shadow: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.35));
    --ink: light-dark(#1c1c1e, #f2f2f7);
    --ink-muted: light-dark(#8e8e93, #a5a5ab);
    --faint: light-dark(#a3a2a8, #8a898f);
    --tile-ink: light-dark(#a1a1a6, #a5a5ab);
    --about: light-dark(#b9b8bd, #8e8d94);
    --wash: light-dark(#e4e3e9, #38373e);
    --space: light-dark(#e8e7ec, #2e2d33);
    --space-current: light-dark(rgb(47 124 246 / 0.4), rgb(79 157 255 / 0.5));
    --space-go: light-dark(#d6d5dc, #4a4950);
    --space-go-ink: light-dark(#fff, #d0d0d6);
    --trail: light-dark(#b4b3b9, #8a898f);
    --banner: light-dark(#e5e3ee, #2f2e36);
    --banner-ink: light-dark(#626169, #b4b3bc);
    --banner-used: light-dark(#b9b7c3, #5a5862);
    --banner-go: light-dark(#d4d2dc, #45434d);
    --banner-go-ink: light-dark(#6e6c77, #c0bec8);
    --purple: light-dark(#8e6cf0, #a48af5);
    --orange: light-dark(#f0703a, #ff8452);
    --indigo: light-dark(#6f6be8, #8c88f0);
    --glyph-top: light-dark(#a7a6ad, #77767d);
    --glyph-bottom: light-dark(#86858c, #5c5b62);
    --title-more: light-dark(#e7e6eb, #333237);
    --title-more-ink: light-dark(#b4b3b9, #8a898f);
    --filter: light-dark(#e4e3e8, #333237);
    --filter-ink: light-dark(#8a898f, #a3a2a8);
    --filter-on: light-dark(#dce8fd, #1c2c45);
    --accent-text: light-dark(#0a73f5, #4f9dff);
    --focus: light-dark(#0a73f5, #4f9dff);
    --check-edge: light-dark(#c5c5ca, #6c6c72);
    --check-edge-selecting: light-dark(#d1d1d6, #6c6c72);
    --pick-edge: light-dark(#c7c7cc, #6c6c72);
    --row-card: light-dark(#fff, #2e2e32);
    --row-shadow: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.4));
    --card: light-dark(#fff, #2c2c2f);
    --card-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.1));
    --card-shadow: light-dark(rgb(0 0 0 / 0.14), rgb(0 0 0 / 0.5));
    --placeholder: light-dark(#c4c3c8, #95949a);
    --chips: light-dark(#f4f4f6, #363639);
    --chips-ink: light-dark(#a9a8ae, #a3a2a8);
    --menu: light-dark(rgb(250 250 252 / 0.98), rgb(44 44 48 / 0.98));
    --menu-edge: light-dark(rgb(0 0 0 / 0.12), rgb(255 255 255 / 0.12));
    --menu-shadow: light-dark(rgb(0 0 0 / 0.2), rgb(0 0 0 / 0.5));
    --menu-lit: light-dark(#e9e8ee, #3a3a3f);
    --danger: light-dark(#ff3b30, #ff8077);
    --inbox-top: light-dark(#a5d4fa, #4f7fae);
    --inbox-bottom: light-dark(#4a86de, #234f93);
    --inbox-rim: light-dark(rgb(255 255 255 / 0.55), rgb(255 255 255 / 0.22));
    --inbox-shadow: light-dark(rgb(36 84 180 / 0.28), rgb(0 0 0 / 0.45));
    --inbox-ring: light-dark(rgb(10 115 245 / 0.18), rgb(79 157 255 / 0.3));
    --inbox-back-top: light-dark(#0f3756, #07192a);
    --inbox-back-bottom: light-dark(#24506f, #112a40);
    --inbox-lip-top: light-dark(#7fc0f1, #4a7fb3);
    --inbox-lip-middle: light-dark(#5893d8, #2f5a96);
    --inbox-lip-bottom: light-dark(#437bd8, #264d95);
    --inbox-lip-shine: light-dark(rgb(255 255 255 / 0.85), rgb(255 255 255 / 0.32));
    --inbox-lip-shadow: light-dark(rgb(10 30 80 / 0.3), rgb(0 8 30 / 0.5));
    --inbox-lip-ink: light-dark(rgb(20 60 150 / 0.45), rgb(4 20 60 / 0.55));
    --today-top: light-dark(#fbfbfc, #6c6c72);
    --today-bottom: light-dark(#d3d3d8, #404045);
    --today-rim: light-dark(rgb(255 255 255 / 0.8), rgb(255 255 255 / 0.22));
    --today-shadow: light-dark(rgb(0 0 0 / 0.16), rgb(0 0 0 / 0.45));
    --today-ring: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.12));
    --today-back-top: light-dark(#262c2c, #121616);
    --today-back-bottom: light-dark(#3c4242, #202525);
    --today-lip-top: light-dark(#ffffff, #85858b);
    --today-lip-middle: light-dark(#e2e1e5, #626267);
    --today-lip-bottom: light-dark(#b2b3bc, #45464d);
    --today-lip-shine: light-dark(#fff, rgb(255 255 255 / 0.4));
    --today-lip-shadow: light-dark(rgb(0 0 0 / 0.22), rgb(0 0 0 / 0.45));
    --today-lip-ink: light-dark(#a9a9af, #3c3c41);
    --back-shade: light-dark(rgb(0 0 0 / 0.45), rgb(0 0 0 / 0.6));
    --paper-top: light-dark(#fff, #b9bac0);
    --paper-bottom: light-dark(#eff0f3, #a5a7ae);
    --paper-shadow: light-dark(rgb(0 0 0 / 0.35), rgb(0 0 0 / 0.55));
    --flight-shadow: light-dark(rgb(0 0 0 / 0.25), rgb(0 0 0 / 0.5));
    --paper-box: light-dark(#a9afba, #7b808b);
    --paper-ink: light-dark(#3a3a3e, #26262a);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.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;
}

/* Until the script has measured the stage, a container query picks its layout (see the end). */
.root:not([data-layout]) {
  container-type: inline-size;
}

.stage {
  --pitch: 38.5px;
  --tile: 80px;
  --gap: 10.5px;
  --spring: cubic-bezier(0.3, 1.25, 0.45, 1);
  position: relative;
  contain: size layout;
  box-sizing: content-box;
  /* The window's height follows its width, and the stage adds its margins: the padding's
     percentage is of the width. */
  height: 42px;
  padding-bottom: max(378px, min(79.4% - 31.76px, 527.216px));
  border-radius: 18px;
  /* A misty grey screen: light above, darker hills below. */
  background:
    radial-gradient(ellipse 60% 40% at 78% 8%, var(--screen-haze), transparent 70%),
    radial-gradient(ellipse 70% 38% at 12% 100%, var(--screen-hill), transparent 70%),
    radial-gradient(ellipse 60% 30% at 90% 104%, var(--screen-far-hill), transparent 70%),
    linear-gradient(var(--screen-top), var(--screen-middle) 55%, var(--screen-bottom));
  box-shadow: inset 0 0 0 1px var(--screen-edge);
  color: var(--ink);
  font:
    400 13px/1.3 -apple-system,
    BlinkMacSystemFont,
    'SF Pro Text',
    Inter,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
.stage *,
.stage *::before,
.stage *::after {
  box-sizing: border-box;
}
/* Buttons start from the stage's type; :where() keeps this below every class. */
:where(.stage) :is(button, input) {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
}
:where(.stage) button {
  cursor: pointer;
}
:where(.stage) :is(p, h2, h3, ul) {
  margin: 0;
}
:where(.stage) svg {
  display: block;
  flex: none;
}
.stage :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The window: the sidebar's grey everywhere, the list's pane over its right part. */
.window {
  position: absolute;
  top: 22px;
  bottom: 20px;
  left: 0;
  right: 0;
  width: min(664px, calc(100% - 40px));
  margin: 0 auto;
  border-radius: 22px;
  background: var(--surface);
  box-shadow:
    0 0 0 0.5px var(--window-edge),
    0 18px 44px -14px var(--shadow);
}

/* The sidebar. */
.sidebar {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 200px;
  display: flex;
  flex-direction: column;
  min-height: 0;
  padding: 5px 9px 0 7px;
  overflow: hidden;
}
.chrome {
  display: flex;
  flex: none;
  align-items: center;
  gap: 11.5px;
  height: 34px;
  padding-left: 6px;
}
.lights,
.chromePill,
.chromeCircle {
  display: flex;
  align-items: center;
  border-radius: 999px;
  background: var(--surface-2);
  box-shadow: 0 1px 3px var(--control-shadow);
}
.lights {
  gap: 2.5px;
  height: 13.5px;
  padding: 0 4.5px;
}
.lights > span {
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--light-red);
}
.lights > span + span {
  background: var(--light-yellow);
}
.lights > span + span + span {
  background: var(--light-green);
}
.chromePill {
  gap: 20px;
  height: 28.5px;
  padding: 0 10.5px;
  color: var(--control-ink);
}
.chromePill svg,
.chromeCircle svg {
  width: 17px;
  height: 17px;
}
.chromeCircle {
  justify-content: center;
  width: 28px;
  height: 28px;
  margin-right: -1.5px;
  margin-left: auto;
  color: var(--control-ink);
}

.banner {
  display: flex;
  flex: none;
  align-items: center;
  gap: 6.5px;
  height: 32px;
  margin-top: 33.5px;
  padding: 0 8.5px 0 9.5px;
  border-radius: 10.5px;
  background: var(--banner);
  color: var(--banner-ink);
  font-size: 11.3px;
  text-align: left;
}
.banner[aria-disabled='true'] {
  color: var(--ink-muted);
  cursor: default;
}
.bannerIcon,
.bannerGo {
  display: grid;
  place-items: center;
  width: 14px;
  height: 14px;
  border-radius: 50%;
}
.bannerIcon {
  background: var(--accent);
  color: var(--accent-ink);
}
.banner[aria-disabled='true'] .bannerIcon {
  background: var(--banner-used);
}
.bannerIcon svg,
.bannerGo svg {
  width: 10px;
  height: 10px;
  stroke-width: 2.2;
}
.bannerText {
  flex: 1;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.bannerGo {
  background: var(--banner-go);
  color: var(--banner-go-ink);
}

/* The two trays, with their names under them, and the slips that fly between them. */
.tiles {
  /* Room around the trays for what reaches out of them (the slips' arc above, the rings and
     shadows, focus outlines), clipped there so that the layer keeps one size. */
  --room: 30px;
  position: relative;
  display: flex;
  flex: none;
  justify-content: center;
  gap: var(--gap);
  margin: calc(12.5px - var(--room)) -6px -6px;
  padding: var(--room) 6px 6px;
  overflow: clip;
  will-change: transform;
}
.tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6.5px;
  width: var(--tile);
  border-radius: 19px;
}
.tileLabel {
  display: flex;
  align-items: center;
  gap: 6px;
  height: 19.5px;
  padding: 0 7px;
  border-radius: 10px;
  color: var(--tile-ink);
  font-size: 12.2px;
}
.tile[aria-current] .tileLabel {
  background: var(--wash);
}
/* A small layer of its own: it counts up as each slip lands, and the trays are not painted again. */
.count {
  display: grid;
  will-change: transform;
  place-items: center;
  min-width: 19px;
  height: 19px;
  margin-right: -7px;
  padding: 0 5px;
  border-radius: 10px;
  background: var(--wash);
  font-size: 11px;
}

.tray {
  position: relative;
  display: block;
  width: var(--tile);
  height: calc(var(--tile) - 3px);
  border-radius: 19px;
}
.tray[data-tray='inbox'] {
  background: linear-gradient(var(--inbox-top), var(--inbox-bottom));
  box-shadow:
    inset 0 0 0 1px var(--inbox-rim),
    0 3px 8px var(--inbox-shadow);
}
.tray[data-tray='today'] {
  background: linear-gradient(var(--today-top), var(--today-bottom));
  box-shadow:
    inset 0 0 0 1px var(--today-rim),
    0 3px 8px var(--today-shadow);
}
.tile[aria-current] .tray[data-tray='inbox'] {
  box-shadow:
    inset 0 0 0 1px var(--inbox-rim),
    0 3px 8px var(--inbox-shadow),
    0 0 0 4px var(--inbox-ring);
}
.tile[aria-current] .tray[data-tray='today'] {
  box-shadow:
    inset 0 0 0 1px var(--today-rim),
    0 3px 8px var(--today-shadow),
    0 0 0 4px var(--today-ring);
}
/* Inside the tray: its dark back, where the papers stand. */
.slot {
  position: absolute;
  top: 5px;
  right: 4px;
  left: 4px;
  height: 52px;
  overflow: hidden;
  border-radius: 14px 14px 5px 5px;
  box-shadow: inset 0 2px 5px var(--back-shade);
}
.tray[data-tray='inbox'] .slot {
  background: linear-gradient(var(--inbox-back-top), var(--inbox-back-bottom));
}
.tray[data-tray='today'] .slot {
  background: linear-gradient(var(--today-back-top), var(--today-back-bottom));
}
/* The tray's front, over the bottom of the papers. */
.lip {
  position: absolute;
  z-index: 20;
  right: 0;
  bottom: 0;
  left: 0;
  display: grid;
  place-items: center;
  height: 28px;
  padding-top: 3px;
  border-radius: 6px 6px 19px 19px;
}
.tray[data-tray='inbox'] .lip {
  background: linear-gradient(
    var(--inbox-lip-top),
    var(--inbox-lip-middle) 50%,
    var(--inbox-lip-bottom)
  );
  box-shadow:
    inset 0 1px 0 var(--inbox-lip-shine),
    0 -2px 5px var(--inbox-lip-shadow);
  color: var(--inbox-lip-ink);
}
.tray[data-tray='today'] .lip {
  background: linear-gradient(
    var(--today-lip-top),
    var(--today-lip-middle) 45%,
    var(--today-lip-bottom)
  );
  box-shadow:
    inset 0 1px 0 var(--today-lip-shine),
    0 -2px 5px var(--today-lip-shadow);
  color: var(--today-lip-ink);
}
.lipGlyph {
  width: 15px;
  height: 15px;
}

/* The papers, on a layer that draws nothing itself: papers that come and go do not paint the
   trays again. The tray's back clips them. */
.papers {
  position: absolute;
  inset: 0;
  will-change: transform;
}

/* A paper: placed by its depth in the pile (--k) and the pile's rise; the front one on top. */
.slip,
.sheet {
  position: absolute;
  z-index: calc(10 - var(--k));
  right: 2px;
  left: 2px;
  border-radius: 4px;
  background: linear-gradient(var(--paper-top), var(--paper-bottom));
  box-shadow: 0 1px 2px var(--paper-shadow);
  transform-origin: 50% 0;
  transition:
    translate 0.55s var(--spring),
    scale 0.55s var(--spring),
    opacity 0.3s ease;
  /* A layer of its own for good, moved by translate and scale, which never paint it again. */
  will-change: transform;
}
.slip {
  bottom: -8px;
  display: flex;
  align-items: flex-start;
  gap: 4px;
  height: 30px;
  padding: 4px 6px 0;
  /* Dimmer further back; the fourth, at the very back, out of sight. */
  opacity: clamp(0, 1 - var(--k) * 0.08 - max(0, var(--k) - 2), 1);
  translate: 0 calc(var(--k) * -9px - var(--rise));
  scale: calc(1 - var(--k) * 0.04);
}
.sheet {
  bottom: -2px;
  display: flex;
  flex-direction: column;
  gap: 2px;
  height: 44px;
  padding: 5px 6px;
  translate: 0 calc(var(--k) * -3px - var(--rise));
  scale: calc(1 - var(--k) * 0.04);
}
.sheetLine {
  display: flex;
  align-items: center;
  gap: 4px;
}
.slipBox {
  flex: none;
  width: 7px;
  height: 7px;
  border: 1px solid var(--paper-box);
  border-radius: 50%;
}
.slipText {
  overflow: hidden;
  color: var(--paper-ink);
  font-size: 7.2px;
  font-weight: 600;
  line-height: 1;
  white-space: nowrap;
  text-overflow: ellipsis;
}
/* Leaving: lifted out of the tray, or flung up and away. */
.slip[data-exit='lift'],
.sheet[data-exit='lift'] {
  animation: lift 0.5s ease-in forwards;
}
.slip[data-exit='fly'],
.sheet[data-exit='fly'] {
  animation: fling 0.55s cubic-bezier(0.3, 0, 0.6, 1) forwards;
}
@keyframes lift {
  to {
    opacity: 0;
    translate: 0 -48px;
    scale: 0.9;
  }
}
@keyframes fling {
  to {
    opacity: 0;
    translate: 34px -66px;
    rotate: 24deg;
    scale: 1;
  }
}

/* A slip on its way from one tray to the other, on an arc; over the trays, on a layer that draws
   nothing itself, so that the slips that come and go do not paint the trays again. */
.flights {
  position: absolute;
  z-index: 30;
  inset: 0;
  pointer-events: none;
  will-change: transform;
}
.flight {
  --hop: calc(var(--tile) + var(--gap));
  position: absolute;
  top: calc(var(--room) + 18px);
  /* Over the Inbox tray: the trays sit side by side in the middle. */
  left: calc(50% - var(--tile) - var(--gap) / 2 + 6px);
  display: flex;
  align-items: center;
  gap: 4px;
  width: 68px;
  height: 22px;
  padding: 0 6px;
  border-radius: 4px;
  background: linear-gradient(var(--paper-top), var(--paper-bottom));
  /* As a box-shadow of 0 3px 8px, drawn by the compositor around the slip. */
  filter: drop-shadow(0 3px 4px var(--flight-shadow));
  opacity: 0;
  animation: to-today 0.64s cubic-bezier(0.45, 0, 0.3, 1) both;
  will-change: transform;
}
.flight[data-to='inbox'] {
  animation-name: to-inbox;
}
@keyframes to-today {
  0% {
    opacity: 0;
    translate: 0 10px;
    rotate: 0deg;
    scale: 0.9;
  }
  15% {
    opacity: 1;
  }
  50% {
    translate: calc(var(--hop) * 0.5) -34px;
    rotate: 10deg;
    scale: 1;
  }
  85% {
    opacity: 1;
  }
  100% {
    opacity: 0;
    translate: var(--hop) 14px;
    rotate: 0deg;
    scale: 0.9;
  }
}
@keyframes to-inbox {
  0% {
    opacity: 0;
    translate: var(--hop) 10px;
    rotate: 0deg;
    scale: 0.9;
  }
  15% {
    opacity: 1;
  }
  50% {
    translate: calc(var(--hop) * 0.5) -34px;
    rotate: -10deg;
    scale: 1;
  }
  85% {
    opacity: 1;
  }
  100% {
    opacity: 0;
    translate: 0 14px;
    rotate: 0deg;
    scale: 0.9;
  }
}

/* The lists below the trays. */
.group {
  display: flex;
  flex: none;
  flex-direction: column;
  margin-top: 18px;
  padding-top: 15.5px;
  border-top: 1px solid var(--group-edge);
}
.group + .group {
  margin-top: 11.5px;
}
.groupTitle {
  margin: 0 0 5px;
  color: var(--faint);
  font-size: 11.9px;
  font-weight: 600;
  line-height: 15px;
}
.listButton,
.space {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  height: 26.5px;
  padding: 0 6px 0 9px;
  border-radius: 8px;
  font-size: 14.2px;
  text-align: left;
}
.listButton[aria-current],
.listButton:hover {
  background: var(--wash);
}
.listName {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.listIcon {
  width: 14px;
  height: 14px;
}
.listIcon[data-tone='purple'] {
  color: var(--purple);
}
.listIcon[data-tone='orange'] {
  color: var(--orange);
}
.listIcon[data-tone='indigo'] {
  color: var(--indigo);
}
.emoji {
  width: 14px;
  font-size: 13px;
  line-height: 1;
  text-align: center;
}
.chip {
  margin-left: auto;
  padding: 1.5px 4px;
  border-radius: 7px;
  background: var(--wash);
  color: var(--ink-muted);
  font-size: 8px;
  font-weight: 600;
  white-space: nowrap;
}
.trail {
  width: 15px;
  height: 15px;
  color: var(--trail);
}
.space {
  height: 32.5px;
  margin-top: 4px;
  padding: 0 8px 0 9px;
  border-radius: 10.5px;
  background: var(--space);
  font-size: 13.4px;
  font-weight: 600;
}
.space + .space {
  margin-top: 11.5px;
}
.space[aria-current] {
  box-shadow: inset 0 0 0 1.5px var(--space-current);
}
.spaceGo {
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  margin-left: auto;
  border-radius: 50%;
  background: var(--space-go);
  color: var(--space-go-ink);
}
.spaceGo svg {
  width: 12px;
  height: 12px;
  stroke-width: 2.4;
}

/* The list's pane. */
.main {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 200px;
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  padding: 0 9px 0 7px;
  border-left: 1px solid var(--pane-edge);
  border-radius: 0 22px 22px 0;
  background: var(--pane);
}
.corner {
  position: absolute;
  z-index: 6;
  top: 9px;
  right: 7.5px;
  display: flex;
  gap: 8.5px;
}
.anchor {
  position: relative;
}
.round {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: var(--surface-2);
  box-shadow: 0 1px 4px var(--round-shadow);
  color: var(--control-ink);
}
.round svg {
  width: 15px;
  height: 15px;
}
.round[aria-pressed='true'] .pin {
  color: var(--pin);
}

/* The list's heading: what changes with the list, its title and what it is for, is on small
   layers of its own; the rest never changes. */
.head {
  position: relative;
  z-index: 4;
  display: grid;
  flex: none;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  row-gap: 3.5px;
  padding: 69.5px 0 13px;
  border-bottom: 1px solid var(--head-edge);
}
.title {
  display: flex;
  align-items: center;
  gap: 7.5px;
  min-width: 0;
  font-size: 19px;
  font-weight: 700;
  line-height: 24px;
  will-change: transform;
}
.glyph {
  position: relative;
  flex: none;
  width: 19px;
  height: 19px;
}
.glyph > * {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  visibility: hidden;
}
.glyph[data-glyph='inbox'] > [data-for='inbox'],
.glyph[data-glyph='today'] > [data-for='today'],
.glyph:not([data-glyph='inbox'], [data-glyph='today']) > [data-for='other'] {
  visibility: visible;
}
.glyphOther {
  border-radius: 6px;
  background: linear-gradient(var(--glyph-top), var(--glyph-bottom));
}
.titleMore {
  display: grid;
  place-items: center;
  width: 27px;
  height: 17px;
  border-radius: 8.5px;
  background: var(--title-more);
  color: var(--title-more-ink);
}
.titleMore svg {
  width: 14px;
  height: 14px;
}
.about {
  grid-column: 1 / -1;
  overflow: hidden;
  will-change: transform;
  color: var(--about);
  font-size: 12px;
  line-height: 15px;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.filter {
  display: flex;
  align-items: center;
  gap: 3px;
  height: 25.5px;
  margin-top: 15px;
  padding: 0 5px 0 6px;
  border-radius: 6px;
  background: var(--filter);
  color: var(--filter-ink);
  font-size: 11.7px;
}
.filter[data-active] {
  background: var(--filter-on);
  color: var(--accent-text);
}
.filterChevron {
  width: 10px;
  height: 10px;
  stroke-width: 2;
}

/* The rows: each placed by its index (--i), so they slide when one leaves. */
.rows {
  position: relative;
  flex: none;
  /* One row tall whatever the count: the rows hang from it, so it is never laid out again. Its
     layer draws nothing: it keeps the rows that come and go from painting the page. */
  height: var(--pitch);
  margin: 13.5px 11px 0 12px;
  padding: 0;
  list-style: none;
  will-change: transform;
}
.row {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  display: flex;
  align-items: center;
  gap: 9px;
  height: 32.5px;
  padding: 0 12px 0 9px;
  border-radius: 9px;
  translate: 0 calc(var(--i) * var(--pitch));
  transition:
    translate 0.45s var(--spring),
    color 0.2s ease;
  will-change: transform;
}
/* A row draws nothing itself: it is put together from layers that hold no pixels (its line and
   its background, plain colours, the background clipped to the row's rounded corners), small ones
   for its title, its box and its dot, and its shadow, there only in select mode. So its colours
   fade without painting anything, and only what has to be is painted again, small. */
.row::after {
  position: absolute;
  right: 0;
  bottom: -3px;
  left: 34px;
  height: 1px;
  background: var(--line);
  content: '';
  will-change: transform;
}
/* The row's shadow in select mode: painted once as it shows, and dropped when it hides. Its own
   colour keeps the row's fading text colour from painting it again. */
.rowShade {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 0.5px 1.5px var(--row-shadow);
  color: #000;
  visibility: hidden;
  will-change: transform;
}
.main[data-selecting] .rowShade {
  visibility: visible;
}
/* The row's background, white in select mode and blue when picked. */
.rowFill {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: inherit;
  will-change: transform;
}
.rowFill > span {
  position: absolute;
  inset: 0;
  transition: background-color 0.2s ease;
  will-change: transform;
}
.main[data-selecting] .rowFill > span {
  background-color: var(--row-card);
}
.main[data-selecting] .row::after {
  background: transparent;
}
.main[data-selecting] .row[data-picked] {
  color: var(--accent-ink);
}
.main[data-selecting] .row[data-picked] .rowFill > span {
  background-color: var(--accent);
}
/* A row that leaves plays out on the element it had, over the others; it takes its last look at
   once, as a new element would. */
.row[data-leaving] {
  z-index: 1;
}
.row[data-leaving],
.row[data-leaving] * {
  transition: none;
}
.row[data-leaving='moved'] {
  animation: row-out 0.38s ease forwards;
}
.row[data-leaving='done'] {
  animation: row-fade 0.38s ease forwards;
}
@keyframes row-out {
  to {
    opacity: 0;
    translate: 32px calc(var(--i) * var(--pitch));
  }
}
@keyframes row-fade {
  to {
    opacity: 0;
    translate: 0 calc(var(--i) * var(--pitch));
    scale: 0.97;
  }
}
.check {
  position: relative;
  display: grid;
  flex: none;
  place-items: center;
  width: 16px;
  height: 16px;
  border: 1.5px solid var(--check-edge);
  border-radius: 50%;
  /* A colour of its own, so that the row's fading text colour does not paint it again. */
  color: #000;
  will-change: transform;
  transition:
    background-color 0.2s ease,
    border-color 0.2s ease,
    border-radius 0.2s ease;
}
.check::before {
  /* A larger target than the circle. */
  position: absolute;
  inset: -8px;
  content: '';
}
.main[data-selecting] .check {
  border-color: var(--check-edge-selecting);
  border-radius: 4.5px;
}
.main[data-selecting] .row[data-picked] .check {
  border-color: var(--picked-edge);
}
.row[data-ticked] .check {
  border-color: var(--accent);
  background: var(--accent);
}
.tick {
  width: 4px;
  height: 8px;
  margin-top: -2px;
  border: solid var(--accent-ink);
  border-width: 0 2px 2px 0;
  opacity: 0;
  transform: rotate(45deg) scale(0.6);
  transition:
    opacity 0.15s ease,
    transform 0.25s var(--spring);
}
.row[data-ticked] .tick {
  opacity: 1;
  transform: rotate(45deg);
}
.rowTitle {
  flex: 1;
  will-change: transform;
  overflow: hidden;
  font-size: 12px;
  white-space: nowrap;
  text-overflow: ellipsis;
}
/* In select mode, the whole row picks; its dot on the right says whether it is picked. */
.pick {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  padding-right: 11px;
  border-radius: inherit;
  visibility: hidden;
}
.main[data-selecting] .pick {
  visibility: visible;
}
.pickDot {
  width: 12px;
  height: 12px;
  border: 1.5px solid var(--pick-edge);
  border-radius: 50%;
  color: #000;
  will-change: transform;
}
.row[data-picked] .pickDot {
  border-color: var(--accent-ink);
  background: var(--accent-ink);
}
.empty {
  margin-top: -28px;
  padding: 0 10px;
  color: var(--faint);
  font-size: 13px;
}

/* The round + at the bottom; in select mode, Cancel and what to do with the picked tasks. */
.fab {
  position: absolute;
  right: 14px;
  bottom: 6px;
  display: grid;
  place-items: center;
  width: 41px;
  height: 41px;
  border-radius: 50%;
  background: var(--accent);
  /* As a box-shadow of 0 2px 8px, drawn by the compositor around the layer instead of in it. */
  filter: drop-shadow(0 2px 4px var(--accent-glow));
  color: var(--accent-ink);
  transition: opacity 0.2s ease;
  will-change: opacity;
}
.fab svg {
  width: 18px;
  height: 18px;
  stroke-width: 2.2;
}
.main[data-selecting] .fab {
  opacity: 0;
  pointer-events: none;
}
.bottom {
  position: absolute;
  right: 14px;
  bottom: 6px;
  left: 7px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  transition:
    opacity 0.2s ease,
    visibility 0.2s;
  will-change: opacity;
}
.main:not([data-selecting]) .bottom {
  visibility: hidden;
  opacity: 0;
}
.cancel {
  height: 41px;
  padding: 0 10.5px;
  border-radius: 20.5px;
  background: var(--surface-2);
  box-shadow:
    0 0 0 0.5px var(--cancel-edge),
    0 2px 8px var(--cancel-shadow);
  font-size: 12.4px;
}
.actions {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 41px;
  padding: 0 8px 0 10px;
  border-radius: 20.5px;
  background: var(--accent);
  box-shadow: 0 2px 8px var(--accent-glow);
  color: var(--accent-ink);
  font-size: 13.9px;
  font-weight: 500;
}
.actions[aria-disabled='true'] {
  opacity: 0.55;
  cursor: default;
}
.actionsChevron {
  width: 13px;
  height: 13px;
  stroke-width: 2.4;
}

/* The new task's card, over the top of the window. */
.composer {
  position: absolute;
  z-index: 45;
  top: 5px;
  left: 129px;
  width: 403px;
  padding: 12px 14px 22px;
  border-radius: 15px;
  /* As a box-shadow of 0 10px 24px: the compositor draws it around the card. The card itself is
     plain colours, which hold no pixels (see below), and what is on it is on small layers: so the
     card costs the size of its lines, painted again only as it opens and closes. */
  filter: drop-shadow(0 10px 12px var(--card-shadow));
  /* Closed, it is only faded out (inert keeps it out of reach): a change of visibility would
     restyle the drawings in it, and a restyled drawing lays the page out again. It moves by
     translate and scale, which unlike transform never paint it again. */
  opacity: 0;
  pointer-events: none;
  translate: 0 -8px;
  scale: 0.98;
  transform-origin: 50% 0;
  transition:
    opacity 0.18s ease,
    translate 0.26s var(--spring),
    scale 0.26s var(--spring);
  will-change: opacity;
}
.composer[data-open] {
  opacity: 1;
  pointer-events: auto;
  translate: none;
  scale: none;
}
/* The card under it: a hairline of 0.5px (as a box-shadow's spread) and the card's fill, each a
   colour clipped to its rounded corners. */
.card {
  position: absolute;
  inset: 0;
  border-radius: inherit;
}
.card > span {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: inherit;
  will-change: transform;
}
.card > span:first-child {
  inset: -0.5px;
  border-radius: 15.5px;
}
.card > span::before {
  position: absolute;
  inset: 0;
  background: var(--card);
  content: '';
  will-change: transform;
}
.card > span:first-child::before {
  background: var(--card-edge);
}
/* Its lines, each a small layer; the title being typed is painted at each letter on its own. */
.composeTitle,
.composeExtras,
.composeList {
  will-change: transform;
}
.composeTitle,
.composeNotes,
.composeList {
  display: flex;
  align-items: center;
  gap: 9.5px;
  height: 18px;
}
.composeCheck {
  flex: none;
  width: 14px;
  height: 14px;
  border: 1.5px solid var(--check-edge);
  border-radius: 50%;
}
.input {
  flex: 1;
  min-width: 0;
  font-size: 13px;
  font-weight: 600;
}
/* The caret shows where typing goes; the open card is the field's frame. */
.input:focus-visible {
  outline: none;
}
.input::placeholder {
  color: var(--placeholder);
  font-weight: 400;
}
.composeExtras {
  display: block;
  margin-top: 28px;
}
.composeNotes {
  color: var(--placeholder);
  font-size: 13px;
}
.composeIcon {
  width: 14px;
  height: 14px;
}
.composeChips {
  display: flex;
  align-items: center;
  gap: 12px;
  height: 34px;
  margin-top: 12px;
  padding: 0 12px;
  overflow: hidden;
  border-radius: 11px;
  background: var(--chips);
  color: var(--chips-ink);
  font-size: 11px;
  white-space: nowrap;
}
.composeChips > span {
  display: flex;
  align-items: center;
  gap: 4px;
}
.chipIcon {
  width: 13px;
  height: 13px;
}
.composeList {
  margin-top: 14px;
  color: var(--accent-text);
  font-size: 13px;
  font-weight: 600;
}
.composeList .glyph {
  width: 14px;
  height: 14px;
}

/* The menus. */
.menu {
  position: absolute;
  z-index: 40;
  right: 0;
  width: 168px;
  padding: 4px;
  border-radius: 12px;
  background: var(--menu);
  box-shadow: 0 0 0 0.5px var(--menu-edge);
  /* As a box-shadow of 0 12px 32px, drawn by the compositor around the layer. */
  filter: drop-shadow(0 12px 16px var(--menu-shadow));
  visibility: hidden;
  opacity: 0;
  scale: 0.94;
  /* Hidden once faded out; shown at once, so that focus can go in as it opens. */
  transition:
    opacity 0.16s ease,
    scale 0.22s var(--spring),
    visibility 0s 0.22s;
  will-change: opacity;
}
.menu[data-place='below-end'] {
  top: calc(100% + 8px);
  transform-origin: 100% 0;
}
.menu[data-place='below-start'] {
  top: calc(100% + 8px);
  right: auto;
  left: 0;
  transform-origin: 0 0;
}
.menu[data-place='above-end'] {
  bottom: calc(100% + 4px);
  transform-origin: 100% 100%;
}
.menu[data-open] {
  visibility: visible;
  opacity: 1;
  scale: none;
  transition-delay: 0s;
}
.menuItem {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  height: 29.5px;
  padding: 0 9px;
  border-radius: 8px;
  font-size: 12.5px;
  text-align: left;
}
.menuItem:hover,
.menuItem:focus-visible {
  background: var(--menu-lit);
}
.menuItem:focus-visible {
  outline-offset: -2px;
}
.menuItem[data-danger] {
  color: var(--danger);
}
.menuItem[role='menuitemradio']::after {
  width: 5px;
  height: 9px;
  margin: -3px 2px 0 0;
  border: solid var(--accent-text);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
  visibility: hidden;
  content: '';
}
.menuItem[aria-checked='true']::after {
  /* Shown with its menu: visible in a hidden menu, it kept the menu's layer painted. */
  visibility: inherit;
}
.menuLabel {
  flex: 1;
}
.itemIcon {
  width: 15px;
  height: 15px;
}

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

/* Narrow: the window fills the screen, the trays over the list. The script measures the stage and
   sets data-layout; until it has (the server's frame, or without JavaScript), the container query
   picks the same layout, so the rules are given twice, once for each: keep them in step. The
   container is taken off once the script has measured, because in Safari a query container
   around parts that change made some frames lay out the whole page. */
@container (max-width: 520px) {
  .stage {
    --tile: 70px;
    height: 560px;
    padding-bottom: 0;
  }
  .window {
    top: 10px;
    bottom: 10px;
    width: calc(100% - 20px);
  }
  .sidebar {
    right: 0;
    bottom: auto;
    width: auto;
    height: 159.5px;
    padding: 10px 12px 12px;
  }
  .chrome,
  .group,
  .titleMore {
    display: none;
  }
  .banner {
    margin-top: 0;
  }
  .main {
    top: 159.5px;
    left: 0;
    border-top: 1px solid var(--narrow-edge);
    border-left: 0;
    border-radius: 0 0 22px 22px;
  }
  .head {
    padding-top: 14px;
  }
  .composer {
    right: 10px;
    left: 10px;
    width: auto;
  }
  .corner {
    top: 10px;
  }
  .filter {
    margin-top: 8px;
  }
}

.root[data-layout='narrow'] .stage {
  --tile: 70px;
  height: 560px;
  padding-bottom: 0;
}
.root[data-layout='narrow'] .window {
  top: 10px;
  bottom: 10px;
  width: calc(100% - 20px);
}
.root[data-layout='narrow'] .sidebar {
  right: 0;
  bottom: auto;
  width: auto;
  height: 159.5px;
  padding: 10px 12px 12px;
}
.root[data-layout='narrow'] .chrome,
.root[data-layout='narrow'] .group,
.root[data-layout='narrow'] .titleMore {
  display: none;
}
.root[data-layout='narrow'] .banner {
  margin-top: 0;
}
.root[data-layout='narrow'] .main {
  top: 159.5px;
  left: 0;
  border-top: 1px solid var(--narrow-edge);
  border-left: 0;
  border-radius: 0 0 22px 22px;
}
.root[data-layout='narrow'] .head {
  padding-top: 14px;
}
.root[data-layout='narrow'] .composer {
  right: 10px;
  left: 10px;
  width: auto;
}
.root[data-layout='narrow'] .corner {
  top: 10px;
}
.root[data-layout='narrow'] .filter {
  margin-top: 8px;
}

@media (prefers-reduced-motion: reduce) {
  .slip,
  .sheet,
  .row,
  .rowFill > span,
  .check,
  .tick,
  .bottom,
  .menu {
    transition: none;
  }
}
```

### skeuo-tasks/trays.tsx

```tsx
'use client';

import {
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type RefObject,
} from 'react';
import { StarFilled, TrayIcon } from './icons';
import type { Task } from './data';
import styles from './skeuo-tasks.module.css';

/*
 * The two trays at the top of the sidebar, drawn in HTML and CSS: a glossy blue tray of paper
 * slips for the Inbox, and a white tray with a stack of sheets for Today.
 *
 * - Each paper is a box placed by its depth in the pile, --k (0 at the front), through a
 *   transform. A new one drops in from above (an animation played as it first shows), and those
 *   already there move back and rise a little, by a transition on that transform. The whole pile
 *   also rises as it grows (--rise).
 * - A paper that leaves stays a moment as a ghost, to play its way out: lifted out of the tray
 *   when its task moves to another list, flung up and away when it is done.
 * - Only transforms and opacity move, on boxes the size of a slip.
 */

/** How a paper leaves its tray. */
export type Exit = 'lift' | 'fly';

type Paper = { id: string; title: string };
type Ghost = Paper & { k: number; exit: Exit };

/**
 * How many slips the Inbox tray shows, at most. One more is drawn, faded out at the back, so that
 * a slip pushed back by a new one sinks out of sight rather than leaving.
 */
const SLIPS = 3;
/** How long a leaving paper plays, in ms. */
const LEAVE = 560;
/** Where a new paper drops in from, to its place in the pile. */
const DROP: Keyframe[] = [
  { translate: '0 -58px', rotate: '-7deg', scale: '1', opacity: 0, offset: 0 },
];

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

/** Drops in the papers that were not there at the last render (none on the first). */
function useDrop(ids: string[], box: RefObject<HTMLElement | null>) {
  const before = useRef<string[] | null>(null);
  const key = ids.join();
  useLayoutEffect(() => {
    const now = key ? key.split(',') : [];
    const was = before.current;
    before.current = now;
    if (!was || reduced()) return;
    now
      .filter((id) => !was.includes(id))
      .forEach((id, index) => {
        box.current?.querySelector(`[data-paper="${id}"]`)?.animate(DROP, {
          duration: 560,
          delay: index * 70,
          easing: 'cubic-bezier(0.3, 1.25, 0.45, 1)',
          fill: 'backwards',
        });
      });
  }, [key, box]);
}

/**
 * Keeps the papers that left as ghosts for a moment, in the place they had, and says how each
 * one leaves.
 */
function useGhosts(papers: Paper[], exit: Exit) {
  const [ghosts, setGhosts] = useState<Ghost[]>([]);
  const before = useRef(papers);
  const ids = papers.map((paper) => paper.id).join();
  const current = useEffectEvent(() => papers);
  // Runs when the ids change, not on every render.
  useEffect(() => {
    const papers = current();
    const now = new Set(papers.map((paper) => paper.id));
    const gone: Ghost[] = before.current.flatMap((paper, index, all) =>
      now.has(paper.id) ? [] : [{ ...paper, k: all.length - 1 - index, exit }],
    );
    before.current = papers;
    if (!gone.length || reduced()) return;
    setGhosts((all) => [...all, ...gone]);
    const timer = window.setTimeout(
      () => setGhosts((all) => all.filter((ghost) => !gone.includes(ghost))),
      LEAVE,
    );
    return () => window.clearTimeout(timer);
  }, [ids, exit]);
  return ghosts;
}

/** The Inbox: the newest slips at the front of a blue tray. */
export function InboxTray({ tasks, exit }: { tasks: Task[]; exit: Exit }) {
  const papers = tasks.slice(-(SLIPS + 1)).map(({ id, title }) => ({ id, title }));
  const ghosts = useGhosts(papers, exit);
  const box = useRef<HTMLSpanElement>(null);
  useDrop(
    papers.map((paper) => paper.id),
    box,
  );
  return (
    <span
      ref={box}
      className={styles.tray}
      data-tray="inbox"
      style={{ '--rise': `${Math.min(tasks.length, 6)}px` } as CSSProperties}
    >
      <span className={styles.slot}>
        <span className={styles.papers}>
          {papers.map((paper, index) => (
            <span
              key={paper.id}
              className={styles.slip}
              data-paper={paper.id}
              style={{ '--k': papers.length - 1 - index } as CSSProperties}
            >
              <span className={styles.slipBox} />
              <span className={styles.slipText}>{paper.title}</span>
            </span>
          ))}
          {ghosts.map((ghost) => (
            <span
              key={`ghost-${ghost.id}`}
              className={styles.slip}
              data-exit={ghost.exit}
              style={{ '--k': ghost.k } as CSSProperties}
            >
              <span className={styles.slipBox} />
              <span className={styles.slipText}>{ghost.title}</span>
            </span>
          ))}
        </span>
      </span>
      <span className={styles.lip}>
        <TrayIcon className={styles.lipGlyph} />
      </span>
    </span>
  );
}

/** Today: a stack of sheets that grows with the list, its first tasks written on the top one. */
export function TodayTray({ tasks, exit }: { tasks: Task[]; exit: Exit }) {
  const sheets = Math.max(1, Math.min(4, Math.ceil(tasks.length / 3)));
  // From the back of the stack to the front: new sheets go in behind the front one.
  const papers = Array.from({ length: sheets }, (_, index) => ({
    id: index === sheets - 1 ? 'front' : `back-${sheets - 1 - index}`,
    title: '',
  }));
  const ghosts = useGhosts(papers, exit);
  const box = useRef<HTMLSpanElement>(null);
  useDrop(
    papers.map((paper) => paper.id),
    box,
  );
  const lines = tasks.slice(0, 4);
  return (
    <span
      ref={box}
      className={styles.tray}
      data-tray="today"
      style={{ '--rise': `${(sheets - 1) * 2}px` } as CSSProperties}
    >
      <span className={styles.slot}>
        <span className={styles.papers}>
          {papers.map((paper, index) => (
            <span
              key={paper.id}
              className={styles.sheet}
              data-paper={paper.id}
              style={{ '--k': papers.length - 1 - index } as CSSProperties}
            >
              {paper.id === 'front' &&
                lines.map((task) => (
                  <span key={task.id} className={styles.sheetLine}>
                    <span className={styles.slipBox} />
                    <span className={styles.slipText}>{task.title}</span>
                  </span>
                ))}
            </span>
          ))}
          {ghosts.map((ghost) => (
            <span
              key={`ghost-${ghost.id}`}
              className={styles.sheet}
              data-exit={ghost.exit}
              style={{ '--k': ghost.k } as CSSProperties}
            />
          ))}
        </span>
      </span>
      <span className={styles.lip}>
        <StarFilled className={styles.lipGlyph} />
      </span>
    </span>
  );
}
```

### skeuo-tasks/menu.tsx

```tsx
'use client';

import { useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react';
import styles from './skeuo-tasks.module.css';

/*
 * A pop-up menu: the list's options, the filter, the actions on the selected tasks. It is always
 * in the page, hidden until open, so opening it adds nothing to the page. It follows the menu
 * pattern: arrow keys, Home and End move between the items, Enter or Space picks one, Escape
 * closes and hands focus back to its button.
 */

export type Item = {
  id: string;
  label: string;
  icon?: ReactNode;
  /** A destructive action, in red. */
  danger?: boolean;
  /** For a choice among several: whether it is the one picked. */
  checked?: boolean;
};

export function Menu({
  id,
  label,
  open,
  focusOnOpen,
  items,
  place,
  onPick,
  onClose,
}: {
  id: string;
  /** The menu's accessible name. */
  label: string;
  open: boolean;
  /** Whether opening it moves focus in: yes for the reader's opening, no for the demo's. */
  focusOnOpen: boolean;
  items: Item[];
  /** Where it opens: under its button on the right or the left, or above it on the right. */
  place: 'below-end' | 'below-start' | 'above-end';
  onPick: (id: string) => void;
  /** Closes it; `refocus` hands focus back to its button. */
  onClose: (refocus: boolean) => void;
}) {
  const list = useRef<HTMLDivElement>(null);
  const all = () =>
    Array.from(list.current?.querySelectorAll<HTMLElement>('[role^="menuitem"]') ?? []);

  // Opened by the reader: focus goes to the picked item, or the first.
  useEffect(() => {
    if (!open || !focusOnOpen) return;
    const items = all();
    (items.find((item) => item.getAttribute('aria-checked') === 'true') ?? items[0])?.focus();
  }, [open, focusOnOpen]);

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const items = all();
    const at = items.indexOf(document.activeElement as HTMLElement);
    const go = (index: number) => items[(index + items.length) % items.length]?.focus();
    if (event.key === 'ArrowDown') go(at + 1);
    else if (event.key === 'ArrowUp') go(at < 0 ? items.length - 1 : at - 1);
    else if (event.key === 'Home') go(0);
    else if (event.key === 'End') go(items.length - 1);
    else if (event.key === 'Escape') onClose(true);
    else if (event.key === 'Tab') return onClose(false);
    else return;
    event.preventDefault();
    event.stopPropagation();
  };

  return (
    <div
      ref={list}
      id={id}
      className={styles.menu}
      data-part="menu"
      role="menu"
      aria-label={label}
      data-open={open || undefined}
      data-place={place}
      inert={!open}
      onKeyDown={onKeyDown}
    >
      {items.map((item) => (
        <button
          key={item.id}
          type="button"
          role={item.checked === undefined ? 'menuitem' : 'menuitemradio'}
          aria-checked={item.checked}
          className={styles.menuItem}
          data-item={item.id}
          data-danger={item.danger || undefined}
          tabIndex={-1}
          onClick={() => onPick(item.id)}
        >
          <span className={styles.menuLabel}>{item.label}</span>
          {item.icon}
        </button>
      ))}
    </div>
  );
}
```

### skeuo-tasks/data.ts

```tsx
/*
 * The lists and the tasks the app starts with. A task belongs to one list; moving it changes its
 * list. The sidebar shows the lists in three groups: the two trays, the collections, and the
 * spaces.
 */

export type ListId =
  'inbox' | 'today' | 'upcoming' | 'someday' | 'log' | 'goals' | 'projects' | 'studio' | 'personal';

export type Task = {
  id: string;
  title: string;
  list: ListId;
  /** Added in this session: the "Added today" filter keeps these. */
  fresh?: boolean;
};

export type List = {
  id: ListId;
  name: string;
  /** The line under the list's name. */
  about: string;
};

export const LISTS: Record<ListId, List> = {
  inbox: {
    id: 'inbox',
    name: 'Inbox',
    about: 'Uncategorized, random, or other tasks without a Plan.',
  },
  today: { id: 'today', name: 'Today', about: 'What you picked to get done today.' },
  upcoming: { id: 'upcoming', name: 'Upcoming', about: 'Tasks with a date, the soonest first.' },
  someday: { id: 'someday', name: 'Someday', about: 'Ideas to come back to, one day.' },
  log: { id: 'log', name: 'Adventure Log', about: 'Places you have been, and places to go.' },
  goals: { id: 'goals', name: '2026 Family Goals', about: '290 days left to get there.' },
  projects: { id: 'projects', name: 'Family Projects', about: 'Things to make and mend together.' },
  studio: { id: 'studio', name: 'Studio', about: 'Work for the studio, in lists of its own.' },
  personal: { id: 'personal', name: 'Personal', about: 'Your own lists, for nobody else.' },
};

let made = 0;
/** A task with an id of its own. */
const task = (title: string, list: ListId): Task => {
  made += 1;
  return { id: `t${made}`, title, list };
};

export const TASKS: Task[] = [
  task('Book the dentist', 'inbox'),
  task('Return library books', 'inbox'),
  task('Call Grandma back', 'inbox'),
  task('Fix the bike light', 'inbox'),
  task('Window repair quote', 'today'),
  task('Water the plants', 'today'),
  task('Pay the electricity bill', 'today'),
  task('Pack the gym bag', 'today'),
  task('Renew passport', 'upcoming'),
  task('Dinner at Ana’s', 'upcoming'),
  task('Learn to bake sourdough', 'someday'),
  task('Repaint the hallway', 'someday'),
  task('Lisbon in the spring', 'log'),
  task('Run a 10K together', 'goals'),
  task('Read twenty books', 'goals'),
  task('Build a bird box', 'projects'),
];

/** The task that waits to be brought in from elsewhere, and where from. */
export const INCOMING = { title: 'Reply to Sam’s invite', from: 'Mail' };
```

### skeuo-tasks/icons.tsx

```tsx
/*
 * The app's icons, on a 20-unit grid. Outlines take the current colour; the coloured list icons
 * carry their own. All of them are still drawings, in the page from the start.
 */

import type { ReactNode, SVGProps } from 'react';

type IconProps = SVGProps<SVGSVGElement>;

function Icon({ children, ...props }: IconProps & { children: ReactNode }) {
  return (
    <svg
      viewBox="0 0 20 20"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.6"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
      {...props}
    >
      {children}
    </svg>
  );
}

/** A tray: the Inbox. */
export const TrayIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M3.5 11.5v3a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-3M3.5 11.5l1.8-6.2a1.6 1.6 0 0 1 1.5-1.1h6.4a1.6 1.6 0 0 1 1.5 1.1l1.8 6.2M3.5 11.5h3.6l1 1.8h3.8l1-1.8h3.6" />
  </Icon>
);

/** The Inbox's filled glyph, by its title. */
export const TrayGlyph = (props: IconProps) => (
  <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false" {...props}>
    <rect x="1.5" y="1.5" width="17" height="17" rx="4.2" fill="#2f7cf6" />
    <path d="M5 4.8h10v6.4H5z" fill="#fff" />
    <path
      d="M1.5 10.6h4.8l1.3 2h4.8l1.3-2h4.8v3.7a4.2 4.2 0 0 1-4.2 4.2H5.7a4.2 4.2 0 0 1-4.2-4.2z"
      fill="#1d5fd3"
    />
  </svg>
);

export const StarIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="m10 3 2.1 4.4 4.8.6-3.5 3.3.9 4.8L10 13.8l-4.3 2.3.9-4.8L3.1 8l4.8-.6z" />
  </Icon>
);

export const StarFilled = (props: IconProps) => (
  <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false" {...props}>
    <path
      d="m10 3 2.1 4.4 4.8.6-3.5 3.3.9 4.8L10 13.8l-4.3 2.3.9-4.8L3.1 8l4.8-.6z"
      fill="currentColor"
      stroke="currentColor"
      strokeWidth="1.4"
      strokeLinejoin="round"
    />
  </svg>
);

/** Today's filled glyph, by its title. */
export const TodayGlyph = (props: IconProps) => (
  <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false" {...props}>
    <rect x="1.5" y="1.5" width="17" height="17" rx="4.2" fill="#f5b301" />
    <path
      d="m10 4.6 1.6 3.3 3.6.5-2.6 2.5.6 3.6-3.2-1.7-3.2 1.7.6-3.6-2.6-2.5 3.6-.5z"
      fill="#fff"
    />
  </svg>
);

export const MoonIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M15.6 12.6A6.6 6.6 0 0 1 7.4 4.4a6.6 6.6 0 1 0 8.2 8.2z" />
    <path d="M13.2 3.4v2.4M12 4.6h2.4" />
  </Icon>
);

export const CalendarIcon = (props: IconProps) => (
  <Icon {...props}>
    <rect x="3" y="4.2" width="14" height="12.6" rx="2.2" />
    <path d="M3 8.2h14M7 2.8v2.6M13 2.8v2.6" />
  </Icon>
);

export const BookIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M5.2 3h9.3v14H5.2a1.7 1.7 0 0 1-1.7-1.7V4.7A1.7 1.7 0 0 1 5.2 3z" />
    <path d="M7.5 7h4.5M7.5 10h3" />
  </Icon>
);

export const HouseIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M3.5 9 10 3.5 16.5 9v6.6a1.4 1.4 0 0 1-1.4 1.4H4.9a1.4 1.4 0 0 1-1.4-1.4z" />
  </Icon>
);

export const InfinityIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M10 10c-1.6-1.8-2.8-2.8-4.2-2.8a2.8 2.8 0 1 0 0 5.6c1.4 0 2.6-1 4.2-2.8 1.6 1.8 2.8 2.8 4.2 2.8a2.8 2.8 0 1 0 0-5.6c-1.4 0-2.6 1-4.2 2.8z" />
  </Icon>
);

export const ChevronDown = (props: IconProps) => (
  <Icon {...props}>
    <path d="m6 8 4 4 4-4" />
  </Icon>
);

export const ChevronRight = (props: IconProps) => (
  <Icon {...props}>
    <path d="m8 6 4 4-4 4" />
  </Icon>
);

export const MoreIcon = (props: IconProps) => (
  <svg viewBox="0 0 20 20" aria-hidden="true" focusable="false" fill="currentColor" {...props}>
    <circle cx="4.5" cy="10" r="1.6" />
    <circle cx="10" cy="10" r="1.6" />
    <circle cx="15.5" cy="10" r="1.6" />
  </svg>
);

export const ArrowDownIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M10 5v10M5.8 11 10 15.2l4.2-4.2" />
  </Icon>
);

export const PlusIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M10 4.5v11M4.5 10h11" />
  </Icon>
);

export const CheckCircleIcon = (props: IconProps) => (
  <Icon {...props}>
    <circle cx="10" cy="10" r="7" />
    <path d="m7 10.2 2 2 4-4.4" />
  </Icon>
);

export const TrashIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M4 6h12M8.2 6V4.4h3.6V6M5.6 6l.8 10.2a1.4 1.4 0 0 0 1.4 1.3h4.4a1.4 1.4 0 0 0 1.4-1.3L14.4 6" />
  </Icon>
);

export const SidebarIcon = (props: IconProps) => (
  <Icon {...props}>
    <rect x="2.8" y="4" width="14.4" height="12" rx="2.4" />
    <path d="M8 4v12M4.8 7h1.4M4.8 9.4h1.4" />
  </Icon>
);

export const PlayCircleIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M10 3a7 7 0 1 1-6.1 3.6M3.2 3.6l.7 3 3-.7" />
    <path d="m8.6 7.6 4 2.4-4 2.4z" />
  </Icon>
);

/** A page with lines: a task's notes. */
export const NotesIcon = (props: IconProps) => (
  <Icon {...props}>
    <rect x="4" y="3" width="12" height="14" rx="2.2" />
    <path d="M7 7.5h6M7 10.5h6M7 13.5h3.5" />
  </Icon>
);

/** A calendar with a clock: a deadline. */
export const DeadlineIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M9 16.5H5.2A2.2 2.2 0 0 1 3 14.3V6.4a2.2 2.2 0 0 1 2.2-2.2h9.6A2.2 2.2 0 0 1 17 6.4V9M3 8.2h14M7 2.8v2.6M13 2.8v2.6" />
    <circle cx="14" cy="14" r="3.6" />
    <path d="M14 12.4V14l1 .8" />
  </Icon>
);

/** Two arrows in a loop: a task that repeats. */
export const RepeatIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M4 9.5V8.2a2.6 2.6 0 0 1 2.6-2.6H15M12.6 3.2 15 5.6 12.6 8M16 10.5v1.3a2.6 2.6 0 0 1-2.6 2.6H5M7.4 16.8 5 14.4 7.4 12" />
  </Icon>
);

/** A bell: a reminder. */
export const BellIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M5.4 13.6V9.4a4.6 4.6 0 0 1 9.2 0v4.2l1.4 1.6H4zM8.4 17.2h3.2" />
  </Icon>
);

/** A tag. */
export const TagIcon = (props: IconProps) => (
  <Icon {...props}>
    <path d="M3.5 4.9v4.3a1.4 1.4 0 0 0 .4 1l6 6a1.4 1.4 0 0 0 2 0l4.3-4.3a1.4 1.4 0 0 0 0-2l-6-6a1.4 1.4 0 0 0-1-.4H4.9a1.4 1.4 0 0 0-1.4 1.4z" />
    <circle cx="7" cy="7" r="1" />
  </Icon>
);
```

### skeuo-tasks/demo-pointer.tsx

```tsx
'use client';

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

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, glide
 * the pointer onto a control, press it and call the component's own handler, as a click would.
 * Stopping the demo (the reader takes over, or it scrolls away) ends the step in progress at once:
 * its promise rejects, which ends the script right there, and the pointer fades out.
 *
 * The pointer is an HTML box around a still drawing: it glides by an animation the compositor
 * plays, and the press scales a box that keeps a transform at rest, so nothing it does needs a
 * layout.
 */

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

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

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

/** Where the arrow's tip is, in its 22 × 22 box: the point the pointer acts on. */
const TIP: Point = { x: 3.3, y: 2.2 };

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

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.86, y: stage.offsetHeight * 0.96 };

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

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

  /** Glides to `to`, in the stage's own px. */
  const glide = (to: Point) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(380, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen px to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    return glide({
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    });
  };

  const press: Demo['press'] = async (action) => {
    // The glyph dips towards its tip.
    const tap = pointer.firstElementChild?.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.82)', offset: 0.35 },
        { transform: 'scale(1)' },
      ],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

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

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

/** The pointer's box: it moves, on a layer of its own. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  display: 'block',
  width: 22,
  height: 22,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

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

const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };
/** The arrow's colours, like its shadow's, are the stage's tokens (skeuo-tasks.module.css). */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };

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

### skeuo-tasks/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 };
}
```