# Project timeline

> A project tool’s Gantt screen: bars by status with progress and faces, dependency arrows, milestones, drag to reschedule, Weeks or Months.

The timeline of a studio’s workspace, in an app window. A task list beside a day grid where each task is a bar coloured by its status, its progress filled in and its assignee’s face on it, with arrows to the tasks that wait on it and a row of milestones. Drag a bar, or press the arrow keys on it, and whatever depends on it is pushed along live; hover it for its card. Switch Weeks and Months, jump to today, filter, sort, group by project, status or assignee, fold a group, tick a task done. Light and dark.

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

Category: Dashboards.

## Anatomy

1. **Sidebar**: The workspace’s navigation and its projects, with the timeline marked. A rail of icons when folded, a drawer under 760 px.
2. **Toolbar**: The task search, Filter by status, Sort, Weeks or Months, Group by, and Today, which scrolls the chart to today’s line.
3. **Time scale**: The months over the days, sticky as the chart scrolls; in Weeks a column a day, in Months a narrow one with a label a week.
4. **Milestones**: A row of diamonds in their project’s colour, each named while there is room before the next one.
5. **Task row**: The status circle, which ticks the task done, its title, the subtasks done and the assignee’s face, in the sticky list column.
6. **Schedule bar**: From the task’s first day to its last, tinted by status, its progress filled in; drag it, or press ← → on it, to move it.
7. **Dependency arrow**: From the end of a task to the start of one that waits on it; it lights while either bar is hovered or dragged.
8. **Today**: The `today` date marked in the scale, with its line down the whole chart; Today scrolls to it and makes it pulse.

## When to use

- A schedule of tasks that last days or weeks, where when things happen matters as much as what they are.
- Work with dependencies: one task cannot start before another ends, and moving it has to push what waits on it.
- Several projects or people over the same weeks, where grouping by project, status or assignee shows who is overloaded and when.
- Planning by hand: dragging a bar, or nudging it a day with the arrow keys, is the way dates are changed.
- Dates to hold the team to: milestones on their own row, and today’s line to read the plan against.

## When not to use

- Work that flows through stages rather than dates. Use a board with a column per status.
- A short list of to-dos with due dates and no durations. Use a task list sorted by due date.
- One person’s day or week of meetings. Use a calendar with hours.
- Hundreds of tasks across a year. The rows and the arrows stop reading; show a roadmap of epics by quarter, or page the list.
- Reporting progress to people who will not change the plan. A burn-up chart or a status summary says it in one look.

## Installation

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

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

