# Workspace home

> A project tool’s home screen: sidebar with a projects tree, four figures, tabbed tasks you can tick, project progress and deadlines.

The first screen of a studio’s workspace, in an app window. A sidebar with a workspace switcher, navigation with counts, a folding projects tree and teams; a greeting, four figures, My tasks in Upcoming, Overdue and Completed tabs, every project’s status and progress, the deadlines ahead and the latest activity. Tick a task and it folds into Completed while the counts, the figures and the activity feed follow. Light and dark.

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

Category: Dashboards.

## Anatomy

1. **Sidebar**: Workspace switcher, navigation with counts, the projects tree and teams. Folds to a 58 px rail; under 760 px it becomes a drawer.
2. **Search**: Filters the tasks, the projects table and the deadlines at once. The / key focuses it from anywhere in the screen; Escape clears it.
3. **Greeting**: Who it is for and what day it is, with the three actions at its right: invite, new project, new task.
4. **Figures**: Four counts in one strip. Open tasks, Completed and Overdue are worked out from the task list, so they follow every tick.
5. **My tasks**: The reader’s tasks in Upcoming, Overdue and Completed tabs. A ticked task stays 650 ms, then folds away in 300 ms.
6. **Project progress**: Every project’s status, progress bar, due date and team. Under 760 px the table scrolls inside its card.
7. **Upcoming deadlines**: What is due today and tomorrow, each with its project’s dot, and a “+N more” that opens the rest.
8. **Recent activity**: The last five things done in the workspace. A task you tick shows here as “You completed …, just now”.

## When to use

- The first screen of a project or task tool: where someone lands to see what is theirs today.
- A workspace with a handful of projects and people, where the reader’s own tasks matter most and the rest is context.
- When ticking a task should update everything that counts it (tabs, figures, sidebar badge, activity) in one go.
- A screen that has to work in a wide window, a narrow panel and a phone: it lays out by its own width, not the window’s.

## When not to use

- Hundreds of tasks or projects. The cards show a handful; use a real list or board with paging and filters.
- Reporting on numbers over time. Use an analytics dashboard with charts, not four fixed figures.
- A single project’s workspace. Open on its board or list view instead of a home that sums up many.
- A product with no notion of workspaces, projects or assignees: the sidebar and cards would be empty shells.

## Installation

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

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