export function Schedule() {
  return (
    <ProjectTimeline
      workspace="Fernway Studio"
      today="2026-10-13"
      onTaskMove={(id, start, end) => console.log(id, start, end)}
      autoplay={false}
    />
  );
}
```

## States

- **Weeks**: The default: a column a day, grouped by project, opened on the week before last.
- **Task card**: Hovering or focusing a bar: dates, assignee, progress, what it waits on and what it blocks; its arrows light.
- **Rescheduled**: The wireframes moved four days later: the two tasks after them, and the handoff after those, are pushed along.
- **Months**: 11 px a day with a label a week: the whole quarter at once. Narrow bars write their name beside them.
- **By status**: Grouped by status, In progress first and Done last, each group with its count.
- **By assignee**: Grouped by person: each one’s face, their tasks and how many.
- **Folded groups**: A folded group keeps one summary bar from its first task’s start to its last task’s end.
- **Ticked done**: The status circle ticked: the bar turns green and full; unticking puts back the status it had.
- **Search**: Typing in the search keeps the tasks whose title matches, under their groups.
- **No matches**: A search or a filter that leaves nothing: the chart says so under the milestones.
- **Filter menu**: Statuses as checkboxes; the Filter button counts the ones ticked and only those tasks show.
- **Sort menu**: Manual, start date, due date or name, as a radio menu; the button says the choice.
- **Group menu**: Project, Status, Assignee or None, as a radio menu.
- **Sidebar folded**: `defaultCollapsed`: the sidebar keeps its icons only and the chart takes the room.
- **Dark**: `colorScheme="dark"`: every colour is a light-dark() token.
- **Narrow**: Under 760 px of its own width the list column keeps the titles, the toolbar wraps and a menu button opens the sidebar.
- **Drawer**: The sidebar over a scrim; Escape or the scrim closes it.
- **No frame**: `frame={false}`: the app without the window’s bar, to sit inside a page of its own.

## How it works

- The schedule is worked out on every render, never stored: each task keeps only the start it was planned for, and is drawn at the later of that and the day after every task it waits on ends (a few relaxation passes over the dependencies). A drag changes one planned start, so the tasks after it are pushed along live and fall back when it is dragged back.
- A drag captures the pointer and snaps to whole days; a bar dragged against a task it waits on stays after it, and what is drawn becomes the plan on release. The arrow keys move a focused bar by a day (Shift by a week). Bars are placed by transform, so drags, pushes and view changes all slide on the compositor.
- List and grid are one sheet scrolling both ways inside the chart: the header and the list column are sticky, so every row lines up with its bar with no scroll syncing. Month names stay at the left edge while their month is on show, and a bar’s face and name stay in view while the bar runs off the left.
- The dependency arrows are one SVG of cubic curves from the end of a task to the start of the one that waits on it, worked out from the rows on show, so folding a group, filtering or sorting redraws them; the ones touching the hovered or dragged bar light up.
- Weeks draws a 35 px column per day, Months an 11 px one with a label per week; the day at the left edge stays put through the switch. Weekends are shaded and weeks ruled by two repeating gradients on one layer, and today is a line; Today scrolls to it.
- Grouping by project, status or assignee, sorting and the status filter are toolbar menus; a group folds to one summary bar from its first start to its last end. Ticking a task’s status circle marks it done and turns its bar green; unticking puts back the status it had.
- Layout by container queries on the root: the sidebar is a column from 760 px (or a rail of icons, folded), a drawer under it, where the list column narrows to the task names and the toolbar wraps.
- While on screen and untouched, a demo pointer hovers a bar, drags it three days so its dependants follow and back, switches to Months and back, jumps to today, ticks a task and folds a group. Any real input hands control to the reader, and what the demo changed is put back.

## API reference

- **workspace** (`string`, default `'Fernway Studio'`): The workspace’s name, in the sidebar and the breadcrumb.
- **title** (`string`, default `'Timeline'`): The page’s title, also in the breadcrumb.
- **subtitle** (`string`, default `'Schedules, dependencies, and milestones across projects.'`): The line under the title.
- **today** (`string`, default `'2026-10-13'`): Today, as YYYY-MM-DD: its line on the chart and where Today scrolls.
- **defaultView** (`'weeks' | 'months'`, default `'weeks'`): A column per day, or a narrow one with a label per week.
- **defaultGroup** (`'project' | 'status' | 'assignee' | 'none'`, default `'project'`): How the tasks are grouped.
- **defaultCollapsed** (`boolean`, default `false`): The sidebar starts folded to its rail of icons (from 760 px of width).
- **frame** (`boolean`, default `true`): Draws the app window around it: its bar with the three lights and the title.
- **address** (`string`, default `'Fernway Studio — Timeline'`): What the window’s bar says.
- **userName** (`string`, default `'Maya'`): Who is signed in, top right.
- **onTaskMove** (`(id: string, start: Date, end: Date) => void`): Called when a task is dragged or moved by keys, with its new first and last day.
- **onTaskToggle** (`(id: string, done: boolean) => void`): Called when a task is ticked done or unticked.
- **defaultState** (`ProjectTimelineState`): Opens in this state, held still: view, group, sort, query (the task search), statuses (the filter), folded (group keys), menu (filter, sort or group open), tip (a task’s card open), moved (days added to tasks’ planned starts), done (task ids ticked), scrollTo (the day at the left edge, as month-date) and drawer. Turns the demo off.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer hovers and drags a bar, switches the scale, jumps to today, ticks a task and folds a group.
- **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

- Each bar is a button named with its task and dates; the arrow keys move it by a day (Shift by a week), and focusing it opens its card, linked by aria-describedby. Every move is announced with the new dates and how many dependent tasks moved.
- Each task’s status circle is a checkbox named after the task; group headers are buttons with aria-expanded. Milestones are images named with their title and date; faces carry the person’s name.
- The toolbar is a labelled toolbar: Filter, Sort and Group are menu buttons with checkbox or radio menus (arrow keys, Home, End, Escape returns focus); Weeks and Months are a radio group.
- / focuses the task search and Ctrl K (or ⌘K) the jump box, which scrolls to the first matching task. Changes of view, grouping, ticks and jumps are announced in a polite live region.
- The sidebar is a labelled navigation landmark with aria-current on the page; under 760 px it is a drawer that Escape closes. Reduced motion removes the transitions and stops the demo; the demo pointer and the chart’s art are hidden from assistive technology.

## Source

### project-timeline/project-timeline.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
} from 'react';
import {
  DAYS,
  GROUP_PROJECTS,
  MILESTONES,
  MONTH_LONG,
  PEOPLE,
  PROJECTS,
  STATUSES,
  STATUS_LABEL,
  TASKS,
  TEAMS,
  dateOf,
  day,
  projectById,
  shortDate,
  type IconName,
  type PersonId,
  type Project,
  type ProjectId,
  type Status,
  type Task,
} from './data';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { Icon } from './icons';
import { useAutoplay } from './use-autoplay';
import styles from './project-timeline.module.css';

/*
 * Project timeline: a project tool's Gantt screen, in an app window. The sidebar of the workspace,
 * then a toolbar (search, filter, sort, Weeks / Months, grouping, Today) over a chart: the task
 * list on the left, a day grid on the right with each task as a bar coloured by its status, its
 * progress filled in, its assignee's face on it and arrows to the tasks that wait on it.
 *
 * - The schedule is worked out, not stored: each task keeps the start it was planned for, and is
 *   drawn at the later of that and the day after every task it waits on ends. Drag a bar (or press
 *   the arrow keys on it) and only its planned start changes; whatever waits on it is pushed along
 *   by the same sums, live, and comes back when it is dragged back.
 * - Weeks shows a column per day, Months a narrow one with a label per week; the day at the left
 *   edge stays put through the switch. Today scrolls the chart to today's line.
 * - The list and the grid are one scrolling sheet: the list column and the header are sticky, so
 *   rows always line up. Groups (by project, status or assignee) fold to a single summary bar.
 * - Ticking a task's status circle marks it done (the bar turns green); unticking puts back the
 *   status it had.
 * - Layout by container queries: the sidebar is a column from 760 px, a drawer under it, where the
 *   list column narrows to the titles and the toolbar wraps.
 * - While on screen and untouched, a demo pointer hovers a bar, drags it so its dependants follow,
 *   switches to Months and back, jumps to today, ticks a task and folds a group. Any real input
 *   hands it over to the reader, and what it changed is put back.
 * - Light or dark: every colour is a light-dark() token, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type TimelineView = 'weeks' | 'months';
export type TimelineGroup = 'project' | 'status' | 'assignee' | 'none';
export type TimelineSort = 'manual' | 'start' | 'end' | 'name';

const GROUPS: { id: TimelineGroup; label: string }[] = [
  { id: 'project', label: 'Project' },
  { id: 'status', label: 'Status' },
  { id: 'assignee', label: 'Assignee' },
  { id: 'none', label: 'None' },
];
const SORTS: { id: TimelineSort; label: string }[] = [
  { id: 'manual', label: 'Manual' },
  { id: 'start', label: 'Start date' },
  { id: 'end', label: 'Due date' },
  { id: 'name', label: 'Name' },
];
/** Column width of a day, in px: Weeks and Months. */
const DAY_W: Record<TimelineView, number> = { weeks: 35.25, months: 11 };
/** A bar narrower than this writes its name beside it, without a face. */
const NARROW = 100;

const PRIMARY: { id: string; label: string; icon: IconName }[] = [
  { id: 'home', label: 'Home', icon: 'home' },
  { id: 'inbox', label: 'Inbox', icon: 'inbox' },
  { id: 'my-tasks', label: 'My Tasks', icon: 'checkCircle' },
  { id: 'favorites', label: 'Favorites', icon: 'star' },
  { id: 'search', label: 'Search', icon: 'search' },
  { id: 'notifications', label: 'Notifications', icon: 'bell' },
];
const WORKSPACE: { id: string; label: string; icon: IconName }[] = [
  { id: 'overview', label: 'Overview', icon: 'overview' },
  { id: 'projects', label: 'Projects', icon: 'folder' },
  { id: 'tasks', label: 'Tasks', icon: 'tasks' },
  { id: 'calendar', label: 'Calendar', icon: 'calendar' },
  { id: 'timeline', label: 'Timeline', icon: 'timeline' },
  { id: 'members', label: 'Members', icon: 'members' },
  { id: 'activity', label: 'Activity', icon: 'activity' },
];
const VIEWS: { label: string; icon: IconName }[] = [
  { label: 'Board', icon: 'board' },
  { label: 'List', icon: 'list' },
  { label: 'Timeline', icon: 'timeline' },
  { label: 'Files', icon: 'clip' },
];

/** Who is signed in: their open tasks are the My Tasks count. */
const ME: PersonId = 'mo';

/**
 * A state to open in, held until the reader changes it; giving it turns the demo off.
 * - `view`, `group`, `sort`: the toolbar's settings.
 * - `query`: the task search filled in.
 * - `statuses`: the Filter menu's ticked statuses (only those show).
 * - `folded`: groups folded, by key (`milestones`, a project id, a status or a person id).
 * - `menu`: one of the toolbar's menus open.
 * - `tip`: a task's bar drawn as under the pointer, its card open.
 * - `moved`: tasks whose planned start is moved by so many days.
 * - `done`: tasks (by id) ticked done.
 * - `scrollTo`: the day (as `month-date`, e.g. `10-1`) at the chart's left edge.
 * - `drawer`: under 760 px of width, the sidebar open as a drawer.
 */
export type ProjectTimelineState = {
  view?: TimelineView;
  group?: TimelineGroup;
  sort?: TimelineSort;
  query?: string;
  statuses?: Status[];
  folded?: string[];
  menu?: 'filter' | 'sort' | 'group';
  tip?: string;
  moved?: Record<string, number>;
  done?: string[];
  scrollTo?: string;
  drawer?: boolean;
};

type Row =
  | { kind: 'milestones'; key: 'milestones' }
  | {
      kind: 'group';
      key: string;
      label: string;
      count: number;
      mark: ReactNode;
      span: [number, number] | null;
      hue: string;
    }
  | { kind: 'task'; key: string; task: Task };

type Tip = { id: string; x: number; y: number; below: boolean };

const parseToday = (iso: string) => {
  const [y, m, d] = iso.split('-').map(Number);
  return Math.round((Date.UTC(y, m - 1, d) - Date.UTC(2026, 8, 7)) / 86_400_000);
};

/**
 * Where each task is drawn: its planned start, pushed past the end of every task it waits on.
 * `extra` moves one task's planned start (a drag in progress).
 */
function schedule(tasks: Task[], extra?: { id: string; delta: number } | null) {
  const at: Record<string, { start: number; end: number }> = {};
  for (const task of tasks) {
    const start = task.start + (extra?.id === task.id ? extra.delta : 0);
    at[task.id] = { start, end: start + task.length - 1 };
  }
  // Relax until nothing moves; the dependencies have no cycles, so a few passes do.
  for (let pass = 0; pass < tasks.length; pass += 1) {
    let moved = false;
    for (const task of tasks) {
      for (const before of task.after ?? []) {
        const need = (at[before]?.end ?? -Infinity) + 1;
        if (at[task.id].start < need) {
          at[task.id] = { start: need, end: need + task.length - 1 };
          moved = true;
        }
      }
    }
    if (!moved) break;
  }
  return at;
}

const progressOf = (task: Task) =>
  task.status === 'done'
    ? 1
    : task.subtasks
      ? task.subtasks[0] / task.subtasks[1]
      : (task.progress ?? 0);

export function ProjectTimeline({
  workspace = 'Fernway Studio',
  title = 'Timeline',
  subtitle = 'Schedules, dependencies, and milestones across projects.',
  today = '2026-10-13',
  defaultView = 'weeks',
  defaultGroup = 'project',
  defaultCollapsed = false,
  frame = true,
  address = 'Fernway Studio — Timeline',
  userName = 'Maya',
  onTaskMove,
  onTaskToggle,
  defaultState,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The workspace's name, in the sidebar and the breadcrumb. */
  workspace?: string;
  /** The page's title, and the line under it. */
  title?: string;
  subtitle?: string;
  /** Today, as YYYY-MM-DD: its line on the chart, and where Today scrolls. */
  today?: string;
  /** Days or weeks across. */
  defaultView?: TimelineView;
  /** How the tasks are grouped. */
  defaultGroup?: TimelineGroup;
  /** The sidebar starts folded to its rail of icons (from 760 px of width). */
  defaultCollapsed?: boolean;
  /** Draw the app window around it: its bar with the three lights and the title. */
  frame?: boolean;
  /** What the window's bar says. */
  address?: string;
  /** Who is signed in, top right. */
  userName?: string;
  /** Called when a task is dragged (or moved by keys) to a new start, with its new dates. */
  onTaskMove?: (id: string, start: Date, end: Date) => void;
  /** Called when a task is ticked done or unticked. */
  onTaskToggle?: (id: string, done: boolean) => void;
  /** Opens in this state (a menu, a search, a drag…), held still: the demo is off. */
  defaultState?: ProjectTimelineState;
  /** While on screen and untouched, a demo pointer shows the main interactions. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const id = useId();
  const [held] = useState(defaultState !== undefined);
  const todayIndex = Math.min(DAYS - 1, Math.max(0, parseToday(today)));

  const [tasks, setTasks] = useState<(Task & { was?: Status })[]>(() =>
    TASKS.map((task) => ({
      ...task,
      start: task.start + (defaultState?.moved?.[task.id] ?? 0),
      ...(defaultState?.done?.includes(task.id) && task.status !== 'done'
        ? { status: 'done' as Status, was: task.status }
        : {}),
    })),
  );
  const [view, setView] = useState<TimelineView>(defaultState?.view ?? defaultView);
  const [group, setGroup] = useState<TimelineGroup>(defaultState?.group ?? defaultGroup);
  const [sort, setSort] = useState<TimelineSort>(defaultState?.sort ?? 'manual');
  const [statuses, setStatuses] = useState<Set<Status>>(
    () => new Set(defaultState?.statuses ?? []),
  );
  const [query, setQuery] = useState(defaultState?.query ?? '');
  const [folded, setFolded] = useState<Set<string>>(() => new Set(defaultState?.folded ?? []));
  const [menu, setMenu] = useState<'filter' | 'sort' | 'group' | null>(defaultState?.menu ?? null);
  const quietMenu = useRef(!!defaultState?.menu);
  const [drag, setDrag] = useState<{ id: string; delta: number } | null>(null);
  const [tip, setTip] = useState<Tip | null>(null);
  const [flash, setFlash] = useState<string | null>(null);
  const [pulse, setPulse] = useState(0);
  const [collapsed, setCollapsed] = useState(defaultCollapsed);
  const [drawer, setDrawer] = useState(!!defaultState?.drawer);
  const [open, setOpen] = useState<Set<ProjectId>>(() => new Set(['web']));
  const [current, setCurrent] = useState('timeline');
  const [jump, setJump] = useState('');
  const [message, setMessage] = useState('');

  const scroller = useRef<HTMLDivElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const searchBox = useRef<HTMLInputElement>(null);
  const jumpBox = useRef<HTMLInputElement>(null);
  /** The day to keep at the chart's left edge through a change of view. */
  const anchor = useRef<number | null>(null);
  const placed = useRef<{ day: number; px: number } | null>(null);
  const dragging = useRef<{
    id: string;
    x: number;
    pointerId: number;
    moved: boolean;
  } | null>(null);
  const dayW = DAY_W[view];

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

  const at = useMemo(() => schedule(tasks, drag), [tasks, drag]);
  const byId = useMemo(() => Object.fromEntries(tasks.map((task) => [task.id, task])), [tasks]);
  const myOpen = tasks.filter((task) => task.who === ME && task.status !== 'done').length;

  // The rows, as the toolbar's settings make them.
  const q = query.trim().toLowerCase();
  const shown = tasks.filter(
    (task) =>
      (!statuses.size || statuses.has(task.status)) &&
      (!q ||
        task.title.toLowerCase().includes(q) ||
        projectById(task.project).name.toLowerCase().includes(q) ||
        PEOPLE[task.who].name.toLowerCase().includes(q)),
  );
  const order = (list: Task[]) => {
    const sorted = [...list];
    if (sort === 'start') sorted.sort((a, b) => at[a.id].start - at[b.id].start);
    if (sort === 'end') sorted.sort((a, b) => at[a.id].end - at[b.id].end);
    if (sort === 'name') sorted.sort((a, b) => a.title.localeCompare(b.title));
    return sorted;
  };
  const buckets: { key: string; label: string; mark: ReactNode; hue: string; list: Task[] }[] =
    group === 'project'
      ? GROUP_PROJECTS.map((projectId) => {
          const project = projectById(projectId);
          return {
            key: projectId,
            label: project.name,
            hue: project.hue,
            mark: (
              <span className={styles.groupDot} style={{ '--hue': project.hue } as CSSProperties} />
            ),
            list: shown.filter((task) => task.project === projectId),
          };
        })
      : group === 'status'
        ? STATUSES.map((status) => ({
            key: status.id,
            label: status.label,
            hue: 'var(--muted)',
            mark: <StatusMark status={status.id} />,
            list: shown.filter((task) => task.status === status.id),
          }))
        : group === 'assignee'
          ? (Object.keys(PEOPLE) as PersonId[]).map((who) => ({
              key: who,
              label: PEOPLE[who].name,
              hue: PEOPLE[who].hue,
              mark: <Face who={who} small />,
              list: shown.filter((task) => task.who === who),
            }))
          : [{ key: 'all', label: 'All tasks', hue: 'var(--muted)', mark: null, list: shown }];

  const rows: Row[] = [{ kind: 'milestones', key: 'milestones' }];
  for (const bucket of buckets) {
    if (!bucket.list.length) continue;
    if (group !== 'none') {
      const starts = bucket.list.map((task) => at[task.id].start);
      const ends = bucket.list.map((task) => at[task.id].end);
      rows.push({
        kind: 'group',
        key: bucket.key,
        label: bucket.label,
        count: bucket.list.length,
        mark: bucket.mark,
        hue: bucket.hue,
        span: [Math.min(...starts), Math.max(...ends)],
      });
      if (folded.has(bucket.key)) continue;
    }
    for (const task of order(bucket.list)) rows.push({ kind: 'task', key: task.id, task });
  }
  const rowOf = new Map(rows.map((row, index) => [row.key, index]));

  // The dependency arrows between the rows on show.
  const links: { key: string; d: string; head: string; hot: boolean }[] = [];
  for (const row of rows) {
    if (row.kind !== 'task') continue;
    for (const before of row.task.after ?? []) {
      const from = rowOf.get(before);
      const to = rowOf.get(row.task.id);
      if (from === undefined || to === undefined) continue;
      const x1 = (at[before].end + 1) * dayW - 1;
      const x2 = at[row.task.id].start * dayW - 1;
      const y1 = (from + 0.5) * ROW;
      const y2 = (to + 0.5) * ROW;
      const reach = Math.max(28, Math.min(120, Math.abs(x2 - x1) * 0.6 + Math.abs(y2 - y1) * 0.25));
      links.push({
        key: `${before}-${row.task.id}`,
        d: `M${x1} ${y1}C${x1 + reach} ${y1} ${x2 - reach} ${y2} ${x2 - 5} ${y2}`,
        head: `M${x2 - 6} ${y2 - 3.5}L${x2} ${y2}L${x2 - 6} ${y2 + 3.5}z`,
        hot: tip?.id === before || tip?.id === row.task.id || drag?.id === before,
      });
    }
  }

  // Through a change of view, the day at the left edge stays at the left edge.
  useLayoutEffect(() => {
    const element = scroller.current;
    if (!element || anchor.current === null) return;
    element.scrollLeft = anchor.current * dayW;
    // Remembered, in case the new width clamps the scroll: switching back finds the same day.
    placed.current = { day: anchor.current, px: element.scrollLeft };
    anchor.current = null;
  }, [dayW]);
  // It opens on the week before last, as a project lead would leave it.
  const openingScroll = useRef(false);
  useLayoutEffect(() => {
    const element = scroller.current;
    if (!element) return;
    const [m, d] = (defaultState?.scrollTo ?? '9-21').split('-').map(Number);
    const before = element.scrollLeft;
    element.scrollLeft = day(m, d) * DAY_W[defaultState?.view ?? defaultView];
    // The scroll event this sets off is not the reader's: it must not close a held tooltip.
    if (element.scrollLeft !== before) openingScroll.current = true;
  }, []);
  // The held tooltip of defaultState.tip, placed once the chart is laid out.
  useLayoutEffect(() => {
    if (!defaultState?.tip) return;
    const bar = root.current?.querySelector<HTMLElement>(
      `[data-part="bar"][data-id="${defaultState.tip}"]`,
    );
    if (bar) showTip(defaultState.tip, bar);
  }, []);

  const leftDay = () => {
    const element = scroller.current;
    return element ? Math.round(element.scrollLeft / dayW) : 0;
  };
  const changeView = (next: TimelineView) => {
    if (next === view) return;
    const now = scroller.current?.scrollLeft ?? 0;
    anchor.current =
      placed.current && Math.abs(placed.current.px - now) < 2 ? placed.current.day : leftDay();
    setView(next);
    setTip(null);
    announce(next === 'weeks' ? 'Showing days.' : 'Showing weeks.');
  };
  const reduced = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const scrollToDay = (index: number, where: 'center' | 'left' = 'center') => {
    const element = scroller.current;
    if (!element) return;
    const list = element.querySelector<HTMLElement>(`.${styles.corner}`)?.offsetWidth ?? 0;
    const lane = element.clientWidth - list;
    const left = where === 'left' ? index * dayW : index * dayW - lane / 2 + dayW / 2;
    element.scrollTo({ left, behavior: reduced() ? 'auto' : 'smooth' });
  };
  const goToday = () => {
    scrollToDay(todayIndex);
    setPulse((n) => n + 1);
    announce(`Today, ${shortDate(todayIndex)}.`);
  };

  const fold = (key: string) =>
    setFolded((set) => {
      const next = new Set(set);
      if (next.has(key)) next.delete(key);
      else next.add(key);
      return next;
    });

  /** `quiet`: the demo's ticks are not announced, nor reported. */
  const toggle = (taskId: string, quiet = false) => {
    const task = byId[taskId];
    if (!task) return;
    const done = task.status !== 'done';
    setTasks((list) =>
      list.map((each) =>
        each.id !== taskId
          ? each
          : done
            ? { ...each, status: 'done', was: each.status }
            : { ...each, status: each.was ?? 'todo', was: undefined },
      ),
    );
    if (quiet) return;
    announce(
      done
        ? `${task.title} done.`
        : `${task.title} back to ${STATUS_LABEL[task.was ?? 'todo'].toLowerCase()}.`,
    );
    onTaskToggle?.(taskId, done);
  };

  /** Moves a task's planned start by `delta` days; reports where it and its followers went. */
  const move = (taskId: string, delta: number, quiet = false) => {
    const task = byId[taskId];
    if (!task || !delta) return;
    const before = schedule(tasks);
    // What is drawn becomes the plan: a bar dragged against a task it waits on stays after it.
    const after = schedule(tasks, { id: taskId, delta });
    const start = Math.min(DAYS - task.length, Math.max(0, after[taskId].start));
    const next = tasks.map((each) => (each.id === taskId ? { ...each, start } : each));
    setTasks(next);
    if (quiet) return;
    const settled = schedule(next);
    const pushed = tasks.filter(
      (each) => each.id !== taskId && settled[each.id].start !== before[each.id].start,
    ).length;
    announce(
      `${task.title}: ${shortDate(settled[taskId].start)} to ${shortDate(settled[taskId].end)}.` +
        (pushed ? ` ${pushed} dependent task${pushed > 1 ? 's' : ''} moved.` : ''),
    );
    onTaskMove?.(taskId, dateOf(settled[taskId].start), dateOf(settled[taskId].end));
  };

  /** Opens a bar's card, placed under it (or over it near the bottom), kept inside the chart. */
  const showTip = (taskId: string, bar: HTMLElement) => {
    const element = scroller.current;
    if (!element) return;
    const sheet = element.firstElementChild as HTMLElement;
    const box = bar.getBoundingClientRect();
    const frameBox = element.getBoundingClientRect();
    const sheetBox = sheet.getBoundingClientRect();
    const scale = frameBox.width / element.offsetWidth || 1;
    const list = element.querySelector<HTMLElement>(`.${styles.corner}`)?.offsetWidth ?? 0;
    const width = Math.min(272, element.clientWidth - 16);
    const minX = element.scrollLeft + Math.min(list + 8, element.clientWidth - width - 8);
    const maxX = element.scrollLeft + element.clientWidth - width - 8;
    const x = Math.max(minX, Math.min(maxX, (box.left - sheetBox.left) / scale));
    const below = box.bottom + 190 * scale < frameBox.bottom;
    const y = below
      ? (box.bottom - sheetBox.top) / scale + 6
      : (box.top - sheetBox.top) / scale - 6;
    setTip({ id: taskId, x, y, below });
  };

  const onBarDown = (event: ReactPointerEvent<HTMLButtonElement>, taskId: string) => {
    if (event.button !== 0) return;
    dragging.current = { id: taskId, x: event.clientX, pointerId: event.pointerId, moved: false };
    event.currentTarget.setPointerCapture(event.pointerId);
  };
  const onBarMove = (event: ReactPointerEvent<HTMLButtonElement>, taskId: string) => {
    const now = dragging.current;
    if (!now || now.id !== taskId || now.pointerId !== event.pointerId) return;
    const scale =
      event.currentTarget.getBoundingClientRect().width / event.currentTarget.offsetWidth || 1;
    const dx = (event.clientX - now.x) / scale;
    if (!now.moved && Math.abs(dx) < 4) return;
    now.moved = true;
    const delta = Math.round(dx / dayW);
    setDrag((was) => (was?.delta === delta && was.id === taskId ? was : { id: taskId, delta }));
    showTip(taskId, event.currentTarget);
  };
  const onBarUp = (event: ReactPointerEvent<HTMLButtonElement>, taskId: string) => {
    const now = dragging.current;
    if (!now || now.id !== taskId) return;
    dragging.current = null;
    if (now.moved && drag?.id === taskId) move(taskId, drag.delta);
    setDrag(null);
    if (event.currentTarget.hasPointerCapture(event.pointerId))
      event.currentTarget.releasePointerCapture(event.pointerId);
  };
  const onBarKey = (event: ReactKeyboardEvent<HTMLButtonElement>, taskId: string) => {
    const step = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
    if (step) {
      event.preventDefault();
      move(taskId, event.shiftKey ? step * 7 : step);
      return;
    }
    if (event.key === 'Escape' && tip) {
      event.stopPropagation();
      setTip(null);
    }
  };
  // After a move by keys, the card follows its bar.
  useLayoutEffect(() => {
    if (!tip || drag) return;
    const bar = root.current?.querySelector<HTMLElement>(`[data-part="bar"][data-id="${tip.id}"]`);
    if (bar && bar === document.activeElement) showTip(tip.id, bar);
  }, [tasks]);

  /** Jump to: the first task whose name matches, scrolled to and flashed. */
  const jumpTo = (text: string) => {
    const want = text.trim().toLowerCase();
    if (!want) return;
    const task = tasks.find((each) => each.title.toLowerCase().includes(want));
    if (!task) {
      announce(`No task matches “${text.trim()}”.`);
      return;
    }
    setQuery('');
    setStatuses(new Set());
    setFolded((set) => {
      const next = new Set(set);
      next.delete(group === 'project' ? task.project : group === 'status' ? task.status : task.who);
      return next;
    });
    scrollToDay(at[task.id].start, 'left');
    setFlash(task.id);
    window.setTimeout(() => setFlash(null), 1400);
    requestAnimationFrame(() =>
      root.current
        ?.querySelector(`[data-part="task"][data-id="${task.id}"]`)
        ?.scrollIntoView({ block: 'nearest' }),
    );
    announce(`${task.title}, ${shortDate(at[task.id].start)} to ${shortDate(at[task.id].end)}.`);
  };

  // Menus close on a press outside them.
  useEffect(() => {
    if (!menu) return;
    const close = (event: PointerEvent) => {
      if (quietMenu.current) return;
      if (!(event.target as Element).closest(`.${styles.dropdown}`)) setMenu(null);
    };
    document.addEventListener('pointerdown', close);
    return () => document.removeEventListener('pointerdown', close);
  }, [menu]);

  const onRootKey = (event: ReactKeyboardEvent) => {
    take();
    const target = event.target as HTMLElement;
    const typing = target.closest('input, textarea, [contenteditable]');
    if ((event.key === 'k' || event.key === 'K') && (event.ctrlKey || event.metaKey)) {
      event.preventDefault();
      jumpBox.current?.focus();
    } else if (event.key === '/' && !typing) {
      event.preventDefault();
      searchBox.current?.focus();
    } else if (event.key === 'Escape' && drawer) setDrawer(false);
  };

  // The demo: a script of glides and presses, over the real controls.
  const demo = autoplay && !held && playing;
  const act = useEffectEvent(
    (
      kind: 'tip' | 'drag' | 'drop' | 'view' | 'today' | 'tick' | 'fold' | 'home' | 'reset',
      arg?: string,
      n = 0,
    ) => {
      if (kind === 'tip') {
        const bar = arg
          ? root.current?.querySelector<HTMLElement>(`[data-part="bar"][data-id="${arg}"]`)
          : null;
        if (bar && arg) showTip(arg, bar);
        else setTip(null);
      }
      if (kind === 'drag' && arg) {
        setDrag({ id: arg, delta: n });
        const bar = root.current?.querySelector<HTMLElement>(`[data-part="bar"][data-id="${arg}"]`);
        if (bar) requestAnimationFrame(() => showTip(arg, bar));
      }
      if (kind === 'drop' && arg) {
        move(arg, n, true);
        setDrag(null);
        setTip(null);
      }
      if (kind === 'view') changeView(arg as TimelineView);
      if (kind === 'today') goToday();
      if (kind === 'tick' && arg) toggle(arg, true);
      if (kind === 'fold' && arg) fold(arg);
      if (kind === 'home') scrollToDay(day(9, 21), 'left');
      if (kind === 'reset') {
        // Puts back what the demo changed, should it stop half-way.
        setDrag(null);
        setTip(null);
        setTasks((list) =>
          list.map((task) => {
            const first = TASKS.find((each) => each.id === task.id)!;
            return arg?.split(',').includes(task.id) ? { ...first, start: first.start } : task;
          }),
        );
        setFolded((set) => {
          if (!set.has('web')) return set;
          const next = new Set(set);
          next.delete('web');
          return next;
        });
        if (view !== 'weeks') changeView('weeks');
      }
    },
  );
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    let touched = false;
    const find = (selector: string) => stage.querySelector<HTMLElement>(selector);
    /** Where a point of an element is, in the stage's own px; null if it is not on show. */
    const spot = (element: HTMLElement | null, fx = 0.5, fy = 0.5): Point | null => {
      if (!element) return null;
      const box = stage.getBoundingClientRect();
      const rect = element.getBoundingClientRect();
      const chart = scroller.current?.getBoundingClientRect();
      const scale = box.width / stage.offsetWidth || 1;
      if (!rect.width || rect.right <= box.left || rect.left >= box.right) return null;
      if (!element.checkVisibility({ visibilityProperty: true })) return null;
      const x = rect.left + rect.width * fx;
      const y = rect.top + rect.height * fy;
      if (element.closest('[data-part="chart"]') && chart) {
        if (x < chart.left || x > chart.right || y < chart.top + 50 || y > chart.bottom)
          return null;
      }
      return { x: (x - box.left) / scale, y: (y - box.top) / scale };
    };

    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      /** Glides to an element and rests there; false if it is not on show. */
      const go = async (selector: string, fx = 0.5, fy = 0.5, ms = 560, over?: boolean) => {
        const to = spot(find(selector), fx, fy);
        if (!to) return false;
        glyph('arrow');
        await glide(to, ms);
        if (over) glyph('hand');
        return true;
      };
      const press = async () => {
        arrow.dataset.press = '';
        await wait(140);
        delete arrow.dataset.press;
      };
      await wait(900);
      for (;;) {
        // Hover a bar: its card opens.
        if (await go('[data-part="bar"][data-id="w5"]', 0.45, 0.5, 800)) {
          act('tip', 'w5');
          await wait(1700);
          // Drag it three days on: the tasks after it are pushed along.
          const from = spot(find('[data-part="bar"][data-id="w5"]'), 0.45, 0.5);
          if (from) {
            touched = true;
            arrow.dataset.press = '';
            await wait(180);
            for (let step = 1; step <= 3; step += 1) {
              await glide({ x: from.x + step * DAY_W.weeks, y: from.y }, 240);
              act('drag', 'w5', step);
              await wait(140);
            }
            await wait(300);
            delete arrow.dataset.press;
            act('drop', 'w5', 3);
            await wait(1600);
            // And back.
            arrow.dataset.press = '';
            await wait(160);
            for (let step = 1; step <= 3; step += 1) {
              await glide({ x: from.x + (3 - step) * DAY_W.weeks, y: from.y }, 220);
              act('drag', 'w5', -step);
              await wait(120);
            }
            await wait(200);
            delete arrow.dataset.press;
            act('drop', 'w5', -3);
            touched = false;
            await wait(900);
          }
        }
        // Months, then back to Weeks.
        if (await go('[data-part="view"][data-view="months"]', 0.5, 0.5, 700, true)) {
          await wait(200);
          await press();
          act('view', 'months');
          await wait(1800);
          if (await go('[data-part="view"][data-view="weeks"]', 0.5, 0.5, 420, true)) {
            await wait(200);
            await press();
            act('view', 'weeks');
            await wait(900);
          }
        }
        // Today.
        if (await go('[data-part="today"]', 0.5, 0.5, 520, true)) {
          await wait(200);
          await press();
          act('today');
          await wait(1800);
        }
        // Tick a task, then untick it.
        if (
          await go(
            '[data-part="task"][data-id="w10"] [data-part="task-check"]',
            0.5,
            0.5,
            700,
            true,
          )
        ) {
          await wait(240);
          await press();
          act('tick', 'w10');
          touched = true;
          await wait(1300);
          await press();
          act('tick', 'w10');
          touched = false;
          await wait(800);
        }
        // Fold the project's group, and open it again.
        if (await go('[data-part="group"][data-id="web"]', 0.3, 0.5, 700, true)) {
          await wait(220);
          await press();
          act('fold', 'web');
          await wait(1500);
          await press();
          act('fold', 'web');
          await wait(900);
        }
        act('home');
        glyph('arrow');
        await wait(1600);
      }
    });
    return () => {
      stop();
      delete arrow.dataset.press;
      act('reset', touched ? 'w5,w10' : 'w5');
    };
  }, [demo, root]);

  const navItem = (item: { id: string; label: string; icon: IconName }, extra?: ReactNode) => (
    <li key={item.id}>
      <button
        type="button"
        className={styles.navItem}
        data-part="nav-item"
        aria-current={current === item.id ? 'page' : undefined}
        title={collapsed ? item.label : undefined}
        onClick={() => {
          if (item.id === 'search') jumpBox.current?.focus();
          else setCurrent(item.id);
          setDrawer(false);
        }}
      >
        <Icon name={item.icon} className={styles.navIcon} />
        <span className={styles.navLabel}>{item.label}</span>
        {extra}
      </button>
    </li>
  );

  const sidebar = (
    <nav className={styles.sidebar} data-part="sidebar" aria-label="Workspace" id={`${id}-sidebar`}>
      <div className={styles.sideHead}>
        <button
          type="button"
          className={styles.switchButton}
          aria-label={`Workspace: ${workspace}`}
          data-part="switcher"
        >
          <Logo />
          <span className={styles.switchName}>{workspace}</span>
          <Icon name="updown" className={styles.switchIcon} />
        </button>
        <button
          type="button"
          className={styles.iconButton}
          data-part="collapse"
          aria-label={drawer ? 'Close sidebar' : collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
          aria-expanded={drawer ? true : !collapsed}
          aria-controls={`${id}-sidebar`}
          onClick={() => {
            if (drawer) setDrawer(false);
            else setCollapsed((value) => !value);
          }}
        >
          <Icon name={drawer ? 'close' : 'panel'} className={styles.headIcon} />
        </button>
      </div>

      <div className={styles.sideScroll}>
        <ul className={styles.navList}>
          {navItem(PRIMARY[0])}
          {navItem(PRIMARY[1])}
          {navItem(
            PRIMARY[2],
            myOpen > 0 && (
              <span className={styles.count} aria-label={`${myOpen} open`}>
                {myOpen}
              </span>
            ),
          )}
          {navItem(PRIMARY[3])}
          {navItem(
            PRIMARY[4],
            <kbd className={styles.kbd} aria-hidden="true">
              /
            </kbd>,
          )}
          {navItem(PRIMARY[5])}
        </ul>

        <p className={styles.sideLabel} id={`${id}-ws`}>
          Workspace
        </p>
        <ul className={styles.navList} aria-labelledby={`${id}-ws`}>
          {WORKSPACE.map((item) => navItem(item))}
        </ul>

        <p className={styles.sideLabel} id={`${id}-pr`}>
          Projects
        </p>
        <ul className={styles.navList} aria-labelledby={`${id}-pr`} data-part="project-tree">
          {PROJECTS.map((project) => {
            const isOpen = open.has(project.id);
            return (
              <li key={project.id}>
                <button
                  type="button"
                  className={styles.navItem}
                  data-part="project-link"
                  data-id={project.id}
                  aria-expanded={isOpen}
                  title={collapsed ? project.name : undefined}
                  onClick={() =>
                    setOpen((set) => {
                      const next = new Set(set);
                      if (next.has(project.id)) next.delete(project.id);
                      else next.add(project.id);
                      return next;
                    })
                  }
                >
                  <ProjectTile project={project} />
                  <span className={styles.navLabel}>
                    {project.name}
                    {project.favorite && <Icon name="star" className={styles.inlineIcon} />}
                    {project.locked && <Icon name="lock" className={styles.inlineIcon} />}
                  </span>
                  <span className={styles.statusDot} data-tone={project.tone} aria-hidden="true" />
                </button>
                <div className={styles.fold} data-open={isOpen}>
                  <ul className={styles.views} inert={!isOpen}>
                    {VIEWS.map((each) => (
                      <li key={each.label}>
                        <button
                          type="button"
                          className={`${styles.navItem} ${styles.view}`}
                          aria-current={
                            current === `${project.id}-${each.label}` ? 'page' : undefined
                          }
                          onClick={() => {
                            setCurrent(`${project.id}-${each.label}`);
                            setDrawer(false);
                          }}
                        >
                          <Icon name={each.icon} className={styles.navIcon} />
                          <span className={styles.navLabel}>{each.label}</span>
                        </button>
                      </li>
                    ))}
                  </ul>
                </div>
              </li>
            );
          })}
          {navItem({ id: 'archive', label: 'Archive', icon: 'archive' })}
        </ul>

        <p className={styles.sideLabel} id={`${id}-tm`}>
          Teams
        </p>
        <ul className={styles.navList} aria-labelledby={`${id}-tm`}>
          {TEAMS.map((team) => (
            <li key={team.name}>
              <button
                type="button"
                className={styles.navItem}
                aria-current={current === `team-${team.name}` ? 'page' : undefined}
                title={collapsed ? team.name : undefined}
                onClick={() => setCurrent(`team-${team.name}`)}
              >
                <span
                  className={styles.teamMark}
                  style={{ '--hue': team.hue } as CSSProperties}
                  aria-hidden="true"
                >
                  {team.name.charAt(0)}
                </span>
                <span className={styles.navLabel}>{team.name}</span>
              </button>
            </li>
          ))}
        </ul>
      </div>
    </nav>
  );

  const tipTask = tip ? byId[tip.id] : null;
  const filterCount = statuses.size;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      data-collapsed={collapsed || undefined}
      data-drawer={drawer || undefined}
      data-view={view}
      data-dragging={drag ? '' : undefined}
      style={{ '--day': `${dayW}px`, '--days': DAYS } as CSSProperties}
      onPointerDownCapture={() => {
        quietMenu.current = false;
        take();
      }}
      onKeyDown={onRootKey}
      onWheel={take}
      onPointerMove={(event) => {
        if (event.pointerType !== 'touch') take();
      }}
    >
      <div className={styles.window} data-part="window">
        {frame && (
          <div className={styles.chrome} data-part="window-bar" aria-hidden="true">
            <span className={styles.lights}>
              <i />
              <i />
              <i />
            </span>
            <span className={styles.address}>{address}</span>
          </div>
        )}
        <div className={styles.app}>
          {sidebar}
          <button
            type="button"
            className={styles.scrim}
            tabIndex={-1}
            aria-hidden="true"
            onClick={() => setDrawer(false)}
          />
          <div className={styles.main} data-part="main">
            <header className={styles.topbar} data-part="topbar">
              <button
                type="button"
                className={`${styles.iconButton} ${styles.menuButton}`}
                aria-label="Open sidebar"
                aria-expanded={drawer}
                aria-controls={`${id}-sidebar`}
                onClick={() => setDrawer(true)}
              >
                <Icon name="menu" className={styles.headIcon} />
              </button>
              <nav className={styles.crumbs} aria-label="Breadcrumb" data-part="breadcrumb">
                <Logo small />
                <span className={styles.crumbSpace}>{workspace}</span>
                <span className={styles.crumbSep} aria-hidden="true">
                  /
                </span>
                <span className={styles.crumbHere} aria-current="page">
                  {title}
                </span>
              </nav>
              <form
                className={styles.jump}
                data-part="jump"
                role="search"
                onSubmit={(event) => {
                  event.preventDefault();
                  jumpTo(jump);
                }}
              >
                <Icon name="search" className={styles.jumpIcon} />
                <input
                  ref={jumpBox}
                  type="search"
                  className={styles.jumpInput}
                  placeholder="Search or jump to…"
                  aria-label="Jump to a task"
                  value={jump}
                  onChange={(event) => setJump(event.target.value)}
                  onKeyDown={(event) => {
                    if (event.key === 'Escape' && jump) {
                      event.stopPropagation();
                      setJump('');
                    }
                  }}
                />
                <kbd className={styles.kbd} aria-hidden="true">
                  Ctrl K
                </kbd>
              </form>
              <div className={styles.topTools} data-part="top-tools">
                <button type="button" className={styles.iconButton} aria-label="Notifications">
                  <Icon name="bell" className={styles.headIcon} />
                  <i className={styles.unread} aria-hidden="true" />
                </button>
                <button type="button" className={styles.iconButton} aria-label="Help">
                  <Icon name="help" className={styles.headIcon} />
                </button>
                <span className={styles.topRule} aria-hidden="true" />
                <span className={styles.user} data-part="user">
                  <span className={styles.userFace} aria-hidden="true">
                    {userName.slice(0, 1)}
                  </span>
                  {userName}
                </span>
              </div>
            </header>

            <div className={styles.page}>
              <div className={styles.head} data-part="page-head">
                <h2 className={styles.title}>{title}</h2>
                <p className={styles.subtitle}>{subtitle}</p>
              </div>

              <div
                className={styles.toolbar}
                role="toolbar"
                aria-label="Timeline"
                data-part="toolbar"
              >
                <label className={styles.search} data-part="search">
                  <Icon name="search" className={styles.searchIcon} />
                  <input
                    ref={searchBox}
                    type="search"
                    className={styles.searchInput}
                    placeholder="Search tasks"
                    aria-label="Search tasks"
                    value={query}
                    onChange={(event) => setQuery(event.target.value)}
                    onKeyDown={(event) => {
                      if (event.key === 'Escape' && query) {
                        event.stopPropagation();
                        setQuery('');
                      }
                    }}
                  />
                </label>
                <Dropdown
                  name="filter"
                  open={menu === 'filter'}
                  onOpen={(value) => setMenu(value ? 'filter' : null)}
                  quiet={quietMenu}
                  label={
                    <>
                      <Icon name="filter" className={styles.toolIcon} />
                      <span>Filter</span>
                      {filterCount > 0 && <span className={styles.toolBadge}>{filterCount}</span>}
                    </>
                  }
                  ariaLabel={filterCount ? `Filter, ${filterCount} statuses` : 'Filter'}
                >
                  <li role="presentation" className={styles.menuHead}>
                    Status
                  </li>
                  {STATUSES.map((status) => (
                    <li key={status.id} role="none">
                      <button
                        type="button"
                        role="menuitemcheckbox"
                        aria-checked={statuses.has(status.id)}
                        className={styles.menuItem}
                        tabIndex={-1}
                        onClick={() =>
                          setStatuses((set) => {
                            const next = new Set(set);
                            if (next.has(status.id)) next.delete(status.id);
                            else next.add(status.id);
                            return next;
                          })
                        }
                      >
                        <StatusMark status={status.id} />
                        {status.label}
                        <Icon name="check" className={styles.menuCheck} />
                      </button>
                    </li>
                  ))}
                  {filterCount > 0 && (
                    <li role="none">
                      <button
                        type="button"
                        role="menuitem"
                        className={`${styles.menuItem} ${styles.menuClear}`}
                        tabIndex={-1}
                        onClick={() => {
                          setStatuses(new Set());
                          setMenu(null);
                        }}
                      >
                        Clear filter
                      </button>
                    </li>
                  )}
                </Dropdown>
                <Dropdown
                  name="sort"
                  open={menu === 'sort'}
                  onOpen={(value) => setMenu(value ? 'sort' : null)}
                  quiet={quietMenu}
                  label={
                    <>
                      <Icon name="sort" className={styles.toolIcon} />
                      <span>
                        Sort:
                        <span className={styles.toolValue}>
                          {SORTS.find((s) => s.id === sort)?.label}
                        </span>
                      </span>
                    </>
                  }
                  ariaLabel={`Sort: ${SORTS.find((s) => s.id === sort)?.label}`}
                >
                  {SORTS.map((each) => (
                    <li key={each.id} role="none">
                      <button
                        type="button"
                        role="menuitemradio"
                        aria-checked={sort === each.id}
                        className={styles.menuItem}
                        tabIndex={-1}
                        onClick={() => {
                          setSort(each.id);
                          setMenu(null);
                          announce(`Sorted by ${each.label.toLowerCase()}.`);
                        }}
                      >
                        {each.label}
                        <Icon name="check" className={styles.menuCheck} />
                      </button>
                    </li>
                  ))}
                </Dropdown>
                <div className={styles.seg} role="radiogroup" aria-label="Scale" data-part="scale">
                  {(['weeks', 'months'] as const).map((each) => (
                    <button
                      key={each}
                      type="button"
                      role="radio"
                      aria-checked={view === each}
                      tabIndex={view === each ? 0 : -1}
                      className={styles.segButton}
                      data-part="view"
                      data-view={each}
                      onClick={() => changeView(each)}
                      onKeyDown={(event) => {
                        if (
                          ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)
                        ) {
                          event.preventDefault();
                          const next = view === 'weeks' ? 'months' : 'weeks';
                          changeView(next);
                          (
                            event.currentTarget.parentElement?.querySelector(
                              `[data-view="${next}"]`,
                            ) as HTMLElement | null
                          )?.focus();
                        }
                      }}
                    >
                      {each === 'weeks' ? 'Weeks' : 'Months'}
                    </button>
                  ))}
                </div>
                <Dropdown
                  name="group-by"
                  open={menu === 'group'}
                  onOpen={(value) => setMenu(value ? 'group' : null)}
                  quiet={quietMenu}
                  boxed
                  label={
                    <>
                      <span>Group: {GROUPS.find((g) => g.id === group)?.label}</span>
                      <Icon name="chevronDown" className={styles.selectIcon} />
                    </>
                  }
                  ariaLabel={`Group: ${GROUPS.find((g) => g.id === group)?.label}`}
                >
                  {GROUPS.map((each) => (
                    <li key={each.id} role="none">
                      <button
                        type="button"
                        role="menuitemradio"
                        aria-checked={group === each.id}
                        className={styles.menuItem}
                        tabIndex={-1}
                        onClick={() => {
                          setGroup(each.id);
                          setMenu(null);
                          setFolded(new Set());
                          announce(
                            each.id === 'none'
                              ? 'Not grouped.'
                              : `Grouped by ${each.label.toLowerCase()}.`,
                          );
                        }}
                      >
                        {each.label}
                        <Icon name="check" className={styles.menuCheck} />
                      </button>
                    </li>
                  ))}
                </Dropdown>
                <button type="button" className={styles.tool} data-part="today" onClick={goToday}>
                  <Icon name="today" className={styles.toolIcon} />
                  <span>Today</span>
                </button>
              </div>

              <div
                ref={scroller}
                className={styles.chart}
                data-part="chart"
                onScroll={() => {
                  if (openingScroll.current) {
                    openingScroll.current = false;
                    return;
                  }
                  if (tip && !drag && !document.activeElement?.closest('[data-part="bar"]'))
                    setTip(null);
                }}
              >
                <div className={styles.sheet}>
                  <div className={styles.header} data-part="header" aria-hidden="true">
                    <div className={styles.corner}>Task</div>
                    <div className={styles.scale}>
                      <Months dayW={dayW} />
                      <Days view={view} todayIndex={todayIndex} />
                    </div>
                  </div>

                  <div className={styles.body} data-part="body">
                    <div className={styles.grid} aria-hidden="true" />
                    <div
                      key={pulse}
                      className={styles.todayLine}
                      data-part="today-line"
                      data-pulse={pulse ? '' : undefined}
                      style={{ '--at': todayIndex } as CSSProperties}
                      aria-hidden="true"
                    />
                    <svg
                      className={styles.links}
                      width={DAYS * dayW}
                      height={rows.length * ROW}
                      aria-hidden="true"
                      data-part="links"
                    >
                      {links.map((link) => (
                        <g
                          key={link.key}
                          data-part="link"
                          data-id={link.key}
                          data-hot={link.hot || undefined}
                        >
                          <path className={styles.linkPath} d={link.d} />
                          <path className={styles.linkHead} d={link.head} />
                        </g>
                      ))}
                    </svg>

                    <ul className={styles.rows} aria-label="Tasks">
                      {rows.map((row) => {
                        if (row.kind === 'milestones')
                          return (
                            <li key={row.key} className={`${styles.row} ${styles.groupRow}`}>
                              <button
                                type="button"
                                className={styles.groupCell}
                                data-part="group"
                                data-id="milestones"
                                aria-expanded={!folded.has('milestones')}
                                onClick={() => fold('milestones')}
                              >
                                <span className={styles.groupMark}>
                                  <Icon name="diamond" className={styles.diamondIcon} />
                                  <Icon name="chevronDown" className={styles.foldIcon} />
                                </span>
                                <span className={styles.groupName}>Milestones</span>
                                <span className={styles.groupCount}>{MILESTONES.length}</span>
                              </button>
                              <div className={styles.lane} data-part="milestones">
                                {!folded.has('milestones') &&
                                  MILESTONES.map((stone, index) => {
                                    const next = MILESTONES[index + 1];
                                    const room = next ? (next.day - stone.day) * dayW - 20 : 220;
                                    return (
                                      <span
                                        key={stone.id}
                                        className={styles.milestone}
                                        style={
                                          {
                                            '--at': stone.day,
                                            '--hue': projectById(stone.project).hue,
                                            '--room': `${Math.max(0, room)}px`,
                                          } as CSSProperties
                                        }
                                        data-name={`${stone.title} · ${shortDate(stone.day)}`}
                                        data-part="milestone"
                                        data-id={stone.id}
                                        role="img"
                                        aria-label={`Milestone: ${stone.title}, ${shortDate(stone.day)}`}
                                      >
                                        <i className={styles.diamond} />
                                        {room > 30 && (
                                          <span className={styles.milestoneLabel}>
                                            {stone.title}
                                          </span>
                                        )}
                                      </span>
                                    );
                                  })}
                              </div>
                            </li>
                          );
                        if (row.kind === 'group')
                          return (
                            <li key={row.key} className={`${styles.row} ${styles.groupRow}`}>
                              <button
                                type="button"
                                className={styles.groupCell}
                                data-part="group"
                                data-id={row.key}
                                aria-expanded={!folded.has(row.key)}
                                onClick={() => {
                                  fold(row.key);
                                  announce(
                                    `${row.label} ${folded.has(row.key) ? 'expanded' : 'collapsed'}.`,
                                  );
                                }}
                              >
                                <span className={styles.groupMark}>
                                  {row.mark}
                                  <Icon name="chevronDown" className={styles.foldIcon} />
                                </span>
                                <span className={styles.groupName}>{row.label}</span>
                                <span className={styles.groupCount}>{row.count}</span>
                              </button>
                              <div className={styles.lane}>
                                {folded.has(row.key) && row.span && (
                                  <span
                                    className={styles.summary}
                                    style={
                                      {
                                        '--x': row.span[0],
                                        '--w': row.span[1] - row.span[0] + 1,
                                        '--hue': row.hue,
                                      } as CSSProperties
                                    }
                                    data-part="summary"
                                  />
                                )}
                              </div>
                            </li>
                          );
                        const task = row.task;
                        const place = at[task.id];
                        const length = place.end - place.start + 1;
                        const narrow = length * dayW < NARROW;
                        const isTip = tip?.id === task.id;
                        return (
                          <li
                            key={row.key}
                            className={styles.row}
                            data-part="task"
                            data-id={task.id}
                            data-status={task.status}
                            data-flash={flash === task.id || undefined}
                          >
                            <div className={styles.cell} data-part="task-cell">
                              <button
                                type="button"
                                role="checkbox"
                                aria-checked={task.status === 'done'}
                                aria-label={`Done: ${task.title}`}
                                className={styles.check}
                                data-part="task-check"
                                onClick={() => toggle(task.id)}
                              >
                                <StatusMark status={task.status} />
                              </button>
                              <span className={styles.taskTitle}>{task.title}</span>
                              {task.subtasks && (
                                <span
                                  className={styles.subtasks}
                                  aria-label={`${task.subtasks[0]} of ${task.subtasks[1]} subtasks done`}
                                >
                                  {task.subtasks[0]}/{task.subtasks[1]}
                                </span>
                              )}
                              <Face who={task.who} labelled />
                            </div>
                            <div className={styles.lane}>
                              <button
                                type="button"
                                className={styles.bar}
                                data-part="bar"
                                data-id={task.id}
                                data-status={task.status}
                                data-narrow={narrow || undefined}
                                data-dragging={drag?.id === task.id || undefined}
                                data-hover={isTip || undefined}
                                aria-label={`${task.title}, ${shortDate(place.start)} to ${shortDate(place.end)}`}
                                aria-describedby={isTip ? `${id}-tip` : undefined}
                                aria-roledescription="schedule bar"
                                style={
                                  {
                                    '--x': place.start,
                                    '--w': length,
                                    '--p': progressOf(task),
                                  } as CSSProperties
                                }
                                onPointerDown={(event) => onBarDown(event, task.id)}
                                onPointerMove={(event) => onBarMove(event, task.id)}
                                onPointerUp={(event) => onBarUp(event, task.id)}
                                onPointerCancel={(event) => onBarUp(event, task.id)}
                                onPointerEnter={(event) => {
                                  if (!dragging.current && event.pointerType !== 'touch')
                                    showTip(task.id, event.currentTarget);
                                }}
                                onPointerLeave={() => {
                                  if (!dragging.current && tip?.id === task.id) setTip(null);
                                }}
                                onFocus={(event) => {
                                  if (event.currentTarget.matches(':focus-visible'))
                                    showTip(task.id, event.currentTarget);
                                }}
                                onBlur={() => {
                                  if (tip?.id === task.id) setTip(null);
                                }}
                                onKeyDown={(event) => onBarKey(event, task.id)}
                              >
                                <span className={styles.barFill} aria-hidden="true" />
                                <span className={styles.barBody}>
                                  {!narrow && <Face who={task.who} />}
                                  <span className={styles.barLabel}>{task.title}</span>
                                </span>
                              </button>
                            </div>
                          </li>
                        );
                      })}
                      {rows.length === 1 && (
                        <li className={styles.empty}>
                          {q
                            ? `No tasks match “${query.trim()}”.`
                            : 'No tasks with these statuses.'}
                        </li>
                      )}
                    </ul>

                    {tip && tipTask && (
                      <TipCard
                        id={`${id}-tip`}
                        tip={tip}
                        task={tipTask}
                        place={at[tipTask.id]}
                        byId={byId}
                      />
                    )}
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrow}
          handClass={styles.hand}
        />
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

/** Row height, in px (the stylesheet's --row). */
const ROW = 39.6;

/** The months across the top: each label stays at the left edge while its month is on show. */
function Months({ dayW }: { dayW: number }) {
  const months: { key: string; start: number; days: number; label: string }[] = [];
  for (let index = 0; index < DAYS; index += 1) {
    const date = dateOf(index);
    const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}`;
    const last = months[months.length - 1];
    if (last?.key === key) last.days += 1;
    else
      months.push({
        key,
        start: index,
        days: 1,
        label: `${MONTH_LONG[date.getUTCMonth()]} ${date.getUTCFullYear()}`,
      });
  }
  return (
    <div className={styles.months}>
      {months.map((month) => (
        <div
          key={month.key}
          className={styles.month}
          style={{ width: month.days * dayW }}
          data-part="month"
        >
          <span className={styles.monthLabel}>{month.label}</span>
        </div>
      ))}
    </div>
  );
}