export function Home() {
  return (
    <WorkspaceHome
      workspace="Fernway Studio"
      userName="Maya"
      onTaskToggle={(id, done) => console.log(id, done)}
      autoplay={false}
    />
  );
}
```

## States

- **Upcoming**: As it opens: the reader’s next tasks, today and tomorrow’s in amber.
- **Overdue**: The tab of what is late, its dates in red. `defaultTab="overdue"`.
- **Completed**: Ticked tasks, the newest first, checked and struck through.
- **All caught up**: Every upcoming task ticked: the tab says so, the figures and the activity feed have followed.
- **Searching**: One query filters the tasks, the projects table and the deadlines together.
- **No match**: Each card says nothing matches, in its own words, instead of going blank.
- **Sidebar folded**: A 58 px rail of icons; section titles become hairlines and the Inbox count a dot.
- **Workspace menu**: The switcher open over the sidebar, the current workspace checked.
- **Phone**: Under 760 px the sidebar hides behind a menu button and the figures go two by two.
- **Phone, drawer open**: The sidebar slides over the screen with a scrim; Escape or the scrim closes it.

## How it works

- One list of tasks drives everything that counts them: the three tab counts, the Open tasks and Completed figures, the My Tasks badge in the sidebar and the activity feed are all worked out from it on render, so they can never disagree.
- Ticking a task checks and strikes it at once, keeps it in its tab for 650 ms so the tick reads, then folds it away in 300 ms by animating its grid row from 1fr to 0fr. Unticking it under Completed sends it back to Upcoming or Overdue the same way. A project’s views in the sidebar open and close by the same grid-row fold.
- The search box, or the / key from anywhere in the screen, filters the tasks, the projects table and the deadlines at once; Escape clears it.
- Layout by container queries on the root, so it answers to the room it is given rather than the window: the cards sit in two columns from 1340 px and in one under it, the deadlines and the activity side by side between 980 and 1340 px. Under 760 px the sidebar becomes a drawer behind a menu button, the figures go two by two and the projects table scrolls inside its card.
- The sidebar’s button folds it to a rail of icons (58 px), the section titles turning into hairlines and the Inbox count into a dot; every item keeps its name for screen readers and a tooltip.
- While on screen and untouched, a demo pointer hovers a task, ticks one, opens Overdue and Completed, unticks it, folds a project in the sidebar and walks the projects table; each glide is a Web Animations API flight. Any real input hands control to the reader, and what the demo changed is put back.
- Every colour is a light-dark() token on the root, so the screen follows the page’s color-scheme or the colorScheme prop: near-black with a step-darker sidebar and a periwinkle accent at night, white and warm greys by day.

## API reference

- **workspace** (`string`, default `'Fernway Studio'`): The workspace’s name, in the switcher and the breadcrumb.
- **workspaces** (`string[]`, default `['Fernway Studio', 'Personal']`): The workspaces the switcher’s menu offers.
- **userName** (`string`, default `'Maya'`): Who the greeting is for.
- **greeting** (`string`, default `'Good afternoon'`): The greeting’s first words.
- **dateLabel** (`string`, default `'Tuesday, October 13'`): Today, as the line under the greeting writes it.
- **defaultTab** (`'upcoming' | 'overdue' | 'completed'`, default `'upcoming'`): The tab My tasks opens on.
- **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 address.
- **address** (`string`, default `'app.fernway.studio'`): The address in the window’s bar.
- **onTaskToggle** (`(id: string, done: boolean) => void`): Called when a task is ticked or unticked.
- **onWorkspaceChange** (`(name: string) => void`): Called when the reader picks a workspace in the switcher, with its name.
- **defaultState** (`WorkspaceHomeState`): Opens in this state, held still: menu (the switcher’s menu open), query (the search filled in, filtering the tasks, the table and the deadlines), drawer (the sidebar open as a drawer, under 760 px), more (the deadlines’ “+N more” expanded), done (task ids already ticked, the newest first) and hover (a task’s or project row’s id, drawn as under the pointer). Turns the demo off; the menu doesn’t take the focus and stays open through presses outside until the screen is used.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer ticks a task, walks the tabs, folds a project and hovers the table.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root.

## Accessibility

- The sidebar is a labelled navigation landmark; the current item carries aria-current="page", each project is a button with aria-expanded over its views, and the folded rail keeps every name for screen readers. The workspace switcher is a menu button with a radio menu: arrow keys move, Escape closes it and returns focus.
- My tasks is a tablist with arrow, Home and End keys and one tab stop. Each task’s circle is a checkbox (role="checkbox", aria-checked) named after the task; its status, priority and project dot have text equivalents.
- The projects table is a real table with column and row headers; each progress bar is a progressbar with its value, each team stack an image named with its people.
- Ticking, unticking and switching workspace are announced in a polite live region. / focuses the search from anywhere in the screen; Escape clears it, and closes the drawer on a phone.
- Reduced motion removes the folds and transitions and stops the demo; the demo pointer is hidden from assistive technology.

## Source

### workspace-home/workspace-home.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
} from 'react';
import {
  ACTIVITY,
  DEADLINES,
  PEOPLE,
  PRIORITY_LABEL,
  PROJECTS,
  TASKS,
  TEAMS,
  projectById,
  type Activity,
  type IconName,
  type PersonId,
  type Priority,
  type Project,
  type ProjectId,
  type Task,
} from './data';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { Icon } from './icons';
import { useAutoplay } from './use-autoplay';
import styles from './workspace-home.module.css';

/*
 * Workspace home: the first screen of a project tool, in an app window. A sidebar (workspace
 * switcher, navigation with counts, the projects tree, teams) and a main column: a greeting, four
 * figures, the reader's tasks in three tabs, every project's progress, the deadlines ahead and the
 * latest activity.
 *
 * - Ticking a task completes it: the row checks and strikes through, stays a moment so the tick
 *   reads, then folds away into the Completed tab. The tab counts, the figures, the My Tasks count
 *   and the activity feed all follow from the same list of tasks, so they never disagree.
 *   Unticking one under Completed sends it back where it came from.
 * - The search box (or the / key) filters the tasks, the projects table and the deadlines at once.
 * - The sidebar folds to a rail of icons with its button; a project in it opens and closes its
 *   views. Under 760 px of width it becomes a drawer behind a menu button.
 * - Layout by container queries, so it fits the room it is given, not the window's: two columns
 *   of cards from 1340 px, one under it; the stats go two by two and the wide table scrolls inside
 *   its card on a phone.
 * - While on screen and untouched, a demo pointer ticks a task, walks through the tabs, folds a
 *   project in the sidebar and hovers the table. Any real input hands it over to the reader.
 * - 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 TaskTab = 'upcoming' | 'overdue' | 'completed';
const TABS: { id: TaskTab; label: string }[] = [
  { id: 'upcoming', label: 'Upcoming' },
  { id: 'overdue', label: 'Overdue' },
  { id: 'completed', label: 'Completed' },
];

/** The workspace's own figures, beyond the reader's tasks. */
const OTHER_OPEN = 32;
const OTHER_DONE = 6;

/** A ticked task stays in its tab this long, then folds away in FOLD ms. */
const STAY = 650;
const FOLD = 300;

const belongs = (task: Task, tab: TaskTab) =>
  tab === 'completed' ? !!task.done : !task.done && !!task.overdue === (tab === 'overdue');

const matches = (query: string, ...texts: string[]) =>
  !query || texts.some((text) => text.toLowerCase().includes(query));

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' },
];
/**
 * A state to open in, held until the reader changes it; giving it turns the demo off.
 * - `menu`: the workspace switcher's menu open (it doesn't take the focus, and ignores presses
 *   outside until the screen itself is used).
 * - `query`: the search box filled in, filtering the tasks, the table and the deadlines.
 * - `drawer`: under 760 px of width, the sidebar open as a drawer.
 * - `more`: the deadlines' "+N more" expanded.
 * - `done`: tasks (by id) already ticked, as if just completed (the newest first).
 * - `hover`: a task or a project row (by id) drawn as under the pointer.
 */
export type WorkspaceHomeState = {
  menu?: boolean;
  query?: string;
  drawer?: boolean;
  more?: boolean;
  done?: string[];
  hover?: string;
};

const STATUS_TONE: Record<Project['status'], string> = {
  'In Progress': 'blue',
  Planning: 'gray',
  'At Risk': 'red',
  'On Hold': 'amber',
};

export function WorkspaceHome({
  workspace = 'Fernway Studio',
  workspaces = ['Fernway Studio', 'Personal'],
  userName = 'Maya',
  greeting = 'Good afternoon',
  dateLabel = 'Tuesday, October 13',
  defaultTab = 'upcoming',
  defaultCollapsed = false,
  frame = true,
  address = 'app.fernway.studio',
  onTaskToggle,
  onWorkspaceChange,
  defaultState,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The workspace's name, in the switcher and the breadcrumb. */
  workspace?: string;
  /** The workspaces the switcher offers. */
  workspaces?: string[];
  /** Who the greeting is for. */
  userName?: string;
  /** The greeting's first words. */
  greeting?: string;
  /** Today, as the line under the greeting writes it. */
  dateLabel?: string;
  /** The tab My tasks opens on. */
  defaultTab?: TaskTab;
  /** 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 address. */
  frame?: boolean;
  /** The address in the window's bar. */
  address?: string;
  /** Called when a task is ticked or unticked. */
  onTaskToggle?: (id: string, done: boolean) => void;
  /** Called when the reader picks a workspace in the switcher. */
  onWorkspaceChange?: (name: string) => void;
  /** Opens in this state (a menu, a search, the drawer…), held still: the demo is off. */
  defaultState?: WorkspaceHomeState;
  /** 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 [tasks, setTasks] = useState<Task[]>(() =>
    TASKS.map((task, index) => {
      const at = defaultState?.done?.indexOf(task.id) ?? -1;
      if (at >= 0) return { ...task, done: true, doneAt: 100 - at };
      return task.done ? { ...task, doneAt: -index } : task;
    }),
  );
  /** Held in defaultState: no demo, and a menu it opened stays until the screen is used. */
  const [held] = useState(defaultState !== undefined);
  const quietMenu = useRef(!!defaultState?.menu);
  const [hovered] = useState(defaultState?.hover);
  const [tab, setTab] = useState<TaskTab>(defaultTab);
  /** Tasks just ticked or unticked, still shown in the tab they left: `fold` once they go. */
  const [linger, setLinger] = useState<Record<string, { tab: TaskTab; fold: boolean }>>({});
  const [collapsed, setCollapsed] = useState(defaultCollapsed);
  const [drawer, setDrawer] = useState(!!defaultState?.drawer);
  const [open, setOpen] = useState<Set<ProjectId>>(() => new Set(['web']));
  const [current, setCurrent] = useState('home');
  const [query, setQuery] = useState(defaultState?.query ?? '');
  const [space, setSpace] = useState(workspace);
  const [menu, setMenu] = useState(!!defaultState?.menu);
  const [more, setMore] = useState(!!defaultState?.more);
  const [feed, setFeed] = useState<Activity[]>(() => [
    ...(defaultState?.done ?? []).flatMap((taskId): Activity[] => {
      const task = TASKS.find((each) => each.id === taskId);
      return task && !task.done
        ? [{ id: `done-${taskId}`, who: 'mo', verb: 'completed', what: task.title, when: 'now' }]
        : [];
    }),
    ...ACTIVITY,
  ]);
  const [message, setMessage] = useState('');
  const timers = useRef(new Map<string, number[]>());
  const clock = useRef(1);
  const search = useRef<HTMLInputElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const switcher = useRef<HTMLButtonElement>(null);
  const menuList = useRef<HTMLUListElement>(null);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  // A new workspace prop replaces the one picked in the switcher.
  const [given, setGiven] = useState(workspace);
  if (given !== workspace) {
    setGiven(workspace);
    setSpace(workspace);
  }
  useEffect(() => {
    const pending = timers.current;
    return () => pending.forEach((list) => list.forEach((timer) => window.clearTimeout(timer)));
  }, []);

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

  const q = query.trim().toLowerCase();
  const mine = tasks.filter((task) => !task.done);
  const counts = {
    upcoming: tasks.filter((task) => belongs(task, 'upcoming')).length,
    overdue: tasks.filter((task) => belongs(task, 'overdue')).length,
    completed: tasks.filter((task) => belongs(task, 'completed')).length,
  };
  const dueSoon = mine.filter((task) => task.soon).length;
  const shown = tasks
    .filter((task) => belongs(task, tab) || linger[task.id]?.tab === tab)
    .filter((task) => matches(q, task.title, projectById(task.project).name));
  if (tab === 'completed') shown.sort((a, b) => (b.doneAt ?? 0) - (a.doneAt ?? 0));

  /** `quiet`: the demo's ticks are not announced, nor reported to onTaskToggle. */
  const toggle = (taskId: string, quiet = false) => {
    const task = tasks.find((each) => each.id === taskId);
    if (!task) return;
    const done = !task.done;
    setTasks((list) =>
      list.map((each) =>
        each.id === taskId ? { ...each, done, doneAt: done ? clock.current++ : each.doneAt } : each,
      ),
    );
    // It stays where it was a moment, so the tick reads, then folds away.
    const from = tab;
    setLinger((all) => ({ ...all, [taskId]: { tab: from, fold: false } }));
    timers.current.get(taskId)?.forEach((timer) => window.clearTimeout(timer));
    timers.current.set(taskId, [
      window.setTimeout(
        () =>
          setLinger((all) => (all[taskId] ? { ...all, [taskId]: { tab: from, fold: true } } : all)),
        STAY,
      ),
      window.setTimeout(() => {
        timers.current.delete(taskId);
        setLinger((all) => {
          const rest = { ...all };
          delete rest[taskId];
          return rest;
        });
      }, STAY + FOLD),
    ]);
    setFeed((list) =>
      done
        ? [
            { id: `done-${taskId}`, who: 'mo', verb: 'completed', what: task.title, when: 'now' },
            ...list.filter((entry) => entry.id !== `done-${taskId}`),
          ]
        : list.filter((entry) => entry.id !== `done-${taskId}`),
    );
    if (quiet) return;
    announce(
      done
        ? `${task.title} completed.`
        : `${task.title} moved back to ${task.overdue ? 'Overdue' : 'Upcoming'}.`,
    );
    onTaskToggle?.(taskId, done);
  };

  const toggleProject = (projectId: ProjectId) =>
    setOpen((set) => {
      const next = new Set(set);
      if (next.has(projectId)) next.delete(projectId);
      else next.add(projectId);
      return next;
    });

  const onTabKey = (event: ReactKeyboardEvent, index: number) => {
    const step = { ArrowRight: 1, ArrowLeft: -1, Home: -index, End: TABS.length - 1 - index }[
      event.key
    ];
    if (step === undefined) return;
    event.preventDefault();
    const next = (index + step + TABS.length) % TABS.length;
    setTab(TABS[next].id);
    tabRefs.current[next]?.focus();
  };

  // The workspace menu: focus goes into it on opening, and back to its button on Escape.
  useEffect(() => {
    if (!menu) return;
    const items = menuList.current?.querySelectorAll<HTMLElement>('[role="menuitemradio"]');
    const checked = menuList.current?.querySelector<HTMLElement>('[aria-checked="true"]');
    if (!quietMenu.current) (checked ?? items?.[0])?.focus();
    const close = (event: PointerEvent) => {
      if (quietMenu.current) return;
      if (!(event.target as Element).closest(`.${styles.switch}`)) setMenu(false);
    };
    document.addEventListener('pointerdown', close);
    return () => document.removeEventListener('pointerdown', close);
  }, [menu]);
  const onMenuKey = (event: ReactKeyboardEvent) => {
    const items = [
      ...(menuList.current?.querySelectorAll<HTMLElement>('[role="menuitemradio"]') ?? []),
    ];
    const at = items.indexOf(document.activeElement as HTMLElement);
    if (event.key === 'Escape') {
      event.preventDefault();
      setMenu(false);
      switcher.current?.focus();
    } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      event.preventDefault();
      const next = (at + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
      items[next]?.focus();
    } else if (event.key === 'Tab') setMenu(false);
  };

  const onRootKey = (event: ReactKeyboardEvent) => {
    take();
    const target = event.target as HTMLElement;
    if (event.key === '/' && !target.closest('input, textarea, [contenteditable]')) {
      event.preventDefault();
      search.current?.focus();
    }
    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: 'tick' | 'tab' | 'project' | 'reset', arg: string) => {
    if (kind === 'tick') toggle(arg, true);
    if (kind === 'tab') setTab(arg as TaskTab);
    if (kind === 'project') toggleProject(arg as ProjectId);
    if (kind === 'reset') {
      // Puts back what the demo changed, should it stop half-way.
      if (tasks.find((task) => task.id === arg)?.done) toggle(arg, true);
      setOpen((set) => (set.has('web') ? set : new Set([...set, 'web'])));
    }
  });
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    let hovered: Element | null = null;
    let ticked = false;
    const hover = (element: Element | null) => {
      hovered?.removeAttribute('data-hover');
      hovered = element;
      element?.setAttribute('data-hover', '');
    };
    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 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;
      return {
        x: (rect.left + rect.width * fx - box.left) / scale,
        y: (rect.top + rect.height * fy - 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 element = find(selector);
        const to = spot(element, fx, fy);
        if (!to) return false;
        glyph('arrow');
        hover(null);
        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 (;;) {
        act('tab', 'upcoming');
        // Along the first task, then tick it.
        if (await go('[data-part="task"][data-id="t3"]', 0.42, 0.5, 700)) {
          hover(find('[data-part="task"][data-id="t3"]'));
          await wait(500);
        }
        if (
          await go('[data-part="task"][data-id="t1"] [data-part="task-check"]', 0.5, 0.5, 520, true)
        ) {
          await wait(260);
          await press();
          act('tick', 't1');
          ticked = true;
          await wait(1500);
        }
        // Through the tabs: Overdue, then Completed, where the task now heads the list.
        if (await go('[data-part="tab"][data-tab="overdue"]', 0.5, 0.55, 620, true)) {
          await wait(220);
          await press();
          act('tab', 'overdue');
          await wait(1300);
        }
        if (await go('[data-part="tab"][data-tab="completed"]', 0.5, 0.55, 480, true)) {
          await wait(220);
          await press();
          act('tab', 'completed');
          await wait(1100);
        }
        // Untick it: it goes back to Upcoming.
        if (
          ticked &&
          (await go(
            '[data-part="task"][data-id="t1"] [data-part="task-check"]',
            0.5,
            0.5,
            560,
            true,
          ))
        ) {
          await wait(240);
          await press();
          act('tick', 't1');
          ticked = false;
          await wait(1100);
        }
        if (await go('[data-part="tab"][data-tab="upcoming"]', 0.5, 0.55, 600, true)) {
          await wait(220);
          await press();
          act('tab', 'upcoming');
          await wait(900);
        }
        // Fold a project in the sidebar, and open it again.
        if (await go('[data-part="project-link"][data-id="web"]', 0.4, 0.5, 820, true)) {
          await wait(260);
          await press();
          act('project', 'web');
          await wait(1200);
          await press();
          act('project', 'web');
          await wait(1000);
        }
        // Along the projects table.
        for (const row of ['launch', 'system']) {
          if (await go(`[data-part="project-row"][data-id="${row}"]`, 0.36, 0.5, 700)) {
            hover(find(`[data-part="project-row"][data-id="${row}"]`));
            await wait(900);
          }
        }
        hover(null);
        await wait(1200);
      }
    });
    return () => {
      stop();
      hover(null);
      delete arrow.dataset.press;
      if (ticked) act('reset', 't1');
    };
  }, [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') search.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}>
        <div className={styles.switch} data-part="switcher">
          <button
            ref={switcher}
            type="button"
            className={styles.switchButton}
            aria-haspopup="menu"
            aria-expanded={menu}
            aria-label={`Workspace: ${space}`}
            onClick={() => setMenu((value) => !value)}
          >
            <Logo />
            <span className={styles.switchName}>{space}</span>
            <Icon name="updown" className={styles.switchIcon} />
          </button>
          {menu && (
            <ul
              ref={menuList}
              className={styles.menu}
              role="menu"
              aria-label="Switch workspace"
              data-part="switcher-menu"
              onKeyDown={onMenuKey}
            >
              {workspaces.map((name) => (
                <li key={name} role="none">
                  <button
                    type="button"
                    role="menuitemradio"
                    aria-checked={name === space}
                    className={styles.menuItem}
                    tabIndex={-1}
                    onClick={() => {
                      setSpace(name);
                      setMenu(false);
                      if (name !== space) onWorkspaceChange?.(name);
                      switcher.current?.focus();
                      announce(`Switched to ${name}.`);
                    }}
                  >
                    <span className={styles.menuMark} aria-hidden="true">
                      {name.charAt(0)}
                    </span>
                    {name}
                    {name === space && <Icon name="check" className={styles.menuCheck} />}
                  </button>
                </li>
              ))}
            </ul>
          )}
        </div>
        <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],
            <span className={styles.badge} aria-label="4 unread">
              4
            </span>,
          )}
          {navItem(
            PRIMARY[2],
            dueSoon > 0 && (
              <span className={styles.count} aria-label={`${dueSoon} due soon`}>
                {dueSoon}
              </span>
            ),
          )}
          {navItem(PRIMARY[3])}
          {navItem(
            PRIMARY[4],
            <kbd className={styles.kbd} aria-hidden="true">
              /
            </kbd>,
          )}
          {navItem(
            PRIMARY[5],
            <span className={styles.count} aria-label="6 new">
              6
            </span>,
          )}
        </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={() => toggleProject(project.id)}
                >
                  <ProjectTile project={project} small />
                  <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={STATUS_TONE[project.status]}
                    role="img"
                    aria-label={project.status}
                  />
                </button>
                <div className={styles.fold} data-open={isOpen}>
                  <ul className={styles.views} inert={!isOpen}>
                    {VIEWS.map((view) => (
                      <li key={view.label}>
                        <button
                          type="button"
                          className={`${styles.navItem} ${styles.view}`}
                          aria-current={
                            current === `${project.id}-${view.label}` ? 'page' : undefined
                          }
                          onClick={() => {
                            setCurrent(`${project.id}-${view.label}`);
                            setDrawer(false);
                          }}
                        >
                          <Icon name={view.icon} className={styles.navIcon} />
                          <span className={styles.navLabel}>{view.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 deadlineGroups = DEADLINES.map((group) => ({
    ...group,
    list: [...group.items, ...(more ? (group.more ?? []) : [])].filter((item) =>
      matches(q, item.title, projectById(item.project).name),
    ),
  })).filter((group) => group.list.length);
  const projects = PROJECTS.slice(0, 5).filter((project) =>
    matches(q, project.name, project.status),
  );

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      data-collapsed={collapsed || undefined}
      data-drawer={drawer || undefined}
      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}>{space}</span>
                <span className={styles.crumbSep} aria-hidden="true">
                  /
                </span>
                <span className={styles.crumbHere} aria-current="page">
                  Home
                </span>
              </nav>
              <label className={styles.search} data-part="search">
                <Icon name="search" className={styles.searchIcon} />
                <input
                  ref={search}
                  type="search"
                  className={styles.searchInput}
                  placeholder="Search or jump to…"
                  aria-label="Search tasks and projects"
                  value={query}
                  onChange={(event) => setQuery(event.target.value)}
                  onKeyDown={(event) => {
                    if (event.key === 'Escape' && query) {
                      event.stopPropagation();
                      setQuery('');
                    }
                  }}
                />
                <kbd className={styles.kbd} aria-hidden="true">
                  /
                </kbd>
              </label>
              <div className={styles.topTools} data-part="top-tools">
                <button
                  type="button"
                  className={styles.iconButton}
                  aria-label="Notifications"
                  onClick={() => setCurrent('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.content}>
              <div className={styles.hero} data-part="greeting">
                <div>
                  <h2 className={styles.title}>
                    {greeting}, {userName}
                  </h2>
                  <p className={styles.subtitle}>
                    {dateLabel} · Here’s what’s happening across your workspace.
                  </p>
                </div>
                <div className={styles.actions}>
                  <button
                    type="button"
                    className={styles.button}
                    data-part="action"
                    aria-label="Invite member"
                  >
                    <Icon name="invite" className={styles.buttonIcon} />
                    <span>Invite member</span>
                  </button>
                  <button
                    type="button"
                    className={styles.button}
                    data-part="action"
                    aria-label="New project"
                  >
                    <Icon name="folderPlus" className={styles.buttonIcon} />
                    <span>New project</span>
                  </button>
                  <button
                    type="button"
                    className={`${styles.button} ${styles.primary}`}
                    data-part="action"
                    aria-label="New task"
                  >
                    <Icon name="plus" className={styles.buttonIcon} />
                    <span>New task</span>
                  </button>
                </div>
              </div>

              <dl className={styles.stats} data-part="stats">
                <Stat icon="folder" label="Active projects" value={5} note="1 at risk" />
                <Stat
                  icon="dashed"
                  label="Open tasks"
                  value={OTHER_OPEN + mine.length}
                  note={`${mine.length} assigned to you`}
                />
                <Stat
                  icon="checkCircle"
                  label="Completed"
                  value={OTHER_DONE + counts.completed}
                  note="this week"
                  tone="green"
                />
                <Stat
                  icon="clockAlert"
                  label="Overdue"
                  value={5 + counts.overdue}
                  note="need attention"
                  tone="red"
                  strong
                />
              </dl>

              <div className={styles.grid}>
                <div className={styles.column}>
                  <section className={styles.card} data-part="tasks" aria-labelledby={`${id}-mt`}>
                    <div className={styles.cardHead}>
                      <h3 className={styles.cardTitle} id={`${id}-mt`}>
                        My tasks
                      </h3>
                      <div className={styles.headTools}>
                        <div className={styles.tabs} role="tablist" aria-label="My tasks">
                          {TABS.map((each, index) => (
                            <button
                              key={each.id}
                              ref={(element) => {
                                tabRefs.current[index] = element;
                              }}
                              type="button"
                              role="tab"
                              id={`${id}-tab-${each.id}`}
                              aria-selected={tab === each.id}
                              aria-controls={`${id}-panel`}
                              tabIndex={tab === each.id ? 0 : -1}
                              className={styles.tab}
                              data-part="tab"
                              data-tab={each.id}
                              onClick={() => setTab(each.id)}
                              onKeyDown={(event) => onTabKey(event, index)}
                            >
                              {each.label}
                              <span className={styles.tabCount}>{counts[each.id]}</span>
                            </button>
                          ))}
                        </div>
                        <button
                          type="button"
                          className={styles.iconButton}
                          aria-label="Open My Tasks"
                          onClick={() => setCurrent('my-tasks')}
                        >
                          <Icon name="arrowUpRight" className={styles.headIcon} />
                        </button>
                      </div>
                    </div>
                    <ul
                      className={styles.taskList}
                      role="tabpanel"
                      id={`${id}-panel`}
                      aria-labelledby={`${id}-tab-${tab}`}
                      data-part="task-list"
                    >
                      {shown.map((task) => (
                        <TaskRow
                          key={task.id}
                          task={task}
                          folding={!!linger[task.id]?.fold}
                          hovered={hovered === task.id}
                          onToggle={() => toggle(task.id)}
                        />
                      ))}
                      {!shown.length && (
                        <li className={styles.empty}>
                          {q
                            ? `No tasks match “${query.trim()}”.`
                            : tab === 'overdue'
                              ? 'Nothing overdue. Nicely done.'
                              : tab === 'completed'
                                ? 'Nothing completed yet.'
                                : 'You’re all caught up.'}
                        </li>
                      )}
                    </ul>
                  </section>

                  <section
                    className={styles.card}
                    data-part="progress"
                    aria-labelledby={`${id}-pp`}
                  >
                    <div className={`${styles.cardHead} ${styles.plainHead}`}>
                      <h3 className={styles.cardTitle} id={`${id}-pp`}>
                        Project progress
                      </h3>
                      <button
                        type="button"
                        className={styles.linkButton}
                        onClick={() => setCurrent('projects')}
                      >
                        All projects
                      </button>
                    </div>
                    <div className={styles.tableScroll} tabIndex={-1}>
                      <table className={styles.table}>
                        <thead>
                          <tr>
                            <th scope="col">Project</th>
                            <th scope="col">Status</th>
                            <th scope="col">Progress</th>
                            <th scope="col">Due</th>
                            <th scope="col" className={styles.right}>
                              Team
                            </th>
                          </tr>
                        </thead>
                        <tbody>
                          {projects.map((project) => (
                            <tr
                              key={project.id}
                              className={styles.projectRow}
                              data-part="project-row"
                              data-id={project.id}
                              data-hover={hovered === project.id ? '' : undefined}
                            >
                              <th scope="row">
                                <span className={styles.projectName}>
                                  <ProjectTile project={project} />
                                  {project.name}
                                </span>
                              </th>
                              <td>
                                <span
                                  className={styles.pill}
                                  data-tone={STATUS_TONE[project.status]}
                                  data-part="status"
                                >
                                  {project.status}
                                </span>
                              </td>
                              <td>
                                <span className={styles.progress}>
                                  <span
                                    className={styles.bar}
                                    role="progressbar"
                                    aria-label={`${project.name} progress`}
                                    aria-valuenow={project.progress}
                                    aria-valuemin={0}
                                    aria-valuemax={100}
                                  >
                                    <span
                                      className={styles.barFill}
                                      style={{ transform: `scaleX(${project.progress / 100})` }}
                                    />
                                  </span>
                                  <span className={styles.percent}>{project.progress}%</span>
                                </span>
                              </td>
                              <td className={styles.due}>{project.due}</td>
                              <td className={styles.right}>
                                <Faces team={project.team} more={project.more} />
                              </td>
                            </tr>
                          ))}
                          {!projects.length && (
                            <tr>
                              <td colSpan={5} className={styles.empty}>
                                No projects match “{query.trim()}”.
                              </td>
                            </tr>
                          )}
                        </tbody>
                      </table>
                    </div>
                  </section>
                </div>

                <div className={`${styles.column} ${styles.side}`}>
                  <section
                    className={styles.card}
                    data-part="deadlines"
                    aria-labelledby={`${id}-ud`}
                  >
                    <div className={`${styles.cardHead} ${styles.plainHead}`}>
                      <h3 className={styles.cardTitle} id={`${id}-ud`}>
                        Upcoming deadlines
                      </h3>
                    </div>
                    <div className={styles.deadlines}>
                      {deadlineGroups.map((group) => (
                        <div key={group.label} className={styles.group} data-part="deadline-group">
                          <h4 className={styles.groupLabel}>
                            {group.label}
                            <span className={styles.groupCount}>{group.total}</span>
                          </h4>
                          <ul className={styles.groupList}>
                            {group.list.map((item) => (
                              <li key={item.title}>
                                <button
                                  type="button"
                                  className={styles.deadline}
                                  data-part="deadline"
                                >
                                  <span
                                    className={styles.dot}
                                    style={
                                      {
                                        '--hue': projectById(item.project).hue,
                                      } as CSSProperties
                                    }
                                    role="img"
                                    aria-label={projectById(item.project).name}
                                  />
                                  <span className={styles.deadlineTitle}>{item.title}</span>
                                </button>
                              </li>
                            ))}
                          </ul>
                          {group.more && !q && (
                            <button
                              type="button"
                              className={styles.more}
                              aria-expanded={more}
                              onClick={() => setMore((value) => !value)}
                            >
                              {more ? 'Show less' : `+${group.more.length} more`}
                            </button>
                          )}
                        </div>
                      ))}
                      {!deadlineGroups.length && (
                        <p className={styles.empty}>No deadlines match “{query.trim()}”.</p>
                      )}
                    </div>
                  </section>

                  <section
                    className={styles.card}
                    data-part="activity"
                    aria-labelledby={`${id}-ra`}
                  >
                    <div className={`${styles.cardHead} ${styles.plainHead}`}>
                      <h3 className={styles.cardTitle} id={`${id}-ra`}>
                        Recent activity
                      </h3>
                    </div>
                    <ol className={styles.feed} data-part="feed">
                      {feed.slice(0, 5).map((entry) => (
                        <li key={entry.id} className={styles.feedItem}>
                          <Face who={entry.who} />
                          <p className={styles.feedText}>
                            <b>
                              {entry.who === 'mo' ? 'You' : PEOPLE[entry.who].name.split(' ')[0]}
                            </b>{' '}
                            {entry.verb} <b>{entry.what}</b>
                          </p>
                          <span className={styles.feedWhen}>
                            {entry.when === 'now' ? 'just now' : `${entry.when} ago`}
                          </span>
                        </li>
                      ))}
                    </ol>
                  </section>
                </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>
  );
}

/** 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, small }: { project: Project; small?: boolean }) {
  return (
    <span
      className={styles.tile}
      data-small={small || undefined}
      style={{ '--hue': project.hue } as CSSProperties}
      aria-hidden="true"
    >
      <Icon name={project.icon} className={styles.tileIcon} />
    </span>
  );
}

function Stat({
  icon,
  label,
  value,
  note,
  tone,
  strong,
}: {
  icon: IconName;
  label: string;
  value: number;
  note: string;
  tone?: 'green' | 'red';
  /** The figure itself takes the tone, not only its note. */
  strong?: boolean;
}) {
  return (
    <div className={styles.stat} data-part="stat">
      <dt className={styles.statLabel}>
        <Icon name={icon} className={styles.statIcon} />
        {label}
      </dt>
      <dd className={styles.statValue} data-tone={strong ? tone : undefined}>
        {value}
      </dd>
      <dd className={styles.statNote} data-tone={tone}>
        {note}
      </dd>
    </div>
  );
}

function Bars({ priority }: { priority: Priority }) {
  if (priority === 'urgent')
    return (
      <span className={styles.urgent} role="img" aria-label="Urgent" data-part="priority">
        !
      </span>
    );
  return (
    <span
      className={styles.bars}
      role="img"
      aria-label={PRIORITY_LABEL[priority]}
      data-part="priority"
    >
      {[1, 2, 3].map((level) => (
        <i key={level} data-on={level <= priority || undefined} />
      ))}
    </span>
  );
}

function TaskRow({
  task,
  folding,
  hovered,
  onToggle,
}: {
  task: Task;
  folding: boolean;
  hovered?: boolean;
  onToggle: () => void;
}) {
  const project = projectById(task.project);
  const state = task.done ? 'done' : task.started ? 'started' : 'todo';
  return (
    <li className={styles.taskItem} data-folding={folding || undefined}>
      <div className={styles.taskClip}>
        <div
          className={styles.taskRow}
          data-part="task"
          data-id={task.id}
          data-done={task.done || undefined}
          data-hover={hovered ? '' : undefined}
        >
          <button
            type="button"
            role="checkbox"
            aria-checked={!!task.done}
            aria-label={`Complete “${task.title}”`}
            className={styles.check}
            data-part="task-check"
            onClick={onToggle}
          >
            <Icon name="check" className={styles.checkIcon} />
          </button>
          <span
            className={styles.state}
            data-state={state}
            role="img"
            aria-label={task.done ? 'Done' : task.started ? 'In progress' : 'To do'}
          />
          <span className={styles.taskTitle}>{task.title}</span>
          <span className={styles.taskProject} data-part="task-project">
            <span
              className={styles.dot}
              style={{ '--hue': project.hue } as CSSProperties}
              aria-hidden="true"
            />
            {project.name}
          </span>
          <Bars priority={task.priority} />
          <span
            className={styles.taskDue}
            data-tone={
              task.done ? undefined : task.overdue ? 'red' : task.soon ? 'amber' : undefined
            }
          >
            {task.due}
          </span>
        </div>
      </div>
    </li>
  );
}

function Face({ who }: { who: PersonId }) {
  const person = PEOPLE[who];
  return (
    <span
      className={styles.face}
      style={{ '--hue': person.hue } as CSSProperties}
      data-name={person.name}
      aria-hidden="true"
    >
      {person.initials}
    </span>
  );
}

function Faces({ team, more }: { team: PersonId[]; more: number }) {
  const names = team.map((who) => PEOPLE[who].name).join(', ');
  return (
    <span
      className={styles.faces}
      role="img"
      aria-label={more ? `${names} and ${more} more` : names}
      data-part="team"
    >
      {team.map((who) => (
        <Face key={who} who={who} />
      ))}
      {more > 0 && <span className={`${styles.face} ${styles.faceMore}`}>+{more}</span>}
    </span>
  );
}
```