/** A number per day (Weeks), or per week, on its Monday (Months). */
function Days({ view, todayIndex }: { view: TimelineView; todayIndex: number }) {
  const cells: { index: number; span: number }[] = [];
  for (let index = 0; index < DAYS; index += view === 'weeks' ? 1 : 7)
    cells.push({ index, span: view === 'weeks' ? 1 : 7 });
  return (
    <div className={styles.days}>
      {cells.map(({ index, span }) => {
        const date = dateOf(index);
        const isToday =
          view === 'weeks' ? index === todayIndex : todayIndex - index < 7 && todayIndex >= index;
        return (
          <span
            key={index}
            className={styles.dayCell}
            style={{ '--span': span } as CSSProperties}
            data-today={isToday || undefined}
            data-week={date.getUTCDay() === 1 || undefined}
          >
            <span className={styles.dayNumber}>
              {view === 'weeks' ? date.getUTCDate() : shortDate(index)}
            </span>
          </span>
        );
      })}
    </div>
  );
}

function TipCard({
  id,
  tip,
  task,
  place,
  byId,
}: {
  id: string;
  tip: Tip;
  task: Task;
  place: { start: number; end: number };
  byId: Record<string, Task>;
}) {
  const length = place.end - place.start + 1;
  const followers = TASKS.filter((each) => each.after?.includes(task.id)).map((each) => each.title);
  const waits = (task.after ?? []).map((each) => byId[each]?.title).filter(Boolean);
  const progress = progressOf(task);
  return (
    <div
      id={id}
      role="tooltip"
      className={styles.tip}
      data-part="tip"
      data-below={tip.below || undefined}
      style={{ left: tip.x, top: tip.y }}
    >
      <p className={styles.tipTitle}>{task.title}</p>
      <p className={styles.tipMeta}>
        <StatusMark status={task.status} />
        {STATUS_LABEL[task.status]}
        <span className={styles.tipDot} aria-hidden="true" />
        {projectById(task.project).name}
      </p>
      <dl className={styles.tipList}>
        <dt>Dates</dt>
        <dd>
          {shortDate(place.start)} – {shortDate(place.end)}
          <span className={styles.tipMuted}>
            {' '}
            · {length} day{length > 1 ? 's' : ''}
          </span>
        </dd>
        <dt>Assignee</dt>
        <dd className={styles.tipWho}>
          <Face who={task.who} />
          {PEOPLE[task.who].name}
        </dd>
        {progress > 0 && progress < 1 && (
          <>
            <dt>{task.subtasks ? 'Subtasks' : 'Progress'}</dt>
            <dd className={styles.tipProgress}>
              <span className={styles.tipBar}>
                <span style={{ transform: `scaleX(${progress})` }} />
              </span>
              {task.subtasks
                ? `${task.subtasks[0]} of ${task.subtasks[1]}`
                : `${Math.round(progress * 100)}%`}
            </dd>
          </>
        )}
        {waits.length > 0 && (
          <>
            <dt>After</dt>
            <dd>{waits.join(', ')}</dd>
          </>
        )}
        {followers.length > 0 && (
          <>
            <dt>Blocks</dt>
            <dd>{followers.join(', ')}</dd>
          </>
        )}
      </dl>
      <p className={styles.tipHint}>Drag to reschedule, or use ← →</p>
    </div>
  );
}