### workspace-home/workspace-home.module.css

```css
/* Workspace home (workspace-home.tsx): a project tool's first screen in an app window. Near-black
   at night, the sidebar a step darker than the page, cards drawn by hairlines rather than fills,
   one periwinkle accent; 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).

   Layout by container queries on the root, so it answers to the room it is given: the sidebar
   is a column from 760 px (or a rail of icons, folded), a drawer under it; the cards sit in two
   columns from 1340 px and in one under it.

   Memory. Folds (a project's views, a ticked task leaving) animate grid rows on one small box;
   everything else moves by transforms and opacity, and the demo pointer on its own layer. */

.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);
  --sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  --side-w: 275px;
  position: relative;
  container: wh / 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);
  }
}
.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: 54px;
  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;
}

/* The sidebar. */
.sidebar {
  flex: 0 0 var(--side-w);
  width: var(--side-w);
  min-width: 0;
  padding: 0 9px 18px;
  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;
}
.switch {
  position: relative;
  min-width: 0;
}
.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;
}

.menu {
  position: absolute;
  z-index: 5;
  top: calc(100% + 4px);
  left: 0;
  width: 210px;
  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);
  }
}
.menuItem {
  display: flex;
  align-items: center;
  gap: 9px;
  width: 100%;
  height: 32px;
  padding: 0 8px;
  border-radius: 6px;
  text-align: left;
}
.menuItem:hover,
.menuItem:focus-visible {
  background: var(--hover);
}
.menuMark {
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  border-radius: 5px;
  background: var(--active);
  color: var(--ink-2);
  font-size: 10.5px;
  font-weight: 600;
}
.menuCheck {
  width: 15px;
  height: 15px;
  margin-left: auto;
  fill: none;
  stroke: var(--accent);
  stroke-width: 2.2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.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);
  font-family: inherit;
  font-size: 12px;
}
.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: 30px;
  height: 30px;
  border-radius: 8px;
  background: color-mix(in srgb, var(--hue) 16%, var(--app));
}
.tile[data-small] {
  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. */
.main {
  flex: 1;
  min-width: 0;
}
.topbar {
  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;
}
.search {
  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;
}
.search:focus-within {
  border-color: color-mix(in srgb, var(--accent) 60%, var(--line));
}
.searchIcon {
  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;
}
.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;
}
/* 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;
}

.content {
  padding: 28.5px clamp(16px, 4.2cqi, 56px) 48px;
}
.hero {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
}
.title {
  font-size: 26px;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -0.022em;
}
.subtitle {
  margin-top: 5.5px;
  color: var(--ink-2);
  font-size: 15px;
}
.actions {
  display: flex;
  gap: 7px;
  flex: none;
}
.button {
  display: flex;
  align-items: center;
  gap: 7px;
  height: 33px;
  padding: 0 12px 0 11px;
  border: 1px solid var(--line);
  border-radius: 8px;
  font-size: 14px;
  font-weight: 500;
  white-space: nowrap;
  transition: background-color 120ms ease;
}
.button:hover {
  background: var(--hover);
}
.primary {
  border-color: transparent;
  background: var(--accent);
  color: var(--accent-ink);
}
.primary:hover {
  background: color-mix(in srgb, var(--accent) 88%, var(--ink));
}
.button:active {
  transform: translateY(0.5px);
}
.buttonIcon {
  width: 19px;
  height: 19px;
  margin: 0 -1.5px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.45;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.stats {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  margin-top: 22px;
  border: 1px solid var(--line);
  border-radius: 12px;
}
.stat {
  min-width: 0;
  padding: 14px 18px 15px;
}
.stat + .stat {
  border-left: 1px solid var(--line);
}
.statLabel {
  display: flex;
  align-items: center;
  gap: 7px;
  color: var(--ink-2);
  font-size: 14px;
  white-space: nowrap;
}
.statIcon {
  width: 19px;
  height: 19px;
  flex: none;
  margin: -2px -2px -2px -2px;
  fill: none;
  stroke: var(--muted);
  stroke-width: 1.35;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.statValue {
  margin: 7px 0 9px;
  font-size: 25px;
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
}
.statValue[data-tone='red'] {
  color: var(--red);
}
.statNote {
  overflow: hidden;
  color: var(--muted);
  font-size: 13.5px;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.statNote[data-tone='green'] {
  color: var(--green);
}
.statNote[data-tone='red'] {
  color: var(--red);
}

.grid {
  display: grid;
  gap: 20px;
  margin-top: 18px;
}
.column {
  display: grid;
  align-content: start;
  gap: 18px;
  min-width: 0;
}
.card {
  min-width: 0;
  overflow: hidden;
  overflow: clip;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--app);
}
.cardHead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  min-height: 56px;
  padding: 0 12px 0 16px;
  border-bottom: 1px solid var(--divider);
}
.plainHead {
  border-bottom: 0;
}
.cardTitle {
  font-size: 15px;
  font-weight: 600;
  letter-spacing: -0.01em;
  white-space: nowrap;
}
.headTools {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
}
.tabs {
  display: flex;
  min-width: 0;
  padding: 2px;
  border-radius: 9px;
  background: var(--seg);
}
.tab {
  display: flex;
  align-items: center;
  gap: 6px;
  height: 28px;
  padding: 0 10px;
  border-radius: 6px;
  color: var(--ink-2);
  font-size: 13.5px;
  font-weight: 500;
  white-space: nowrap;
  transition:
    background-color 140ms ease,
    color 140ms ease,
    box-shadow 140ms ease;
}
.tab:hover {
  color: var(--ink);
}
.tab[aria-selected='true'] {
  background: var(--seg-on);
  color: var(--ink);
  box-shadow: 0 1px 2px var(--seg-shadow);
}
.tabCount {
  color: var(--muted);
  font-weight: 450;
  font-variant-numeric: tabular-nums;
}
.linkButton {
  height: 28px;
  padding: 0 8px;
  border-radius: 6px;
  color: var(--ink-2);
  font-size: 13.5px;
  font-weight: 500;
}
.linkButton:hover {
  background: var(--hover);
  color: var(--ink);
}

/* My tasks. */
.taskItem {
  display: grid;
  grid-template-rows: 1fr;
  transition:
    grid-template-rows 280ms cubic-bezier(0.3, 0, 0.2, 1),
    opacity 200ms ease;
}
.taskItem + .taskItem {
  border-top: 1px solid var(--divider);
}
.taskItem[data-folding] {
  grid-template-rows: 0fr;
  opacity: 0;
}
.taskClip {
  min-height: 0;
  overflow: hidden;
}
.taskRow {
  display: flex;
  align-items: center;
  gap: 11px;
  height: 41px;
  padding: 0 16px;
  transition: background-color 120ms ease;
}
.taskRow:hover,
.taskRow[data-hover] {
  background: var(--hover);
}
.check {
  display: grid;
  place-items: center;
  flex: none;
  width: 17px;
  height: 17px;
  margin-right: 2px;
  padding: 0;
  border: 1.5px solid var(--faint);
  border-radius: 50%;
  transition:
    border-color 140ms ease,
    background-color 140ms ease,
    transform 140ms ease;
}
.check:hover {
  border-color: var(--ink-2);
}
.check:active {
  transform: scale(0.9);
}
.check:focus-visible {
  outline-offset: 2px;
  border-radius: 50%;
}
.checkIcon {
  width: 11px;
  height: 11px;
  fill: none;
  stroke: var(--accent-ink);
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 18;
  stroke-dashoffset: 18;
  transition: stroke-dashoffset 220ms ease 40ms;
}
.check[aria-checked='true'] {
  border-color: var(--accent);
  background: var(--accent);
}
.check[aria-checked='true'] .checkIcon {
  stroke-dashoffset: 0;
}
.state {
  flex: none;
  width: 15px;
  height: 15px;
  padding: 2px;
  border: 1.5px solid var(--ink-2);
  border-radius: 50%;
  background-clip: content-box;
}
.state[data-state='started'] {
  border-color: var(--amber);
  background-image: conic-gradient(transparent 0 50%, var(--amber) 0);
}
.state[data-state='done'] {
  border-color: var(--accent);
  background-color: var(--accent);
}
.taskTitle {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  font-size: 15px;
  font-weight: 500;
  white-space: nowrap;
  text-overflow: ellipsis;
  transition: color 200ms ease;
}
.taskRow[data-done] .taskTitle {
  color: var(--muted);
  text-decoration: line-through;
  text-decoration-color: color-mix(in srgb, var(--muted) 70%, transparent);
}
.taskProject {
  display: flex;
  align-items: center;
  gap: 7px;
  flex: none;
  color: var(--ink-2);
  font-size: 13.5px;
  white-space: nowrap;
}
.dot {
  width: 8px;
  height: 8px;
  flex: none;
  border-radius: 2.5px;
  background: var(--hue);
}
.bars {
  display: flex;
  align-items: flex-end;
  gap: 1.5px;
  flex: none;
  width: 14px;
  height: 14px;
  margin-left: -0.5px;
  padding: 2px 1px;
}
.bars i {
  width: 3px;
  height: 4px;
  border-radius: 1px;
  background: var(--faint);
}
.bars i:nth-child(2) {
  height: 7px;
}
.bars i:nth-child(3) {
  height: 10px;
}
.bars i[data-on] {
  background: var(--ink-2);
}
.urgent {
  display: grid;
  place-items: center;
  flex: none;
  width: 14px;
  height: 14px;
  margin-left: -0.5px;
  border-radius: 3.5px;
  background: var(--red);
  color: var(--on-red);
  font-size: 10px;
  font-weight: 800;
  line-height: 1;
}
.taskDue {
  flex: none;
  width: 82px;
  color: var(--ink-2);
  font-size: 13.5px;
  text-align: right;
  white-space: nowrap;
}
.taskDue[data-tone='amber'] {
  color: var(--amber);
}
.taskDue[data-tone='red'] {
  color: var(--red);
}
.empty {
  padding: 18px 14px;
  color: var(--muted);
  font-size: 13px;
}

/* Project progress. */
.tableScroll {
  overflow-x: auto;
  overscroll-behavior-x: contain;
}
.table {
  width: 100%;
  min-width: 560px;
  border-collapse: collapse;
  font-size: 14px;
}
.table th,
.table td {
  padding: 0 10px 0 0;
  text-align: left;
  white-space: nowrap;
}
.table th:first-child,
.table td:first-child {
  padding-left: 17px;
}
.table th:last-child,
.table td:last-child {
  padding-right: 16px;
}
/* Set widths for all but the first column, which takes the rest. */
.table thead th:nth-child(2) {
  width: 18%;
}
.table thead th:nth-child(3) {
  width: 26%;
}
.table thead th:nth-child(4) {
  width: 9.4%;
}
.table thead th:nth-child(5) {
  width: 14.1%;
}
.table thead th {
  height: 35px;
  color: var(--ink-2);
  font-size: 14px;
  font-weight: 450;
}
.table .right {
  text-align: right;
}
.projectRow {
  border-top: 1px solid var(--divider);
  transition: background-color 120ms ease;
}
.projectRow:hover,
.projectRow[data-hover] {
  background: var(--hover);
}
.projectRow th,
.projectRow td {
  height: 51.5px;
}
.projectRow th {
  font-weight: 500;
}
.projectName {
  display: flex;
  align-items: center;
  gap: 9px;
}
.pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 22px;
  margin-left: 1px;
  padding: 0 8px 0 7px;
  border: 1px solid var(--line);
  border-radius: 6px;
  font-size: 13.5px;
  font-weight: 500;
}
.pill::before {
  content: '';
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--blue);
}
.pill[data-tone='gray']::before {
  background: var(--grey-dot);
}
.pill[data-tone='red']::before {
  background: var(--red);
}
.pill[data-tone='amber']::before {
  background: var(--amber);
}
.progress {
  display: flex;
  align-items: center;
  gap: 7px;
}
.bar {
  flex: 0 1 128px;
  min-width: 56px;
  height: 5px;
  overflow: hidden;
  border-radius: 2px;
  background: var(--active);
}
.barFill {
  display: block;
  height: 100%;
  border-radius: 2px;
  background: var(--accent);
  transform-origin: left;
}
.percent {
  width: 30px;
  color: var(--ink-2);
  font-size: 13px;
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.due {
  color: var(--ink-2);
}
.faces {
  display: inline-flex;
  justify-content: flex-end;
  padding-left: 5px;
}
.face {
  position: relative;
  display: grid;
  place-items: center;
  flex: none;
  width: 21px;
  height: 21px;
  margin-left: -3.5px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--hue) 24%, var(--app));
  box-shadow: 0 0 0 2px var(--app);
  color: color-mix(in srgb, var(--hue), var(--tint-to) 18%);
  font-size: 7.5px;
  font-weight: 600;
  letter-spacing: -0.03em;
}
.faceMore {
  --hue: var(--muted);
  color: var(--ink-2);
  background: var(--active);
}
.projectRow:hover .face,
.projectRow[data-hover] .face {
  box-shadow: 0 0 0 2px var(--hover);
}
.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;
}

/* Upcoming deadlines. */
.deadlines {
  margin-top: -5.5px;
  padding: 0 8px 18px;
}
.group + .group {
  margin-top: 7px;
}
.groupLabel {
  display: flex;
  gap: 8px;
  padding: 2px 6px 5px;
  color: var(--ink-2);
  font-size: 13px;
  font-weight: 600;
}
.groupCount {
  color: var(--muted);
  font-weight: 450;
}
.deadline {
  display: flex;
  align-items: center;
  gap: 9px;
  width: 100%;
  height: 33px;
  padding: 0 6px;
  border-radius: 6px;
  font-size: 14px;
  text-align: left;
  transition: background-color 120ms ease;
  font-weight: 500;
}
.deadline:hover {
  background: var(--hover);
}
.deadlineTitle {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.more {
  height: 20px;
  margin-top: -1px;
  margin-left: 19px;
  padding: 0 4px;
  border-radius: 5px;
  color: var(--muted);
  font-size: 13.5px;
}
.more:hover {
  color: var(--ink);
}

/* Recent activity. */
.feed {
  position: relative;
  display: grid;
  gap: 9px;
  padding: 0 14px 14px;
}
.feedItem {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: start;
  gap: 10px;
  padding: 6px 0;
  animation: enter 300ms cubic-bezier(0.2, 0, 0, 1);
}
@keyframes enter {
  from {
    opacity: 0;
    transform: translateY(-4px);
  }
}
.feedItem .face {
  margin: 0;
  box-shadow: none;
}
.feedText {
  color: var(--muted);
  font-size: 14.5px;
  line-height: 1.5;
}
.feedText b {
  color: var(--ink);
  font-weight: 500;
}
.feedWhen {
  color: var(--muted);
  font-size: 13px;
  line-height: 20px;
  white-space: nowrap;
}

/* Wide: two columns of cards, the side one narrower. */
@container wh (min-width: 1340px) {
  .grid {
    grid-template-columns: minmax(0, 1.555fr) minmax(280px, 1fr);
    align-items: start;
  }
}
/* Medium: one column, the deadlines and the activity side by side. */
@container wh (min-width: 980px) and (max-width: 1339.98px) {
  .side {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
/* Under two columns, the primary action keeps only its icon. */
@container wh (max-width: 1339.98px) {
  .actions .primary span {
    display: none;
  }
  .primary {
    width: 33px;
    padding: 0;
    justify-content: center;
  }
}
@container wh (max-width: 1100px) {
  .topTools {
    display: none;
  }
  .actions .button span {
    display: none;
  }
  .button {
    width: 33px;
    padding: 0;
    justify-content: center;
  }
}

/* The rail: the sidebar folded to its icons (only from 760 px; under it, a drawer). */
@container wh (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 stats go two by two. */
@container wh (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;
  }
  .content {
    padding-top: 22px;
  }
  .hero {
    flex-direction: column;
  }
  .stats {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .stat:nth-child(3) {
    border-left: 0;
  }
  .stat:nth-child(n + 3) {
    border-top: 1px solid var(--line);
  }
  .actions {
    flex-wrap: wrap;
  }
  .actions .button span {
    display: inline;
  }
  .button {
    width: auto;
    padding: 0 12px 0 10px;
  }
  .actions .primary span {
    display: none;
  }
  .primary {
    width: 33px;
    padding: 0;
  }
}
.scrim {
  display: none;
}
@container wh (max-width: 759.98px) {
  .scrim {
    display: block;
  }
}
@container wh (max-width: 600px) {
  .taskProject {
    display: none;
  }
  .cardHead:has(.tabs) {
    flex-wrap: wrap;
    padding-top: 8px;
    padding-bottom: 8px;
  }
  .address {
    width: min(220px, 100% - 190px);
  }
}
@container wh (max-width: 440px) {
  .crumbSpace,
  .crumbSep,
  .crumbs .logo {
    display: none;
  }
  .search .kbd {
    display: none;
  }
  .title {
    font-size: 21px;
  }
  .taskDue {
    width: auto;
    min-width: 38px;
  }
  .tab {
    padding: 0 7px;
    gap: 4px;
  }
}

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

### workspace-home/data.ts

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

/*
 * The workspace the home screen shows: an invented design studio, its projects, its people, the
 * reader's tasks and what is due soon. All of it is made up for the demo; the dates are written as
 * the app would show them on Tuesday, October 13.
 */

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

export type ProjectId = 'web' | 'mobile' | 'marketing' | 'launch' | 'system' | 'portal';
export type IconName = keyof typeof PATHS;

export type Project = {
  id: ProjectId;
  name: string;
  icon: IconName;
  /** Its colour: the dot beside its tasks and the tint of its tile. */
  hue: string;
  status: 'In Progress' | 'Planning' | 'At Risk' | 'On Hold';
  progress: number;
  due: string;
  team: PersonId[];
  /** Members beyond the faces shown. */
  more: number;
  favorite?: boolean;
  locked?: boolean;
};

export type PersonId = 'mo' | 'sl' | 'jr' | 'et' | 'pn';
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' },
};

export const PROJECTS: Project[] = [
  {
    id: 'web',
    name: 'Website Redesign',
    icon: 'globe',
    hue: '#6470e0',
    status: 'In Progress',
    progress: 22,
    due: 'Nov 2',
    team: ['mo', 'sl', 'jr'],
    more: 4,
    favorite: true,
  },
  {
    id: 'mobile',
    name: 'Mobile App',
    icon: 'phone',
    hue: '#3d8bd0',
    status: 'In Progress',
    progress: 20,
    due: 'Nov 30',
    team: ['mo', 'jr', 'et'],
    more: 2,
    favorite: true,
  },
  {
    id: 'marketing',
    name: 'Marketing Campaign',
    icon: 'megaphone',
    hue: '#c94f7e',
    status: 'Planning',
    progress: 14,
    due: 'Nov 18',
    team: ['mo', 'et', 'pn'],
    more: 2,
  },
  {
    id: 'launch',
    name: 'Product Launch',
    icon: 'rocket',
    hue: '#cf952b',
    status: 'At Risk',
    progress: 0,
    due: 'Oct 21',
    team: ['mo', 'sl', 'jr'],
    more: 2,
  },
  {
    id: 'system',
    name: 'Design System',
    icon: 'components',
    hue: '#8a67cf',
    status: 'In Progress',
    progress: 17,
    due: 'Dec 18',
    team: ['sl', 'jr', 'et'],
    more: 0,
  },
  {
    id: 'portal',
    name: 'Customer Portal',
    icon: 'building',
    hue: '#33a28f',
    status: 'On Hold',
    progress: 9,
    due: 'Jan 14',
    team: ['jr', 'pn'],
    more: 1,
    locked: true,
  },
];

export const projectById = (id: ProjectId) => PROJECTS.find((project) => project.id === id)!;

/** Priority, drawn as signal bars: 1 to 3 bars lit, or the urgent mark. */
export type Priority = 1 | 2 | 3 | 'urgent';
export const PRIORITY_LABEL: Record<string, string> = {
  1: 'Low priority',
  2: 'Medium priority',
  3: 'High priority',
  urgent: 'Urgent',
};

export type Task = {
  id: string;
  title: string;
  project: ProjectId;
  priority: Priority;
  /** As the list shows it; `soon` dates (today, tomorrow) are drawn in amber. */
  due: string;
  soon?: boolean;
  overdue?: boolean;
  started?: boolean;
  done?: boolean;
  /** When it was ticked, for the Completed tab's order (newest first). */
  doneAt?: number;
};

export const TASKS: Task[] = [
  {
    id: 't1',
    title: 'Weekly design sync notes',
    project: 'web',
    priority: 1,
    due: 'Today',
    soon: true,
  },
  {
    id: 't2',
    title: 'Launch readiness checklist',
    project: 'launch',
    priority: 'urgent',
    due: 'Tomorrow',
    soon: true,
    started: true,
  },
  { id: 't3', title: 'Prepare design system tokens', project: 'web', priority: 2, due: 'Thu' },
  { id: 't4', title: 'Triage beta tester feedback', project: 'mobile', priority: 2, due: 'Fri' },
  { id: 't5', title: 'Go / no-go meeting', project: 'launch', priority: 3, due: 'Oct 20' },
  { id: 't6', title: 'Launch webinar deck', project: 'marketing', priority: 2, due: 'Oct 24' },
  {
    id: 't7',
    title: 'Review onboarding copy',
    project: 'mobile',
    priority: 3,
    due: 'Oct 9',
    overdue: true,
  },
  {
    id: 't8',
    title: 'Audit color contrast',
    project: 'system',
    priority: 2,
    due: 'Oct 12',
    done: true,
  },
  {
    id: 't9',
    title: 'Kickoff notes for the campaign',
    project: 'marketing',
    priority: 1,
    due: 'Oct 10',
    done: true,
  },
];

export type Deadline = { title: string; project: ProjectId };
export const DEADLINES: { label: string; total: number; items: Deadline[]; more?: Deadline[] }[] = [
  {
    label: 'Today',
    total: 4,
    items: [
      { title: 'Biometric sign-in', project: 'mobile' },
      { title: 'Dark mode palette', project: 'system' },
      { title: 'Image optimization pipeline', project: 'web' },
      { title: 'Weekly design sync notes', project: 'web' },
    ],
  },
  {
    label: 'Tomorrow',
    total: 2,
    items: [
      { title: 'Launch readiness checklist', project: 'launch' },
      { title: 'Design responsive navigation', project: 'web' },
    ],
  },
  {
    label: 'This week',
    total: 13,
    items: [
      { title: 'Offline mode sync', project: 'mobile' },
      { title: 'Onboarding flow prototype', project: 'mobile' },
      { title: 'Push notification service', project: 'mobile' },
      { title: 'Paid social creatives', project: 'marketing' },
    ],
    more: [
      { title: 'Press kit refresh', project: 'launch' },
      { title: 'Footer and sitemap', project: 'web' },
      { title: 'Icon set, second pass', project: 'system' },
      { title: 'Pricing page copy', project: 'web' },
      { title: 'Crash report triage', project: 'mobile' },
      { title: 'Newsletter template', project: 'marketing' },
      { title: 'Release notes draft', project: 'launch' },
      { title: 'Form field states', project: 'system' },
      { title: 'Case study photos', project: 'web' },
    ],
  },
];

export type Activity = { id: string; who: PersonId; verb: string; what: string; when: string };
export const ACTIVITY: Activity[] = [
  { id: 'a1', who: 'mo', verb: 'created', what: 'Triage beta tester feedback', when: '2h' },
  { id: 'a2', who: 'sl', verb: 'moved', what: 'Hero illustration to Review', when: '3h' },
  { id: 'a3', who: 'et', verb: 'commented on', what: 'Paid social creatives', when: '5h' },
  { id: 'a4', who: 'jr', verb: 'completed', what: 'Offline cache spike', when: '1d' },
];

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

### workspace-home/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'],
  invite: [
    circle(9.5, 8.5, 3.3),
    'M3.8 19.5c.6-3.2 3-5 5.7-5 1.2 0 2.3.3 3.3 1',
    'M18 13.5v6M15 16.5h6',
  ],
  folderPlus: [
    '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',
    'M12 10.5v5.5M9.2 13.2h5.6',
  ],
  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',
  ],
  clockAlert: ['M20.2 12a8.2 8.2 0 1 1-8.2-8.2', 'M12 7.5V12l3 2', 'M18 3.8v3.4M18 9.6v.1'],
  arrowUpRight: ['M7.5 16.5l9-9M9 7.5h7.5V15'],
  menu: ['M4.5 7h15M4.5 12h15M4.5 17h15'],
  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>
  );
}
```

### workspace-home/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 (workspace-home.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>
  );
}
```

### workspace-home/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 };
}
```