/**
 * A toolbar button with its menu: a radio or checkbox menu under it. Arrow keys move through the
 * items, Escape closes it and gives the focus back.
 */
function Dropdown({
  name,
  open,
  onOpen,
  quiet,
  label,
  ariaLabel,
  boxed,
  children,
}: {
  name: string;
  open: boolean;
  onOpen: (open: boolean) => void;
  quiet: { current: boolean };
  label: ReactNode;
  ariaLabel: string;
  boxed?: boolean;
  children: ReactNode;
}) {
  const button = useRef<HTMLButtonElement>(null);
  const list = useRef<HTMLUListElement>(null);
  const menuId = useId();
  useEffect(() => {
    if (!open || quiet.current) return;
    const items = list.current?.querySelectorAll<HTMLElement>('[role^="menuitem"]');
    const checked = list.current?.querySelector<HTMLElement>('[aria-checked="true"]');
    (checked ?? items?.[0])?.focus();
  }, [open, quiet]);
  const onKey = (event: ReactKeyboardEvent) => {
    const items = [...(list.current?.querySelectorAll<HTMLElement>('[role^="menuitem"]') ?? [])];
    const index = items.indexOf(document.activeElement as HTMLElement);
    if (event.key === 'Escape') {
      event.preventDefault();
      event.stopPropagation();
      onOpen(false);
      button.current?.focus();
    } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      event.preventDefault();
      const next = (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
      items[next]?.focus();
    } else if (event.key === 'Home' || event.key === 'End') {
      event.preventDefault();
      items[event.key === 'Home' ? 0 : items.length - 1]?.focus();
    } else if (event.key === 'Tab') onOpen(false);
  };
  return (
    <div className={styles.dropdown} data-part={`${name}-menu-root`}>
      <button
        ref={button}
        type="button"
        className={boxed ? styles.select : styles.tool}
        data-part={name}
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={open ? menuId : undefined}
        aria-label={ariaLabel}
        onClick={() => onOpen(!open)}
        onKeyDown={(event) => {
          if (event.key === 'ArrowDown' && !open) {
            event.preventDefault();
            onOpen(true);
          }
        }}
      >
        {label}
      </button>
      {open && (
        <ul
          ref={list}
          id={menuId}
          className={styles.menu}
          role="menu"
          aria-label={ariaLabel}
          data-part={`${name}-menu`}
          onKeyDown={onKey}
        >
          {children}
        </ul>
      )}
    </div>
  );
}

/** The status circle: done ticked, in progress half, in review three-quarters, to do empty. */
function StatusMark({ status }: { status: Status }) {
  return (
    <span className={styles.status} data-status={status} aria-hidden="true">
      {status === 'done' && (
        <svg viewBox="0 0 16 16" className={styles.statusTick}>
          <path d="m4.8 8.3 2.2 2.2 4.2-4.4" />
        </svg>
      )}
    </span>
  );
}

/** The workspace's mark: an invented monogram, a light tile with a coral dot. */
function Logo({ small }: { small?: boolean }) {
  return (
    <span className={styles.logo} data-small={small || undefined} aria-hidden="true">
      <svg viewBox="0 0 20 20" className={styles.logoArt}>
        <path d="M7 15.5V7.6c0-1.6 1-2.6 2.6-2.6h2.2M5.4 9.4h5" />
        <circle cx="14.4" cy="13.6" r="1.9" />
      </svg>
    </span>
  );
}

function ProjectTile({ project }: { project: Project }) {
  return (
    <span
      className={styles.tile}
      style={{ '--hue': project.hue } as CSSProperties}
      aria-hidden="true"
    >
      <Icon name={project.icon} className={styles.tileIcon} />
    </span>
  );
}

function Face({ who, labelled, small }: { who: PersonId; labelled?: boolean; small?: boolean }) {
  const person = PEOPLE[who];
  return (
    <span
      className={styles.face}
      style={{ '--hue': person.hue } as CSSProperties}
      data-small={small || undefined}
      data-name={labelled ? person.name : undefined}
      role={labelled ? 'img' : undefined}
      aria-label={labelled ? person.name : undefined}
      aria-hidden={labelled ? undefined : true}
    >
      {person.initials}
    </span>
  );
}
```

### project-timeline/project-timeline.module.css

```css
/* Project timeline (project-timeline.tsx): a project tool's Gantt screen in an app window.
   Near-black at night, the sidebar a step darker than the page, hairlines rather than fills, the
   bars tinted by status (green done, amber in progress, violet in review, grey to do), each with
   its progress filled in; by day the same in white and warm greys.

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

   Geometry: a day is --day wide (set inline: 35.25 px in Weeks, 11 px in Months), a row --row
   tall, the list column --list wide. Bars sit at translateX(start × day), so a drag, a push from
   a dependency and a change of view all move them by transform.

   Layout by container queries on the root: the sidebar is a column from 760 px (or a rail of
   icons, folded), a drawer under it, where the list column narrows to the task names. */

.root {
  --app: #ffffff;
  --side: #f7f7f5;
  --line: #e6e6e2;
  --divider: #efefec;
  --hover: #f4f4f2;
  --active: #ebebe8;
  --ink: #1b1b19;
  --ink-2: #5c5c58;
  --muted: #8a8a85;
  --faint: #c3c3be;
  --seg: #f0f0ed;
  --seg-on: #ffffff;
  --seg-shadow: rgb(0 0 0 / 0.08);
  --accent: #5868d6;
  --accent-ink: #ffffff;
  --amber: #b07612;
  --green: #1f8f5c;
  --red: #d14a3a;
  --blue: #3b7fd6;
  --grey-dot: #9a9a95;
  --chrome: #ececea;
  --chrome-line: #dcdcd8;
  --address: #ffffff;
  --address-ink: #6b6b67;
  /* Mixed into a project's hue for its icon: darker by day, lighter at night. */
  --tint-to: #000;
  --shade: rgb(0 0 0 / 0.14);
  --shade-deep: rgb(0 0 0 / 0.18);
  --nav-ink: #3a3a37;
  --logo: #1b1b19;
  --logo-ink: #ffffff;
  --on-red: #ffffff;
  /* The same on both schemes: the focus ring, and the demo's pointer, which looks like the
     system's. */
  --focus: #4d90ff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.3);
  --violet: #7d5fd3;
  --todo: #9a9a95;
  --on-green: #ffffff;
  --done-ink: #8a8a85;
  --weekend: #f7f7f5;
  --week-line: #e9e9e6;
  --row-line: #f0f0ed;
  --group: #fafaf9;
  --group-cell: #fafaf9;
  --link: #b9b9b4;
  --link-hot: #6b6b67;
  --face-base: #ffffff;
  --bar-ring: #ffffff;
  --bar-text: #1b1b19;
  --done-fill: #dcefe3;
  --done-edge: #a8d4b9;
  --done-bar-ink: #1d4d33;
  --amber-fill: #fbf2df;
  --amber-done: #f3dfb1;
  --amber-edge: #e2c27e;
  --violet-fill: #f1eefa;
  --violet-done: #ddd5f4;
  --violet-edge: #c3b6ea;
  --todo-fill: #f2f2ef;
  --todo-edge: #d9d9d4;
  --todo-ink: #3a3a37;
  --sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  --side-w: 275px;
  --list: 330px;
  --row: 39.6px;
  position: relative;
  container: pt / inline-size;
  width: 100%;
  min-width: 0;
  color: var(--ink);
  font-family: var(--sans);
  font-size: 14.5px;
  line-height: 1.4;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  text-align: left;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --app: light-dark(#ffffff, #1d1d1b);
    --side: light-dark(#f7f7f5, #151513);
    --line: light-dark(#e6e6e2, #2c2c2a);
    --divider: light-dark(#efefec, #252523);
    --hover: light-dark(#f4f4f2, #232321);
    --active: light-dark(#ebebe8, #2d2d2b);
    --ink: light-dark(#1b1b19, #ededea);
    --ink-2: light-dark(#5c5c58, #b3b3ae);
    --muted: light-dark(#8a8a85, #82827e);
    --faint: light-dark(#c3c3be, #3c3c39);
    --seg: light-dark(#f0f0ed, #282826);
    --seg-on: light-dark(#ffffff, #161614);
    --seg-shadow: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0));
    --accent: light-dark(#5868d6, #8b99f1);
    --accent-ink: light-dark(#ffffff, #14152a);
    --amber: light-dark(#b07612, #d29a38);
    --green: light-dark(#1f8f5c, #4cb782);
    --red: light-dark(#d14a3a, #ef7f70);
    --blue: light-dark(#3b7fd6, #6ea4ec);
    --grey-dot: light-dark(#9a9a95, #8d8d89);
    --chrome: light-dark(#ececea, #1c1c1e);
    --chrome-line: light-dark(#dcdcd8, #2a2a2b);
    --address: light-dark(#ffffff, #2a2a2c);
    --address-ink: light-dark(#6b6b67, #9b9b9b);
    --tint-to: light-dark(#000, #fff);
    --shade: light-dark(rgb(0 0 0 / 0.14), rgb(0 0 0 / 0.5));
    --shade-deep: light-dark(rgb(0 0 0 / 0.18), rgb(0 0 0 / 0.6));
    --nav-ink: light-dark(#34342f, #e2e2de);
    --logo: light-dark(#1b1b19, #f3f3f0);
    --logo-ink: light-dark(#ffffff, #151513);
    --on-red: light-dark(#ffffff, #1d1d1b);
    --violet: light-dark(#7d5fd3, #9d83e6);
    --todo: light-dark(#9a9a95, #8f8f8a);
    --on-green: light-dark(#ffffff, #18241d);
    --done-ink: light-dark(#8a8a85, #92928d);
    --weekend: light-dark(#f7f7f5, #181816);
    --week-line: light-dark(#e9e9e6, #232321);
    --row-line: light-dark(#f0f0ed, #181818);
    --group: light-dark(#fafaf9, #1a1a18);
    --group-cell: light-dark(#fafaf9, #181816);
    --link: light-dark(#b9b9b4, #5a5a57);
    --link-hot: light-dark(#6b6b67, #a3a39e);
    --face-base: light-dark(#ffffff, #121211);
    --bar-ring: light-dark(#ffffff, #1b1b19);
    --bar-text: light-dark(#1b1b19, #ecebe6);
    --done-fill: light-dark(#dcefe3, #355743);
    --done-edge: light-dark(#a8d4b9, #3c6a50);
    --done-bar-ink: light-dark(#1d4d33, #e8ebe6);
    --amber-fill: light-dark(#fbf2df, #362f20);
    --amber-done: light-dark(#f3dfb1, #5f4d28);
    --amber-edge: light-dark(#e2c27e, #6a552b);
    --violet-fill: light-dark(#f1eefa, #2c2a35);
    --violet-done: light-dark(#ddd5f4, #4d4466);
    --violet-edge: light-dark(#c3b6ea, #504566);
    --todo-fill: light-dark(#f2f2ef, #262625);
    --todo-edge: light-dark(#d9d9d4, #353533);
    --todo-ink: light-dark(#3a3a37, #c4c4bf);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

:where(.root) :where(button, input) {
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
}
:where(.root) :where(button) {
  margin: 0;
  border: 0;
  background: none;
  cursor: pointer;
}
:where(.root) :where(ul, ol, dl, dd, p, h2, h3, h4) {
  margin: 0;
  padding: 0;
}
:where(.root) :where(ul, ol) {
  list-style: none;
}
:where(.root) :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: -2px;
  border-radius: 6px;
}

/* The window. */
.window {
  overflow: hidden;
  overflow: clip;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--app);
  box-shadow: 0 18px 48px -18px var(--shade-deep);
}
.chrome {
  position: relative;
  display: flex;
  align-items: center;
  height: 52px;
  padding: 0 21px;
  border-bottom: 1px solid var(--chrome-line);
  background: var(--chrome);
}
.lights {
  display: flex;
  gap: 10px;
}
.lights i {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: #ff5f57;
}
.lights i:nth-child(2) {
  background: #febc2e;
}
.lights i:nth-child(3) {
  background: #28c840;
}
.address {
  position: absolute;
  left: 50%;
  top: 50%;
  width: min(416px, 100% - 200px);
  height: 28px;
  border-radius: 8px;
  background: var(--address);
  color: var(--address-ink);
  font-size: 16px;
  line-height: 28px;
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  transform: translate(-50%, -50%);
}

.app {
  position: relative;
  display: flex;
  align-items: stretch;
  min-width: 0;
  height: 860px;
}

/* The sidebar. */
.sidebar {
  flex: 0 0 var(--side-w);
  width: var(--side-w);
  min-width: 0;
  display: flex;
  flex-direction: column;
  padding: 0 9px;
  border-right: 1px solid var(--divider);
  background: var(--side);
  transition:
    flex-basis 240ms cubic-bezier(0.3, 0, 0.2, 1),
    width 240ms cubic-bezier(0.3, 0, 0.2, 1);
}
.sideHead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 6px;
  height: 54px;
}
.switchButton {
  display: flex;
  align-items: center;
  gap: 9px;
  min-width: 0;
  height: 32px;
  padding: 0 6px 0 4px;
  border-radius: 7px;
  transition: background-color 120ms ease;
}
.switchButton:hover,
.switchButton[aria-expanded='true'] {
  background: var(--hover);
}
.switchName {
  overflow: hidden;
  font-size: 15.5px;
  font-weight: 600;
  letter-spacing: -0.01em;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.switchIcon {
  width: 14px;
  height: 14px;
  flex: none;
  fill: none;
  stroke: var(--muted);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.logo {
  display: grid;
  place-items: center;
  flex: none;
  width: 24px;
  height: 24px;
  border-radius: 7px;
  background: var(--logo);
}
.logo[data-small] {
  width: 18px;
  height: 18px;
  border-radius: 5px;
}
.logoArt {
  width: 78%;
  height: 78%;
  overflow: visible;
}
.logoArt path {
  fill: none;
  stroke: var(--logo-ink);
  stroke-width: 2.4;
  stroke-linecap: round;
}
.logoArt circle {
  fill: #ff6a4d;
}

.iconButton {
  display: grid;
  place-items: center;
  flex: none;
  width: 28px;
  height: 28px;
  border-radius: 7px;
  color: var(--muted);
  transition:
    background-color 120ms ease,
    color 120ms ease;
}
.iconButton:hover {
  background: var(--hover);
  color: var(--ink);
}
.headIcon {
  width: 17px;
  height: 17px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.navList {
  display: grid;
  gap: 1px;
}
.navItem {
  position: relative;
  display: flex;
  align-items: center;
  gap: 11px;
  width: 100%;
  height: 32px;
  padding: 0 8px;
  border-radius: 7px;
  color: var(--nav-ink);
  font-size: 15px;
  font-weight: 500;
  text-align: left;
  white-space: nowrap;
  transition: background-color 120ms ease;
}
.navItem:hover {
  background: var(--hover);
}
.navItem[aria-current='page'] {
  background: var(--active);
  color: var(--ink);
  font-weight: 500;
}
.navIcon {
  width: 20px;
  height: 20px;
  flex: none;
  margin: 0 -1.5px;
  fill: none;
  stroke: var(--muted);
  stroke-width: 1.45;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.navItem[aria-current='page'] .navIcon {
  stroke: var(--ink);
}
.navLabel {
  display: flex;
  align-items: center;
  gap: 13px;
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
.inlineIcon {
  width: 14px;
  height: 14px;
  flex: none;
  fill: none;
  stroke: var(--muted);
  stroke-width: 1.7;
  stroke-linejoin: round;
}
.badge {
  display: grid;
  place-items: center;
  min-width: 19px;
  height: 19px;
  padding: 0 5px;
  border-radius: 10px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 12px;
  font-weight: 600;
}
.count {
  min-width: 18px;
  color: var(--muted);
  font-size: 13px;
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.kbd {
  display: grid;
  place-items: center;
  min-width: 20px;
  height: 20px;
  border: 1px solid var(--line);
  border-radius: 5px;
  color: var(--muted);
  padding: 0 5px;
  font-family: inherit;
  font-size: 12px;
  white-space: nowrap;
}
.sideLabel {
  margin: 19px 8px 8px;
  overflow: hidden;
  color: var(--muted);
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  white-space: nowrap;
}
.tile {
  display: grid;
  place-items: center;
  flex: none;
  width: 22px;
  height: 22px;
  margin: 0 -3.5px 0 4px;
  border-radius: 6px;
  background: color-mix(in srgb, var(--hue) 18%, var(--side));
}
.tileIcon {
  width: 62%;
  height: 62%;
  fill: none;
  stroke: color-mix(in srgb, var(--hue), var(--tint-to) 18%);
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.statusDot {
  width: 6px;
  height: 6px;
  flex: none;
  margin-right: 0;
  border-radius: 50%;
  background: var(--blue);
}
.statusDot[data-tone='gray'] {
  background: var(--grey-dot);
}
.statusDot[data-tone='red'] {
  background: var(--red);
}
.statusDot[data-tone='amber'] {
  background: var(--amber);
}
.fold {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 220ms cubic-bezier(0.3, 0, 0.2, 1);
}
.fold[data-open='true'] {
  grid-template-rows: 1fr;
}
.views {
  position: relative;
  display: grid;
  gap: 1px;
  min-height: 0;
  overflow: hidden;
  padding-left: 28px;
  opacity: 0;
  transition: opacity 180ms ease;
}
.fold[data-open='true'] .views {
  padding-top: 1px;
  opacity: 1;
}
.views::before {
  content: '';
  position: absolute;
  top: 2px;
  bottom: 2px;
  left: 11px;
  width: 1px;
  background: var(--line);
}
.view {
  gap: 8.5px;
  padding-left: 8px;
}
.teamMark {
  display: grid;
  place-items: center;
  flex: none;
  width: 18px;
  height: 18px;
  border-radius: 5px;
  background: color-mix(in srgb, var(--hue) 20%, var(--side));
  color: color-mix(in srgb, var(--hue), var(--tint-to) 18%);
  font-size: 10.5px;
  font-weight: 600;
}

/* The main column. */
.sideScroll {
  flex: 1;
  min-height: 0;
  margin: 0 -9px;
  padding: 0 9px 18px;
  overflow-y: auto;
  scrollbar-width: none;
}
.main {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-width: 0;
}
.topbar {
  flex: none;
  display: flex;
  align-items: center;
  gap: 12px;
  height: 52px;
  padding: 0 16px 0 22px;
  border-bottom: 1px solid var(--divider);
}
.menuButton {
  display: none;
}
.crumbs {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
  white-space: nowrap;
}
.crumbSpace {
  overflow: hidden;
  color: var(--ink-2);
  text-overflow: ellipsis;
}
.crumbSep {
  color: var(--faint);
}
.crumbHere {
  font-weight: 600;
}
.jump {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 0 1 238px;
  min-width: 0;
  height: 28px;
  margin-left: auto;
  padding: 0 4px 0 9px;
  border: 1px solid var(--line);
  border-radius: 7px;
  cursor: text;
  transition: border-color 120ms ease;
}
.jump:focus-within {
  border-color: color-mix(in srgb, var(--accent) 60%, var(--line));
}
.jumpIcon {
  width: 19px;
  height: 19px;
  flex: none;
  margin: 0 -2.5px 0 1.5px;
  fill: none;
  stroke: var(--muted);
  stroke-width: 1.4;
  stroke-linecap: round;
}
.jumpInput {
  flex: 1;
  min-width: 0;
  height: 100%;
  border: 0;
  background: none;
  font-size: 14px;
  outline: none;
  -webkit-appearance: none;
  appearance: none;
}
.jumpInput::placeholder {
  color: var(--muted);
}
.jumpInput::-webkit-search-cancel-button {
  display: none;
}
.jumpInput:focus-visible {
  outline: none;
}
/* Beside the search: notifications, help and who is signed in. */
.topTools {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: none;
}
.topTools .iconButton {
  position: relative;
}
.unread {
  position: absolute;
  top: 5px;
  right: 5px;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 1.5px var(--app);
}
.topRule {
  width: 1px;
  height: 18px;
  margin: 0 10px;
  background: var(--line);
}
.user {
  display: flex;
  align-items: center;
  gap: 8px;
  color: var(--ink-2);
  font-weight: 500;
  white-space: nowrap;
}
.userFace {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--accent) 24%, var(--app));
  color: var(--accent);
  font-size: 11px;
  font-weight: 600;
}

/* The page: its title, the toolbar, and the chart filling the rest. */
.page {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}
.head {
  padding: 24px 22.5px 0;
}
.title {
  font-size: 26px;
  font-weight: 600;
  line-height: 31px;
  letter-spacing: -0.022em;
}
.subtitle {
  margin-top: 4.5px;
  color: var(--ink-2);
  font-size: 15px;
  line-height: 21px;
}

.toolbar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px 6px;
  padding: 45px 22.5px 7.5px;
  border-bottom: 1px solid var(--line);
}
.search {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 0 1 220px;
  min-width: 0;
  height: 28.5px;
  margin-right: 0;
  padding: 0 8px 0 9px;
  border: 1px solid var(--line);
  border-radius: 7px;
  cursor: text;
  transition: border-color 120ms ease;
}
.search:focus-within {
  border-color: color-mix(in srgb, var(--accent) 60%, var(--line));
}
.searchIcon {
  width: 17px;
  height: 17px;
  flex: none;
  margin: 0 -1px 0 0;
  fill: none;
  stroke: var(--muted);
  stroke-width: 1.5;
  stroke-linecap: round;
}
.searchInput {
  flex: 1;
  min-width: 0;
  height: 100%;
  border: 0;
  background: none;
  font-size: 14px;
  outline: none;
  -webkit-appearance: none;
  appearance: none;
}
.searchInput::placeholder {
  color: var(--muted);
}
.searchInput::-webkit-search-cancel-button {
  display: none;
}
.searchInput:focus-visible {
  outline: none;
}
.dropdown {
  position: relative;
}
.tool,
.select {
  display: flex;
  align-items: center;
  gap: 7px;
  height: 28.5px;
  padding: 0 8px;
  border-radius: 7px;
  color: var(--ink-2);
  font-size: 14px;
  font-weight: 500;
  white-space: nowrap;
  transition:
    background-color 120ms ease,
    color 120ms ease;
}
.tool:hover,
.tool[aria-expanded='true'] {
  background: var(--hover);
  color: var(--ink);
}
.toolIcon {
  width: 17px;
  height: 17px;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.toolValue {
  margin-left: 6px;
}
.toolBadge {
  display: grid;
  place-items: center;
  min-width: 17px;
  height: 17px;
  padding: 0 4px;
  border-radius: 9px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 11px;
  font-weight: 600;
}
.seg {
  display: flex;
  margin: 0 1px 0 4px;
  padding: 2px;
  border-radius: 8px;
  background: var(--seg);
}
.segButton {
  height: 26px;
  padding: 0 10.5px;
  border-radius: 6px;
  color: var(--ink-2);
  font-size: 14px;
  font-weight: 500;
  transition:
    background-color 140ms ease,
    color 140ms ease,
    box-shadow 140ms ease;
}
.segButton:hover {
  color: var(--ink);
}
.segButton[aria-checked='true'] {
  background: var(--seg-on);
  color: var(--ink);
  box-shadow: 0 1px 2px var(--seg-shadow);
}
.select {
  justify-content: space-between;
  gap: 10px;
  min-width: 141px;
  margin-right: 0;
  padding: 0 9px 0 11px;
  border: 1px solid var(--line);
  color: var(--ink);
}
.select:hover,
.select[aria-expanded='true'] {
  background: var(--hover);
}
.selectIcon {
  width: 15px;
  height: 15px;
  flex: none;
  fill: none;
  stroke: var(--muted);
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.menu {
  position: absolute;
  z-index: 8;
  top: calc(100% + 5px);
  left: 0;
  width: 196px;
  padding: 4px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--app);
  box-shadow: 0 12px 32px var(--shade);
  animation: pop 140ms cubic-bezier(0.2, 0, 0, 1);
  transform-origin: top left;
}
@keyframes pop {
  from {
    opacity: 0;
    transform: scale(0.97) translateY(-3px);
  }
}
.menuHead {
  padding: 6px 8px 4px;
  color: var(--muted);
  font-size: 12px;
  font-weight: 600;
}
.menuItem {
  display: flex;
  align-items: center;
  gap: 9px;
  width: 100%;
  height: 31px;
  padding: 0 8px;
  border-radius: 6px;
  font-size: 14px;
  text-align: left;
  white-space: nowrap;
}
.menuItem:hover,
.menuItem:focus-visible {
  background: var(--hover);
}
.menuItem:focus-visible {
  outline-offset: -2px;
}
.menuCheck {
  width: 15px;
  height: 15px;
  margin-left: auto;
  fill: none;
  stroke: var(--accent);
  stroke-width: 2.2;
  stroke-linecap: round;
  stroke-linejoin: round;
  visibility: hidden;
}
.menuItem[aria-checked='true'] .menuCheck {
  visibility: visible;
}
.menuClear {
  margin-top: 4px;
  color: var(--ink-2);
  box-shadow: 0 -1px 0 var(--divider);
  border-radius: 0 0 6px 6px;
}

/* The chart: one sheet that scrolls both ways, its header and its list column sticky. */
.chart {
  position: relative;
  isolation: isolate;
  flex: 1;
  min-height: 0;
  overflow: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: var(--faint) transparent;
}
.sheet {
  position: relative;
  width: calc(var(--list) + var(--days) * var(--day));
  min-height: 100%;
}
.header {
  position: sticky;
  z-index: 6;
  top: 0;
  display: flex;
  height: 58px;
  border-bottom: 1px solid var(--line);
  background: var(--app);
}
.corner {
  position: sticky;
  z-index: 2;
  left: 0;
  flex: none;
  width: var(--list);
  padding: 30px 13.5px 0;
  border-right: 1px solid var(--line);
  background: var(--app);
  color: var(--ink-2);
  font-size: 14.5px;
  font-weight: 500;
}
.scale {
  flex: none;
  width: calc(var(--days) * var(--day));
}
.months {
  display: flex;
  height: 28px;
}
.month {
  flex: none;
  height: 100%;
  border-right: 1px solid var(--line);
}
.monthLabel {
  margin-left: 12px;
  position: sticky;
  left: calc(var(--list) + 12px);
  display: inline-block;
  padding: 5px 0 0;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: -0.005em;
  white-space: nowrap;
}
.days {
  display: flex;
  height: 30px;
}
.dayCell {
  display: grid;
  place-items: center;
  flex: none;
  width: calc(var(--span) * var(--day));
  border-left: 1px solid var(--divider);
  color: var(--ink-2);
  font-size: 14px;
  font-variant-numeric: tabular-nums;
}
.root[data-view='months'] .dayCell {
  justify-items: start;
  padding-left: 6px;
  font-size: 13px;
}
.dayNumber {
  display: grid;
  place-items: center;
  min-width: 24px;
  height: 21px;
  padding: 0 3px;
  border-radius: 6px;
  white-space: nowrap;
}
.dayCell[data-today] .dayNumber {
  background: var(--accent);
  color: var(--accent-ink);
  font-weight: 600;
}

.body {
  position: relative;
}
/* Weekends shaded, a line at each week's start. */
.grid {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--list);
  width: calc(var(--days) * var(--day));
  background:
    repeating-linear-gradient(90deg, var(--week-line) 0 1px, transparent 1px calc(var(--day) * 7)),
    repeating-linear-gradient(
      90deg,
      transparent 0 calc(var(--day) * 5),
      var(--weekend) calc(var(--day) * 5) calc(var(--day) * 7)
    );
}
.root[data-view='months'] .grid {
  background: repeating-linear-gradient(
    90deg,
    var(--week-line) 0 1px,
    transparent 1px calc(var(--day) * 7)
  );
}
.todayLine {
  position: absolute;
  z-index: 2;
  top: 0;
  bottom: 0;
  left: calc(var(--list) + (var(--at) + 0.5) * var(--day) - 0.75px);
  width: 1.5px;
  background: var(--accent);
  opacity: 0.75;
  pointer-events: none;
}
.todayLine[data-pulse] {
  animation: pulse 900ms ease;
}
@keyframes pulse {
  0%,
  100% {
    opacity: 0.75;
  }
  30% {
    opacity: 1;
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent);
  }
}
.links {
  position: absolute;
  z-index: 1;
  top: 0;
  left: var(--list);
  overflow: visible;
  pointer-events: none;
}
.linkPath {
  fill: none;
  stroke: var(--link);
  stroke-width: 1.3;
  transition: stroke 160ms ease;
}
.linkHead {
  fill: var(--link);
  transition: fill 160ms ease;
}
.links g[data-hot] .linkPath {
  stroke: var(--link-hot);
}
.links g[data-hot] .linkHead {
  fill: var(--link-hot);
}

.rows {
  position: relative;
}
.row {
  position: relative;
  display: flex;
  height: var(--row);
  border-bottom: 1px solid var(--row-line);
}
.groupRow {
  background: var(--group);
}
.cell,
.groupCell {
  position: sticky;
  z-index: 4;
  left: 0;
  display: flex;
  align-items: center;
  flex: none;
  width: var(--list);
  height: 100%;
  padding: 0 13px 0 13.5px;
  border-right: 1px solid var(--line);
  background: var(--app);
  transition: background-color 120ms ease;
}
.groupCell {
  gap: 7px;
  background: var(--group-cell);
  text-align: left;
}
.groupCell:hover {
  background: var(--hover);
}
.row[data-part='task']:hover .cell,
.row[data-flash] .cell {
  background: var(--hover);
}
.row[data-flash] .cell {
  animation: flash 1.4s ease;
}
@keyframes flash {
  0%,
  60% {
    background: color-mix(in srgb, var(--accent) 22%, var(--app));
  }
}
.groupMark {
  position: relative;
  display: grid;
  place-items: center;
  flex: none;
  width: 18px;
  height: 18px;
  margin-left: -1px;
}
.groupMark > * {
  transition: opacity 140ms ease;
}
.foldIcon {
  position: absolute;
  inset: 0;
  width: 18px;
  height: 18px;
  fill: none;
  stroke: var(--ink-2);
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0;
  transition:
    opacity 140ms ease,
    transform 180ms ease;
}
.groupCell:hover .groupMark > :not(.foldIcon),
.groupCell:focus-visible .groupMark > :not(.foldIcon),
.groupCell[aria-expanded='false'] .groupMark > :not(.foldIcon) {
  opacity: 0;
}
.groupCell:hover .foldIcon,
.groupCell:focus-visible .foldIcon,
.groupCell[aria-expanded='false'] .foldIcon {
  opacity: 1;
}
.groupCell[aria-expanded='false'] .foldIcon {
  transform: rotate(-90deg);
}
.groupCell:not([data-id='milestones']) .groupMark {
  width: 13px;
}
.groupDot {
  width: 8px;
  height: 8px;
  border-radius: 2.5px;
  background: var(--hue);
}
.diamondIcon {
  width: 17px;
  height: 17px;
  fill: none;
  stroke: var(--ink-2);
  stroke-width: 1.7;
  stroke-linejoin: round;
}
.groupName {
  overflow: hidden;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: -0.005em;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.groupCount {
  color: var(--ink-2);
  font-size: 14px;
  font-variant-numeric: tabular-nums;
}
.lane {
  position: relative;
  flex: none;
  width: calc(var(--days) * var(--day));
}

/* A task in the list. */
.check {
  display: grid;
  place-items: center;
  flex: none;
  width: 22px;
  height: 22px;
  margin: 0 5px 0 -4px;
  padding: 0;
  border-radius: 50%;
}
.check:hover .status {
  transform: scale(1.12);
}
.check:active .status {
  transform: scale(0.9);
}
.check:focus-visible {
  outline-offset: 0;
  border-radius: 50%;
}
.status {
  position: relative;
  display: grid;
  place-items: center;
  flex: none;
  width: 14px;
  height: 14px;
  border: 1.5px solid var(--todo);
  border-radius: 50%;
  transition:
    transform 140ms ease,
    background-color 160ms ease,
    border-color 160ms ease;
}
.status[data-status='progress'] {
  border-color: var(--amber);
  background: conic-gradient(from 180deg, var(--amber) 0 50%, transparent 0) content-box;
  padding: 1.5px;
}
.status[data-status='review'] {
  border-color: var(--violet);
  background: conic-gradient(from 90deg, var(--violet) 0 75%, transparent 0) content-box;
  padding: 1.5px;
}
.status[data-status='backlog'] {
  border-style: dashed;
  border-color: var(--muted);
}
.status[data-status='done'] {
  border-color: var(--green);
  background: var(--green);
}
.statusTick {
  position: absolute;
  inset: -1.5px;
  width: 14px;
  height: 14px;
  fill: none;
  stroke: var(--on-green);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 11;
  animation: tick 260ms ease;
}
@keyframes tick {
  from {
    stroke-dashoffset: 11;
  }
}
.taskTitle {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  font-size: 14px;
  font-weight: 500;
  white-space: nowrap;
  text-overflow: ellipsis;
  transition: color 200ms ease;
}
.row[data-status='done'] .taskTitle {
  color: var(--done-ink);
  font-weight: 450;
}
.subtasks {
  flex: none;
  margin: 0 9px 0 8px;
  color: var(--ink-2);
  font-size: 14px;
  font-variant-numeric: tabular-nums;
}
.face {
  position: relative;
  display: grid;
  place-items: center;
  flex: none;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--hue) 20%, var(--app));
  color: color-mix(in srgb, var(--hue), var(--tint-to) 14%);
  font-size: 8.5px;
  font-weight: 700;
  letter-spacing: 0.01em;
}
.cell .face {
  margin-left: auto;
}
.face[data-small] {
  width: 18px;
  height: 18px;
  font-size: 7px;
}
.face[data-name]:hover::after {
  content: attr(data-name);
  position: absolute;
  z-index: 3;
  bottom: calc(100% + 6px);
  right: -4px;
  padding: 4px 7px;
  border-radius: 6px;
  background: var(--ink);
  color: var(--app);
  font-size: 11.5px;
  font-weight: 500;
  letter-spacing: 0;
  white-space: nowrap;
  pointer-events: none;
}

/* A task's bar. */
.bar {
  --fill: var(--todo-fill);
  --fill-done: var(--todo-fill);
  --edge: var(--todo-edge);
  --bar-ink: var(--todo-ink);
  position: absolute;
  z-index: 2;
  top: calc((var(--row) - 27px) / 2);
  left: 0;
  display: flex;
  align-items: center;
  gap: 8px;
  width: calc(var(--w) * var(--day) - 2px);
  height: 26px;
  padding: 0 8px 0 7px;
  border: 1px solid var(--edge);
  border-radius: 6px;
  background: var(--fill);
  color: var(--bar-ink);
  font-size: 14px;
  font-weight: 500;
  text-align: left;
  white-space: nowrap;
  cursor: grab;
  touch-action: pan-y;
  user-select: none;
  -webkit-user-select: none;
  transform: translateX(calc(var(--x) * var(--day) + 1px));
  transition:
    transform 240ms cubic-bezier(0.3, 0, 0.2, 1),
    background-color 200ms ease,
    border-color 200ms ease,
    opacity 200ms ease,
    box-shadow 160ms ease;
}
.bar:focus-visible {
  outline-offset: 2px;
  border-radius: 7px;
}
.bar[data-hover],
.bar:hover {
  box-shadow:
    0 0 0 1px var(--edge),
    0 4px 12px -4px var(--shade);
}
.bar[data-dragging] {
  z-index: 3;
  cursor: grabbing;
  transition: background-color 200ms ease;
  box-shadow:
    0 0 0 1px var(--edge),
    0 8px 18px -6px var(--shade-deep);
}
.root[data-dragging] {
  cursor: grabbing;
}
.bar[data-status='done'] {
  --fill: var(--done-fill);
  --fill-done: var(--done-fill);
  --edge: var(--done-edge);
  --bar-ink: var(--done-bar-ink);
}
.bar[data-status='progress'] {
  --fill: var(--amber-fill);
  --fill-done: var(--amber-done);
  --edge: var(--amber-edge);
  --bar-ink: var(--bar-text);
}
.bar[data-status='review'] {
  --fill: var(--violet-fill);
  --fill-done: var(--violet-done);
  --edge: var(--violet-edge);
  --bar-ink: var(--bar-text);
}
.bar[data-status='todo'] {
  opacity: 0.82;
}
.bar[data-status='backlog'] {
  border-style: dashed;
  opacity: 0.5;
}
.barFill {
  position: absolute;
  inset: 0 auto 0 0;
  width: calc(var(--p) * 100%);
  border-radius: 5px 0 0 5px;
  background: var(--fill-done);
  transition: width 240ms ease;
}
.bar[data-status='done'] .barFill,
.bar[data-status='todo'] .barFill,
.bar[data-status='backlog'] .barFill {
  display: none;
}
.bar .face {
  background: color-mix(in srgb, var(--hue) 20%, var(--face-base));
  z-index: 1;
  width: 23px;
  height: 23px;
  margin-left: 0;
  box-shadow: 0 0 0 1.5px var(--bar-ring);
  font-size: 8.5px;
}
/* The face and the name keep to the chart's left edge while the bar runs off it. */
.barBody {
  position: sticky;
  left: calc(var(--list) + 8px);
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}
.bar[data-narrow] .barBody {
  position: static;
}
.barLabel {
  position: relative;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
.bar[data-narrow] .barLabel {
  position: absolute;
  left: calc(100% + 12px);
  overflow: visible;
  color: var(--ink-2);
  font-weight: 450;
}
.root[data-view='months'] .bar {
  padding-right: 4px;
}
.root[data-view='months'] .bar .face {
  width: 20px;
  height: 20px;
  margin-left: -5px;
}

/* A folded group: one bar from its first start to its last end. */
.summary {
  position: absolute;
  top: calc(var(--row) / 2 - 4px);
  left: 0;
  width: calc(var(--w) * var(--day));
  height: 7px;
  border-radius: 4px;
  background: color-mix(in srgb, var(--hue) 45%, transparent);
  transform: translateX(calc(var(--x) * var(--day)));
  transform-origin: left;
  animation: grow 300ms cubic-bezier(0.3, 0, 0.2, 1);
}
@keyframes grow {
  from {
    opacity: 0;
  }
}

.milestone {
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  align-items: center;
  gap: 7px;
  height: 100%;
  transform: translateX(calc((var(--at) + 0.5) * var(--day) - 8px));
  transition: transform 240ms ease;
}
.diamond {
  flex: none;
  width: 12px;
  height: 12px;
  margin: 0 2px;
  border-radius: 2px;
  background: var(--hue);
  transform: rotate(45deg);
}
.milestoneLabel {
  max-width: var(--room);
  overflow: hidden;
  font-size: 13.5px;
  font-weight: 600;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.milestone:hover::after {
  content: attr(data-name);
  position: absolute;
  z-index: 7;
  top: calc(100% - 6px);
  left: -8px;
  padding: 4px 7px;
  border-radius: 6px;
  background: var(--ink);
  color: var(--app);
  font-size: 11.5px;
  font-weight: 500;
  white-space: nowrap;
  pointer-events: none;
}
.empty {
  position: sticky;
  left: 0;
  width: min(100%, 360px);
  padding: 18px 14px;
  color: var(--muted);
  font-size: 13.5px;
}

/* The card a bar opens. */
.tip {
  position: absolute;
  z-index: 7;
  width: min(272px, 100cqi - 16px);
  padding: 11px 12px 10px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--app);
  box-shadow: 0 14px 34px -8px var(--shade-deep);
  font-size: 13px;
  pointer-events: none;
  transform: translateY(-100%);
  animation: tipIn 140ms cubic-bezier(0.2, 0, 0, 1);
}
.tip[data-below] {
  transform: none;
}
@keyframes tipIn {
  from {
    opacity: 0;
  }
}
.tipTitle {
  font-size: 14px;
  font-weight: 600;
  letter-spacing: -0.005em;
}
.tipMeta {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-top: 4px;
  color: var(--ink-2);
}
.tipMeta .status {
  width: 12px;
  height: 12px;
}
.tipMeta .statusTick {
  width: 12px;
  height: 12px;
}
.tipDot {
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: var(--muted);
}
.tipList {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 6px 12px;
  margin: 10px 0 0;
  padding-top: 9px;
  border-top: 1px solid var(--divider);
}
.tipList dt {
  color: var(--muted);
}
.tipList dd {
  margin: 0;
  min-width: 0;
}
.tipMuted {
  color: var(--muted);
}
.tipWho {
  display: flex;
  align-items: center;
  gap: 6px;
}
.tipWho .face {
  width: 18px;
  height: 18px;
  font-size: 7px;
}
.tipProgress {
  display: flex;
  align-items: center;
  gap: 8px;
}
.tipBar {
  flex: 0 0 72px;
  height: 4px;
  overflow: hidden;
  border-radius: 2px;
  background: var(--active);
}
.tipBar span {
  display: block;
  height: 100%;
  background: var(--accent);
  transform-origin: left;
}
.tipHint {
  margin-top: 9px;
  color: var(--muted);
  font-size: 12px;
}

/* The rail: the sidebar folded to its icons (only from 760 px; under it, a drawer). */
@container pt (min-width: 760px) {
  /* A container query styles what is inside the root, never the root itself. */
  .root[data-collapsed] .sidebar {
    flex-basis: 58px;
    width: 58px;
    padding: 0 10px 16px;
  }
  .root[data-collapsed] .sideHead {
    flex-direction: column;
    justify-content: flex-start;
    height: auto;
    padding: 10px 0 8px;
  }
  .root[data-collapsed] .switchButton {
    padding: 0 4px;
  }
  .root[data-collapsed] .switchName,
  .root[data-collapsed] .switchIcon,
  .root[data-collapsed] .navLabel,
  .root[data-collapsed] .count,
  .root[data-collapsed] .kbd,
  .root[data-collapsed] .statusDot,
  .root[data-collapsed] .fold {
    display: none;
  }
  .root[data-collapsed] .sideLabel {
    height: 1px;
    margin: 14px 6px;
    background: var(--line);
    color: transparent;
  }
  .root[data-collapsed] .navItem {
    justify-content: center;
    padding: 0;
  }
  .root[data-collapsed] .tile[data-small] {
    margin: 0;
  }
  .root[data-collapsed] .badge {
    position: absolute;
    top: 2px;
    right: 3px;
    min-width: 8px;
    width: 8px;
    height: 8px;
    padding: 0;
    color: transparent;
  }
}

/* Narrow: the sidebar becomes a drawer behind a menu button; the list column narrows to the titles and the toolbar wraps. */
@container pt (max-width: 759.98px) {
  .sidebar {
    position: absolute;
    z-index: 4;
    top: 0;
    bottom: 0;
    left: 0;
    width: min(264px, 84%);
    overflow-y: auto;
    border-right: 1px solid var(--line);
    visibility: hidden;
    transform: translateX(-101%);
    transition:
      transform 260ms cubic-bezier(0.3, 0, 0.2, 1),
      visibility 0s linear 260ms;
  }
  .root[data-drawer] .sidebar {
    visibility: visible;
    transform: none;
    transition: transform 260ms cubic-bezier(0.3, 0, 0.2, 1);
  }
  .scrim {
    position: absolute;
    z-index: 3;
    inset: 0;
    background: rgb(0 0 0 / 0.36);
    opacity: 0;
    visibility: hidden;
    transition:
      opacity 260ms ease,
      visibility 0s linear 260ms;
  }
  .root[data-drawer] .scrim {
    opacity: 1;
    visibility: visible;
    transition: opacity 260ms ease;
  }
  .menuButton {
    display: grid;
    margin-left: -6px;
  }
  .topbar {
    gap: 8px;
    padding: 0 10px 0 14px;
  }
}
.scrim {
  display: none;
}
@container pt (max-width: 759.98px) {
  .scrim {
    display: block;
  }
}
/* Narrower: the top bar's tools go, then the list column keeps only the task names. */
@container pt (max-width: 1100px) {
  .topTools {
    display: none;
  }
}
@container pt (max-width: 759.98px) {
  /* A container query styles what is inside the root, never the root itself. */
  .window {
    --list: 184px;
  }
  .app {
    height: 780px;
  }
  .cell .face,
  .subtasks {
    display: none;
  }
  .cell {
    padding-right: 10px;
  }
  .head {
    padding: 20px 16px 0;
  }
  .toolbar {
    padding: 18px 16px 10px;
  }
  .search {
    flex: 1 1 100%;
    margin-right: 0;
  }
  .seg {
    margin: 0;
  }
  .jump {
    flex: 0 1 200px;
  }
  .jump .kbd {
    display: none;
  }
}
@container pt (max-width: 440px) {
  .crumbSpace,
  .crumbSep,
  .crumbs .logo {
    display: none;
  }
  .title {
    font-size: 22px;
  }
  .subtitle {
    font-size: 14px;
  }
}

/* The demo pointer, over everything; one of its glyphs shows at a time. */
.pointerLayer {
  position: absolute;
  inset: 0;
  z-index: 6;
  overflow: hidden;
  pointer-events: none;
}
.pointer {
  width: 22px;
  height: 22px;
}
.arrow,
.hand {
  transition: transform 120ms ease;
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}
.pointer[data-press] .hand,
.pointer[data-press] .arrow {
  transform: scale(0.86);
}

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

@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    transition-duration: 0s !important;
    animation-duration: 0s !important;
  }
}
```

### project-timeline/data.ts

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

/*
 * The schedule the timeline shows: an invented design studio, its projects, its people, their
 * tasks with planned dates, what waits on what, and the milestones. All of it is made up.
 *
 * Dates are whole days counted from the first day of the chart (Monday, September 7, 2026), so
 * the arithmetic of a Gantt chart is plain integer sums; `day()` turns a calendar date into one.
 */

import type { PATHS } from './icons';

export type IconName = keyof typeof PATHS;

/** The first day of the chart: a Monday, so weeks line up with the grid. */
export const FIRST = Date.UTC(2026, 8, 7);
/** How many days the chart spans: twelve weeks, to Sunday, November 29. */
export const DAYS = 84;
const DAY_MS = 86_400_000;

/** The chart's day number of a calendar date (`month` is 1–12). */
export const day = (month: number, date: number) =>
  Math.round((Date.UTC(2026, month - 1, date) - FIRST) / DAY_MS);
/** The calendar date of a chart day. */
export const dateOf = (index: number) => new Date(FIRST + index * DAY_MS);

const MONTH_SHORT = [
  'Jan',
  'Feb',
  'Mar',
  'Apr',
  'May',
  'Jun',
  'Jul',
  'Aug',
  'Sep',
  'Oct',
  'Nov',
  'Dec',
];
export const MONTH_LONG = [
  'January',
  'February',
  'March',
  'April',
  'May',
  'June',
  'July',
  'August',
  'September',
  'October',
  'November',
  'December',
];
/** "Oct 3". */
export const shortDate = (index: number) => {
  const date = dateOf(index);
  return `${MONTH_SHORT[date.getUTCMonth()]} ${date.getUTCDate()}`;
};

export type PersonId = 'mo' | 'sl' | 'jr' | 'et' | 'pn' | 'dk';
export const PEOPLE: Record<PersonId, { name: string; initials: string; hue: string }> = {
  mo: { name: 'Maya Okafor', initials: 'MO', hue: '#8b8fd6' },
  sl: { name: 'Sam Lindqvist', initials: 'SL', hue: '#d27a86' },
  jr: { name: 'Jonah Reyes', initials: 'JR', hue: '#5f9fd8' },
  et: { name: 'Elif Turan', initials: 'ET', hue: '#4fb39a' },
  pn: { name: 'Priya Nair', initials: 'PN', hue: '#d29b3c' },
  dk: { name: 'Dara Kim', initials: 'DK', hue: '#a585dc' },
};

export type ProjectId = 'web' | 'mobile' | 'marketing' | 'launch' | 'system' | 'portal';
export type Project = {
  id: ProjectId;
  name: string;
  icon: IconName;
  hue: string;
  /** The dot beside it in the sidebar. */
  tone: 'blue' | 'gray' | 'red' | 'amber';
  favorite?: boolean;
  locked?: boolean;
};
export const PROJECTS: Project[] = [
  {
    id: 'web',
    name: 'Website Redesign',
    icon: 'globe',
    hue: '#6470e0',
    tone: 'blue',
    favorite: true,
  },
  { id: 'mobile', name: 'Mobile App', icon: 'phone', hue: '#3d8bd0', tone: 'blue', favorite: true },
  { id: 'marketing', name: 'Marketing Campaign', icon: 'megaphone', hue: '#c94f7e', tone: 'gray' },
  { id: 'launch', name: 'Product Launch', icon: 'rocket', hue: '#cf952b', tone: 'red' },
  { id: 'system', name: 'Design System', icon: 'components', hue: '#8a67cf', tone: 'blue' },
  {
    id: 'portal',
    name: 'Customer Portal',
    icon: 'building',
    hue: '#33a28f',
    tone: 'amber',
    locked: true,
  },
];
export const projectById = (id: ProjectId) => PROJECTS.find((project) => project.id === id)!;

export type Status = 'done' | 'progress' | 'review' | 'todo' | 'backlog';
export const STATUSES: { id: Status; label: string }[] = [
  { id: 'progress', label: 'In progress' },
  { id: 'review', label: 'In review' },
  { id: 'todo', label: 'To do' },
  { id: 'backlog', label: 'Backlog' },
  { id: 'done', label: 'Done' },
];
export const STATUS_LABEL = Object.fromEntries(STATUSES.map((s) => [s.id, s.label])) as Record<
  Status,
  string
>;

export type Task = {
  id: string;
  title: string;
  project: ProjectId;
  status: Status;
  who: PersonId;
  /** Planned first day, and length in days (both ends included). */
  start: number;
  length: number;
  /** Subtasks done and in all, when it has any. */
  subtasks?: [number, number];
  /** How far along, 0 to 1, when it has no subtasks to tell. */
  progress?: number;
  /** The tasks that must end before this one starts. */
  after?: string[];
};

const span = (from: [number, number], to: [number, number]) => ({
  start: day(...from),
  length: day(...to) - day(...from) + 1,
});

export const TASKS: Task[] = [
  // Website Redesign
  {
    id: 'w1',
    title: 'Audit existing navigation',
    project: 'web',
    status: 'done',
    who: 'sl',
    ...span([9, 22], [9, 27]),
  },
  {
    id: 'w2',
    title: 'Summarize stakeholder interviews',
    project: 'web',
    status: 'done',
    who: 'mo',
    ...span([9, 26], [9, 30]),
  },
  {
    id: 'w3',
    title: 'Footer and legal pages',
    project: 'web',
    status: 'done',
    who: 'pn',
    ...span([9, 30], [10, 4]),
  },
  {
    id: 'w4',
    title: 'Design mobile onboarding',
    project: 'web',
    status: 'progress',
    who: 'et',
    subtasks: [1, 3],
    ...span([10, 2], [10, 8]),
  },
  {
    id: 'w5',
    title: 'Create homepage wireframes',
    project: 'web',
    status: 'review',
    who: 'sl',
    subtasks: [3, 4],
    ...span([10, 3], [10, 10]),
  },
  {
    id: 'w6',
    title: 'Fix broken anchor links on pricing',
    project: 'web',
    status: 'done',
    who: 'jr',
    ...span([10, 5], [10, 6]),
  },
  {
    id: 'w7',
    title: 'Finalize navigation',
    project: 'web',
    status: 'progress',
    who: 'jr',
    subtasks: [1, 3],
    ...span([10, 5], [10, 9]),
  },
  {
    id: 'w8',
    title: 'Image optimization pipeline',
    project: 'web',
    status: 'review',
    who: 'dk',
    progress: 0.8,
    ...span([10, 6], [10, 11]),
  },
  {
    id: 'w9',
    title: 'Design responsive navigation',
    project: 'web',
    status: 'progress',
    who: 'et',
    subtasks: [1, 2],
    ...span([10, 6], [10, 12]),
  },
  {
    id: 'w10',
    title: 'Weekly design sync notes',
    project: 'web',
    status: 'todo',
    who: 'mo',
    ...span([10, 7], [10, 11]),
  },
  {
    id: 'w11',
    title: 'Prepare design system tokens',
    project: 'web',
    status: 'todo',
    who: 'mo',
    ...span([10, 11], [10, 16]),
  },
  {
    id: 'w12',
    title: 'Review responsive layouts',
    project: 'web',
    status: 'todo',
    who: 'et',
    after: ['w5'],
    ...span([10, 13], [10, 16]),
  },
  {
    id: 'w13',
    title: 'Build hero component',
    project: 'web',
    status: 'todo',
    who: 'jr',
    after: ['w5'],
    ...span([10, 12], [10, 17]),
  },
  {
    id: 'w14',
    title: 'Prepare developer handoff',
    project: 'web',
    status: 'todo',
    who: 'sl',
    after: ['w12', 'w13'],
    ...span([10, 19], [10, 23]),
  },
  {
    id: 'w15',
    title: 'Write homepage copy',
    project: 'web',
    status: 'backlog',
    who: 'pn',
    ...span([10, 20], [10, 24]),
  },
  {
    id: 'w16',
    title: 'Set up analytics events',
    project: 'web',
    status: 'backlog',
    who: 'dk',
    ...span([10, 22], [10, 27]),
  },
  {
    id: 'w17',
    title: 'Accessibility review',
    project: 'web',
    status: 'backlog',
    who: 'et',
    after: ['w14'],
    ...span([10, 26], [10, 30]),
  },
  {
    id: 'w18',
    title: 'QA pass on staging',
    project: 'web',
    status: 'backlog',
    who: 'jr',
    after: ['w17'],
    ...span([11, 2], [11, 6]),
  },
  // Mobile App
  {
    id: 'm1',
    title: 'Offline cache spike',
    project: 'mobile',
    status: 'done',
    who: 'jr',
    ...span([9, 21], [9, 25]),
  },
  {
    id: 'm2',
    title: 'Biometric sign-in',
    project: 'mobile',
    status: 'progress',
    who: 'jr',
    subtasks: [2, 5],
    after: ['m1'],
    ...span([9, 28], [10, 14]),
  },
  {
    id: 'm3',
    title: 'Onboarding flow prototype',
    project: 'mobile',
    status: 'review',
    who: 'et',
    progress: 0.7,
    ...span([10, 1], [10, 13]),
  },
  {
    id: 'm4',
    title: 'Push notification service',
    project: 'mobile',
    status: 'todo',
    who: 'dk',
    after: ['m2'],
    ...span([10, 16], [10, 23]),
  },
  {
    id: 'm5',
    title: 'Triage beta tester feedback',
    project: 'mobile',
    status: 'todo',
    who: 'mo',
    after: ['m3'],
    ...span([10, 15], [10, 20]),
  },
  {
    id: 'm6',
    title: 'Store listing screenshots',
    project: 'mobile',
    status: 'backlog',
    who: 'pn',
    after: ['m5'],
    ...span([10, 26], [10, 30]),
  },
  // Design System
  {
    id: 's1',
    title: 'Audit color contrast',
    project: 'system',
    status: 'done',
    who: 'sl',
    ...span([9, 14], [9, 23]),
  },
  {
    id: 's2',
    title: 'Dark mode palette',
    project: 'system',
    status: 'progress',
    who: 'sl',
    subtasks: [3, 5],
    after: ['s1'],
    ...span([9, 28], [10, 13]),
  },
  {
    id: 's3',
    title: 'Icon set, second pass',
    project: 'system',
    status: 'todo',
    who: 'dk',
    ...span([10, 12], [10, 21]),
  },
  {
    id: 's4',
    title: 'Form field states',
    project: 'system',
    status: 'backlog',
    who: 'et',
    after: ['s2'],
    ...span([10, 19], [10, 28]),
  },
];

/** The groups Group: Project shows, in this order. */
export const GROUP_PROJECTS: ProjectId[] = ['web', 'mobile', 'system'];

export type Milestone = { id: string; title: string; day: number; project: ProjectId };
export const MILESTONES: Milestone[] = [
  { id: 'ms1', title: 'Kickoff', day: day(9, 9), project: 'web' },
  { id: 'ms2', title: 'Research readout', day: day(9, 15), project: 'web' },
  { id: 'ms3', title: 'Wireframes approved', day: day(10, 11), project: 'web' },
  { id: 'ms4', title: 'Navigation sign-off', day: day(10, 12), project: 'system' },
  { id: 'ms5', title: 'Design freeze', day: day(10, 23), project: 'web' },
  { id: 'ms6', title: 'Beta release', day: day(10, 30), project: 'mobile' },
  { id: 'ms7', title: 'Content complete', day: day(11, 6), project: 'web' },
  { id: 'ms8', title: 'Launch review', day: day(11, 13), project: 'launch' },
  { id: 'ms9', title: 'Go live', day: day(11, 20), project: 'web' },
  { id: 'ms10', title: 'Retrospective', day: day(11, 27), project: 'web' },
];

export const TEAMS = [
  { name: 'Design', hue: '#8a67cf' },
  { name: 'Engineering', hue: '#3d8bd0' },
  { name: 'Marketing', hue: '#c94f7e' },
];
```

### project-timeline/icons.tsx

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

/*
 * The line icons, drawn on a 24-unit grid with a 1.7-unit round stroke in the text's colour. Each
 * is a list of path data; a circle is written as two arcs. The stylesheet sizes them (the lab page
 * would otherwise force every unsized SVG to 16 px).
 */

const circle = (cx: number, cy: number, r: number) =>
  `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0`;

export const PATHS = {
  home: ['M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-6h-5v6H5a1 1 0 0 1-1-1z'],
  inbox: [
    'M4 13.5 6.4 6.2A1.8 1.8 0 0 1 8.1 5h7.8a1.8 1.8 0 0 1 1.7 1.2L20 13.5V18a1.8 1.8 0 0 1-1.8 1.8H5.8A1.8 1.8 0 0 1 4 18z',
    'M4 13.5h4.5l1.2 2.3h4.6l1.2-2.3H20',
  ],
  checkCircle: [circle(12, 12, 8.2), 'm8.6 12.2 2.3 2.3 4.6-4.8'],
  star: ['m12 3.8 2.5 5.1 5.6.8-4 3.9 1 5.6L12 16.6l-5 2.6 1-5.6-4.1-3.9 5.6-.8z'],
  search: [circle(11, 11, 6.3), 'm15.7 15.7 4.2 4.2'],
  help: [circle(12, 12, 8.2), 'M9.7 9.6a2.4 2.4 0 1 1 3.3 2.2c-.6.3-1 .8-1 1.4v.4', 'M12 16.3v.1'],
  bell: ['M6.3 16.5V11a5.7 5.7 0 0 1 11.4 0v5.5l1.5 1.5H4.8z', 'M10 20.5a2.2 2.2 0 0 0 4 0'],
  overview: ['M4.5 4.5h6v6h-6z', 'M13.5 4.5h6v4h-6z', 'M13.5 11.5h6v8h-6z', 'M4.5 13.5h6v6h-6z'],
  folder: [
    'M3.8 7.2c0-1 .8-1.8 1.8-1.8h3.8l1.8 2h7.2c1 0 1.8.8 1.8 1.8v8.2c0 1-.8 1.8-1.8 1.8H5.6c-1 0-1.8-.8-1.8-1.8z',
    'M8.5 15.5v-3M12 15.5v-5M15.5 15.5v-2',
  ],
  tasks: ['m4 7 1.6 1.6L8.6 5.5', 'm4 15 1.6 1.6 3-3.1', 'M12 7h8M12 15h8'],
  calendar: [
    'M4.5 7.3c0-1 .8-1.8 1.8-1.8h11.4c1 0 1.8.8 1.8 1.8v11c0 1-.8 1.8-1.8 1.8H6.3c-1 0-1.8-.8-1.8-1.8z',
    'M4.5 10h15M8.5 3.8v3.4M15.5 3.8v3.4',
  ],
  timeline: ['M5 4v16h15', 'M8.5 8h5M8.5 12h8M8.5 16h4'],
  members: [
    circle(9.5, 8.5, 3.3),
    'M3.8 19.5c.6-3.2 3-5 5.7-5s5.1 1.8 5.7 5',
    'M15.5 5.4a3.2 3.2 0 0 1 0 6.2M17.6 14.8c1.4.7 2.4 2.3 2.7 4.7',
  ],
  activity: ['M3.5 12.5h3.4l2.3-6.3 4.6 12 2.4-5.7h4.3'],
  globe: [
    circle(12, 12, 8.2),
    'M3.8 12h16.4',
    'M12 3.8c2.3 2.4 3.4 5.1 3.4 8.2s-1.1 5.8-3.4 8.2c-2.3-2.4-3.4-5.1-3.4-8.2s1.1-5.8 3.4-8.2',
  ],
  phone: [
    'M7.2 5.2c0-1 .8-1.8 1.8-1.8h6c1 0 1.8.8 1.8 1.8v13.6c0 1-.8 1.8-1.8 1.8H9c-1 0-1.8-.8-1.8-1.8z',
    'M11 17.3h2',
  ],
  megaphone: [
    'M4.5 10v4h3l8 4.5v-13l-8 4.5z',
    'M7.5 14l1.2 5h2.3l-1-5M18.5 9.5c.9.6 1.3 1.5 1.3 2.5s-.4 1.9-1.3 2.5',
  ],
  rocket: [
    'M14.2 4.8c2.3-.9 4.2-1 5-.8.2.8.1 2.7-.8 5-1 2.6-3.3 5-6.4 6.8l-3.8-3.8c1.8-3.1 4.2-5.4 6-7.2',
    'M8.7 10.4 5.5 10l-1.8 2.2 3.8 1.2M13.6 15.3l.4 3.2-2.2 1.8-1.2-3.8M6.8 16.2c-1.4.6-2.2 2.2-2.3 3.3 1.1-.1 2.7-.9 3.3-2.3',
    circle(15.3, 8.7, 1.4),
  ],
  components: [
    'm12 3.6 2.6 2.6L12 8.8 9.4 6.2z',
    'm12 15.2 2.6 2.6-2.6 2.6-2.6-2.6z',
    'm6.2 9.4 2.6 2.6-2.6 2.6L3.6 12z',
    'm17.8 9.4 2.6 2.6-2.6 2.6-2.6-2.6z',
  ],
  building: [
    'M5.5 20V5.6c0-.9.7-1.6 1.6-1.6h6.3c.9 0 1.6.7 1.6 1.6V20',
    'M15 9.5h3.4c.9 0 1.6.7 1.6 1.6V20M3.5 20h17.5M8.7 7.8h2.6M8.7 11.3h2.6M8.7 14.8h2.6',
  ],
  archive: [
    'M4 5.5h16v3.8H4z',
    'M5.5 9.3v9.2c0 .8.7 1.5 1.5 1.5h10c.8 0 1.5-.7 1.5-1.5V9.3M10 13h4',
  ],
  board: [
    'M4.5 5.5c0-.6.4-1 1-1h13c.6 0 1 .4 1 1v13c0 .6-.4 1-1 1h-13c-.6 0-1-.4-1-1z',
    'M9 8v5.5M12.2 8v8.5M15.4 8v3.5',
  ],
  list: ['M9 7h11M9 12h11M9 17h11', 'M4.5 7h.5M4.5 12h.5M4.5 17h.5'],
  clip: [
    'M19.2 11.6 12.4 18.4a4.5 4.5 0 0 1-6.4-6.4l7.1-7.1a3 3 0 0 1 4.2 4.2l-7 7a1.5 1.5 0 0 1-2.1-2.1l6.4-6.4',
  ],
  lock: [
    'M6.5 11.5c0-.8.6-1.4 1.4-1.4h8.2c.8 0 1.4.6 1.4 1.4v6.6c0 .8-.6 1.4-1.4 1.4H7.9c-.8 0-1.4-.6-1.4-1.4z',
    'M8.8 10.1V7.8a3.2 3.2 0 0 1 6.4 0v2.3',
  ],
  updown: ['m8.5 9.5 3.5-3.5 3.5 3.5', 'm8.5 14.5 3.5 3.5 3.5-3.5'],
  panel: ['M4 6c0-1.1.9-2 2-2h12c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H6c-1.1 0-2-.9-2-2z', 'M9.5 4v16'],
  dashed: [
    'M12.66 3.83A8.2 8.2 0 0 1 15.15 4.43M17.33 5.77A8.2 8.2 0 0 1 19 7.73M19.98 10.1A8.2 8.2 0 0 1 20.17 12.66M19.57 15.15A8.2 8.2 0 0 1 18.23 17.33M16.27 19A8.2 8.2 0 0 1 13.9 19.98M11.34 20.17A8.2 8.2 0 0 1 8.85 19.57M6.67 18.23A8.2 8.2 0 0 1 5 16.27M4.02 13.9A8.2 8.2 0 0 1 3.83 11.34M4.43 8.85A8.2 8.2 0 0 1 5.77 6.67M7.73 5A8.2 8.2 0 0 1 10.1 4.02',
  ],
  menu: ['M4.5 7h15M4.5 12h15M4.5 17h15'],
  filter: ['M4.5 7h15M7.5 12h9M10.5 17h3'],
  sort: ['M8 19V5m0 0L4.8 8.2M8 5l3.2 3.2', 'M16 5v14m0 0-3.2-3.2M16 19l3.2-3.2'],
  chevronDown: ['m7 10 5 5 5-5'],
  today: [circle(12, 12, 7.6), 'M12 2.8v3M12 18.2v3M2.8 12h3M18.2 12h3'],
  diamond: ['M12 4.2 19.8 12 12 19.8 4.2 12z'],
  link: ['M5 7.5h4.5c2 0 3 1 3 3v3c0 2 1 3 3 3H19', 'm16.5 14 2.5 2.5-2.5 2.5'],
  chevron: ['m9 6 6 6-6 6'],
  check: ['m6.5 12.5 3.5 3.5 7.5-8'],
  plus: ['M12 5.5v13M5.5 12h13'],
  close: ['M6.5 6.5l11 11M17.5 6.5l-11 11'],
} satisfies Record<string, string[]>;

export function Icon({ name, className }: { name: keyof typeof PATHS; className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
      {PATHS[name].map((d) => (
        <path key={d} d={d} />
      ))}
    </svg>
  );
}
```

### project-timeline/demo-pointer.tsx

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

'use client';

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

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or
 * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends
 * the step in progress at once: its promise rejects, which ends the script right there, and the
 * pointer fades out.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it. The arrow and the
 * hand are HTML boxes that swap by their visibility: a change of style inside an SVG would lay the
 * SVG out again.
 */

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

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

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

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

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

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

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  // The stylesheet sizes the pointer from the stage's width; its tip is where it stands.
  const tip = { x: pointer.offsetWidth * TIP.x, y: pointer.offsetHeight * TIP.y };
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.9 };

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

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

  const glide: Demo['glide'] = (to, ms, along) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.18, y: (from.y + to.y) / 2 + dx * 0.18 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
        y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
      });
    }
    const timing: KeyframeAnimationOptions = {
      duration: ms,
      easing: 'cubic-bezier(0.45, 0, 0.25, 1)',
    };
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      timing,
    );
    along?.({ points, timing });
    return step(flight.finished);
  };

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

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

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

/**
 * The pointer's box: it moves, on a layer of its own. Its size comes from its class, its colours
 * from the stage's tokens (project-timeline.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

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

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

### project-timeline/use-autoplay.ts

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

'use client';

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

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
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 };
}
```