# Inbox widgets

> Six settings panels of a mail app in one card language, every slider, dial, pin and heatmap cell working.

A split inbox whose threads you drag between slots, a send-later slider over the hours they open their mail, quiet hours on a 24-hour dial, snoozed threads as pins on today’s timeline, fourteen weeks of inbox load as a heatmap, and the threads waiting on you. Each panel is a component of its own on a shared card; together they lay out as a two-column masonry, or one column on a phone.

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

Category: Inputs.

## Anatomy

1. **Card**: The shell every panel shares: a rounded section on its own colour tokens, named by its title, its padding stepping up when it is wide.
2. **Title**: A 16 px glyph and the panel’s name, the section’s heading.
3. **Tag**: A small pill beside the title for context: the setup step, a count, the span shown.
4. **Close**: Shown when `onClose` is given. In the grid it folds the panel into a pill that brings it back.
5. **Split bar**: Today’s mail by slot: each segment grows by its count, eased, and a slot that is off is hatched.
6. **Slot key**: Names a slot with its rolling count; pressing it turns the slot on or off.
7. **Thread row**: Drag it onto the bar or another row to move it to that slot; it lifts and tilts as it goes. ←/→ or 1–4 on the keyboard.
8. **Primary button**: The panel’s one filled action, a pill whose label crossfades to a check when done.

## When to use

- The settings of a mail or messaging app, where each setting is easier to see than to read: a time on a chart, quiet hours on a dial, load on a heatmap.
- A settings page or dashboard made of independent panels, each closable, that reflow from two columns to one.
- A setup flow’s step that previews its effect live, as Split inbox does with today’s mail.
- Any one panel on its own: each is exported by itself (`SplitInbox`, `SendLater`, `Notifications`, `Snoozed`, `InboxLoad`, `Threads`) and carries its own colours.

## When not to use

- Plain preferences that are only on or off. A list of labelled switches says it faster.
- Exact values typed from somewhere else (an API key, an address). Use text fields.
- Data the reader must compare across many rows. Use a table, not a stack of cards.
- A dense admin screen with dozens of settings: six rich panels is about the most a page can hold.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/inbox-widgets.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 { InboxWidgets, Snoozed } from './inbox-widgets';

export function MailSettings() {
  return <InboxWidgets autoplay={false} />;
}

export function SnoozedCard() {
  return <Snoozed now={9 * 60 + 20} onWake={(ids) => console.log('back:', ids)} />;
}
```

## States

- **Split inbox**: Setup step 2 of 3: four slots sorted as mail lands, Receipts off and hatched.
- **Split turned on**: The primary button reads On with a check; pressing again turns it off.
- **Send later**: A day, then a time on a 24-hour slider over when they open mail; the busiest hours are blue.
- **Time zones open**: The zone list opens in place and pushes the footer down; ↑/↓, Enter and Escape work in it.
- **Notifications**: Needs you: quiet hours as an arc on a 24-hour dial, 22:00 to 07:00, with VIPs always on.
- **Notifications off**: None picked: the quiet hours dim and the footer says notifications are off.
- **Snoozed**: Today’s pins on a timeline from 08 to 20, the next one back counted down.
- **Nothing snoozed**: Everything woken: the list says so and Wake all is disabled.
- **Inbox load**: Threads a day over 14 weeks; a cell shows its count on hover or focus.
- **One level shown**: A step of the legend pressed: only the busiest days stay lit.
- **Threads**: Who is waiting on you and for how long, an orange bar from their last message to now.
- **Marked replied**: A pressed row gets your reply as a new dot and drops out of the count.
- **Closed panels**: In the grid, a closed panel leaves a dashed pill in its place that brings it back.

## How it works

- Every panel is its own component on one card shell (card.tsx): a section named by its title, with the glyph, the tag and the close button. The shell carries the colour tokens and the colorScheme prop, so each panel works on its own as well as in the grid.
- The grid is two flex columns, 520 px for the wide panels and 300 px for the narrow ones, as they were drawn; under 860 px the columns dissolve (display: contents) and the panels stack in reading order, each sized by container queries on its own card.
- Figures roll: each digit is a strip of 0–9 behind a one-line window as wide as the digit it shows, moved by a transform over 520 ms. Counts, times, countdowns and waits all use it.
- Drags follow one pointer on the window from the press until it lifts, without pointer capture, so they keep going outside the control. A dragged thread lifts with a dashed place left behind and tilts with its sideways speed; over the bar it joins the segment under the pointer, over another row that row’s slot, and the bar’s segments grow by their counts (flex-grow, eased).
- The send time, the quiet hours and the snoozed pins are sliders: the send time snaps to the minute along a 24-hour track, the quiet hours to the quarter hour by the pointer’s angle round the dial (midnight at the top), and the pins to the quarter hour, never before now. The arc is one SVG path redrawn from the two values.
- The heatmap reads the tooltip’s place from the cell it is about, hangs it under the cell (over it late in the week) and joins them by a dotted line. Its legend’s steps show one level at a time.
- The tray and the rack are drawn in code: a few boxes in 3D, projected at one dimetric slant and painted back to front. The blue envelope slides into its slot when a thread is sorted (Web Animations API); the hung letters sway on their pegs, each on its own phase, and a woken thread’s letter drops off.
- While on screen and untouched, a demo pointer plays the panels in view, in turn: it drags a thread to another slot, slides the send time and looks down the zone list, turns the start of the quiet hours and sends a test, moves a snoozed pin an hour, reads two days of the heatmap and marks a thread replied. It drives them with pointer events of its own, which are untrusted and never hand the demo over; any real input does.
- Every colour is a light-dark() token: by night the near-black cards on black of the original, by day the same cards in white on a pale grey, with the same blue, orange and green.

## API reference

- **autoplay** (`boolean`, default `true`): InboxWidgets: while on screen and untouched, a demo pointer plays the panels in view, in turn.
- **defaultClosed** (`PanelId[]`): InboxWidgets: the panels folded into their pills at first ('split', 'send', 'notif', 'snooze', 'load' or 'threads'). Given, the demo pointer does not play.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme. Every panel takes it too.
- **onClose** (`() => void`): Each panel: called by its round close button; without it the button is not shown. In the grid, a closed panel folds into a pill that brings it back.
- **slots, threads** (`Slot[], SortedThread[]`): SplitInbox: the four slots with their counts and whether each is on, and the live list. onSlotsChange, onMove, onBack and onTurnOn report what the reader does.
- **defaultTurnedOn** (`boolean`, default `false`): SplitInbox: opens with the split already turned on, its button reading On.
- **days, zones, opens, defaultTime** (`SendDay[], Zone[], number[], number`): SendLater: the day chips, their time zones, opens per hour (24 values; the four highest are blue) and the starting time in minutes. defaultZonesOpen shows the zone list open; onSchedule gets the day, minutes and zone.
- **defaultFrom, defaultTo, vips, now** (`number, number, Vip[], number`): Notifications: the quiet hours in minutes past midnight, the VIP senders, and the time now for the countdown. onChange gets every setting; onSendTest is called by Send test.
- **threads, now** (`Snoozed[], number`): Snoozed: the snoozed threads (today’s as minutes past midnight, later ones with a day) and the time now. onChange, onWake and onRules report what the reader does.
- **load, summary** (`number[][], LoadSummary`): InboxLoad: threads per day (seven rows, Monday first, one column per week), and the weekly figure, its change, the busiest hours and three stats.
- **defaultOnly** (`number | null`, default `null`): InboxLoad: one level of the heatmap (0 to 4) shown alone at first, the others dimmed, as if picked in the legend.
- **threads, shown** (`WaitingThread[], number`): Threads: every open thread with its messages (from them or you, days ago); the first `shown` are drawn and the heading counts them all. onReply reports a row marked replied.
- **defaultReplied** (`string[]`, default `[]`): Threads: the ids of the threads already marked replied at first.
- **className** (`string`): Extra class for the root (or the panel’s card).

## Accessibility

- Each panel is a section named by its heading. Controls are what they look like: radio groups for the days and the notification modes, sliders (role="slider" with a spoken time) for the send time, the two quiet-hour handles and each snoozed pin, spinbuttons for the hours and minutes, switches, toggle buttons for the slots, the weekend days and the heatmap levels, and a listbox under a button for the time zone.
- Everything works from the keyboard: arrow keys, Page Up/Down, Home and End on the sliders; ←/→ or 1–4 move a sorted thread to another slot; the heatmap is a grid with one tab stop and arrow keys between days and weeks; the zone list takes ↑/↓, Enter and Escape. Focus is always visible.
- Changes are announced in polite live regions: a thread moved, a slot turned on or off, a VIP sender added or removed, a test sent, a thread woken or moved, the time scheduled. Rolling figures are read as plain text.
- The drawings, the demo pointer, the tooltips that repeat a control’s value and the timeline marks are decorative (aria-hidden).
- Under reduced motion nothing rolls, sways or slides, the lines are drawn at once and the demo never plays.

## Source

### inbox-widgets/inbox-widgets.tsx

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

'use client';

import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import cardStyles from './card.module.css';
import { DemoPointer, runDemo, type Demo, type Point } from './demo-pointer';
import { InboxLoad } from './inbox-load';
import { Notifications } from './notifications';
import { Snoozed } from './snoozed';
import { SendLater } from './send-later';
import { SplitInbox } from './split-inbox';
import { Threads } from './threads';
import { useAutoplay } from './use-autoplay';
import type { ColorScheme } from './card';
import styles from './inbox-widgets.module.css';

export { SplitInbox } from './split-inbox';
export { SendLater } from './send-later';
export { Notifications } from './notifications';
export { Snoozed } from './snoozed';
export { InboxLoad } from './inbox-load';
export { Threads } from './threads';

/*
 * Inbox widgets: six settings panels of a mail app in one card language, laid out as a masonry of
 * two columns (the wide panels left, the narrow ones right) or, on a phone, one.
 *
 * - Each panel is its own component in its own file, on the shared card shell (card.tsx), and is
 *   exported on its own: Split inbox, Send later, Notifications, Snoozed, Inbox load and Threads.
 *   Every control in them works with a pointer and with the keyboard.
 * - The columns: two flex columns side by side when the grid is at least 860 px wide; under that,
 *   the columns dissolve (display: contents) and the panels stack in one, in reading order.
 * - A panel's close button folds it into a small pill that brings it back.
 * - While on screen and untouched, a demo pointer plays the panels in turn, only those in view:
 *   it drags a thread to another slot, slides the send time, turns the quiet hours, moves a
 *   snoozed pin, reads the heatmap and marks a thread replied. It drives them with pointer events
 *   of its own (untrusted, so they never hand the demo over); any real input does.
 * - Light or dark: every colour is a light-dark() token, so the panels follow the page's
 *   color-scheme, or the colorScheme prop.
 */

export type PanelId = 'split' | 'send' | 'notif' | 'snooze' | 'load' | 'threads';

const TITLES: Record<PanelId, string> = {
  split: 'Split inbox',
  send: 'Send later',
  notif: 'Notifications',
  snooze: 'Snoozed',
  load: 'Inbox load',
  threads: 'Threads',
};

/** The beat of the demo: how long a flight takes, and the pause after an action. */
const FLIGHT = 620;
const DRAG = 900;
const REST = 900;

export function InboxWidgets({
  autoplay = true,
  defaultClosed,
  colorScheme,
  className = '',
}: {
  /** While on screen and untouched, a demo pointer plays the panels in turn. */
  autoplay?: boolean;
  /** Panels folded to their pill at first. Given, the demo pointer does not play. */
  defaultClosed?: PanelId[];
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: ColorScheme;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(7000);
  const [closed, setClosed] = useState<PanelId[]>(defaultClosed ?? []);
  const pointer = useRef<HTMLSpanElement>(null);
  const demo = autoplay && playing && !defaultClosed;

  /** The slot whose pill or panel takes the focus once it shows, when the focus was in it. */
  const refocus = useRef<PanelId | null>(null);
  const keepFocus = (id: PanelId) => {
    const slot = root.current?.querySelector(`[data-slot="${id}"]`);
    refocus.current = slot?.contains(document.activeElement) ? id : null;
  };
  const close = (id: PanelId) => {
    keepFocus(id);
    setClosed((was) => [...was, id]);
  };
  const reopen = (id: PanelId) => {
    keepFocus(id);
    setClosed((was) => was.filter((item) => item !== id));
  };
  // The pill that replaced a panel, or the panel back from its pill, takes over the focus.
  useEffect(() => {
    const id = refocus.current;
    if (!id) return;
    refocus.current = null;
    root.current
      ?.querySelector<HTMLElement>(`[data-slot="${id}"] button`)
      ?.focus({ preventScroll: true });
  }, [closed, root]);

  const panel = (id: PanelId, order: number, element: ReactNode) => (
    <div
      className={styles.slot}
      style={{ '--order': order } as CSSProperties}
      key={id}
      data-slot={id}
    >
      {closed.includes(id) ? (
        <button
          type="button"
          className={styles.reopen}
          data-part="reopen"
          onClick={() => reopen(id)}
        >
          <span aria-hidden="true">+</span> {TITLES[id]}
        </button>
      ) : (
        <div className={styles.panel}>{element}</div>
      )}
    </div>
  );

  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    return play(stage, arrow);
  }, [demo, root]);

  return (
    <div
      ref={root}
      className={`${cardStyles.tokens} ${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-inbox-widgets=""
      onPointerDownCapture={(event) => event.nativeEvent.isTrusted && take()}
      onKeyDownCapture={(event) => event.nativeEvent.isTrusted && take()}
      onWheel={take}
      onPointerMove={(event) => {
        if (event.nativeEvent.isTrusted && event.pointerType !== 'touch') take();
      }}
    >
      <div className={styles.grid}>
        <div className={styles.column} data-wide="">
          {panel('split', 1, <SplitInbox onClose={() => close('split')} />)}
          {panel('snooze', 4, <Snoozed onClose={() => close('snooze')} />)}
          {panel('threads', 6, <Threads onClose={() => close('threads')} />)}
        </div>
        <div className={styles.column}>
          {panel('send', 2, <SendLater defaultZonesOpen onClose={() => close('send')} />)}
          {panel('notif', 3, <Notifications onClose={() => close('notif')} />)}
          {panel('load', 5, <InboxLoad onClose={() => close('load')} />)}
        </div>
      </div>
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrow}
          handClass={styles.hand}
        />
      </div>
    </div>
  );
}

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

/** The id the demo's own pointer events carry. */
const POINTER_ID = 41;

/**
 * Plays the panels in turn, as long as it is not stopped. Each step aims at elements found by
 * their data-demo names, in the stage's own px, and skips a panel that is not on screen.
 */
function play(stage: HTMLElement, arrow: HTMLElement) {
  const find = (name: string) => stage.querySelector<HTMLElement>(`[data-demo="${name}"]`);
  const scale = () => stage.getBoundingClientRect().width / stage.offsetWidth || 1;
  /** An element's middle (or a share across it), in the stage's px. */
  const spot = (element: Element, sx = 0.5, sy = 0.5): Point => {
    const frame = stage.getBoundingClientRect();
    const rect = element.getBoundingClientRect();
    const k = scale();
    return {
      x: (rect.left + rect.width * sx - frame.left) / k,
      y: (rect.top + rect.height * sy - frame.top) / k,
    };
  };
  const client = (at: Point) => {
    const frame = stage.getBoundingClientRect();
    const k = scale();
    return { x: frame.left + at.x * k, y: frame.top + at.y * k };
  };
  /** Most of the element is on screen. */
  const inView = (element: Element | null) => {
    if (!element) return false;
    const rect = element.getBoundingClientRect();
    const seen = Math.min(rect.bottom, innerHeight) - Math.max(rect.top, 0);
    return seen > rect.height * 0.75 || seen > innerHeight * 0.6;
  };
  /** Fires a pointer event of the demo's own at `at` (stage px) on `target`. */
  const fire = (target: Element, type: string, at: Point, related?: Element) => {
    const { x, y } = client(at);
    target.dispatchEvent(
      new PointerEvent(type, {
        bubbles: true,
        cancelable: true,
        composed: true,
        clientX: x,
        clientY: y,
        pointerId: POINTER_ID,
        pointerType: 'mouse',
        isPrimary: true,
        button: 0,
        buttons: type === 'pointerup' || type === 'pointerover' || type === 'pointerout' ? 0 : 1,
        relatedTarget: related ?? null,
      }),
    );
  };
  /** Where the pointer is right now, mid-flight included, in the stage's px. */
  const pointerAt = (): Point => {
    const matrix = new DOMMatrixReadOnly(getComputedStyle(arrow).transform);
    return { x: matrix.e + arrow.offsetWidth * 0.15, y: matrix.f + arrow.offsetHeight * 0.1 };
  };

  /** What the demo is pressing or hovering, to let go of if it is stopped midway. */
  let pressing: Element | null = null;
  let hovering: Element | null = null;

  // It starts near the first panel on screen.
  const first = [...stage.querySelectorAll('section')].find(inView);
  const start = first ? spot(first, 0.85, 0.7) : undefined;

  const stop = runDemo(
    stage,
    arrow,
    async (steps: Demo) => {
      const { wait, glide, glyph } = steps;
      const go = async (to: Point, hand = false) => {
        glyph('arrow');
        await glide(to, FLIGHT);
        if (hand) glyph('hand');
      };
      const press = async (element: HTMLElement) => {
        await go(spot(element), true);
        await wait(160);
        element.click();
        await wait(REST);
      };
      const hover = async (element: HTMLElement, hand = false) => {
        await go(spot(element), hand);
        fire(element, 'pointerover', spot(element));
        hovering = element;
        await wait(REST);
      };
      const leave = (element: HTMLElement) => {
        fire(element, 'pointerout', pointerAt(), stage);
        hovering = null;
      };
      /** Presses `element`, drags to `to` (stage px), and lets go, moving the pointer along. */
      const dragTo = async (element: HTMLElement, to: Point, ms = DRAG) => {
        const from = spot(element);
        await go(from, true);
        await wait(140);
        fire(element, 'pointerdown', from);
        pressing = element;
        await wait(120);
        let frame = 0;
        const follow = () => {
          fire(element, 'pointermove', pointerAt());
          frame = requestAnimationFrame(follow);
        };
        frame = requestAnimationFrame(follow);
        try {
          await glide(to, ms);
        } finally {
          cancelAnimationFrame(frame);
        }
        fire(element, 'pointermove', to);
        await wait(90);
        fire(element, 'pointerup', to);
        pressing = null;
        await wait(REST);
      };

      for (let round = 0; ; round += 1) {
        const there = round % 2 === 0;
        let acted = false;

        // Split inbox: drag Dana's thread up to the VIP segment, and later back.
        const dana = find('split-dana');
        if (inView(dana) && dana) {
          const bar = stage.querySelector<HTMLElement>(`[data-slot="${there ? 'vip' : 'needs'}"]`);
          if (bar) {
            await dragTo(dana, spot(bar, 0.5, 0.5), 1100);
            acted = true;
          }
        }

        // Send later: slide the time, then look down the zone list.
        const thumb = stage.querySelector<HTMLElement>('[data-demo="send-chart"] [role="slider"]');
        const chart = find('send-chart');
        if (inView(chart) && thumb && chart) {
          const track = thumb.parentElement!;
          const to = there ? (10 * 60 + 45) / 1440 : (9 * 60 + 12) / 1440;
          await dragTo(thumb, spot(track, to, 0.5));
          const paris = find('send-zone-paris');
          const lisbon = find('send-zone-lisbon');
          if (paris && lisbon && inView(paris)) {
            await hover(paris);
            await hover(lisbon);
          }
          acted = true;
        }

        // Notifications: turn the quiet hours' start, then send a test.
        const handle = find('notif-handle-from');
        const dial = find('notif-dial');
        if (inView(dial) && handle && dial) {
          const minutes = there ? 23 * 60 : 22 * 60;
          const angle = (minutes / 1440) * Math.PI * 2;
          const middle = spot(dial);
          const r = ((dial.getBoundingClientRect().width / 132) * 48) / scale();
          await dragTo(handle, {
            x: middle.x + Math.sin(angle) * r,
            y: middle.y - Math.cos(angle) * r,
          });
          const test = find('notif-test');
          if (test) await press(test);
          acted = true;
        }

        // Snoozed: move a pin an hour later (or back), then look at its row.
        const pin = find('snooze-pin-rui');
        const track = find('snooze-track');
        if (inView(track) && pin && track) {
          const now = Number(pin.getAttribute('aria-valuenow'));
          const to = now + (there ? 60 : -60);
          await dragTo(pin, spot(track, (to - 480) / 720, 0.5));
          const row = find('snooze-row-rui');
          if (row) {
            await hover(row);
            leave(row);
          }
          acted = true;
        }

        // Inbox load: read two days of the heatmap.
        const busy = find('load-1-10');
        if (inView(busy) && busy) {
          await hover(busy);
          const other = find(there ? 'load-2-6' : 'load-3-13');
          if (other) await hover(other);
          leave(other ?? busy);
          acted = true;
        }

        // Threads: mark Dana's thread replied, or not.
        const thread = find('threads-dana');
        if (inView(thread) && thread) {
          await press(thread);
          acted = true;
        }

        if (!acted) await wait(1200);
      }
    },
    start,
  );
  return () => {
    stop();
    if (pressing) fire(pressing, 'pointercancel', pointerAt());
    if (hovering) fire(hovering, 'pointerout', pointerAt(), stage);
  };
}
```

### inbox-widgets/inbox-widgets.module.css

```css
/* Inbox widgets (inbox-widgets.tsx): the six panels on the page's colour, in two columns (520 px
   for the wide panels, 300 px for the narrow, as they were drawn) when there is room for both,
   in one column of up to 520 px under that. The columns are flex columns, a masonry: each panel
   keeps its own height. In one column they dissolve (display: contents) and the panels follow
   their reading order.

   Memory. Nothing here paints over time but what the panels move by transforms; the demo pointer
   moves on a layer of its own. */

.root {
  position: relative;
  container-type: inline-size;
  box-sizing: border-box;
  width: 100%;
  overflow: hidden;
  overflow: clip;
  padding: 40px 16px;
  background: var(--stage);
  -webkit-tap-highlight-color: transparent;
}
.grid {
  display: flex;
  flex-direction: column;
  gap: 16px;
  max-width: 520px;
  margin: 0 auto;
}
.column {
  display: contents;
}
.slot {
  order: var(--order);
  min-width: 0;
}
@container (min-width: 860px) {
  .grid {
    flex-direction: row;
    align-items: flex-start;
    justify-content: center;
    max-width: none;
  }
  .column {
    display: flex;
    flex-direction: column;
    gap: 16px;
    width: 300px;
  }
  .column[data-wide] {
    width: 520px;
  }
}

.panel {
  animation: open 360ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes open {
  from {
    opacity: 0;
    scale: 0.97;
  }
}
/* A closed panel: a pill that brings it back. */
.reopen {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 36px;
  margin: 0;
  padding: 0 16px 0 13px;
  border: 1px dashed var(--control-edge);
  border-radius: 999px;
  background: transparent;
  color: var(--muted);
  font: 500 13px/1 var(--font);
  cursor: pointer;
  animation: open 300ms ease;
  transition:
    color 160ms ease,
    border-color 160ms ease;
}
.reopen > span {
  font-size: 16px;
}
.reopen:hover {
  border-color: var(--muted);
  color: var(--ink);
}
.reopen:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The demo pointer, over everything; one of its glyphs shows at a time. */
.pointerLayer {
  position: absolute;
  inset: 0;
  z-index: 5;
  pointer-events: none;
}
.pointer {
  width: 22px;
  height: 22px;
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}

@container (max-width: 400px) {
  .root {
    padding: 24px 12px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .panel,
  .reopen {
    animation: none;
  }
}
```

### inbox-widgets/card.tsx

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

'use client';

import { useId, type CSSProperties, type ReactNode } from 'react';
import styles from './card.module.css';

/*
 * The shell every panel shares, and the small pieces they have in common.
 *
 * - Card: a <section> named by its title, with the header (glyph, title, tag, close button) and
 *   the panel's body. It carries the colour tokens and the colorScheme prop, so a panel works on
 *   its own as well as in the grid.
 * - Roll: a number whose digits roll to their new values, each a strip of 0–9 slid by a transform.
 * - drag(): follows one pointer from a press until it lifts, on the window, so a drag keeps going
 *   outside the control, and works the same for the demo's own (untrusted) pointer events.
 */

export type ColorScheme = 'light' | 'dark' | 'system';

/** What every panel takes besides its own values. */
export type PanelProps = {
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: ColorScheme;
  /** Called by the round close button; without it, the button is not shown. */
  onClose?: () => void;
  className?: string;
};

export { styles as cardStyles };

export function Card({
  glyph,
  title,
  tag,
  onClose,
  colorScheme,
  className = '',
  children,
}: PanelProps & {
  glyph: ReactNode;
  title: string;
  tag?: ReactNode;
  children: ReactNode;
}) {
  const id = useId();
  return (
    <section
      className={`${styles.tokens} ${styles.card} ${className}`}
      data-color-scheme={colorScheme}
      aria-labelledby={id}
      data-part="card"
    >
      <div className={styles.inner}>
        <header className={styles.head} data-part="head">
          <span className={styles.glyph} aria-hidden="true">
            {glyph}
          </span>
          <h2 id={id} className={styles.title} data-part="title">
            {title}
          </h2>
          {tag != null && (
            <span className={styles.tag} data-part="tag">
              {tag}
            </span>
          )}
          {onClose && (
            <button
              type="button"
              className={styles.close}
              aria-label={`Close ${title}`}
              data-part="close"
              onClick={onClose}
            >
              <CloseIcon />
            </button>
          )}
        </header>
        {children}
      </div>
    </section>
  );
}

/**
 * A figure whose digits roll: each digit is a column of 0–9 behind a one-line window, moved to
 * its value by a transform (the stylesheet eases it). Other characters stand still. Digits are
 * keyed from the right, so 99 → 100 rolls the units and the tens and brings in the hundreds.
 * Screen readers read the plain text.
 */
export function Roll({ value, className }: { value: string; className?: string }) {
  const chars = [...value];
  return (
    <span className={`${styles.roll} ${className ?? ''}`}>
      <span className={styles.hidden}>{value}</span>
      <span aria-hidden="true" style={{ display: 'contents' }}>
        {chars.map((char, index) => {
          const key = chars.length - index;
          if (char < '0' || char > '9') return <span key={`c${key}`}>{char}</span>;
          return (
            <span key={`d${key}`} className={styles.digit}>
              {/* The window is as wide as the digit it shows; the strip is centred in it. */}
              <span className={styles.sizer}>{char}</span>
              <span className={styles.strip} style={{ '--n': char } as CSSProperties}>
                {DIGITS.map((digit) => (
                  <span key={digit}>{digit}</span>
                ))}
              </span>
            </span>
          );
        })}
      </span>
    </span>
  );
}
const DIGITS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];

export function Avatar({
  name,
  tone,
  className,
}: {
  name: string;
  tone: string;
  className?: string;
}) {
  return (
    <span
      className={`${styles.avatar} ${className ?? ''}`}
      style={{ '--tone': tone } as CSSProperties}
      aria-hidden="true"
    >
      {name.charAt(0).toUpperCase()}
    </span>
  );
}

/**
 * Follows the pointer that pressed, on the window, until it lifts or is cancelled. Pointer capture
 * is not used: the demo's synthetic pointer has no capture to take, and window listeners see a
 * drag that leaves the control just as well. Returns the function that stops following.
 */
export function drag(
  start: { pointerId: number },
  move: (event: PointerEvent) => void,
  end: (event: PointerEvent | null) => void,
) {
  const id = start.pointerId;
  const onMove = (event: PointerEvent) => {
    if (event.pointerId !== id) return;
    move(event);
  };
  const onEnd = (event: PointerEvent) => {
    if (event.pointerId !== id) return;
    stop();
    end(event);
  };
  const stop = () => {
    window.removeEventListener('pointermove', onMove);
    window.removeEventListener('pointerup', onEnd);
    window.removeEventListener('pointercancel', onEnd);
  };
  window.addEventListener('pointermove', onMove);
  window.addEventListener('pointerup', onEnd);
  window.addEventListener('pointercancel', onEnd);
  return () => {
    stop();
    end(null);
  };
}

/** Minutes past midnight as HH:MM. */
export const clock = (minutes: number) => {
  const m = ((Math.round(minutes) % 1440) + 1440) % 1440;
  return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
};

/** A span of minutes as "9 H 40 M", "40 M" or "2 H". */
export const span = (minutes: number) => {
  const m = Math.max(0, Math.round(minutes));
  const h = Math.floor(m / 60);
  const rest = m % 60;
  if (!h) return `${rest} M`;
  return rest ? `${h} H ${rest} M` : `${h} H`;
};

/* Glyphs: 16 px drawings in currentColor, strokes of 1.5. */
const STROKE = {
  fill: 'none',
  stroke: 'currentColor',
  strokeWidth: 1.5,
  strokeLinecap: 'round',
  strokeLinejoin: 'round',
} as const;

export const CloseIcon = () => (
  <svg viewBox="0 0 10 10" aria-hidden="true">
    <path d="M1 1l8 8M9 1L1 9" {...STROKE} strokeWidth={1.3} />
  </svg>
);
export const InboxIcon = () => (
  <svg viewBox="0 0 16 16">
    <path
      d="M2 9.2 4 3.6c.2-.5.6-.8 1.1-.8h5.8c.5 0 .9.3 1.1.8L14 9.2v3.2c0 .7-.6 1.2-1.2 1.2H3.2c-.7 0-1.2-.5-1.2-1.2z"
      {...STROKE}
    />
    <path d="M2 9.2h3.3l.8 1.6h3.8l.8-1.6H14" {...STROKE} />
  </svg>
);
export const ClockIcon = () => (
  <svg viewBox="0 0 16 16">
    <circle cx="8" cy="8" r="6.4" {...STROKE} />
    <path d="M8 4.6V8l2.2 1.4" {...STROKE} />
  </svg>
);
export const BellIcon = () => (
  <svg viewBox="0 0 16 16">
    <path
      d="M4 11.2V7.4a4 4 0 0 1 8 0v3.8l1.1 1.3H2.9zM6.5 14.2c.4.4.9.6 1.5.6s1.1-.2 1.5-.6M8 2v1.4"
      {...STROKE}
    />
  </svg>
);
export const MoonIcon = () => (
  <svg viewBox="0 0 16 16">
    <path d="M13.6 10.3A6 6 0 0 1 5.7 2.4a6 6 0 1 0 7.9 7.9z" {...STROKE} strokeWidth={1.7} />
  </svg>
);
export const BarsIcon = () => (
  <svg viewBox="0 0 16 16">
    <path d="M2.5 7v6.5M6 4.5v9M9.5 2.5v11M13 6v7.5" {...STROKE} strokeWidth={1.7} />
  </svg>
);
export const ThreadsIcon = () => (
  <svg viewBox="0 0 16 16">
    <path d="M2 4h12M2 8h12M2 12h12" {...STROKE} />
    <circle cx="10.5" cy="4" r="1.5" fill="currentColor" stroke="var(--card)" strokeWidth="1" />
    <circle cx="5" cy="8" r="1.5" fill="currentColor" stroke="var(--card)" strokeWidth="1" />
    <circle cx="9" cy="12" r="1.5" fill="currentColor" stroke="var(--card)" strokeWidth="1" />
  </svg>
);
export const CheckIcon = () => (
  <svg viewBox="0 0 16 16" aria-hidden="true">
    <path d="M3.2 8.4 6.4 11.4 12.8 4.6" {...STROKE} strokeWidth={1.7} />
  </svg>
);
export const GlobeIcon = () => (
  <svg viewBox="0 0 16 16" aria-hidden="true">
    <circle cx="8" cy="8" r="6.3" {...STROKE} />
    <path d="M1.8 8h12.4M8 1.7c-3.3 3.5-3.3 9.1 0 12.6M8 1.7c3.3 3.5 3.3 9.1 0 12.6" {...STROKE} />
  </svg>
);
export const ChevronIcon = () => (
  <svg viewBox="0 0 16 16" aria-hidden="true">
    <path d="M4.5 10 8 6.5l3.5 3.5" {...STROKE} strokeWidth={1.7} />
  </svg>
);
export const StepperIcon = () => (
  <svg viewBox="0 0 10 16" aria-hidden="true">
    <path d="M2 6 5 3l3 3M2 10l3 3 3-3" {...STROKE} />
  </svg>
);
export const SendIcon = () => (
  <svg viewBox="0 0 16 16" aria-hidden="true">
    <path d="M14 2 2.4 7l4.8 1.8L9 13.6zM14 2 7.2 8.8" {...STROKE} strokeWidth={1.6} />
  </svg>
);
export const BoltIcon = () => (
  <svg viewBox="0 0 16 16" aria-hidden="true">
    <path d="M9 1.8 4 9h3.6L7 14.2 12 7H8.4z" {...STROKE} strokeWidth={1.4} />
  </svg>
);
export const PlusIcon = () => (
  <svg viewBox="0 0 16 16" aria-hidden="true">
    <path d="M8 3.5v9M3.5 8h9" {...STROKE} strokeWidth={1.6} />
  </svg>
);
export const SmallClockIcon = () => (
  <svg viewBox="0 0 16 16" aria-hidden="true">
    <circle cx="8" cy="8" r="6" {...STROKE} strokeWidth={1.6} />
    <path d="M8 5v3.2l2 1.2" {...STROKE} strokeWidth={1.6} />
  </svg>
);

/** Four rising bars, as many lit as `level` (0–4): how strongly a thread fits its slot. */
export const SignalIcon = ({ level }: { level: number }) => (
  <svg viewBox="0 0 19 12" aria-hidden="true">
    {[0, 1, 2, 3].map((bar) => {
      const height = [2.2, 5, 7.6, 10.4][bar];
      return (
        <rect
          key={bar}
          x={bar * 5}
          y={11.6 - height}
          width="3"
          height={height}
          rx="1.2"
          fill="currentColor"
          opacity={bar < level ? 1 : 0.28}
        />
      );
    })}
  </svg>
);
```

### inbox-widgets/card.module.css

```css
/* The shell every panel shares (card.tsx): a rounded card with its header (a blue glyph, the
   title, an outlined tag, a round close button), and the pieces the panels have in common: the
   small uppercase labels, the monospace figures, the pill buttons, the avatars, the switches and
   the rolling digits.

   Colours are the tokens on .tokens, light-dark(light, dark) where the two differ. The panels
   and the grid that lays them out all carry .tokens, so a panel used on its own has them too. At
   night it is the near-black card on a black page; by day the same card in white on a pale grey,
   with the same blue, orange and green.

   Sizes are in px of the card at its own size (300 px narrow, 520 px wide): the card is a size
   container, and its paddings step up when it is wide. */

.tokens {
  /* The page under the cards, the card and its edge. */
  --stage: #ececee;
  --card: #fff;
  --card-edge: rgb(0 0 0 / 0.08);
  --card-shadow: rgb(20 20 30 / 0.06);
  /* Sunk areas (the chart, the segmented control, the time fields) and raised ones (the sorting
     list, the quiet hours, the timeline, the stats), with their edges. */
  --well: #f3f3f5;
  --panel: #f7f7f8;
  --panel-edge: rgb(0 0 0 / 0.06);
  /* Controls: the close button, chips, quiet buttons; a step up for a hovered or chosen one. */
  --control: #efeff1;
  --control-2: #e5e5e8;
  --control-edge: rgb(0 0 0 / 0.1);
  /* Words: titles, values, quiet labels, and the faintest (disabled, empty). */
  --ink: #111113;
  --ink-2: #3d3d42;
  --muted: #86868c;
  --faint: #bfbfc4;
  /* Rules and empty marks: dividers, tracks, ticks. */
  --line: #ececef;
  --track: #e2e2e6;
  --tick: #c9c9ce;
  /* The blue, its light tint, its wash and the words on that wash. */
  --accent: #2f7bf6;
  --accent-light: #7db4ff;
  --accent-wash: #e2edff;
  --accent-ink: #1d63d8;
  --accent-text: #1d63d8;
  --accent-ring: rgb(47 123 246 / 0.28);
  /* Orange (waiting on you, a long wait), green (live, better), and the avatars' colours. */
  --orange: #f19a0e;
  --green: #17b95f;
  --green-wash: #dcf5e7;
  --green-ink: #12884a;
  --amber: #feaf22;
  --pink: #fe6da5;
  --mint: #2fd47f;
  --sky: #84b4fa;
  --grey: #8a8a8a;
  /* Floating things: tooltips, the open list, a dragged row. */
  --float: #fff;
  --float-edge: rgb(0 0 0 / 0.1);
  --float-shadow: rgb(20 20 30 / 0.16);
  --focus: #2f7bf6;
  /* The split's grey slots, the hatch of a slot that is off, a lifted row and its dashed place. */
  --seg-fyi: #c4c4ca;
  --seg-rest: #dadade;
  --hatch: #cfcfd4;
  --hatch-gap: #ececef;
  --swatch-off: #e2e2e6;
  --lifted: #fff;
  --dashed: #c9c9ce;
  /* A thread's messages from them; the edge of a white knob. */
  --them: #a0a0a6;
  --knob-edge: rgb(0 0 0 / 0.12);
  /* The heatmap's five levels. */
  --heat-0: #ececef;
  --heat-1: #cfe0fb;
  --heat-2: #9fc2f7;
  --heat-3: #5f9af2;
  --heat-4: #2f7bf6;
  /* The demo's pointer, as the system draws it. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.3);
  /* The type: the page's sans, and a monospace for the figures. */
  --font: var(--sans, Satoshi), system-ui, -apple-system, 'Segoe UI', sans-serif;
  --mono-font: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
}
@supports (color: light-dark(#fff, #000)) {
  .tokens {
    --stage: light-dark(#ececee, #000);
    --card: light-dark(#fff, #202020);
    --card-edge: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.12));
    --card-shadow: light-dark(rgb(20 20 30 / 0.06), rgb(0 0 0 / 0));
    --well: light-dark(#f3f3f5, #171717);
    --panel: light-dark(#f7f7f8, #282828);
    --panel-edge: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --control: light-dark(#efeff1, #2e2e2e);
    --control-2: light-dark(#e5e5e8, #383838);
    --control-edge: light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.13));
    --ink: light-dark(#111113, #fff);
    --ink-2: light-dark(#3d3d42, #c4c4c4);
    --muted: light-dark(#86868c, #858585);
    --faint: light-dark(#bfbfc4, #4d4d4d);
    --line: light-dark(#ececef, #2e2e2e);
    --track: light-dark(#e2e2e6, #383838);
    --tick: light-dark(#c9c9ce, #4a4a4a);
    --accent: light-dark(#2f7bf6, #2e7bff);
    --accent-light: light-dark(#7db4ff, #6fb1ff);
    --accent-wash: light-dark(#e2edff, #1a3762);
    --accent-ink: light-dark(#1d63d8, #8fbdff);
    --accent-text: light-dark(#1d63d8, #5c9dff);
    --accent-ring: light-dark(rgb(47 123 246 / 0.28), rgb(46 123 255 / 0.35));
    --orange: light-dark(#f19a0e, #ffb224);
    --green: light-dark(#17b95f, #1ce873);
    --green-wash: light-dark(#dcf5e7, #173f28);
    --green-ink: light-dark(#12884a, #68dc9b);
    --float: light-dark(#fff, #2c2c2c);
    --float-edge: light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.1));
    --float-shadow: light-dark(rgb(20 20 30 / 0.16), rgb(0 0 0 / 0.45));
    --seg-fyi: light-dark(#c4c4ca, #575757);
    --seg-rest: light-dark(#dadade, #3e3e3e);
    --hatch: light-dark(#cfcfd4, #535353);
    --hatch-gap: light-dark(#ececef, #2c2c2c);
    --swatch-off: light-dark(#e2e2e6, #2d2d2d);
    --lifted: light-dark(#fff, #3a3a3a);
    --dashed: light-dark(#c9c9ce, #4a4a4a);
    --them: light-dark(#a0a0a6, #a3a3a3);
    --knob-edge: light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0));
    --heat-0: light-dark(#ececef, #2e2e2e);
    --heat-1: light-dark(#cfe0fb, #243758);
    --heat-2: light-dark(#9fc2f7, #274a84);
    --heat-3: light-dark(#5f9af2, #2a5fbb);
    --heat-4: light-dark(#2f7bf6, #2e7bff);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.tokens[data-color-scheme='light'] {
  color-scheme: light;
}
.tokens[data-color-scheme='dark'] {
  color-scheme: dark;
}
.tokens[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The card: a size container, so its paddings follow its own width. */
.card {
  position: relative;
  box-sizing: border-box;
  container-type: inline-size;
  min-width: 0;
  border: 1px solid var(--card-edge);
  border-radius: 32px;
  background: var(--card);
  box-shadow:
    0 1px 2px var(--card-shadow),
    0 12px 32px var(--card-shadow);
  color: var(--ink);
  font: 500 14px/1.25 var(--font);
  letter-spacing: -0.005em;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  text-align: left;
}
.inner {
  --pad: 16px;
  --head-x: 20px;
  --head-r: 16px;
  --head-t: 16px;
  padding: 0 var(--pad) var(--pad);
}
@container (min-width: 440px) {
  .inner {
    --pad: 28px;
    --head-x: 25px;
    --head-r: 19px;
    --head-t: 15px;
    padding-bottom: 24px;
  }
}

/* The header: glyph, title and tag on the left, the close button on the right. */
.head {
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 32px;
  margin: 0 calc(var(--head-r) - var(--pad)) 0 calc(var(--head-x) - var(--pad));
  padding-top: var(--head-t);
}
.glyph {
  flex: none;
  width: 16px;
  height: 16px;
  margin-right: 1px;
  color: var(--accent-light);
}
.glyph svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.title {
  margin: 0;
  overflow: hidden;
  font: 500 15px/1.2 var(--font);
  letter-spacing: -0.02em;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.tag {
  flex: none;
  display: inline-flex;
  align-items: center;
  height: 25px;
  margin-left: 1px;
  padding: 0 8px;
  border: 1px solid var(--control-edge);
  border-radius: 999px;
  color: var(--ink-2);
  font: 500 12px/1 var(--font);
  letter-spacing: 0.01em;
  text-transform: uppercase;
  white-space: nowrap;
}
.close {
  flex: none;
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  margin: 0 0 0 auto;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--control);
  color: var(--ink-2);
  cursor: pointer;
  transition:
    background-color 160ms ease,
    transform 160ms ease;
}
.close svg {
  width: 9px;
  height: 9px;
}
.close:hover {
  background: var(--control-2);
}
.close:active {
  transform: scale(0.94);
}

/* Shared pieces. */
.label {
  color: var(--muted);
  font: 500 12px/1.2 var(--font);
  letter-spacing: 0.012em;
  text-transform: uppercase;
}
.mono {
  font-family: var(--mono-font);
  font-weight: 500;
  font-size: 12px;
  letter-spacing: 0.02em;
}
.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  box-sizing: border-box;
  height: 31px;
  margin: 0;
  padding: 0 13px;
  border: 0;
  border-radius: 999px;
  background: var(--control-2);
  color: var(--ink);
  font: 500 12px/1 var(--font);
  letter-spacing: 0.012em;
  text-transform: uppercase;
  white-space: nowrap;
  cursor: pointer;
  transition:
    background-color 160ms ease,
    color 160ms ease,
    transform 160ms cubic-bezier(0.3, 0.7, 0.4, 1);
}
.button:hover {
  background: color-mix(in srgb, var(--control-2), var(--ink) 6%);
}
.button:active {
  transform: scale(0.96);
}
.primary {
  background: var(--accent);
  color: #fff;
}
.primary:hover {
  background: color-mix(in srgb, var(--accent), #fff 8%);
}
.button svg {
  width: 14px;
  height: 14px;
}

/* A round avatar with an initial. Its colour comes from --tone. */
.avatar {
  flex: none;
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: var(--tone, var(--grey));
  color: #141414;
  font: 500 12px/1 var(--font);
}

/* A switch: a pill track with its knob. */
.switch {
  position: relative;
  flex: none;
  width: 36px;
  height: 20px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: var(--track);
  cursor: pointer;
  transition: background-color 200ms ease;
}
.switch::after {
  content: '';
  position: absolute;
  top: 2px;
  left: 2px;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--muted), var(--ink) 12%);
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.2);
  transition:
    translate 260ms cubic-bezier(0.3, 1.35, 0.5, 1),
    background-color 200ms ease;
}
.switch[aria-checked='true'] {
  background: var(--accent);
}
.switch[aria-checked='true']::after {
  translate: 16px 0;
  background: #fff;
}
.switch:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

/* Rolling digits: each digit is a strip of 0–9 in a one-line window, slid to its value. */
.roll {
  display: inline-flex;
  white-space: pre;
}
.digit {
  position: relative;
  display: inline-block;
  height: 1lh;
  overflow: hidden;
  vertical-align: top;
}
.sizer {
  visibility: hidden;
}
.strip {
  position: absolute;
  top: 0;
  left: 50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-left: -0.5em;
  width: 1em;
  transform: translateY(calc(var(--n) * -10%));
  transition: transform 520ms cubic-bezier(0.22, 1, 0.36, 1);
}
.strip > span {
  height: 1lh;
}

/* Keyboard focus, on every control. */
.card :where(button, [tabindex], input, [role='slider']):focus {
  outline: none;
}
.card :where(button, [tabindex], input, [role='slider']):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.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) {
  .close,
  .button,
  .switch,
  .switch::after,
  .strip {
    transition: none;
  }
}
```

### inbox-widgets/split-inbox.tsx

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

'use client';

import {
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import { TrayArt } from './art';
import {
  Avatar,
  BoltIcon,
  Card,
  CheckIcon,
  InboxIcon,
  Roll,
  SignalIcon,
  cardStyles,
  drag,
  type PanelProps,
} from './card';
import styles from './split-inbox.module.css';

/*
 * Split inbox: the second step of a setup. Mail is sorted into four slots as it lands; the bar
 * shows today's split, the legend turns a slot on or off, and the live list lets the reader
 * correct a thread by dragging it to another slot.
 *
 * - The bar's segments grow by their counts (flex-grow, eased), so a moved thread visibly takes
 *   room from one slot and gives it to another; a slot that is off is hatched, and its threads
 *   count as off.
 * - A row lifts as it is dragged: it follows the pointer, tilts with its speed, and leaves a
 *   dashed place behind. Over the bar it would join the segment under the pointer; over another
 *   row, that row's slot. Its chip shows where it would land. Let go, and it settles back into
 *   its place with its new slot, the counts roll, and the blue envelope slides into the tray.
 * - On the keyboard a row takes ←/→ (the slot before or after) or 1–4; the move is announced.
 */

export type SlotId = 'vip' | 'needs' | 'fyi' | 'receipts';

export type Slot = { id: SlotId; label: string; count: number; on: boolean };

export type SortedThread = {
  id: string;
  name: string;
  subject: string;
  /** The avatar's colour. */
  tone: string;
  slot: SlotId;
  /** How surely it was sorted, 1–4 bars. */
  fit: number;
};

export const SPLIT_SLOTS: Slot[] = [
  { id: 'vip', label: 'VIP', count: 14, on: true },
  { id: 'needs', label: 'Needs you', count: 6, on: true },
  { id: 'fyi', label: 'FYI', count: 31, on: true },
  { id: 'receipts', label: 'Receipts', count: 12, on: false },
];

export const SPLIT_THREADS: SortedThread[] = [
  {
    id: 'rui',
    name: 'Rui Ortega',
    subject: 'Contract redlines v3',
    tone: 'var(--amber)',
    slot: 'vip',
    fit: 4,
  },
  {
    id: 'dana',
    name: 'Dana Lee',
    subject: 'Can you approve the venue?',
    tone: 'var(--pink)',
    slot: 'needs',
    fit: 3,
  },
  {
    id: 'news',
    name: 'Newsroom',
    subject: 'Weekly digest',
    tone: 'var(--grey)',
    slot: 'fyi',
    fit: 4,
  },
];

type Drag = { id: string; dx: number; dy: number; tilt: number; target: SlotId };

const format = (n: number) => n.toLocaleString('en-US');

export function SplitInbox({
  slots: initialSlots = SPLIT_SLOTS,
  threads: initialThreads = SPLIT_THREADS,
  step = 'Setup 2 of 3',
  heading = 'Four slots, sorted as mail lands',
  backlog = { threads: 2400, seconds: 40 },
  defaultTurnedOn = false,
  onSlotsChange,
  onMove,
  onBack,
  onTurnOn,
  onClose,
  colorScheme,
  className,
}: PanelProps & {
  /** The four slots, in order, with today's counts and whether each is on. */
  slots?: Slot[];
  /** The live list: the last threads sorted, each in its slot. */
  threads?: SortedThread[];
  /** The tag by the title. */
  step?: string;
  heading?: string;
  /** What sorting the whole inbox would take, shown in the footer. */
  backlog?: { threads: number; seconds: number };
  /** Open with the split already turned on (the button reads On). */
  defaultTurnedOn?: boolean;
  /** Called whenever a count changes or a slot is turned on or off. */
  onSlotsChange?: (slots: Slot[]) => void;
  /** Called when the reader moves a thread to another slot. */
  onMove?: (threadId: string, slot: SlotId) => void;
  onBack?: () => void;
  onTurnOn?: () => void;
}) {
  const [slots, setSlots] = useState(initialSlots);
  const [threads, setThreads] = useState(initialThreads);
  const [dragging, setDragging] = useState<Drag | null>(null);
  /** The row settling back into its place after a drag (it eases there). */
  const [settling, setSettling] = useState<string | null>(null);
  const [drops, setDrops] = useState(0);
  const [turnedOn, setTurnedOn] = useState(defaultTurnedOn);
  const [message, setMessage] = useState('');
  const segments = useRef(new Map<SlotId, HTMLElement>());
  const rows = useRef(new Map<string, HTMLElement>());
  const list = useRef<HTMLDivElement>(null);
  const stopDrag = useRef<(() => void) | null>(null);

  useEffect(() => () => stopDrag.current?.(), []);

  const total = slots.reduce((sum, slot) => sum + slot.count, 0);
  const off = slots.reduce((sum, slot) => sum + (slot.on ? 0 : slot.count), 0);
  const slotOf = (id: SlotId) => slots.find((slot) => slot.id === id)!;
  const numberOf = (id: SlotId) => slots.findIndex((slot) => slot.id === id) + 1;

  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  const updateSlots = (next: Slot[]) => {
    setSlots(next);
    onSlotsChange?.(next);
  };

  const move = (id: string, to: SlotId) => {
    const thread = threads.find((item) => item.id === id);
    if (!thread || thread.slot === to) return;
    const from = thread.slot;
    setThreads((now) => now.map((item) => (item.id === id ? { ...item, slot: to } : item)));
    updateSlots(
      slots.map((slot) =>
        slot.id === from
          ? { ...slot, count: Math.max(0, slot.count - 1) }
          : slot.id === to
            ? { ...slot, count: slot.count + 1 }
            : slot,
      ),
    );
    setDrops((n) => n + 1);
    onMove?.(id, to);
    announce(`${thread.name} moved to ${slotOf(to).label}.`);
  };

  const toggle = (id: SlotId) => {
    const slot = slotOf(id);
    updateSlots(slots.map((item) => (item.id === id ? { ...item, on: !item.on } : item)));
    announce(`${slot.label} ${slot.on ? 'off' : 'on'}.`);
  };

  /** The slot a dragged row would join, for the pointer at (x, y). */
  const targetAt = (x: number, y: number, thread: SortedThread): SlotId => {
    const box = list.current?.getBoundingClientRect();
    if (box && y < box.top) {
      // Over the bar or above it: the nearest segment of a slot that is on.
      let best: SlotId = thread.slot;
      let distance = Infinity;
      for (const slot of slots) {
        const element = segments.current.get(slot.id);
        if (!element || !slot.on) continue;
        const rect = element.getBoundingClientRect();
        const away = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;
        if (away < distance) {
          distance = away;
          best = slot.id;
        }
      }
      return best;
    }
    for (const other of threads) {
      if (other.id === thread.id) continue;
      const rect = rows.current.get(other.id)?.getBoundingClientRect();
      if (rect && y >= rect.top && y <= rect.bottom) return other.slot;
    }
    return thread.slot;
  };

  const onRowDown = (event: PointerEvent<HTMLButtonElement>, thread: SortedThread) => {
    if (event.button !== 0 || dragging) return;
    const startX = event.clientX;
    const startY = event.clientY;
    let lastX = startX;
    let tilt = 0;
    let live = false;
    let target: SlotId = thread.slot;
    stopDrag.current?.();
    stopDrag.current = drag(
      event,
      (moveEvent) => {
        const dx = moveEvent.clientX - startX;
        const dy = moveEvent.clientY - startY;
        if (!live && Math.hypot(dx, dy) < 4) return;
        live = true;
        // It tilts with its sideways speed, eased, on top of a slight lean as it lifts.
        const speed = moveEvent.clientX - lastX;
        lastX = moveEvent.clientX;
        tilt = tilt * 0.8 + Math.max(-5, Math.min(5, speed * 0.6)) * 0.2;
        target = targetAt(moveEvent.clientX, moveEvent.clientY, thread);
        setDragging({ id: thread.id, dx, dy, tilt: 1.3 + tilt, target });
      },
      () => {
        stopDrag.current = null;
        if (!live) return;
        setDragging(null);
        setSettling(thread.id);
        move(thread.id, target);
      },
    );
  };

  const onRowKey = (event: KeyboardEvent, thread: SortedThread) => {
    const on = slots.filter((slot) => slot.on);
    const at = on.findIndex((slot) => slot.id === thread.slot);
    let to: Slot | undefined;
    if (event.key === 'ArrowRight' || event.key === 'ArrowDown') to = on[(at + 1) % on.length];
    else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp')
      to = on[(at - 1 + on.length) % on.length];
    else if (/^[1-4]$/.test(event.key)) to = slots[Number(event.key) - 1];
    if (!to) return;
    event.preventDefault();
    if (!to.on) return announce(`${to.label} is off.`);
    move(thread.id, to.id);
  };

  return (
    <Card
      glyph={<InboxIcon />}
      title="Split inbox"
      tag={step}
      onClose={onClose}
      colorScheme={colorScheme}
      className={className}
    >
      <TrayArt drop={drops} className={styles.art} />
      <h3 className={styles.heading}>{heading}</h3>

      <div className={styles.splitHead}>
        <span className={cardStyles.label}>Today’s split</span>
        <span className={`${cardStyles.mono} ${styles.totals}`}>
          <Roll value={String(total)} /> THREADS · <Roll value={String(off)} /> OFF
        </span>
      </div>
      <div className={styles.bar} aria-hidden="true" data-part="bar">
        {slots.map((slot) => (
          <span
            key={slot.id}
            ref={(element) => {
              if (element) segments.current.set(slot.id, element);
              else segments.current.delete(slot.id);
            }}
            className={styles.segment}
            data-slot={slot.id}
            data-off={!slot.on || undefined}
            data-target={(dragging && dragging.target === slot.id) || undefined}
            style={{ flexGrow: Math.max(slot.count, 0.001) }}
          />
        ))}
      </div>
      <ul className={styles.legend}>
        {slots.map((slot) => (
          <li key={slot.id}>
            <button
              type="button"
              className={styles.key}
              data-part="slot"
              data-slot={slot.id}
              data-off={!slot.on || undefined}
              aria-pressed={slot.on}
              aria-label={`${slot.label}, ${slot.count} threads`}
              onClick={() => toggle(slot.id)}
            >
              <span className={styles.swatch} aria-hidden="true" />
              <span aria-hidden="true">{slot.label}</span>
              <span className={`${cardStyles.mono} ${styles.count}`} aria-hidden="true">
                {slot.on ? <Roll value={String(slot.count)} /> : 'OFF'}
              </span>
            </button>
          </li>
        ))}
      </ul>

      <div ref={list} className={styles.list}>
        <div className={styles.listHead}>
          <span className={`${cardStyles.label} ${styles.live}`}>
            <span className={styles.dot} aria-hidden="true" />
            Sorting live
          </span>
          <span className={cardStyles.label}>Drag to correct</span>
        </div>
        <ul className={styles.rows}>
          {threads.map((thread) => {
            const lifted = dragging?.id === thread.id ? dragging : null;
            const shown = lifted ? lifted.target : thread.slot;
            const slot = slotOf(shown);
            return (
              <li key={thread.id} className={styles.place} data-lifted={lifted ? true : undefined}>
                <button
                  type="button"
                  ref={(element) => {
                    if (element) rows.current.set(thread.id, element);
                    else rows.current.delete(thread.id);
                  }}
                  className={styles.row}
                  data-part="row"
                  data-demo={`split-${thread.id}`}
                  data-lifted={lifted ? true : undefined}
                  data-settling={settling === thread.id || undefined}
                  aria-label={`${thread.name}, ${thread.subject}, in ${slotOf(thread.slot).label}. Arrow keys or 1 to 4 move it.`}
                  style={
                    lifted
                      ? {
                          transform: `translate(${lifted.dx}px, ${lifted.dy}px) rotate(${lifted.tilt.toFixed(2)}deg)`,
                        }
                      : undefined
                  }
                  onPointerDown={(event) => onRowDown(event, thread)}
                  onTransitionEnd={() => setSettling((now) => (now === thread.id ? null : now))}
                  onKeyDown={(event) => onRowKey(event, thread)}
                >
                  <Avatar name={thread.name} tone={thread.tone} className={styles.avatar} />
                  <span className={styles.words}>
                    <span className={styles.name}>{thread.name}</span>
                    <span className={styles.subject}>{thread.subject}</span>
                  </span>
                  <span className={styles.signal}>
                    <SignalIcon level={thread.fit} />
                  </span>
                  <span
                    className={styles.chip}
                    data-active={lifted ? true : undefined}
                    style={{ '--slot-n': numberOf(shown) } as CSSProperties}
                  >
                    <span className={styles.chipNumber}>{numberOf(shown)}</span>
                    <span className={styles.chipLabel}>{slot.label}</span>
                  </span>
                </button>
              </li>
            );
          })}
        </ul>
      </div>

      <footer className={styles.foot}>
        <span className={`${cardStyles.mono} ${styles.estimate}`}>
          <BoltIcon />
          {format(backlog.threads)} THREADS · {backlog.seconds} S
        </span>
        <span className={styles.actions}>
          <button type="button" className={cardStyles.button} onClick={onBack}>
            Back
          </button>
          <button
            type="button"
            className={`${cardStyles.button} ${cardStyles.primary} ${styles.turnOn}`}
            aria-pressed={turnedOn}
            data-part="primary"
            data-demo="split-turn-on"
            onClick={() => {
              setTurnedOn((now) => !now);
              if (!turnedOn) {
                setDrops((n) => n + 1);
                onTurnOn?.();
              }
              announce(turnedOn ? 'Split inbox off.' : 'Split inbox on.');
            }}
          >
            <span
              className={styles.turnLabel}
              data-shown={!turnedOn || undefined}
              aria-hidden={turnedOn}
            >
              Turn on
            </span>
            <span
              className={styles.turnLabel}
              data-shown={turnedOn || undefined}
              aria-hidden={!turnedOn}
            >
              <CheckIcon /> On
            </span>
          </button>
        </span>
      </footer>
      <p className={cardStyles.hidden} role="status">
        {message}
      </p>
    </Card>
  );
}
```

### inbox-widgets/split-inbox.module.css

```css
/* Split inbox (split-inbox.tsx). Measured on the card at 520 px: the tray drawing, the heading,
   today's split (a label row, the bar, the legend), the live list and the footer. */

.art {
  width: 216px;
  max-width: 64%;
  height: auto;
  margin: 26px auto 0;
}
.heading {
  margin: 22px 0 0;
  font: 500 18.5px/24px var(--font);
  letter-spacing: -0.02em;
}

.splitHead {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin-top: 12px;
}
.totals {
  color: var(--muted);
  white-space: nowrap;
}

/* The bar: one segment per slot, as wide as its count. */
.bar {
  display: flex;
  gap: 3px;
  height: 14px;
  margin-top: 10px;
}
.segment {
  flex: 1 1 0;
  min-width: 0;
  border-radius: 5px;
  background: var(--seg);
  transition:
    flex-grow 560ms cubic-bezier(0.22, 1, 0.36, 1),
    background-color 240ms ease,
    box-shadow 200ms ease;
}
.segment[data-slot='vip'],
.key[data-slot='vip'] {
  --seg: var(--accent-light);
}
.segment[data-slot='needs'],
.key[data-slot='needs'] {
  --seg: var(--accent);
}
.segment[data-slot='fyi'],
.key[data-slot='fyi'] {
  --seg: var(--seg-fyi);
}
.segment[data-slot='receipts'],
.key[data-slot='receipts'] {
  --seg: var(--seg-rest);
}
.segment[data-off] {
  background: repeating-linear-gradient(-55deg, var(--hatch) 0 2px, var(--hatch-gap) 2px 5px);
}
.segment[data-target] {
  box-shadow:
    0 0 0 2px var(--card),
    0 0 0 3.5px var(--accent);
}

/* The legend: each slot a toggle. */
.legend {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 13px;
  margin: 9px 0 0;
  padding: 0;
  list-style: none;
}
.key {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: var(--ink-2);
  font: 500 12px/1.2 var(--font);
  letter-spacing: 0.012em;
  text-transform: uppercase;
  cursor: pointer;
  border-radius: 4px;
  transition: color 200ms ease;
}
.swatch {
  width: 8px;
  height: 8px;
  margin-right: 2px;
  border-radius: 2px;
  background: var(--seg);
  transition: background-color 200ms ease;
}
.count {
  color: var(--ink);
}
.key[data-off] {
  color: var(--muted);
}
.key[data-off] .swatch {
  background: var(--swatch-off);
}
.key[data-off] .count {
  color: var(--muted);
}
.key:hover {
  color: var(--ink);
}

/* The live list. */
.list {
  margin-top: 13px;
  padding: 11px 9px 6px;
  border: 1px solid var(--panel-edge);
  border-radius: 16px;
  background: var(--panel);
}
.listHead {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  padding: 0 9px 0 7px;
}
.listHead > :last-child {
  white-space: nowrap;
}
.live {
  white-space: nowrap;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  color: var(--ink-2);
}
.dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--green);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--green), transparent 78%);
  animation: live 2.4s ease-in-out infinite;
}
@keyframes live {
  50% {
    box-shadow: 0 0 0 3.5px color-mix(in srgb, var(--green), transparent 88%);
  }
}
.rows {
  margin: 4px 0 0;
  padding: 0;
  list-style: none;
}

/* A row's place: when its row is lifted, a dashed outline stays behind. */
.place {
  position: relative;
  height: 52px;
}
.place::before {
  content: '';
  position: absolute;
  inset: 1px 0;
  border: 1px dashed transparent;
  border-radius: 14px;
  transition: border-color 160ms ease;
}
.place[data-lifted]::before {
  border-color: var(--dashed);
}
.row {
  position: relative;
  display: flex;
  align-items: center;
  gap: 10px;
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0 10px 0 7px;
  border: 1px solid transparent;
  border-radius: 14px;
  background: transparent;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: grab;
  touch-action: none;
  user-select: none;
  transition:
    transform 420ms cubic-bezier(0.3, 1.3, 0.5, 1),
    background-color 200ms ease,
    border-color 200ms ease,
    box-shadow 300ms ease;
}
.row:hover {
  background: color-mix(in srgb, var(--control), transparent 50%);
}
.row[data-lifted] {
  z-index: 2;
  border-color: var(--accent);
  background: var(--lifted);
  box-shadow:
    0 12px 28px var(--float-shadow),
    0 2px 6px var(--float-shadow);
  cursor: grabbing;
  transition:
    background-color 160ms ease,
    border-color 160ms ease,
    box-shadow 200ms ease;
}
.row[data-settling] {
  z-index: 2;
}
.avatar {
  margin-right: 0;
}
.words {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  margin-right: auto;
}
.name {
  overflow: hidden;
  font: 500 14.5px/18px var(--font);
  letter-spacing: -0.01em;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.subject {
  overflow: hidden;
  color: var(--muted);
  font: 500 11.8px/15px var(--font);
  letter-spacing: 0.012em;
  text-transform: uppercase;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.signal {
  flex: none;
  width: 19px;
  height: 12px;
  margin-right: 3px;
  color: var(--ink-2);
}
.signal svg {
  display: block;
  width: 100%;
  height: 100%;
}
/* The slot chip: its number in a box, its name. */
.chip {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  height: 24px;
  padding: 0 8px 0 3px;
  border-radius: 7px;
  background: var(--control);
  color: var(--ink-2);
  font: 500 12px/1 var(--font);
  letter-spacing: 0.01em;
  text-transform: uppercase;
  white-space: nowrap;
  transition:
    background-color 200ms ease,
    color 200ms ease;
}
.chipNumber {
  display: grid;
  place-items: center;
  width: 19px;
  height: 19px;
  border-radius: 5px;
  background: var(--control-2);
  font: 500 12px/1 var(--mono-font);
  transition: inherit;
}
.chip[data-active] {
  background: var(--accent-wash);
  color: var(--accent-ink);
}
.chip[data-active] .chipNumber {
  background: var(--accent);
  color: #fff;
}

.foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-top: 17px;
}
.estimate {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  color: var(--muted);
  font-size: 11.5px;
  letter-spacing: 0.02em;
}
.estimate svg {
  width: 14px;
  height: 14px;
}
.actions {
  display: flex;
  gap: 8px;
  margin-left: auto;
}
.turnOn {
  position: relative;
  display: inline-grid;
  min-width: 74px;
}
.turnLabel {
  grid-area: 1 / 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  opacity: 0;
  transform: translateY(6px);
  transition:
    opacity 180ms ease,
    transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
.turnLabel + .turnLabel {
  position: absolute;
  inset: 0;
}
.turnLabel[data-shown] {
  opacity: 1;
  transform: none;
}

/* On a phone the row keeps its name: the bars go, and the chip keeps its number. */
@container (max-width: 400px) {
  .signal {
    display: none;
  }
  .chip {
    padding-right: 3px;
  }
  .chipLabel {
    display: none;
  }
  .listHead > :last-child {
    overflow: hidden;
    text-overflow: ellipsis;
  }
}

@media (prefers-reduced-motion: reduce) {
  .segment,
  .row,
  .turnLabel,
  .place::before {
    transition: none;
  }
  .dot {
    animation: none;
  }
}
```

### inbox-widgets/send-later.tsx

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

'use client';

import {
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import {
  Card,
  CheckIcon,
  ChevronIcon,
  ClockIcon,
  GlobeIcon,
  Roll,
  SendIcon,
  cardStyles,
  clock,
  drag,
  type PanelProps,
} from './card';
import styles from './send-later.module.css';

/*
 * Send later: pick a day, then a time on a 24-hour slider drawn over a histogram of when the
 * recipient opens their mail, in their time zone.
 *
 * - The histogram's tallest hours are blue: the best time to land. The label above names the peak
 *   hour; the slider's thumb carries a dotted line up through the bars and a tooltip with the
 *   time, which rolls digit by digit as it moves.
 * - The slider is a real slider: drag anywhere on the chart (snapping to the minute), or use the
 *   arrow keys (5 minutes; with Shift, 30), Page Up / Page Down (an hour), Home and End.
 * - Their time zone is a listbox under a button: it opens in place, pushing the footer down, and
 *   takes ↑/↓, Enter and Escape. Picking a zone shows its offset; the footer reads the day and
 *   time in their zone, and Schedule confirms it.
 */

export type SendDay = { id: string; label: string; short: string };
export type Zone = { id: string; name: string; offset: string; you?: boolean };

export const SEND_DAYS: SendDay[] = [
  { id: 'tonight', label: 'Tonight', short: 'MON' },
  { id: 'tomorrow', label: 'Tomorrow', short: 'TUE' },
  { id: 'monday', label: 'Monday', short: 'MON' },
];

export const ZONES: Zone[] = [
  { id: 'lisbon', name: 'Lisbon', offset: 'UTC+1' },
  { id: 'paris', name: 'Paris', offset: 'UTC+2', you: true },
  { id: 'new-york', name: 'New York', offset: 'UTC−4' },
];

/** How often they open mail at each hour of the day, in their time (any scale). */
export const OPENS = [
  2.5, 2.5, 2.5, 2.5, 2.5, 2.5, 5.5, 14, 27, 42, 36, 17, 17, 17, 27, 20, 15.5, 12, 9, 7, 5.5, 3.5,
  2.5, 2.5,
];

const DAY = 24 * 60;

export function SendLater({
  days = SEND_DAYS,
  zones = ZONES,
  opens = OPENS,
  defaultDay = 'tomorrow',
  defaultTime = 9 * 60 + 12,
  defaultZone = 'lisbon',
  defaultZonesOpen = false,
  onSchedule,
  onClose,
  colorScheme,
  className,
}: PanelProps & {
  days?: SendDay[];
  zones?: Zone[];
  /** Opens per hour, 24 values: the histogram. Its four highest hours are drawn blue. */
  opens?: number[];
  defaultDay?: string;
  /** Minutes past midnight, in their time. */
  defaultTime?: number;
  defaultZone?: string;
  /** Show the zone list open at first. */
  defaultZonesOpen?: boolean;
  onSchedule?: (when: { day: string; minutes: number; zone: string }) => void;
}) {
  const [day, setDay] = useState(defaultDay);
  const [time, setTime] = useState(defaultTime);
  const [zone, setZone] = useState(defaultZone);
  const [open, setOpen] = useState(defaultZonesOpen);
  const [active, setActive] = useState(() =>
    Math.max(
      0,
      zones.findIndex((item) => item.id === defaultZone),
    ),
  );
  const [scheduled, setScheduled] = useState(false);
  const [sliding, setSliding] = useState(false);
  const track = useRef<HTMLDivElement>(null);
  const listbox = useRef<HTMLUListElement>(null);
  const button = useRef<HTMLButtonElement>(null);
  const stopDrag = useRef<(() => void) | null>(null);
  const timer = useRef(0);
  const id = useId();

  useEffect(
    () => () => {
      stopDrag.current?.();
      window.clearTimeout(timer.current);
    },
    [],
  );

  const chosenDay = days.find((item) => item.id === day) ?? days[0];
  const chosenZone = zones.find((item) => item.id === zone) ?? zones[0];
  const peak = opens.indexOf(Math.max(...opens));
  const best = [...opens].sort((a, b) => b - a)[3];
  const share = time / DAY;

  const setFromPointer = (x: number) => {
    const rect = track.current?.getBoundingClientRect();
    if (!rect) return;
    const at = Math.min(1, Math.max(0, (x - rect.left) / rect.width));
    setTime(Math.min(DAY - 1, Math.round(at * DAY)));
  };

  const onChartDown = (event: PointerEvent<HTMLDivElement>) => {
    if (event.button !== 0) return;
    event.preventDefault();
    setFromPointer(event.clientX);
    setSliding(true);
    // The reader's press focuses the slider; the demo's leaves focus where it is.
    if (event.nativeEvent.isTrusted)
      (event.currentTarget.querySelector('[role="slider"]') as HTMLElement | null)?.focus({
        preventScroll: true,
      });
    stopDrag.current?.();
    stopDrag.current = drag(
      event,
      (move) => setFromPointer(move.clientX),
      () => {
        stopDrag.current = null;
        setSliding(false);
      },
    );
  };

  const onSliderKey = (event: KeyboardEvent) => {
    const steps: Record<string, number> = {
      ArrowRight: 5,
      ArrowUp: 5,
      ArrowLeft: -5,
      ArrowDown: -5,
      PageUp: 60,
      PageDown: -60,
    };
    let next: number | null = null;
    if (event.key in steps) next = time + steps[event.key] * (event.shiftKey ? 6 : 1);
    if (event.key === 'Home') next = 0;
    if (event.key === 'End') next = DAY - 1;
    if (next === null) return;
    event.preventDefault();
    setTime(Math.min(DAY - 1, Math.max(0, next)));
  };

  const choose = (index: number) => {
    setZone(zones[index].id);
    setActive(index);
    setOpen(false);
    button.current?.focus({ preventScroll: true });
  };

  const toggleList = () => {
    const next = !open;
    setOpen(next);
    if (next) {
      setActive(Math.max(0, zones.indexOf(chosenZone)));
      requestAnimationFrame(() => listbox.current?.focus({ preventScroll: true }));
    }
  };

  const onListKey = (event: KeyboardEvent) => {
    if (event.key === 'ArrowDown') setActive((now) => Math.min(zones.length - 1, now + 1));
    else if (event.key === 'ArrowUp') setActive((now) => Math.max(0, now - 1));
    else if (event.key === 'Home') setActive(0);
    else if (event.key === 'End') setActive(zones.length - 1);
    else if (event.key === 'Enter' || event.key === ' ') choose(active);
    else if (event.key === 'Escape') {
      setOpen(false);
      button.current?.focus({ preventScroll: true });
    } else return;
    event.preventDefault();
  };

  const schedule = () => {
    setScheduled(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setScheduled(false), 2200);
    onSchedule?.({ day: chosenDay.id, minutes: time, zone: chosenZone.id });
  };

  return (
    <Card
      glyph={<ClockIcon />}
      title="Send later"
      onClose={onClose}
      colorScheme={colorScheme}
      className={className}
    >
      <div className={styles.days} role="radiogroup" aria-label="Day">
        {days.map((item) => (
          <button
            key={item.id}
            type="button"
            role="radio"
            aria-checked={item.id === day}
            className={styles.day}
            data-demo={`send-${item.id}`}
            onClick={() => setDay(item.id)}
          >
            <span className={styles.dayCheck} aria-hidden="true">
              <CheckIcon />
            </span>
            {item.label}
          </button>
        ))}
      </div>

      <div className={styles.labels}>
        <span className={cardStyles.label} id={`${id}-best`}>
          Best time to land
        </span>
        <span className={`${cardStyles.mono} ${styles.opens}`}>
          OPENS {peak}–{peak + 1}
        </span>
      </div>

      <div
        className={styles.chart}
        data-part="chart"
        data-sliding={sliding || undefined}
        data-flip={share > 0.66 || undefined}
        data-demo="send-chart"
        onPointerDown={onChartDown}
        style={{ '--at': share } as CSSProperties}
      >
        <div className={styles.bars} aria-hidden="true">
          {opens.map((value, hour) => (
            <span
              key={hour}
              className={styles.bar}
              data-best={value >= best || undefined}
              data-low={value < 4 || undefined}
              style={{ height: `${(value / Math.max(...opens)) * 42}px` }}
            />
          ))}
        </div>
        <div ref={track} className={styles.track}>
          <span className={styles.fill} />
          <span className={styles.guide} aria-hidden="true" />
          <span className={`${cardStyles.mono} ${styles.tip}`} aria-hidden="true">
            <Roll value={clock(time)} />
          </span>
          <span
            role="slider"
            tabIndex={0}
            className={styles.thumb}
            aria-labelledby={`${id}-best`}
            aria-valuemin={0}
            aria-valuemax={DAY - 1}
            aria-valuenow={time}
            aria-valuetext={`${clock(time)}, their time`}
            onKeyDown={onSliderKey}
          />
        </div>
        <div className={styles.ticks} aria-hidden="true">
          {Array.from({ length: 25 }, (_, hour) => (
            <span key={hour} data-major={hour % 6 === 0 || undefined} />
          ))}
        </div>
        <div className={`${cardStyles.mono} ${styles.axis}`} aria-hidden="true">
          {['00', '06', '12', '18', '24'].map((hour) => (
            <span key={hour}>{hour}</span>
          ))}
        </div>
      </div>

      <button
        ref={button}
        type="button"
        className={styles.zone}
        data-open={open || undefined}
        data-demo="send-zone"
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-controls={`${id}-zones`}
        onClick={toggleList}
      >
        <span className={styles.globe}>
          <GlobeIcon />
        </span>
        <span className={cardStyles.label}>Their time</span>
        <span className={styles.zoneName}>{chosenZone.name}</span>
        <span className={`${cardStyles.mono} ${styles.zoneOffset}`}>{chosenZone.offset}</span>
        <span className={styles.chevron}>
          <ChevronIcon />
        </span>
      </button>
      <div className={styles.drawer} data-open={open || undefined}>
        <div className={styles.drawerInner}>
          <ul
            ref={listbox}
            id={`${id}-zones`}
            role="listbox"
            tabIndex={open ? 0 : -1}
            aria-label="Their time zone"
            aria-activedescendant={open ? `${id}-zone-${active}` : undefined}
            className={styles.options}
            onKeyDown={onListKey}
            onBlur={(event) => {
              // Focus leaving for another control closes it (a click on an option keeps it).
              if (event.relatedTarget && event.relatedTarget !== button.current) setOpen(false);
            }}
          >
            {zones.map((item, index) => (
              <li
                key={item.id}
                id={`${id}-zone-${index}`}
                role="option"
                aria-selected={item.id === zone}
                className={styles.option}
                data-active={index === active || undefined}
                data-demo={`send-zone-${item.id}`}
                onPointerEnter={() => setActive(index)}
                onClick={() => choose(index)}
              >
                <span className={styles.optionName}>
                  {item.name}
                  {item.you && ' · you'}
                </span>
                <span className={`${cardStyles.mono} ${styles.optionOffset}`}>{item.offset}</span>
                <span className={styles.optionCheck} aria-hidden="true">
                  {item.id === zone && <CheckIcon />}
                </span>
              </li>
            ))}
          </ul>
        </div>
      </div>

      <footer className={styles.foot}>
        <span className={`${cardStyles.mono} ${styles.when}`}>
          {chosenDay.short} <Roll value={clock(time)} />
        </span>
        <button
          type="button"
          className={`${cardStyles.button} ${cardStyles.primary} ${styles.schedule}`}
          data-done={scheduled || undefined}
          data-part="primary"
          data-demo="send-schedule"
          onClick={schedule}
        >
          <span
            className={styles.scheduleLabel}
            data-shown={!scheduled || undefined}
            aria-hidden={scheduled}
          >
            <SendIcon /> Schedule
          </span>
          <span
            className={styles.scheduleLabel}
            data-shown={scheduled || undefined}
            aria-hidden={!scheduled}
          >
            <CheckIcon /> Scheduled
          </span>
        </button>
      </footer>
      <p className={cardStyles.hidden} role="status">
        {scheduled
          ? `Scheduled for ${chosenDay.label} at ${clock(time)}, ${chosenZone.name} time.`
          : ''}
      </p>
    </Card>
  );
}
```

### inbox-widgets/send-later.module.css

```css
/* Send later (send-later.tsx). Measured on the card at 300 px: the day chips, the chart (bars,
   slider, ticks, hours), the time zone button and its list, the footer. */

.days {
  display: flex;
  gap: 6px;
  margin-top: 16px;
}
.day {
  flex: 1 1 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 28px;
  margin: 0;
  padding: 0 10px;
  border: 0;
  border-radius: 999px;
  background: var(--control);
  color: var(--ink-2);
  font: 500 11.7px/1 var(--font);
  letter-spacing: 0.01em;
  text-transform: uppercase;
  white-space: nowrap;
  cursor: pointer;
  transition:
    background-color 200ms ease,
    color 200ms ease;
}
.day:hover {
  background: var(--control-2);
}
.dayCheck {
  display: inline-block;
  width: 0;
  height: 12px;
  overflow: hidden;
  opacity: 0;
  transition:
    width 240ms cubic-bezier(0.22, 1, 0.36, 1),
    margin 240ms cubic-bezier(0.22, 1, 0.36, 1),
    opacity 160ms ease;
}
.dayCheck svg {
  width: 12px;
  height: 12px;
}
.day[aria-checked='true'] {
  background: var(--accent);
  color: #fff;
}
.day[aria-checked='true'] .dayCheck {
  width: 12px;
  margin-right: 7px;
  opacity: 1;
}

.labels {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  margin-top: 12px;
}
.opens {
  color: var(--muted);
}

/* The chart: the bars, the slider over them, the ticks and the hours. */
.chart {
  position: relative;
  height: 117px;
  margin-top: 13px;
  border-radius: 16px;
  background: var(--well);
  cursor: pointer;
  touch-action: none;
  user-select: none;
}
.bars {
  position: absolute;
  bottom: 60px;
  left: 15px;
  right: 14px;
  display: flex;
  align-items: flex-end;
  gap: 3.5px;
  height: 42px;
}
.bar {
  flex: 1 1 0;
  min-height: 2.5px;
  border-radius: 2px;
  background: var(--track);
  transition:
    background-color 240ms ease,
    height 400ms cubic-bezier(0.22, 1, 0.36, 1);
}
.bar[data-low] {
  background: color-mix(in srgb, var(--track), transparent 25%);
}
.bar[data-best] {
  background: var(--accent);
}
.track {
  position: absolute;
  top: 67px;
  left: 12px;
  right: 9px;
  height: 4px;
  border-radius: 2px;
  background: var(--track);
}
.fill {
  position: absolute;
  inset: 0 auto 0 0;
  width: calc(var(--at) * 100%);
  border-radius: inherit;
  background: var(--accent);
}
.guide {
  position: absolute;
  bottom: 2px;
  left: calc(var(--at) * 100% + 1px);
  height: 58px;
  border-left: 1px dashed color-mix(in srgb, var(--ink), transparent 35%);
}
.tip {
  position: absolute;
  top: -56px;
  left: calc(var(--at) * 100% + 13px);
  display: flex;
  align-items: center;
  height: 29px;
  padding: 0 9px;
  border: 1px solid var(--float-edge);
  border-radius: 8px;
  background: color-mix(in srgb, var(--float), transparent 8%);
  box-shadow: 0 4px 12px var(--float-shadow);
  color: var(--ink);
  font-size: 13px;
  line-height: 1.2;
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  pointer-events: none;
}
/* Late in the day the tooltip sits on the thumb's left. */
.chart[data-flip] .tip {
  left: auto;
  right: calc((1 - var(--at)) * 100% + 13px);
}
.thumb {
  position: absolute;
  top: 50%;
  left: calc(var(--at) * 100%);
  box-sizing: border-box;
  width: 26px;
  height: 26px;
  margin: -13px 0 0 -13px;
  border: 3px solid var(--accent);
  border-radius: 50%;
  background: #fff;
  cursor: grab;
  transition: scale 200ms cubic-bezier(0.3, 1.4, 0.5, 1);
}
.chart[data-sliding] .thumb {
  scale: 1.08;
  cursor: grabbing;
}
.thumb:focus-visible {
  outline-offset: 3px;
}
.ticks {
  position: absolute;
  top: 77px;
  left: 12px;
  right: 9px;
  display: flex;
  justify-content: space-between;
  height: 6px;
}
.ticks > span {
  width: 1px;
  height: 3px;
  background: var(--tick);
}
.ticks > span[data-major] {
  height: 6px;
  background: color-mix(in srgb, var(--tick), var(--ink) 18%);
}
.axis {
  position: absolute;
  top: 93px;
  left: 12px;
  right: 9px;
  display: flex;
  justify-content: space-between;
  color: var(--muted);
  font-size: 13px;
  line-height: 15px;
}

/* Their time: the button, and the list it opens in place. */
.zone {
  display: flex;
  align-items: center;
  gap: 9px;
  box-sizing: border-box;
  width: 100%;
  height: 39px;
  margin: 15px 0 0;
  padding: 0 12px 0 11px;
  border: 1.5px solid var(--control-edge);
  border-radius: 12px;
  background: transparent;
  color: inherit;
  font: inherit;
  cursor: pointer;
  transition: border-color 200ms ease;
}
.zone[data-open] {
  border-color: var(--accent);
}
.globe {
  width: 17px;
  height: 17px;
  margin-right: 2px;
  color: var(--muted);
}
.globe svg,
.chevron svg {
  display: block;
  width: 100%;
  height: 100%;
}
.zone > :nth-child(2) {
  white-space: nowrap;
}
.zoneName {
  margin-left: auto;
  font: 500 15px/1 var(--font);
  letter-spacing: -0.01em;
}
.zoneOffset {
  color: var(--ink-2);
  font-size: 12.5px;
}
.chevron {
  width: 14px;
  height: 14px;
  margin-left: 8px;
  color: var(--ink-2);
  rotate: 180deg;
  transition: rotate 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
.zone[data-open] .chevron {
  rotate: 0deg;
}
.drawer {
  display: grid;
  grid-template-rows: 0fr;
  margin: 0 -1px;
  opacity: 0;
  transition:
    grid-template-rows 320ms cubic-bezier(0.22, 1, 0.36, 1),
    opacity 200ms ease;
}
.drawer[data-open] {
  grid-template-rows: 1fr;
  opacity: 1;
}
.drawerInner {
  min-height: 0;
  overflow: hidden;
  padding: 0 0 2px;
  margin-bottom: -2px;
}
.options {
  margin: 7px 0 0;
  padding: 4px;
  border: 1px solid var(--panel-edge);
  border-radius: 14px;
  background: var(--panel);
  list-style: none;
  translate: 0 -6px;
  transition: translate 320ms cubic-bezier(0.22, 1, 0.36, 1);
}
.drawer[data-open] .options {
  translate: 0 0;
}
.option {
  display: flex;
  align-items: center;
  height: 34px;
  padding: 0 7px 0 10px;
  border-radius: 10px;
  color: var(--ink-2);
  cursor: pointer;
  transition: background-color 140ms ease;
}
.option[aria-selected='true'] {
  color: var(--ink);
}
.option[data-active] {
  background: var(--control-2);
}
.optionName {
  margin-right: auto;
  font: 500 15px/1 var(--font);
  letter-spacing: -0.01em;
}
.optionOffset {
  color: var(--muted);
  font-size: 12.5px;
}
.optionCheck {
  display: grid;
  place-items: center;
  width: 16px;
  height: 16px;
  margin-left: 7px;
  color: var(--accent-ink);
}
.optionCheck svg {
  width: 14px;
  height: 14px;
}
.options:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-top: 15px;
}
.when {
  color: var(--ink-2);
  font-size: 12px;
  line-height: 1.2;
  white-space: nowrap;
}
.schedule {
  position: relative;
  display: inline-grid;
  min-width: 105px;
}
.schedule svg {
  width: 12px;
  height: 12px;
}
.scheduleLabel + .scheduleLabel {
  position: absolute;
  inset: 0;
}
.scheduleLabel {
  grid-area: 1 / 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 9px;
  opacity: 0;
  transform: translateY(6px);
  transition:
    opacity 180ms ease,
    transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
.scheduleLabel[data-shown] {
  opacity: 1;
  transform: none;
}
.schedule[data-done] {
  background: var(--green);
}

@container (max-width: 299px) {
  .days {
    gap: 4px;
  }
  .day {
    padding: 0 7px;
    font-size: 11px;
  }
  .day[aria-checked='true'] .dayCheck {
    width: 10px;
    margin-right: 4px;
  }
  .zone > :nth-child(2) {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .day,
  .dayCheck,
  .bar,
  .thumb,
  .chevron,
  .drawer,
  .options,
  .scheduleLabel {
    transition: none;
  }
}
```

### inbox-widgets/notifications.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import {
  Avatar,
  BellIcon,
  Card,
  CheckIcon,
  PlusIcon,
  Roll,
  StepperIcon,
  cardStyles,
  clock,
  drag,
  span,
  type PanelProps,
} from './card';
import styles from './notifications.module.css';

/*
 * Notifications: what may ping, the quiet hours, VIP senders who always get through, and a few
 * switches.
 *
 * - The quiet hours are an arc on a 24-hour dial (midnight at the top), between two handles. Each
 *   handle is a slider: drag it round the dial (it snaps to a quarter of an hour, following the
 *   pointer's angle from the dial's middle) or use the arrow keys. The FROM and TO fields beside
 *   it are the same two values, as hour and minute spinbuttons: type, or press ↑/↓, or use the
 *   stepper. The handle of the field in use is the large one, ringed in blue.
 * - The span in the middle and the countdown in the footer follow, their digits rolling.
 * - VIP senders: press an avatar to remove it, + to add the next one; they slide along.
 * - Send test rings the bell in the header.
 */

export type Vip = { id: string; name: string; tone: string };
export type Mode = 'all' | 'needs' | 'none';

export const VIPS: Vip[] = [
  { id: 'maya', name: 'Maya Kent', tone: 'var(--sky)' },
  { id: 'rui', name: 'Rui Ortega', tone: 'var(--amber)' },
  { id: 'dana', name: 'Dana Lee', tone: 'var(--pink)' },
  { id: 'ines', name: 'Ines Park', tone: 'var(--mint)' },
];
/** Who + adds next, in turn. */
const MORE_VIPS: Vip[] = [
  { id: 'theo', name: 'Theo Marsh', tone: 'var(--grey)' },
  { id: 'lena', name: 'Lena Brook', tone: 'var(--accent-light)' },
];

const MODES: { id: Mode; label: string }[] = [
  { id: 'all', label: 'All' },
  { id: 'needs', label: 'Needs you' },
  { id: 'none', label: 'None' },
];

const DAY = 1440;
const STEP = 15;
const wrap = (m: number) => ((m % DAY) + DAY) % DAY;

/** A point on the dial for `minutes`, at radius `r` around (cx, cy); midnight at the top. */
const onDial = (minutes: number, r: number, cx = 66, cy = 66) => {
  const angle = (minutes / DAY) * Math.PI * 2;
  return [cx + Math.sin(angle) * r, cy - Math.cos(angle) * r] as const;
};

type Which = 'from' | 'to';

export function Notifications({
  defaultMode = 'needs',
  defaultFrom = 22 * 60,
  defaultTo = 7 * 60,
  defaultWeekend = { sat: true, sun: true },
  vips: initialVips = VIPS,
  now = 12 * 60 + 20,
  onChange,
  onSendTest,
  onClose,
  colorScheme,
  className,
}: PanelProps & {
  defaultMode?: Mode;
  /** Quiet hours, minutes past midnight. */
  defaultFrom?: number;
  defaultTo?: number;
  /** Quiet all day on these days. */
  defaultWeekend?: { sat: boolean; sun: boolean };
  vips?: Vip[];
  /** The time now, in minutes past midnight, for the countdown. */
  now?: number;
  onChange?: (settings: {
    mode: Mode;
    from: number;
    to: number;
    weekend: { sat: boolean; sun: boolean };
    vips: Vip[];
    switches: Record<string, boolean>;
  }) => void;
  onSendTest?: () => void;
}) {
  const [mode, setMode] = useState(defaultMode);
  const [from, setFrom] = useState(defaultFrom);
  const [to, setTo] = useState(defaultTo);
  const [weekend, setWeekend] = useState(defaultWeekend);
  const [vips, setVips] = useState(initialVips);
  const [switches, setSwitches] = useState({ bundle: true, badge: false, chat: false });
  const [active, setActive] = useState<Which>('from');
  const [sent, setSent] = useState(0);
  const [message, setMessage] = useState('');
  const dial = useRef<SVGSVGElement>(null);
  const stopDrag = useRef<(() => void) | null>(null);
  const timer = useRef(0);
  const first = useRef(true);
  const id = useId();

  useEffect(
    () => () => {
      stopDrag.current?.();
      window.clearTimeout(timer.current);
    },
    [],
  );

  // Report every change (not the first render).
  const report = useEffectEvent(() => onChange?.({ mode, from, to, weekend, vips, switches }));
  useEffect(() => {
    if (first.current) {
      first.current = false;
      return;
    }
    report();
  }, [mode, from, to, weekend, vips, switches]);

  const quiet = wrap(to - from) || DAY;
  const hours = quiet / 60;
  const until = wrap(from - now);
  const inQuiet = wrap(now - from) < quiet;
  const set = (which: Which, minutes: number) =>
    (which === 'from' ? setFrom : setTo)(wrap(minutes));

  const announce = (text: string) => setMessage((was) => (was === text ? `${text} ` : text));

  /** The minutes at the pointer's angle round the dial's middle, to the quarter hour. */
  const minutesAt = (x: number, y: number) => {
    const rect = dial.current?.getBoundingClientRect();
    if (!rect) return 0;
    const angle = Math.atan2(x - (rect.left + rect.width / 2), -(y - (rect.top + rect.height / 2)));
    return wrap(Math.round(((angle / (Math.PI * 2)) * DAY) / STEP) * STEP);
  };

  const onHandleDown = (event: PointerEvent<SVGElement>, which: Which) => {
    if (event.button !== 0) return;
    event.preventDefault();
    event.stopPropagation();
    setActive(which);
    if (event.nativeEvent.isTrusted) event.currentTarget.focus({ preventScroll: true });
    stopDrag.current?.();
    stopDrag.current = drag(
      event,
      (move) => set(which, minutesAt(move.clientX, move.clientY)),
      () => (stopDrag.current = null),
    );
  };

  const onHandleKey = (event: KeyboardEvent, which: Which) => {
    const value = which === 'from' ? from : to;
    const keys: Record<string, number> = {
      ArrowRight: STEP,
      ArrowUp: STEP,
      ArrowLeft: -STEP,
      ArrowDown: -STEP,
      PageUp: 60,
      PageDown: -60,
    };
    if (!(event.key in keys)) return;
    event.preventDefault();
    set(which, value + keys[event.key]);
  };

  const ring = () => {
    setSent((n) => n + 1);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setSent(0), 1800);
    onSendTest?.();
    announce('Test notification sent.');
  };

  const [startX, startY] = onDial(from, 48);
  const [endX, endY] = onDial(to, 48);
  const large = quiet > DAY / 2 ? 1 : 0;
  const arc = `M${startX.toFixed(2)} ${startY.toFixed(2)}A48 48 0 ${large} 1 ${endX.toFixed(2)} ${endY.toFixed(2)}`;
  const label = Number.isInteger(hours) ? String(hours) : hours.toFixed(2).replace(/0$/, '');

  return (
    <Card
      glyph={
        <span className={styles.bell} key={sent} data-ring={sent > 0 || undefined}>
          <BellIcon />
        </span>
      }
      title="Notifications"
      onClose={onClose}
      colorScheme={colorScheme}
      className={className}
    >
      <div
        className={styles.modes}
        data-part="modes"
        role="radiogroup"
        aria-label="Notify me about"
        style={{ '--i': MODES.findIndex((item) => item.id === mode) } as CSSProperties}
      >
        <span className={styles.modePill} aria-hidden="true" />
        {MODES.map((item) => (
          <button
            key={item.id}
            type="button"
            role="radio"
            aria-checked={item.id === mode}
            className={styles.mode}
            data-demo={`notif-${item.id}`}
            onClick={() => setMode(item.id)}
          >
            {item.label}
          </button>
        ))}
      </div>

      <div className={styles.quiet} data-off={mode === 'none' || undefined}>
        <div className={styles.dialBox}>
          <svg ref={dial} className={styles.dial} viewBox="0 0 132 132" data-demo="notif-dial">
            <g aria-hidden="true">
              {Array.from({ length: 24 }, (_, hour) => {
                const major = hour % 6 === 0;
                const [x1, y1] = onDial(hour * 60, major ? 56.5 : 58.5);
                const [x2, y2] = onDial(hour * 60, major ? 63.5 : 62);
                return (
                  <line
                    key={hour}
                    className={major ? styles.tickMajor : styles.tick}
                    x1={x1.toFixed(2)}
                    y1={y1.toFixed(2)}
                    x2={x2.toFixed(2)}
                    y2={y2.toFixed(2)}
                  />
                );
              })}
              <circle className={styles.ring} cx="66" cy="66" r="48" />
              <path className={styles.arc} d={arc} />
            </g>
            {/* In a fixed order: moving a focused node would take its focus away. */}
            {(['to', 'from'] as const).map((which) => {
              const [x, y] = which === 'from' ? [startX, startY] : [endX, endY];
              const value = which === 'from' ? from : to;
              return (
                <g
                  key={which}
                  role="slider"
                  tabIndex={0}
                  className={styles.handle}
                  data-active={which === active || undefined}
                  data-demo={`notif-handle-${which}`}
                  aria-label={which === 'from' ? 'Quiet from' : 'Quiet until'}
                  aria-valuemin={0}
                  aria-valuemax={DAY - STEP}
                  aria-valuenow={value}
                  aria-valuetext={clock(value)}
                  transform={`translate(${x.toFixed(2)} ${y.toFixed(2)})`}
                  onPointerDown={(event) => onHandleDown(event, which)}
                  onFocus={() => setActive(which)}
                  onKeyDown={(event) => onHandleKey(event, which)}
                >
                  <circle className={styles.hit} r="14" />
                  <circle className={styles.knobRing} r="12.5" />
                  <circle className={styles.knob} r="9.5" />
                </g>
              );
            })}
          </svg>
          <div className={styles.span} aria-live="polite">
            <span className={styles.hours}>
              <Roll value={label} /> h
            </span>
            <span className={cardStyles.label}>Quiet</span>
          </div>
        </div>

        <div className={styles.fields}>
          <TimeField
            label="From"
            value={from}
            active={active === 'from'}
            onFocus={() => setActive('from')}
            onChange={(minutes) => set('from', minutes)}
          />
          <TimeField
            label="To"
            value={to}
            active={active === 'to'}
            onFocus={() => setActive('to')}
            onChange={(minutes) => set('to', minutes)}
          />
          <div className={styles.weekend} role="group" aria-label="Quiet all day">
            {(['sat', 'sun'] as const).map((dayId) => (
              <button
                key={dayId}
                type="button"
                className={styles.weekday}
                aria-pressed={weekend[dayId]}
                aria-label={dayId === 'sat' ? 'Saturday' : 'Sunday'}
                onClick={() => setWeekend((was) => ({ ...was, [dayId]: !was[dayId] }))}
              >
                {dayId}
              </button>
            ))}
          </div>
        </div>
      </div>

      <ul className={styles.settings}>
        <li className={styles.setting}>
          <span className={styles.words}>
            <span className={styles.name} id={`${id}-vip`}>
              VIP senders
            </span>
            <span className={styles.sub}>Always on</span>
          </span>
          <span className={styles.vips} role="group" aria-labelledby={`${id}-vip`}>
            {vips.map((vip, index) => (
              <button
                key={vip.id}
                type="button"
                className={styles.vip}
                style={{ zIndex: vips.length - index } as CSSProperties}
                aria-label={`Remove ${vip.name}`}
                onClick={() => {
                  setVips((was) => was.filter((item) => item.id !== vip.id));
                  announce(`${vip.name} removed from VIP senders.`);
                }}
              >
                <Avatar name={vip.name} tone={vip.tone} className={styles.vipAvatar} />
              </button>
            ))}
            <button
              type="button"
              className={styles.add}
              aria-label="Add a VIP sender"
              data-demo="notif-add"
              disabled={vips.length >= 5}
              onClick={() => {
                const pool = [...VIPS, ...MORE_VIPS].filter(
                  (vip) => !vips.some((item) => item.id === vip.id),
                );
                if (!pool.length) return;
                setVips((was) => [...was, pool[0]]);
                announce(`${pool[0].name} added to VIP senders.`);
              }}
            >
              <PlusIcon />
            </button>
          </span>
        </li>
        <Switch
          name="Bundle newsletters"
          sub="Daily · 18:00"
          on={switches.bundle}
          onToggle={() => setSwitches((was) => ({ ...was, bundle: !was.bundle }))}
          demo="notif-bundle"
        />
        <Switch
          name="Badge count"
          on={switches.badge}
          onToggle={() => setSwitches((was) => ({ ...was, badge: !was.badge }))}
          demo="notif-badge"
        />
        <Switch name="Chat mentions" sub="Pro plan" on={switches.chat} disabled />
      </ul>

      <footer className={styles.foot}>
        <span className={cardStyles.label}>
          {mode === 'none' ? (
            'Notifications off'
          ) : inQuiet ? (
            <>Quiet now · ends in {span(wrap(to - now))}</>
          ) : (
            <>
              Next quiet in <Roll value={span(until)} />
            </>
          )}
        </span>
        <button type="button" className={cardStyles.button} data-demo="notif-test" onClick={ring}>
          <span className={styles.testLabel} data-shown={!sent || undefined} aria-hidden={!!sent}>
            Send test
          </span>
          <span className={styles.testLabel} data-shown={!!sent || undefined} aria-hidden={!sent}>
            <CheckIcon /> Sent
          </span>
        </button>
      </footer>
      <p className={cardStyles.hidden} role="status">
        {message}
      </p>
    </Card>
  );
}

function Switch({
  name,
  sub,
  on,
  onToggle,
  disabled,
  demo,
}: {
  name: string;
  sub?: string;
  on: boolean;
  onToggle?: () => void;
  disabled?: boolean;
  demo?: string;
}) {
  const id = useId();
  return (
    <li className={styles.setting} data-disabled={disabled || undefined}>
      <span className={styles.words}>
        <span className={styles.name} id={id}>
          {name}
        </span>
        {sub && <span className={styles.sub}>{sub}</span>}
      </span>
      <button
        type="button"
        role="switch"
        aria-checked={on}
        aria-labelledby={id}
        aria-description={disabled && sub ? `Needs the ${sub.toLowerCase()}` : undefined}
        className={cardStyles.switch}
        data-part="switch"
        disabled={disabled}
        data-demo={demo}
        onClick={onToggle}
      />
    </li>
  );
}

/**
 * A time as two spinbuttons, hours and minutes. ↑/↓ step them (an hour, or five minutes), digits
 * type over them (two for the hours move on to the minutes), and the stepper beside them moves
 * the whole time by a quarter of an hour.
 */
function TimeField({
  label,
  value,
  active,
  onFocus,
  onChange,
}: {
  label: string;
  value: number;
  active: boolean;
  onFocus: () => void;
  onChange: (minutes: number) => void;
}) {
  const id = useId();
  const minutes = useRef<HTMLSpanElement>(null);
  const typed = useRef('');
  const h = Math.floor(value / 60);
  const m = value % 60;

  const onKey = (event: KeyboardEvent<HTMLSpanElement>, part: 'h' | 'm') => {
    const step = part === 'h' ? 60 : 5;
    if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
      event.preventDefault();
      typed.current = '';
      onChange(value + (event.key === 'ArrowUp' ? step : -step));
      return;
    }
    if (event.key === 'ArrowRight' && part === 'h') {
      event.preventDefault();
      minutes.current?.focus();
      return;
    }
    if (event.key === 'ArrowLeft' && part === 'm') {
      event.preventDefault();
      (minutes.current?.previousElementSibling?.previousElementSibling as HTMLElement)?.focus();
      return;
    }
    if (!/^\d$/.test(event.key)) return;
    event.preventDefault();
    typed.current = (typed.current + event.key).slice(-2);
    const n = Number(typed.current);
    if (part === 'h') {
      onChange(Math.min(23, n) * 60 + m);
      if (typed.current.length === 2 || n > 2) {
        typed.current = '';
        minutes.current?.focus();
      }
    } else {
      onChange(h * 60 + Math.min(59, n));
      if (typed.current.length === 2 || n > 5) typed.current = '';
    }
  };

  const part = (which: 'h' | 'm') => (
    <span
      ref={which === 'm' ? minutes : undefined}
      role="spinbutton"
      tabIndex={0}
      className={styles.part}
      aria-label={`${label}, ${which === 'h' ? 'hours' : 'minutes'}`}
      aria-valuemin={0}
      aria-valuemax={which === 'h' ? 23 : 59}
      aria-valuenow={which === 'h' ? h : m}
      onKeyDown={(event) => onKey(event, which)}
      onFocus={() => {
        typed.current = '';
        onFocus();
      }}
    >
      {String(which === 'h' ? h : m).padStart(2, '0')}
    </span>
  );

  return (
    <div className={styles.field}>
      <span className={styles.fieldLabel} id={id}>
        {label}
      </span>
      <div
        className={styles.time}
        role="group"
        aria-labelledby={id}
        data-active={active || undefined}
      >
        <span className={styles.digits}>
          {part('h')}
          <span className={styles.colon} aria-hidden="true">
            :
          </span>
          {part('m')}
          <span className={styles.caret} aria-hidden="true" />
        </span>
        <span className={styles.stepper}>
          <button
            type="button"
            tabIndex={-1}
            aria-label={`${label} a quarter hour later`}
            onClick={() => onChange(value + STEP)}
          />
          <button
            type="button"
            tabIndex={-1}
            aria-label={`${label} a quarter hour earlier`}
            onClick={() => onChange(value - STEP)}
          />
          <StepperIcon />
        </span>
      </div>
    </div>
  );
}
```

### inbox-widgets/notifications.module.css

```css
/* Notifications (notifications.tsx). Measured on the card at 300 px: the segmented control, the
   quiet hours (the dial, the two time fields, the weekend days), the settings and the footer. */

.bell {
  display: block;
  width: 100%;
  height: 100%;
  transform-origin: 50% 10%;
}
.bell[data-ring] {
  animation: ring 900ms cubic-bezier(0.3, 0, 0.3, 1);
}
@keyframes ring {
  0%,
  100% {
    rotate: 0deg;
  }
  12% {
    rotate: 16deg;
  }
  28% {
    rotate: -14deg;
  }
  44% {
    rotate: 10deg;
  }
  60% {
    rotate: -6deg;
  }
  76% {
    rotate: 3deg;
  }
}

/* The segmented control, its pill sliding to the chosen third. */
.modes {
  position: relative;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  height: 35px;
  margin-top: 16px;
  padding: 3.5px;
  box-sizing: border-box;
  border-radius: 999px;
  background: var(--well);
}
.modePill {
  position: absolute;
  top: 3.5px;
  bottom: 3.5px;
  left: 3.5px;
  width: calc((100% - 7px) / 3);
  border-radius: 999px;
  background: var(--control-2);
  translate: calc(var(--i) * 100%) 0;
  transition: translate 380ms cubic-bezier(0.3, 1.15, 0.5, 1);
}
.mode {
  position: relative;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: none;
  color: var(--muted);
  font: 500 12.5px/1 var(--font);
  letter-spacing: 0.01em;
  text-transform: uppercase;
  cursor: pointer;
  transition: color 200ms ease;
}
.mode[aria-checked='true'] {
  color: var(--ink);
}
.mode:hover {
  color: var(--ink-2);
}
.mode[aria-checked='true']:hover {
  color: var(--ink);
}

/* The quiet hours. */
.quiet {
  position: relative;
  display: flex;
  height: 181px;
  margin-top: 13px;
  box-sizing: border-box;
  border: 1px solid var(--panel-edge);
  border-radius: 16px;
  background: var(--panel);
  transition: opacity 240ms ease;
}
.quiet[data-off] {
  opacity: 0.45;
}
.dialBox {
  position: relative;
  flex: none;
  width: 146px;
}
.dial {
  position: absolute;
  top: 23.5px;
  left: 7.5px;
  width: 132px;
  height: 132px;
  overflow: visible;
  touch-action: none;
}
.tick,
.tickMajor {
  stroke: var(--tick);
  stroke-width: 1;
  stroke-linecap: round;
}
.tickMajor {
  stroke: color-mix(in srgb, var(--tick), var(--ink) 25%);
}
.ring {
  fill: none;
  stroke: var(--ring-track, var(--track));
  stroke-width: 11.5;
}
.arc {
  fill: none;
  stroke: var(--accent);
  stroke-width: 11.5;
  stroke-linecap: round;
}
.handle {
  cursor: grab;
  outline: none;
}
.hit {
  fill: transparent;
}
.knob {
  fill: #fff;
  stroke: var(--knob-edge);
  stroke-width: 0.5;
  r: 8;
  transition: r 220ms cubic-bezier(0.3, 1.4, 0.5, 1);
  filter: drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.35));
}
.knobRing {
  fill: var(--accent);
  r: 0;
  transition: r 220ms cubic-bezier(0.3, 1.4, 0.5, 1);
}
.handle[data-active] .knob {
  r: 9.5;
  filter: none;
}
.handle[data-active] .knobRing {
  r: 12.5;
}
.handle:focus-visible .hit {
  stroke: var(--focus);
  stroke-width: 2;
  r: 16;
}
.span {
  position: absolute;
  top: 68.5px;
  left: 0;
  width: 147px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  pointer-events: none;
}
.hours {
  font: 500 23px/26px var(--font);
  letter-spacing: -0.01em;
}

.fields {
  flex: 1;
  min-width: 0;
  padding: 12px 11.5px 0 2px;
}
.field + .field {
  margin-top: 12px;
}
.fieldLabel {
  display: block;
  color: var(--muted);
  font: 500 12.5px/15px var(--font);
  letter-spacing: 0.012em;
  text-transform: uppercase;
}
.time {
  position: relative;
  display: flex;
  align-items: center;
  height: 35px;
  margin-top: 6px;
  padding: 0 10px 0 9px;
  box-sizing: border-box;
  border: 1.5px solid transparent;
  border-radius: 10px;
  background: var(--well);
  transition: border-color 200ms ease;
}
.time[data-active] {
  border-color: var(--accent);
}
.digits {
  display: flex;
  align-items: center;
  font: 500 15px/20px var(--mono-font);
  letter-spacing: 0;
}
.part {
  padding: 0 1px;
  border-radius: 4px;
  cursor: text;
  font-variant-numeric: tabular-nums;
}
.part:focus {
  outline: none;
  background: var(--accent-wash);
  color: var(--accent-ink);
}
.part:focus-visible {
  outline: none;
}
.colon {
  margin: 0 1.5px;
  color: var(--muted);
}
.caret {
  width: 1.5px;
  height: 18px;
  margin-left: 3px;
  background: var(--accent-light);
  opacity: 0;
}
.time:focus-within .caret {
  animation: caret 1.05s steps(1) infinite;
}
@keyframes caret {
  0%,
  50% {
    opacity: 1;
  }
  51%,
  100% {
    opacity: 0;
  }
}
.stepper {
  position: relative;
  display: grid;
  width: 11px;
  height: 18px;
  margin-left: auto;
  color: var(--ink-2);
}
.stepper svg {
  width: 100%;
  height: 100%;
  pointer-events: none;
}
.stepper button {
  position: absolute;
  left: -6px;
  right: -6px;
  height: 50%;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}
.stepper button:first-child {
  top: -4px;
  padding-top: 4px;
  box-sizing: content-box;
}
.stepper button:nth-child(2) {
  bottom: -4px;
  padding-bottom: 4px;
  box-sizing: content-box;
}
.weekend {
  display: flex;
  gap: 7px;
  margin-top: 11px;
}
.weekday {
  height: 22.5px;
  margin: 0;
  padding: 0 8px;
  border: 0;
  border-radius: 7px;
  background: var(--control-2);
  color: var(--muted);
  font: 500 12px/1 var(--font);
  letter-spacing: 0.01em;
  text-transform: uppercase;
  cursor: pointer;
  transition:
    background-color 200ms ease,
    color 200ms ease;
}
.weekday[aria-pressed='true'] {
  background: var(--accent-wash);
  color: var(--accent-ink);
}

/* The settings. */
.settings {
  margin: 12px 0 0;
  padding: 0;
  list-style: none;
}
.setting {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 40px;
  padding: 10px 0 9.5px;
  box-sizing: border-box;
}
.setting + .setting {
  border-top: 1px solid var(--line);
}
.words {
  display: flex;
  flex-direction: column;
  gap: 1.5px;
  min-width: 0;
  margin-right: auto;
}
.name {
  font: 500 15px/17px var(--font);
  letter-spacing: -0.01em;
}
.sub {
  color: var(--muted);
  font: 500 12px/15px var(--font);
  letter-spacing: 0.012em;
  text-transform: uppercase;
}
.setting[data-disabled] .name {
  color: var(--muted);
}
.setting[data-disabled] .sub {
  color: var(--faint);
}

.vips {
  display: flex;
  align-items: center;
}
.vip {
  position: relative;
  margin: 0 0 0 -6.5px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  cursor: pointer;
  animation: pop 380ms cubic-bezier(0.3, 1.5, 0.5, 1) both;
  transition: translate 200ms ease;
}
.vip:first-child {
  margin-left: 0;
}
.vip:hover {
  translate: 0 -2px;
}
.vipAvatar {
  width: 27px;
  height: 27px;
  box-shadow: 0 0 0 1.5px var(--card);
  font-size: 13px;
}
@keyframes pop {
  from {
    scale: 0.4;
    opacity: 0;
  }
}
.add {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  margin: 0 0 0 8px;
  padding: 0;
  border: 1px dashed color-mix(in srgb, var(--muted), transparent 20%);
  border-radius: 50%;
  background: none;
  color: var(--ink-2);
  cursor: pointer;
  transition:
    border-color 160ms ease,
    color 160ms ease;
}
.add svg {
  width: 13px;
  height: 13px;
}
.add:hover {
  border-color: var(--ink-2);
  color: var(--ink);
}
.add:disabled {
  opacity: 0.4;
  cursor: default;
}

.foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-top: 13px;
}
.foot > :first-child {
  font-size: 11.7px;
}
.foot button {
  position: relative;
  display: inline-grid;
  min-width: 92px;
}
.testLabel {
  grid-area: 1 / 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  opacity: 0;
  transform: translateY(6px);
  transition:
    opacity 180ms ease,
    transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
.testLabel + .testLabel {
  position: absolute;
  inset: 0;
}
.testLabel[data-shown] {
  opacity: 1;
  transform: none;
}

@media (prefers-reduced-motion: reduce) {
  .bell[data-ring],
  .vip,
  .time:focus-within .caret {
    animation: none;
  }
  .modePill,
  .knob,
  .knobRing,
  .testLabel,
  .quiet {
    transition: none;
  }
}
```

### inbox-widgets/snoozed.tsx

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

'use client';

import {
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import { RackArt } from './art';
import {
  Avatar,
  Card,
  MoonIcon,
  Roll,
  SmallClockIcon,
  cardStyles,
  clock,
  drag,
  span,
  type PanelProps,
} from './card';
import styles from './snoozed.module.css';

/*
 * Snoozed: threads put away until later, hung on a rack. Today's are pins on a timeline from
 * 08:00 to 20:00; the next two are listed under it.
 *
 * - A pin is a slider: drag it along the day (it snaps to a quarter of an hour, never before now)
 *   or press ←/→. While it moves, a dashed ring marks where it was and a tooltip gives the new time
 *   and how far it moved; let go and the list, the heading and its countdown follow.
 * - A row shows its time; hovered or focused, it offers Wake now and +1 h instead. A woken thread
 *   leaves: its pin pops, its letter drops off the rack, the count rolls down.
 * - On the rack, the letters sway from their pegs; the blue one is the next to come back.
 */

export type Snoozed = {
  id: string;
  name: string;
  subject: string;
  tone: string;
  /** When it comes back: minutes past midnight today, or later in the week (day > 0). */
  at: number;
  day?: number;
};

export const SNOOZED: Snoozed[] = [
  {
    id: 'maya',
    name: 'Maya Kent',
    subject: 'Q4 budget — final numbers',
    tone: 'var(--sky)',
    at: 11 * 60,
  },
  {
    id: 'rui',
    name: 'Rui Ortega',
    subject: 'Vendor contract redlines',
    tone: 'var(--amber)',
    at: 14 * 60,
  },
  { id: 'ines', name: 'Ines Park', subject: 'Hiring plan', tone: 'var(--mint)', at: 16 * 60 + 30 },
  { id: 'finn', name: 'Finn Cole', subject: 'Invoices', tone: 'var(--grey)', at: 18 * 60 },
  {
    id: 'ada',
    name: 'Ada Moss',
    subject: 'Offsite agenda',
    tone: 'var(--pink)',
    at: 9 * 60,
    day: 2,
  },
  {
    id: 'leo',
    name: 'Leo Grant',
    subject: 'Quarterly review',
    tone: 'var(--sky)',
    at: 10 * 60,
    day: 4,
  },
];

const START = 8 * 60;
const END = 20 * 60;
const STEP = 15;
const SHOWN = 2;
/** Which letter on the rack each thread hangs as, in the order they come back. */
const LETTER_OF = [2, 0, 3, 1, 4, 5];

const share = (minutes: number) => (minutes - START) / (END - START);
const delta = (minutes: number) => {
  const sign = minutes < 0 ? '−' : '+';
  const m = Math.abs(minutes);
  const h = Math.floor(m / 60);
  const rest = m % 60;
  if (!h) return `${sign}${rest} m`;
  return rest ? `${sign}${h} h ${rest}` : `${sign}${h} h`;
};

type Moving = { id: string; from: number; at: number };

export function Snoozed({
  threads: initial = SNOOZED,
  now = 9 * 60 + 20,
  onChange,
  onWake,
  onRules,
  onClose,
  colorScheme,
  className,
}: PanelProps & {
  threads?: Snoozed[];
  /** The time now, in minutes past midnight. */
  now?: number;
  /** Called when a thread's time changes. */
  onChange?: (thread: Snoozed) => void;
  /** Called with the threads woken. */
  onWake?: (ids: string[]) => void;
  onRules?: () => void;
}) {
  const [threads, setThreads] = useState(initial);
  const [woken, setWoken] = useState<string[]>([]);
  const [moving, setMoving] = useState<Moving | null>(null);
  const [hovered, setHovered] = useState<string | null>(null);
  const [message, setMessage] = useState('');
  const track = useRef<HTMLDivElement>(null);
  const stopDrag = useRef<(() => void) | null>(null);

  useEffect(() => () => stopDrag.current?.(), []);

  const order = (list: Snoozed[]) =>
    [...list].sort((a, b) => (a.day ?? 0) - (b.day ?? 0) || a.at - b.at);
  const asleep = order(threads.filter((thread) => !woken.includes(thread.id)));
  const timeOf = (thread: Snoozed) => (moving?.id === thread.id ? moving.at : thread.at);
  const today = asleep.filter((thread) => !thread.day);
  const listed = asleep.slice(0, SHOWN);
  const next = today.filter((thread) => thread.at >= now).sort((a, b) => a.at - b.at)[0];
  const letters = order(threads);
  const nextLetter = next ? LETTER_OF[letters.findIndex((t) => t.id === next.id)] : -1;
  const wokenLetters = Array.from({ length: 6 }, (_, letter) => {
    const thread = letters[LETTER_OF.indexOf(letter)];
    return !thread || woken.includes(thread.id);
  });

  const announce = (text: string) => setMessage((was) => (was === text ? `${text} ` : text));

  const update = (id: string, at: number) => {
    const clamped = Math.min(END, Math.max(Math.ceil(now / STEP) * STEP, at));
    const thread = threads.find((item) => item.id === id);
    if (!thread) return clamped;
    setThreads((was) => was.map((item) => (item.id === id ? { ...item, at: clamped } : item)));
    onChange?.({ ...thread, at: clamped });
    return clamped;
  };

  const wake = (ids: string[]) => {
    setWoken((was) => [...was, ...ids.filter((id) => !was.includes(id))]);
    onWake?.(ids);
    announce(
      ids.length === 1
        ? `${threads.find((thread) => thread.id === ids[0])?.name} is back in the inbox.`
        : 'All snoozed threads are back in the inbox.',
    );
  };

  const minutesAt = (x: number) => {
    const rect = track.current?.getBoundingClientRect();
    if (!rect) return now;
    const at = START + ((x - rect.left) / rect.width) * (END - START);
    return Math.min(END, Math.max(Math.ceil(now / STEP) * STEP, Math.round(at / STEP) * STEP));
  };

  const onPinDown = (event: PointerEvent<HTMLElement>, thread: Snoozed) => {
    if (event.button !== 0) return;
    event.preventDefault();
    if (event.nativeEvent.isTrusted) event.currentTarget.focus({ preventScroll: true });
    const from = thread.at;
    const offset = event.clientX - (track.current?.getBoundingClientRect().left ?? 0);
    const grabbed = offset - share(from) * (track.current?.offsetWidth ?? 1);
    let at = from;
    setMoving({ id: thread.id, from, at });
    stopDrag.current?.();
    stopDrag.current = drag(
      event,
      (move) => {
        at = minutesAt(move.clientX - grabbed);
        setMoving({ id: thread.id, from, at });
      },
      () => {
        stopDrag.current = null;
        setMoving(null);
        if (at !== from) {
          update(thread.id, at);
          announce(`${thread.name} moved to ${clock(at)}.`);
        }
      },
    );
  };

  const onPinKey = (event: KeyboardEvent, thread: Snoozed) => {
    const steps: Record<string, number> = {
      ArrowRight: STEP,
      ArrowUp: STEP,
      ArrowLeft: -STEP,
      ArrowDown: -STEP,
      PageUp: 60,
      PageDown: -60,
    };
    if (!(event.key in steps)) return;
    event.preventDefault();
    update(thread.id, thread.at + steps[event.key]);
  };

  const moved = moving ? threads.find((thread) => thread.id === moving.id) : null;

  return (
    <Card
      glyph={<MoonIcon />}
      title="Snoozed"
      tag={
        <>
          <Roll value={String(asleep.length)} />
          &nbsp;threads
        </>
      }
      onClose={onClose}
      colorScheme={colorScheme}
      className={className}
    >
      <RackArt woken={wokenLetters} next={nextLetter} className={styles.art} />
      <h3 className={styles.heading}>
        {next ? (
          <>
            Next one back at <Roll value={clock(next.at)} />
            <span className={`${cardStyles.mono} ${styles.countdown}`}>
              IN <Roll value={span(next.at - now)} />
            </span>
          </>
        ) : (
          'Nothing else comes back today'
        )}
      </h3>

      <div className={styles.timeline} data-moving={moving ? true : undefined}>
        <div className={styles.timeHead}>
          <span className={cardStyles.label}>Today</span>
          <span className={cardStyles.label}>Drag a pin to move it</span>
        </div>
        <div ref={track} className={styles.track} data-demo="snooze-track">
          <span className={styles.past} style={{ '--p': share(now) } as CSSProperties} />
          <span className={styles.now} style={{ '--p': share(now) } as CSSProperties}>
            <span className={cardStyles.mono}>NOW</span>
          </span>
          {moving && moved && moving.at !== moving.from && (
            <>
              <span
                className={styles.ghost}
                style={{ '--p': share(moving.from) } as CSSProperties}
                aria-hidden="true"
              />
              <span
                className={styles.link}
                style={
                  {
                    '--a': share(Math.min(moving.from, moving.at)),
                    '--b': share(Math.max(moving.from, moving.at)),
                  } as CSSProperties
                }
                aria-hidden="true"
              />
            </>
          )}
          {today.map((thread) => {
            const at = timeOf(thread);
            const lifted = moving?.id === thread.id;
            return (
              <span
                key={thread.id}
                role="slider"
                tabIndex={0}
                className={styles.pin}
                data-part="pin"
                data-next={thread.id === next?.id || undefined}
                data-lifted={lifted || undefined}
                data-demo={`snooze-pin-${thread.id}`}
                aria-label={`${thread.name}, ${thread.subject}`}
                aria-valuemin={START}
                aria-valuemax={END}
                aria-valuenow={at}
                aria-valuetext={clock(at)}
                style={{ '--p': share(at), '--tone': thread.tone } as CSSProperties}
                onPointerDown={(event) => onPinDown(event, thread)}
                onKeyDown={(event) => onPinKey(event, thread)}
              >
                <Avatar name={thread.name} tone={thread.tone} className={styles.pinFace} />
                {lifted && moving && (
                  <span className={`${cardStyles.mono} ${styles.tip}`} aria-hidden="true">
                    {clock(moving.at)}
                    {moving.at !== moving.from && (
                      <span className={styles.tipDelta}>{delta(moving.at - moving.from)}</span>
                    )}
                  </span>
                )}
              </span>
            );
          })}
        </div>
        <div className={styles.ticks} aria-hidden="true">
          {Array.from({ length: 13 }, (_, hour) => (
            <span key={hour} data-major={hour % 3 === 0 || undefined} />
          ))}
        </div>
        <div className={`${cardStyles.mono} ${styles.hours}`} aria-hidden="true">
          {['08', '11', '14', '17', '20'].map((hour) => (
            <span key={hour}>{hour}</span>
          ))}
        </div>
      </div>

      <ul className={styles.rows}>
        {listed.map((thread) => (
          <li
            key={thread.id}
            className={styles.row}
            data-hover={hovered === thread.id || undefined}
            data-demo={`snooze-row-${thread.id}`}
            onPointerEnter={(event) => event.pointerType !== 'touch' && setHovered(thread.id)}
            onPointerLeave={() => setHovered((was) => (was === thread.id ? null : was))}
          >
            <Avatar name={thread.name} tone={thread.tone} className={styles.rowAvatar} />
            <span className={styles.words}>
              <span className={styles.name}>{thread.name}</span>
              <span className={styles.subject}>{thread.subject}</span>
            </span>
            <span className={`${cardStyles.mono} ${styles.when}`}>
              <SmallClockIcon />
              {thread.day ? `+${thread.day} D` : <Roll value={clock(timeOf(thread))} />}
            </span>
            <span className={styles.rowActions}>
              <button
                type="button"
                className={cardStyles.button}
                data-demo={`snooze-wake-${thread.id}`}
                aria-label={`Wake ${thread.name} now`}
                onClick={() => wake([thread.id])}
              >
                Wake now
              </button>
              <button
                type="button"
                className={cardStyles.button}
                data-demo={`snooze-later-${thread.id}`}
                aria-label={`Snooze ${thread.name} one hour more`}
                onClick={() => {
                  const at = update(thread.id, thread.at + 60);
                  announce(`${thread.name} moved to ${clock(at)}.`);
                }}
              >
                +1 h
              </button>
            </span>
          </li>
        ))}
        {!asleep.length && <li className={styles.empty}>Nothing snoozed</li>}
      </ul>

      <footer className={styles.foot}>
        <span className={cardStyles.label}>
          {asleep.length > SHOWN ? (
            <>
              <Roll value={String(asleep.length - SHOWN)} /> more this week
            </>
          ) : (
            'That’s all this week'
          )}
        </span>
        <span className={styles.actions}>
          <button
            type="button"
            className={cardStyles.button}
            data-part="button"
            disabled={!asleep.length}
            onClick={() => wake(asleep.map((thread) => thread.id))}
          >
            Wake all
          </button>
          <button
            type="button"
            className={`${cardStyles.button} ${cardStyles.primary}`}
            data-part="primary"
            onClick={onRules}
          >
            Snooze rules
          </button>
        </span>
      </footer>
      <p className={cardStyles.hidden} role="status">
        {message}
      </p>
    </Card>
  );
}
```

### inbox-widgets/snoozed.module.css

```css
/* Snoozed (snoozed.tsx). Measured on the card at 520 px: the rack, the heading and its countdown,
   the timeline (pins over a track, ticks, hours), the two rows and the footer. */

.art {
  width: 226px;
  max-width: 66%;
  height: auto;
  margin: 30px auto 0;
}
.heading {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  column-gap: 0.3em;
  margin: 31px 0 0;
  font: 500 18.5px/24px var(--font);
  letter-spacing: -0.02em;
}
.countdown {
  margin-left: 0.5em;
  color: var(--accent-text);
  font-size: 13px;
  letter-spacing: 0.08em;
  white-space: nowrap;
}

/* The timeline. */
.timeline {
  position: relative;
  height: 116px;
  margin-top: 10.5px;
  box-sizing: border-box;
  border: 1px solid var(--panel-edge);
  border-radius: 16px;
  background: var(--panel);
  user-select: none;
}
.timeHead {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  padding: 12px 13px 0;
}
.timeHead > :last-child {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.track {
  position: absolute;
  top: 63px;
  left: 14px;
  right: 12px;
  height: 4px;
  border-radius: 2px;
  background: var(--track);
  touch-action: none;
}
.past {
  position: absolute;
  inset: 0 auto 0 0;
  width: calc(var(--p) * 100%);
  border-radius: inherit;
  background: color-mix(in srgb, var(--track), var(--ink) 18%);
}
.now {
  position: absolute;
  top: -27px;
  left: calc(var(--p) * 100%);
  height: 42px;
  border-left: 1px dashed color-mix(in srgb, var(--ink), transparent 45%);
  pointer-events: none;
}
.now > span {
  position: absolute;
  top: -1px;
  left: 4px;
  color: var(--ink-2);
  font-size: 12px;
  line-height: 15px;
}
.pin {
  position: absolute;
  bottom: 2px;
  left: calc(var(--p) * 100%);
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 0;
  touch-action: none;
  cursor: grab;
  transition: left 420ms cubic-bezier(0.3, 1.2, 0.5, 1);
}
.pin::after {
  content: '';
  width: 1.5px;
  height: 7px;
  background: color-mix(in srgb, var(--tone), var(--card) 15%);
}
.pinFace {
  flex: none;
  width: 20px;
  height: 20px;
  margin-bottom: -1px;
  font-size: 10.5px;
  transition:
    width 200ms cubic-bezier(0.3, 1.4, 0.5, 1),
    height 200ms cubic-bezier(0.3, 1.4, 0.5, 1),
    box-shadow 200ms ease,
    translate 200ms ease;
}
.pin[data-next] .pinFace {
  width: 22px;
  height: 22px;
  box-shadow: 0 0 0 2px var(--accent);
}
.pin[data-next]::after {
  background: var(--accent);
}
.pin[data-lifted] {
  z-index: 3;
  cursor: grabbing;
  transition: none;
}
.pin[data-lifted] .pinFace {
  width: 25px;
  height: 25px;
  translate: 0 -4px;
  box-shadow:
    0 0 0 2px #fff,
    0 6px 14px var(--float-shadow);
}
.pin[data-lifted]::after {
  background: var(--accent);
}
.pin:focus-visible {
  outline: none;
}
.pin:focus-visible .pinFace {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}
.ghost {
  position: absolute;
  bottom: 9px;
  left: calc(var(--p) * 100%);
  width: 21px;
  height: 21px;
  margin-left: -10.5px;
  box-sizing: border-box;
  border: 1px dashed color-mix(in srgb, var(--muted), transparent 10%);
  border-radius: 50%;
}
.link {
  position: absolute;
  bottom: 19px;
  left: calc(var(--a) * 100% + 11px);
  width: calc((var(--b) - var(--a)) * 100% - 24px);
  border-top: 1.5px dotted var(--accent);
}
.tip {
  position: absolute;
  bottom: 38px;
  display: flex;
  align-items: center;
  gap: 8px;
  height: 24px;
  padding: 0 8px;
  border: 1px solid var(--float-edge);
  border-radius: 7px;
  background: var(--float);
  box-shadow: 0 4px 12px var(--float-shadow);
  color: var(--ink);
  font-size: 13px;
  white-space: nowrap;
  pointer-events: none;
}
.tipDelta {
  color: var(--accent-text);
}
.ticks {
  position: absolute;
  top: 71px;
  left: 14px;
  right: 12px;
  display: flex;
  justify-content: space-between;
  height: 6px;
}
.ticks > span {
  width: 1px;
  height: 3px;
  background: var(--tick);
}
.ticks > span[data-major] {
  height: 6px;
  background: color-mix(in srgb, var(--tick), var(--ink) 18%);
}
.hours {
  position: absolute;
  top: 87px;
  left: 14px;
  right: 12px;
  display: flex;
  justify-content: space-between;
  color: var(--muted);
  font-size: 13px;
  line-height: 15px;
}

/* The rows. */
.rows {
  margin: 14px 0 0;
  padding: 0;
  list-style: none;
}
.row {
  display: flex;
  align-items: center;
  gap: 12px;
  height: 51px;
  padding: 0 7.5px 0 12px;
  border-radius: 18px;
  transition: background-color 180ms ease;
}
.row + .row {
  margin-top: 3px;
}
.rowAvatar {
  width: 28px;
  height: 28px;
  font-size: 13px;
}
.words {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  margin-right: auto;
}
.name {
  overflow: hidden;
  font: 500 14.5px/18px var(--font);
  letter-spacing: -0.01em;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.subject {
  overflow: hidden;
  color: var(--muted);
  font: 500 11.8px/15px var(--font);
  letter-spacing: 0.012em;
  text-transform: uppercase;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.when {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 24px;
  padding: 0 9px 0 8px;
  border-radius: 6px;
  background: var(--accent-wash);
  color: var(--accent-ink);
  font-size: 13.5px;
}
.when svg {
  width: 13px;
  height: 13px;
}
.rowActions {
  display: none;
  gap: 13px;
}
.rowActions > button {
  height: 28px;
  padding: 0 14px;
  background: var(--control-2);
}
.row[data-hover],
.row:focus-within {
  background: var(--control);
}
.row[data-hover] .when,
.row:focus-within .when {
  display: none;
}
.row[data-hover] .rowActions,
.row:focus-within .rowActions {
  display: flex;
}
.empty {
  padding: 16px 12px;
  color: var(--muted);
  font: 500 13px/1.2 var(--font);
  text-transform: uppercase;
}

.foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin: 17px 0 3px;
}
.foot > :first-child {
  font-size: 12.5px;
}
.actions {
  display: flex;
  gap: 16px;
  margin-left: auto;
}
.actions button {
  height: 32px;
  padding: 0 14.5px;
}
.actions button:disabled {
  opacity: 0.45;
  cursor: default;
}

@container (max-width: 439px) {
  .row {
    padding-left: 8px;
  }
  .rowActions {
    gap: 6px;
  }
  .rowActions > button {
    padding: 0 10px;
  }
  .actions {
    gap: 8px;
  }
  .foot {
    margin-bottom: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .pin,
  .pinFace,
  .row {
    transition: none;
  }
}
```

### inbox-widgets/inbox-load.tsx

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

'use client';

import { useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';
import { BarsIcon, Card, Roll, cardStyles, type PanelProps } from './card';
import styles from './inbox-load.module.css';

/*
 * Inbox load: how much mail came in, day by day, over the last fourteen weeks, as a heatmap
 * (weekdays down, weeks across), with the weekly average, its change, and three figures.
 *
 * - A cell's shade is its level: five steps from none to the most. Hover or focus one and it is
 *   ringed, and a tooltip under it (or over it, low in the week) gives the day, the week and the
 *   count, joined to the cell by a dotted line.
 * - The heatmap is a grid: one tab stop, arrow keys move between days and weeks, Home and End
 *   go to the ends of a row.
 * - The legend's steps are buttons: press one to show only the days at that level.
 */

export type LoadSummary = {
  perWeek: number;
  /** Against the fourteen weeks before, as a share (−0.08 is 8 % less). */
  change: number;
  busiest: string;
  stats: { value: string; label: string; tone?: 'orange' }[];
};

type Shown = { day: number; week: number; x: number; top: number; bottom: number };

const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];

/** The levels of the last fourteen weeks, one row per weekday. */
const LEVELS = [
  [2, 3, 2, 2, 2, 3, 3, 3, 3, 2, 3, 3, 4, 3],
  [2, 2, 2, 3, 2, 2, 3, 3, 4, 4, 4, 3, 4, 4],
  [3, 4, 2, 3, 3, 3, 4, 4, 3, 3, 4, 3, 4, 3],
  [3, 2, 3, 2, 3, 2, 3, 3, 2, 4, 3, 3, 3, 4],
  [2, 1, 2, 2, 2, 2, 2, 2, 2, 3, 2, 2, 2, 3],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
];
/** Counts for each level, spread a little by a fixed hash so the days differ. */
const RANGE = [
  [6, 28],
  [92, 120],
  [122, 160],
  [162, 205],
  [210, 248],
];
const hash = (a: number, b: number) => {
  const x = Math.sin(a * 127.1 + b * 311.7) * 43758.5453;
  return x - Math.floor(x);
};

/** Threads per day: [weekday][week]. */
export const LOAD: number[][] = LEVELS.map((row, day) =>
  row.map((level, week) => {
    if (day === 1 && week === 10) return 230;
    const [low, high] = RANGE[level];
    return Math.round(low + hash(day, week) * (high - low));
  }),
);

export const LOAD_SUMMARY: LoadSummary = {
  perWeek: 1284,
  change: -0.08,
  busiest: 'Tue 9–11',
  stats: [
    { value: '1h 40m', label: 'Reply' },
    { value: '6 d', label: 'Inbox zero', tone: 'orange' },
    { value: '64%', label: 'Auto-sorted' },
  ],
};

const MONTHS = ['Jul', 'Aug', 'Sep', 'Oct'];

/** The level of a count: the highest whose range it reaches. */
const levelOf = (count: number) => {
  for (let level = RANGE.length - 1; level > 0; level -= 1)
    if (count >= RANGE[level][0]) return level;
  return 0;
};

export function InboxLoad({
  load = LOAD,
  summary = LOAD_SUMMARY,
  months = MONTHS,
  defaultOnly = null,
  onClose,
  colorScheme,
  className,
}: PanelProps & {
  /** Threads per day, seven rows (Monday first) of as many weeks. */
  load?: number[][];
  summary?: LoadSummary;
  /** The month names over every fourth week. */
  months?: string[];
  /** Show only one level of the heatmap at first (0–4), the others dimmed. */
  defaultOnly?: number | null;
}) {
  const weeks = load[0]?.length ?? 0;
  const [focus, setFocus] = useState<[number, number]>([1, Math.min(10, weeks - 1)]);
  /** The cell the tooltip is about, with its box in the grid's px (read when it is shown). */
  const [shown, setShown] = useState<Shown | null>(null);
  const [only, setOnly] = useState<number | null>(defaultOnly);
  const cells = useRef(new Map<string, HTMLElement>());

  const show = (day: number, week: number) => {
    const cell = cells.current.get(`${day}:${week}`);
    if (!cell) return;
    setShown({
      day,
      week,
      x: cell.offsetLeft + cell.offsetWidth / 2,
      top: cell.offsetTop,
      bottom: cell.offsetTop + cell.offsetHeight,
    });
  };

  const move = (day: number, week: number) => {
    const next: [number, number] = [
      Math.max(0, Math.min(6, day)),
      Math.max(0, Math.min(weeks - 1, week)),
    ];
    setFocus(next);
    cells.current.get(next.join(':'))?.focus();
  };

  const onKey = (event: KeyboardEvent) => {
    const [day, week] = focus;
    const keys: Record<string, [number, number]> = {
      ArrowUp: [day - 1, week],
      ArrowDown: [day + 1, week],
      ArrowLeft: [day, week - 1],
      ArrowRight: [day, week + 1],
      Home: [day, 0],
      End: [day, weeks - 1],
    };
    if (!(event.key in keys)) return;
    event.preventDefault();
    move(...keys[event.key]);
  };

  const tip = shown && { ...shown, count: load[shown.day][shown.week] };
  const below = tip ? tip.day <= 2 : true;
  /** The tooltip (46 px tall) hangs under the cell, clear of the next rows, or sits over it. */
  const tipTop = tip ? (below ? Math.max(tip.bottom + 30, 80.5) : tip.top - 76) : 0;
  const reach = tip ? (below ? tipTop - tip.bottom : tip.top - tipTop - 46) : 0;
  const change = Math.round(summary.change * 100);

  return (
    <Card
      glyph={<BarsIcon />}
      title="Inbox load"
      tag={`${weeks} weeks`}
      onClose={onClose}
      colorScheme={colorScheme}
      className={className}
    >
      <div className={styles.summary}>
        <span className={styles.big}>
          <Roll value={summary.perWeek.toLocaleString('en-US')} />
        </span>
        <span className={cardStyles.label}>/ week</span>
        <span
          className={`${cardStyles.mono} ${styles.badge}`}
          data-worse={change > 0 || undefined}
          aria-label={`${Math.abs(change)}% ${change > 0 ? 'more' : 'less'} than before`}
        >
          {change > 0 ? '+' : '−'}
          {Math.abs(change)}%
        </span>
      </div>

      <div className={styles.heat} style={{ '--weeks': weeks } as CSSProperties}>
        <div className={`${cardStyles.mono} ${styles.months}`} aria-hidden="true">
          {months.map((month, index) => (
            <span key={month} style={{ gridColumn: `${index * 4 + 2} / span 4` }}>
              {month.toUpperCase()}
            </span>
          ))}
        </div>
        <div
          className={styles.grid}
          role="grid"
          aria-label={`Threads per day, last ${weeks} weeks`}
          data-only={only ?? undefined}
          onKeyDown={onKey}
          onPointerLeave={() => setShown(null)}
          onBlur={(event) => {
            if (!event.currentTarget.contains(event.relatedTarget)) setShown(null);
          }}
        >
          {load.map((row, day) => (
            <div key={day} role="row" className={styles.row}>
              <span role="rowheader" className={`${cardStyles.mono} ${styles.dayName}`}>
                <span aria-hidden="true">{DAYS[day].charAt(0)}</span>
                <span className={cardStyles.hidden}>{DAYS[day]}</span>
              </span>
              {row.map((count, week) => {
                const level = levelOf(count);
                const isFocus = focus[0] === day && focus[1] === week;
                return (
                  <span
                    key={week}
                    ref={(element) => {
                      if (element) cells.current.set(`${day}:${week}`, element);
                      else cells.current.delete(`${day}:${week}`);
                    }}
                    role="gridcell"
                    tabIndex={isFocus ? 0 : -1}
                    className={styles.cell}
                    data-part="cell"
                    data-level={level}
                    data-shown={(shown && shown.day === day && shown.week === week) || undefined}
                    data-dim={(only !== null && only !== level) || undefined}
                    data-demo={`load-${day}-${week}`}
                    aria-label={`${DAYS[day]}, week ${week + 1}: ${count} threads`}
                    onPointerEnter={() => show(day, week)}
                    onFocus={() => {
                      setFocus([day, week]);
                      show(day, week);
                    }}
                    onClick={() => setFocus([day, week])}
                  />
                );
              })}
            </div>
          ))}
          {tip && (
            <span
              className={`${cardStyles.mono} ${styles.tip}`}
              data-below={below || undefined}
              style={
                {
                  '--x': `${tip.x}px`,
                  '--tip-top': `${tipTop}px`,
                  '--reach': `${reach}px`,
                } as CSSProperties
              }
              aria-hidden="true"
            >
              <span className={styles.tipLine} />
              <span className={styles.tipWhen}>
                {DAYS[tip.day].slice(0, 3).toUpperCase()} · WK {tip.week + 1}
              </span>
              <span>
                <Roll value={String(tip.count)} /> THREADS
              </span>
            </span>
          )}
        </div>
      </div>

      <div className={styles.legend}>
        <span className={cardStyles.label}>Less</span>
        <span className={styles.steps} role="group" aria-label="Show only one level">
          {[0, 1, 2, 3, 4].map((level) => (
            <button
              key={level}
              type="button"
              className={styles.step}
              data-level={level}
              aria-pressed={only === level}
              aria-label={`Level ${level + 1} of 5`}
              data-demo={`load-level-${level}`}
              onClick={() => setOnly((was) => (was === level ? null : level))}
            />
          ))}
        </span>
        <span className={cardStyles.label}>More</span>
        <span className={`${cardStyles.label} ${styles.busiest}`}>Busiest {summary.busiest}</span>
      </div>

      <dl className={styles.stats}>
        {summary.stats.map((stat) => (
          <div key={stat.label} className={styles.stat}>
            <dt className={cardStyles.label}>{stat.label}</dt>
            <dd className={`${cardStyles.mono} ${styles.value}`} data-tone={stat.tone}>
              {stat.value}
            </dd>
          </div>
        ))}
      </dl>
    </Card>
  );
}
```

### inbox-widgets/inbox-load.module.css

```css
/* Inbox load (inbox-load.tsx). Measured on the card at 300 px: the weekly figure and its badge,
   the heatmap (months, weekday letters, 7 × 14 square cells), the legend and the stats. */

.summary {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-top: 17px;
}
.big {
  font: 500 26px/26px var(--font);
  letter-spacing: -0.045em;
}
.badge {
  align-self: center;
  margin-left: auto;
  display: grid;
  place-items: center;
  height: 23px;
  padding: 0 8px;
  border-radius: 7px;
  background: var(--green-wash);
  color: var(--green-ink);
  font-size: 12.5px;
  letter-spacing: 0.02em;
}
.badge[data-worse] {
  background: color-mix(in srgb, var(--orange), transparent 82%);
  color: var(--orange);
}

/* The heatmap: the weekday letters take the first 18 px, the weeks share the rest. */
.heat {
  margin-top: 13px;
}
.months {
  display: grid;
  grid-template-columns: 15px repeat(var(--weeks), 1fr);
  column-gap: 3px;
  margin-right: -3px;
  color: var(--muted);
  font-size: 13px;
  line-height: 15px;
}
.grid {
  position: relative;
  margin-top: 12.5px;
}
.row {
  display: flex;
  gap: 3px;
}
.row + .row {
  margin-top: 3px;
}
.dayName {
  flex: none;
  width: 15px;
  color: var(--muted);
  font-size: 12px;
  line-height: 15px;
  align-self: center;
}
.cell {
  position: relative;
  flex: 1 1 0;
  min-width: 0;
  aspect-ratio: 1;
  border-radius: 4px;
  background: var(--level);
  transition:
    opacity 200ms ease,
    background-color 200ms ease;
}
.cell::after {
  content: '';
  position: absolute;
  inset: -2.5px;
  border: 2px solid var(--ink);
  border-radius: 6px;
  opacity: 0;
  scale: 1.15;
  transition:
    opacity 140ms ease,
    scale 200ms cubic-bezier(0.3, 1.3, 0.5, 1);
}
.cell[data-shown]::after {
  opacity: 1;
  scale: 1;
}
.cell:focus-visible {
  outline: none;
}
.cell:focus-visible::after {
  border-color: var(--focus);
  opacity: 1;
  scale: 1;
}
.cell[data-dim] {
  opacity: 0.18;
}
.cell[data-level='0'],
.step[data-level='0'] {
  --level: var(--heat-0);
}
.cell[data-level='1'],
.step[data-level='1'] {
  --level: var(--heat-1);
}
.cell[data-level='2'],
.step[data-level='2'] {
  --level: var(--heat-2);
}
.cell[data-level='3'],
.step[data-level='3'] {
  --level: var(--heat-3);
}
.cell[data-level='4'],
.step[data-level='4'] {
  --level: var(--heat-4);
}

.tip {
  position: absolute;
  top: var(--tip-top);
  left: clamp(18px, calc(var(--x) - 51px), calc(100% - 102px));
  z-index: 2;
  display: flex;
  flex-direction: column;
  justify-content: center;
  box-sizing: border-box;
  width: 103px;
  height: 46px;
  padding: 0 0 0 13px;
  border: 1px solid var(--float-edge);
  border-radius: 9px;
  background: color-mix(in srgb, var(--float), transparent 6%);
  box-shadow: 0 6px 18px var(--float-shadow);
  color: var(--ink);
  font-size: 12.5px;
  line-height: 18px;
  letter-spacing: 0.04em;
  white-space: nowrap;
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  pointer-events: none;
  animation: tip 180ms ease-out;
}
@keyframes tip {
  from {
    opacity: 0;
    translate: 0 4px;
  }
}
.tipWhen {
  color: var(--muted);
}
.tipLine {
  position: absolute;
  left: calc(var(--x) - clamp(18px, calc(var(--x) - 51px), calc(100% - 102px)) - 1px);
  bottom: 100%;
  height: var(--reach);
  border-left: 1px dashed color-mix(in srgb, var(--ink), transparent 40%);
}
.tip:not([data-below]) .tipLine {
  top: 100%;
  bottom: auto;
}

.legend {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-top: 13px;
}
.steps {
  display: flex;
  gap: 5.5px;
}
.step {
  width: 9px;
  height: 9px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 2px;
  background: var(--level);
  cursor: pointer;
  transition: scale 160ms ease;
}
.step:hover {
  scale: 1.25;
}
.step[aria-pressed='true'] {
  box-shadow:
    0 0 0 1.5px var(--card),
    0 0 0 3px var(--ink-2);
}
.busiest {
  margin-left: auto;
  color: var(--ink-2);
  white-space: nowrap;
}

.stats {
  display: grid;
  grid-template-columns: repeat(3, 80px);
  margin: 14px 0 8.5px;
  padding: 9px 12.5px 8px;
  border: 1px solid var(--panel-edge);
  border-radius: 16px;
  background: var(--panel);
}
.stat {
  display: flex;
  flex-direction: column-reverse;
  gap: 4px;
  min-width: 0;
}
.stat dt {
  white-space: nowrap;
}
.value {
  margin: 0;
  font-size: 15px;
  line-height: 20px;
  letter-spacing: 0.01em;
}
.value[data-tone='orange'] {
  color: var(--orange);
}

@container (max-width: 299px) {
  .stats {
    grid-template-columns: repeat(3, 1fr);
    column-gap: 6px;
  }
  .stat dt {
    font-size: 10.5px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .cell,
  .cell::after,
  .step {
    transition: none;
  }
  .tip {
    animation: none;
  }
}
```

### inbox-widgets/threads.tsx

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

'use client';

import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { Card, Roll, ThreadsIcon, cardStyles, type PanelProps } from './card';
import styles from './threads.module.css';

/*
 * Threads: who is waiting on whom. Each thread is a line over the last week, with a dot per
 * message (theirs grey, yours blue). When the last word was theirs, the time since is an orange
 * bar running to now: the thread is waiting on you. When it was yours, a dotted line: you wait.
 *
 * - The heading counts the threads waiting on you (all of them, listed or not) and the longest
 *   wait; the figures roll when they change.
 * - Press a row to mark it replied: a blue dot drops in at now, the orange bar draws back, the
 *   count goes down. Press again to undo.
 * - The first time it comes on screen, the dots pop in from the left and the bars draw out.
 */

export type Message = { from: 'them' | 'you'; /** Days ago. */ ago: number };
export type WaitingThread = { id: string; name: string; topic: string; messages: Message[] };

export const WAITING: WaitingThread[] = [
  {
    id: 'rui',
    name: 'Rui Ortega',
    topic: 'Contract',
    messages: [
      { from: 'you', ago: 5.96 },
      { from: 'them', ago: 4.35 },
      { from: 'you', ago: 3.31 },
    ],
  },
  {
    id: 'dana',
    name: 'Dana Lee',
    topic: 'Offsite',
    messages: [
      { from: 'them', ago: 6.6 },
      { from: 'you', ago: 5.54 },
      { from: 'them', ago: 1.59 },
    ],
  },
  {
    id: 'ines',
    name: 'Ines Park',
    topic: 'Hiring plan',
    messages: [
      { from: 'you', ago: 4.78 },
      { from: 'them', ago: 2.71 },
      { from: 'you', ago: 1.06 },
    ],
  },
  {
    id: 'finance',
    name: 'Finance',
    topic: 'Invoices',
    messages: [
      { from: 'them', ago: 3.29 },
      { from: 'them', ago: 0.82 },
    ],
  },
  {
    id: 'legal',
    name: 'Legal',
    topic: 'Lease renewal',
    messages: [
      { from: 'you', ago: 6.1 },
      { from: 'them', ago: 2.42 },
    ],
  },
];

/** How many days the lines span, back from now. */
const SPAN = 6.8;
const AXIS = [
  { label: 'Mon', ago: 6.5 },
  { label: 'Wed', ago: 4.5 },
  { label: 'Fri', ago: 2.5 },
];
const x = (ago: number) => 1 - Math.min(SPAN, ago) / SPAN;
const days = (ago: number) => `${ago.toFixed(1)} D`;

export function Threads({
  threads: initial = WAITING,
  shown = 4,
  defaultReplied = [],
  onReply,
  onClose,
  colorScheme,
  className,
}: PanelProps & {
  /** Every open thread; the first `shown` are drawn, the heading counts them all. */
  threads?: WaitingThread[];
  shown?: number;
  /** Threads already marked replied at first, by id. */
  defaultReplied?: string[];
  /** Called when a row is marked replied (true) or unmarked. */
  onReply?: (id: string, replied: boolean) => void;
}) {
  const [replied, setReplied] = useState<string[]>(defaultReplied);
  const [entered, setEntered] = useState(false);
  const list = useRef<HTMLOListElement>(null);

  // The first time the lines come on screen, they draw themselves in.
  useEffect(() => {
    const element = list.current;
    if (!element) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;
        setEntered(true);
        observer.disconnect();
      },
      { threshold: 0.4 },
    );
    observer.observe(element);
    return () => observer.disconnect();
  }, []);

  const withReplies = initial.map((thread) =>
    replied.includes(thread.id)
      ? { ...thread, messages: [...thread.messages, { from: 'you' as const, ago: 0 }] }
      : thread,
  );
  const lastOf = (thread: WaitingThread) => thread.messages[thread.messages.length - 1];
  const waiting = withReplies.filter((thread) => lastOf(thread)?.from === 'them');
  const longest = Math.max(0, ...waiting.map((thread) => lastOf(thread).ago));

  return (
    <Card
      glyph={<ThreadsIcon />}
      title="Threads"
      tag="Waiting"
      onClose={onClose}
      colorScheme={colorScheme}
      className={className}
    >
      <div className={styles.head} aria-live="polite">
        <h3 className={styles.heading}>
          <Roll value={String(waiting.length)} /> waiting on you
        </h3>
        {waiting.length > 0 && (
          <span className={`${cardStyles.mono} ${styles.longest}`}>
            LONGEST <Roll value={days(longest)} />
          </span>
        )}
      </div>

      <ol ref={list} className={styles.rows} data-entered={entered || undefined}>
        {withReplies.slice(0, shown).map((thread, row) => {
          const last = lastOf(thread);
          const onYou = last.from === 'them';
          const isReplied = replied.includes(thread.id);
          const lastThem = thread.messages.findLast((message) => message.from === 'them');
          return (
            <li key={thread.id}>
              <button
                type="button"
                className={styles.row}
                aria-pressed={isReplied}
                data-demo={`threads-${thread.id}`}
                aria-label={`${thread.name}, ${thread.topic}: ${
                  onYou
                    ? `waiting on you for ${last.ago.toFixed(1)} days`
                    : `you wrote last, ${last.ago.toFixed(1)} days ago`
                }. Press to mark ${isReplied ? 'not replied' : 'replied'}.`}
                onClick={() => {
                  setReplied((was) =>
                    was.includes(thread.id)
                      ? was.filter((id) => id !== thread.id)
                      : [...was, thread.id],
                  );
                  onReply?.(thread.id, !isReplied);
                }}
              >
                <span className={styles.line1}>
                  <span className={styles.name}>{thread.name}</span>
                  <span className={styles.topic}>{thread.topic}</span>
                  <span
                    className={`${cardStyles.mono} ${styles.ago}`}
                    data-on-you={onYou || undefined}
                  >
                    <Roll value={days(last.ago)} />
                  </span>
                </span>
                <span className={styles.line} aria-hidden="true">
                  <span className={styles.nowMark} />
                  {/* From their last message to now, an orange bar while it is on you; from
                      yours, a dotted line. */}
                  {lastThem && (
                    <span
                      className={styles.onYou}
                      data-off={!onYou || undefined}
                      style={{ '--a': x(lastThem.ago), '--row': row } as CSSProperties}
                    />
                  )}
                  {!onYou && last.ago > 0 && (
                    <span
                      className={styles.waitingOnThem}
                      style={{ '--a': x(last.ago), '--row': row } as CSSProperties}
                    />
                  )}
                  {thread.messages.map((message, index) => (
                    <span
                      key={`${index}-${message.ago}`}
                      className={styles.dot}
                      data-you={message.from === 'you' || undefined}
                      data-new={(isReplied && message.ago === 0) || undefined}
                      style={{ '--a': x(message.ago), '--row': row } as CSSProperties}
                    />
                  ))}
                </span>
              </button>
            </li>
          );
        })}
      </ol>

      <div className={`${cardStyles.mono} ${styles.axis}`} aria-hidden="true">
        {AXIS.map((tick) => (
          <span key={tick.label} style={{ '--a': x(tick.ago) } as CSSProperties}>
            {tick.label.toUpperCase()}
          </span>
        ))}
        <span className={styles.axisNow} style={{ '--a': 1 } as CSSProperties}>
          NOW
        </span>
      </div>

      <ul className={styles.legend} aria-label="Legend">
        <li>
          <span className={styles.dot} aria-hidden="true" /> Them
        </li>
        <li>
          <span className={styles.dot} data-you aria-hidden="true" /> You
        </li>
        <li>
          <span className={styles.bar} aria-hidden="true" /> On you
        </li>
      </ul>
    </Card>
  );
}
```

### inbox-widgets/threads.module.css

```css
/* Threads (threads.tsx). Measured on the card at 300 px: the heading with the longest wait, four
   lines over the week (a 42 px row each: names, then the line), the days and the legend. The
   lines run from the left edge to 34 px short of the right, where now is. */

.head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin-top: 16.5px;
}
.heading {
  margin: 0;
  font: 500 18.5px/24px var(--font);
  letter-spacing: -0.02em;
  white-space: nowrap;
}
.longest {
  color: var(--orange);
  font-size: 13px;
  letter-spacing: 0.06em;
  white-space: nowrap;
}

.rows {
  --now: 34px;
  margin: 4px 0 0;
  padding: 0;
  list-style: none;
}
.row {
  position: relative;
  display: block;
  width: calc(100% + 12px);
  height: 42px;
  margin: 0 -6px;
  padding: 0 6px;
  border: 0;
  border-radius: 10px;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: background-color 160ms ease;
}
.row:hover {
  background: color-mix(in srgb, var(--control), transparent 40%);
}
.line1 {
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding-top: 8px;
  font: 500 12.5px/15px var(--font);
  letter-spacing: 0.012em;
  text-transform: uppercase;
  white-space: nowrap;
}
.name {
  color: var(--ink);
}
.topic {
  overflow: hidden;
  color: var(--muted);
  text-overflow: ellipsis;
}
.ago {
  margin-left: auto;
  color: var(--muted);
  font-size: 13px;
  letter-spacing: 0.06em;
  transition: color 240ms ease;
}
.ago[data-on-you] {
  color: var(--orange);
}

/* The line: a hairline to now, with its marks placed by --a (0 at the left, 1 at now). */
.line {
  position: absolute;
  top: 34.5px;
  left: 6px;
  right: calc(6px + var(--now));
  height: 1px;
  background: var(--track);
}
.nowMark {
  position: absolute;
  top: -6px;
  right: -1px;
  height: 13px;
  border-left: 1px dashed color-mix(in srgb, var(--ink), transparent 55%);
}
.dot {
  position: absolute;
  top: -3px;
  left: calc(var(--a) * 100%);
  width: 7px;
  height: 7px;
  margin-left: -3.5px;
  border-radius: 50%;
  background: var(--them);
}
.dot[data-you] {
  background: var(--accent);
}
.onYou {
  position: absolute;
  top: -1.5px;
  left: calc(var(--a) * 100%);
  right: 0;
  height: 4px;
  border-radius: 2px;
  background: var(--orange);
  transform-origin: left;
  transition: scale 420ms cubic-bezier(0.22, 1, 0.36, 1);
}
.onYou[data-off] {
  scale: 0 1;
}
.waitingOnThem {
  position: absolute;
  top: -0.5px;
  left: calc(var(--a) * 100%);
  right: 0;
  border-top: 2px dotted color-mix(in srgb, var(--ink), transparent 30%);
}
.dot[data-new] {
  animation: drop 420ms cubic-bezier(0.3, 1.5, 0.5, 1);
}
@keyframes drop {
  from {
    scale: 0;
  }
}

/* Drawing in, the first time: dots pop from the left, bars and dotted lines draw out. */
.rows:not([data-entered]) .dot,
.rows:not([data-entered]) .onYou,
.rows:not([data-entered]) .waitingOnThem {
  opacity: 0;
}
.rows[data-entered] .dot {
  animation: pop 420ms cubic-bezier(0.3, 1.5, 0.5, 1) both;
  animation-delay: calc(var(--row) * 80ms + var(--a) * 500ms);
}
.rows[data-entered] .onYou,
.rows[data-entered] .waitingOnThem {
  animation: draw 600ms cubic-bezier(0.22, 1, 0.36, 1) both;
  animation-delay: calc(var(--row) * 80ms + var(--a) * 500ms + 120ms);
  transform-origin: left;
}
@keyframes pop {
  from {
    scale: 0;
    opacity: 0;
  }
}
@keyframes draw {
  from {
    scale: 0 1;
  }
}

.axis {
  position: relative;
  height: 15px;
  margin: 12.5px 34px 0 0;
  color: var(--muted);
  font-size: 13px;
  line-height: 15px;
  letter-spacing: 0.04em;
}
.axis > span {
  position: absolute;
  left: calc(var(--a) * 100%);
  translate: -50% 0;
}
.axisNow {
  color: var(--ink);
}

.legend {
  display: flex;
  gap: 16px;
  margin: 13px 0 0;
  padding: 0;
  list-style: none;
  color: var(--muted);
  font: 500 12.5px/15px var(--font);
  letter-spacing: 0.012em;
  text-transform: uppercase;
}
.legend li {
  display: flex;
  align-items: center;
  gap: 8px;
}
.legend .dot {
  position: static;
  margin: 0 1px;
}
.bar {
  width: 14px;
  height: 4px;
  border-radius: 2px;
  background: var(--orange);
}

@media (prefers-reduced-motion: reduce) {
  .rows .dot,
  .rows .onYou,
  .rows .waitingOnThem {
    animation: none !important;
    opacity: 1 !important;
  }
  .onYou,
  .ago,
  .row {
    transition: none;
  }
}
```

### inbox-widgets/art.tsx

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

'use client';

import { useEffect, useRef, type CSSProperties } from 'react';
import styles from './art.module.css';

/*
 * The two line drawings: the letter tray of the split inbox and the drying rack of the snoozed
 * threads. Both are built here from a few boxes in 3D, projected onto the page at the same slant
 * (a dimetric view: one axis runs down to the right, the other up to the right, heights straight
 * up), and drawn back to front so that nearer faces hide what is behind them. Faces are filled
 * with the card's colour and stroked with a thin grey line; one letter in each is blue.
 *
 * - The tray: the blue envelope slides down into its slot whenever `drop` changes (a thread was
 *   sorted), by a Web Animations API transform on its group.
 * - The rack: every letter sways a little from its peg, each on its own period, by a CSS
 *   animation on its group (none under reduced motion). The blue one is the next to come back;
 *   a letter whose thread is woken unpegs and drops away.
 */

type V3 = [number, number, number];
type V2 = [number, number];

/** A projection: x runs along `ex`, y along `ey`, z straight up the page; all from `origin`. */
const projector =
  (origin: V2, ex: V2, ey: V2) =>
  ([x, y, z]: V3): V2 => [origin[0] + x * ex[0] + y * ey[0], origin[1] + x * ex[1] + y * ey[1] - z];

const f = (n: number) => Math.round(n * 100) / 100;

/** A closed path through `points`, each corner rounded by up to `radius`. */
function shape(points: V2[], radius = 0): string {
  if (!radius) return `M${points.map(([x, y]) => `${f(x)} ${f(y)}`).join('L')}Z`;
  const n = points.length;
  let d = '';
  for (let i = 0; i < n; i += 1) {
    const [px, py] = points[(i + n - 1) % n];
    const [cx, cy] = points[i];
    const [nx, ny] = points[(i + 1) % n];
    const lp = Math.hypot(px - cx, py - cy);
    const ln = Math.hypot(nx - cx, ny - cy);
    const r = Math.min(radius, lp / 2, ln / 2);
    const a: V2 = [cx + ((px - cx) / lp) * r, cy + ((py - cy) / lp) * r];
    const b: V2 = [cx + ((nx - cx) / ln) * r, cy + ((ny - cy) / ln) * r];
    d += `${i ? 'L' : 'M'}${f(a[0])} ${f(a[1])}Q${f(cx)} ${f(cy)} ${f(b[0])} ${f(b[1])}`;
  }
  return `${d}Z`;
}
const line = (points: V2[]) => `M${points.map(([x, y]) => `${f(x)} ${f(y)}`).join('L')}`;

/* ---------------------------------------------------------------- The tray */

const tray = projector([2.5, 135.5], [0.898, 0.44], [0.91, -0.414]);

/** The tray's walls run X 7–124 and Y 7–97; they rise from 17 at the front to 90 at the back. */
const X0 = 7;
const X1 = 124;
const Y0 = 7;
const Y1 = 97;
const wallHeight = (y: number) => 17 + ((y - Y0) / (Y1 - Y0)) * 73;

/** An envelope standing in its slot: its foot at y, its top leaning back by `lean`. */
type Envelope = { x0: number; x1: number; y: number; z: number; h: number; lean: number };

function envelope({ x0, x1, y, z, h, lean }: Envelope) {
  const at = (u: number, v: number) => tray([x0 + (x1 - x0) * u, y + lean * v, z + h * v]);
  return {
    outline: shape([at(0, 0), at(1, 0), at(1, 1), at(0, 1)], 3),
    flap: line([at(0.02, 0.97), at(0.5, 0.45), at(0.98, 0.97)]),
    lines: [line([at(0.2, 0.36), at(0.5, 0.33)]), line([at(0.2, 0.25), at(0.42, 0.23)])],
    stamp: shape([at(0.8, 0.14), at(0.9, 0.14), at(0.9, 0.34), at(0.8, 0.34)], 2),
  };
}

/** A divider between slots: a panel across the tray, under the slope of its walls. */
const divider = (y: number, drop: number) =>
  shape(
    [
      tray([X0, y, 0]),
      tray([X1, y, 0]),
      tray([X1, y, wallHeight(y) - drop]),
      tray([X0, y, wallHeight(y) - drop]),
    ],
    2.5,
  );

/** The slots from the back: each with its divider in front and its envelopes, back first. */
const SLOTS: { divider: number; drop: number; envelopes: Envelope[] }[] = [
  {
    divider: 76,
    drop: 22,
    envelopes: [{ x0: 22, x1: 108, y: 88, z: 30, h: 56, lean: 4 }],
  },
  {
    divider: 54,
    drop: 18,
    envelopes: [{ x0: 34, x1: 118, y: 62, z: 4, h: 36, lean: 5 }],
  },
  {
    divider: 30,
    drop: 14,
    envelopes: [
      { x0: 30, x1: 118, y: 40, z: 4, h: 42, lean: 5 },
      { x0: 28, x1: 117, y: 35, z: 3, h: 44, lean: 5 },
    ],
  },
  {
    divider: Y0,
    drop: 0,
    envelopes: [
      { x0: 22, x1: 118, y: 18, z: 7, h: 56, lean: 6 },
      { x0: 20, x1: 117, y: 13, z: 6, h: 57, lean: 6.5 },
    ],
  },
];
const BLUE: Envelope = { x0: 8, x1: 100, y: 80, z: 8, h: 80, lean: 3 };

const TRAY = (() => {
  const plate = (z: number) =>
    shape([tray([0, 0, z]), tray([131, 0, z]), tray([131, 104, z]), tray([0, 104, z])], 7);
  return {
    plateBottom: plate(-7),
    plateTop: plate(0),
    left: shape(
      [tray([X0, Y0, 0]), tray([X0, Y1, 0]), tray([X0, Y1, 90]), tray([X0, Y0, 17])],
      2.5,
    ),
    back: shape(
      [tray([X0, Y1, 0]), tray([X1, Y1, 0]), tray([X1, Y1, 90]), tray([X0, Y1, 90])],
      2.5,
    ),
    blue: envelope(BLUE),
    slots: SLOTS.map((slot) => ({
      envelopes: slot.envelopes.map(envelope),
      divider: slot.divider === Y0 ? null : divider(slot.divider, slot.drop),
    })),
    front: shape(
      [tray([X0, Y0, 0]), tray([X1, Y0, 0]), tray([X1, Y0, 17]), tray([X0, Y0, 17])],
      2.5,
    ),
    frontCap: shape(
      [tray([X0, Y0 - 3, 17]), tray([X1, Y0 - 3, 17]), tray([X1, Y0, 17]), tray([X0, Y0, 17])],
      1,
    ),
    right: shape(
      [tray([X1, Y0, 0]), tray([X1, Y1, 0]), tray([X1, Y1, 90]), tray([X1, Y0, 17])],
      2.5,
    ),
    rightCap: shape(
      [tray([X1, Y0, 17]), tray([X1, Y1, 90]), tray([X1 + 3, Y1, 90]), tray([X1 + 3, Y0, 17])],
      1,
    ),
    base: line([tray([X0, Y0 - 3, 0]), tray([X1 + 3, Y0 - 3, 0]), tray([X1 + 3, Y1, 0])]),
  };
})();

function EnvelopeShape({
  parts,
  className,
}: {
  parts: ReturnType<typeof envelope>;
  className?: string;
}) {
  return (
    <g className={className}>
      <path className={styles.face} d={parts.outline} />
      <path className={styles.detail} d={parts.flap} />
      {parts.lines.map((d) => (
        <path key={d} className={styles.detail} d={d} />
      ))}
      <path className={styles.detail} d={parts.stamp} />
    </g>
  );
}

/** The letter tray. `drop` counts sorted threads: each change slides the blue envelope in. */
export function TrayArt({ drop, className }: { drop: number; className?: string }) {
  const blue = useRef<SVGGElement>(null);
  const first = useRef(true);
  useEffect(() => {
    const element = blue.current;
    if (!element) return;
    if (first.current) {
      first.current = false;
      return;
    }
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    // Up out of its slot, then down into it, settling with a small overshoot.
    const animation = element.animate(
      [
        { transform: 'translate(0px, 0px)', opacity: 1, offset: 0 },
        { transform: 'translate(3px, -46px)', opacity: 0, offset: 0.32 },
        { transform: 'translate(3px, -46px)', opacity: 0, offset: 0.36 },
        { transform: 'translate(-0.3px, 2px)', opacity: 1, offset: 0.82 },
        { transform: 'translate(0px, 0px)', opacity: 1, offset: 1 },
      ],
      { duration: 1100, easing: 'cubic-bezier(0.33, 0, 0.3, 1)' },
    );
    return () => animation.cancel();
  }, [drop]);

  return (
    <svg className={`${styles.art} ${className ?? ''}`} viewBox="0 0 216 210" aria-hidden="true">
      <path className={styles.face} d={TRAY.plateBottom} />
      <path className={styles.face} d={TRAY.plateTop} />
      <path className={styles.face} d={TRAY.left} />
      <path className={styles.face} d={TRAY.back} />
      {TRAY.slots.map((slot, index) => (
        <g key={index}>
          {slot.envelopes.map((parts, at) => (
            <EnvelopeShape key={at} parts={parts} />
          ))}
          {index === 0 && (
            <g ref={blue}>
              <EnvelopeShape parts={TRAY.blue} className={styles.blue} />
            </g>
          )}
          {slot.divider && <path className={styles.face} d={slot.divider} />}
        </g>
      ))}
      <path className={styles.face} d={TRAY.front} />
      <path className={styles.face} d={TRAY.frontCap} />
      <path className={styles.side} d={TRAY.right} />
      <path className={styles.face} d={TRAY.rightCap} />
    </svg>
  );
}

/* ---------------------------------------------------------------- The rack */

const rack = projector([1.5, 125.5], [0.901, 0.436], [0.86, -0.5]);

/** The rod runs along X at this height over the plate's middle; the posts hold its ends. */
const ROD_Y = 18;
const ROD_Z = 104;
const POSTS = [16, 195];

/** Where each letter hangs on the rod (X), and how it hangs: its sway and its phase. */
const LETTERS = [
  { x: 12, swing: 1.2, delay: -0.4, tilt: 1.5 },
  { x: 44, swing: 1.4, delay: -2.1, tilt: -1 },
  { x: 64, swing: 2.4, delay: -1.2, tilt: -16 },
  { x: 106, swing: 1.3, delay: -3.3, tilt: 0.5 },
  { x: 144, swing: 1.1, delay: -2.6, tilt: 0 },
  { x: 160, swing: 1.5, delay: -0.9, tilt: 1 },
];
const LETTER_W = 30;
const LETTER_H = 54;

const RACK = (() => {
  const plate = (z: number) =>
    shape([rack([0, 0, z]), rack([215, 0, z]), rack([215, 36, z]), rack([0, 36, z])], 7);
  const holes = Array.from({ length: 11 }, (_, i) => rack([12 + i * 18.5, 9, 0]));
  const post = (x: number) => {
    const foot = rack([x, ROD_Y, 0]);
    const top = rack([x, ROD_Y, ROD_Z]);
    return { foot, top };
  };
  const rodA = rack([-4, ROD_Y, ROD_Z]);
  const rodB = rack([214, ROD_Y, ROD_Z]);
  const letters = LETTERS.map((letter) => {
    const at = (u: number, v: number) =>
      rack([letter.x + LETTER_W * u, ROD_Y - 2, ROD_Z - 5 - LETTER_H * v]);
    const peg = rack([letter.x + LETTER_W * 0.5, ROD_Y, ROD_Z]);
    return {
      ...letter,
      pivot: peg,
      outline: shape([at(0, 0), at(1, 0), at(1, 1), at(0, 1)], 3),
      flap: line([at(0.2, 0.12), at(0.48, 0.34), at(0.85, 0.16)]),
      peg,
    };
  });
  return { bottom: plate(-5), top: plate(0), holes, posts: POSTS.map(post), rodA, rodB, letters };
})();

/** The drying rack. `woken` lists the letters (by index) that dropped off; `next` is blue. */
export function RackArt({
  woken,
  next,
  className,
}: {
  woken: readonly boolean[];
  next: number;
  className?: string;
}) {
  const [backPost, frontPost] = RACK.posts;
  const pegs = RACK.letters.map((letter) => letter.peg);
  return (
    <svg className={`${styles.art} ${className ?? ''}`} viewBox="0 0 226 225" aria-hidden="true">
      <path className={styles.face} d={RACK.bottom} />
      <path className={styles.face} d={RACK.top} />
      {RACK.holes.map(([x, y], index) => (
        <circle
          key={index}
          className={index === 4 ? styles.holeBlue : styles.hole}
          cx={f(x)}
          cy={f(y)}
          r={index === 4 ? 1.1 : 0.8}
        />
      ))}
      <Post foot={backPost.foot} top={backPost.top} />
      {/* The blue letter is drawn last, in front of its neighbours. */}
      {RACK.letters
        .map((letter, index) => ({ letter, index }))
        .sort((a, b) => Number(a.index === next) - Number(b.index === next))
        .map(({ letter, index }) => (
          <g
            key={index}
            className={styles.letter}
            data-woken={woken[index] || undefined}
            style={
              {
                transformOrigin: `${f(letter.pivot[0])}px ${f(letter.pivot[1])}px`,
                '--tilt': `${letter.tilt}deg`,
                '--swing': `${letter.swing}deg`,
                animationDelay: `${letter.delay}s`,
              } as CSSProperties
            }
          >
            <path className={index === next ? styles.blueFace : styles.face} d={letter.outline} />
            <path className={index === next ? styles.blueDetail : styles.detail} d={letter.flap} />
          </g>
        ))}
      <path
        className={styles.rod}
        d={line([RACK.rodA, RACK.rodB])}
        strokeWidth="7"
        strokeLinecap="round"
      />
      <path
        className={styles.rodCore}
        d={line([RACK.rodA, RACK.rodB])}
        strokeWidth="5"
        strokeLinecap="round"
      />
      {pegs.map(([x, y], index) => (
        <g key={index} className={styles.peg} data-woken={woken[index] || undefined}>
          <path
            className={styles.face}
            d={shape(
              [
                [x - 4.6, y - 9],
                [x - 1.4, y - 9.6],
                [x - 1.4, y + 8],
                [x - 4.6, y + 8.6],
              ],
              1,
            )}
          />
          <circle className={styles.face} cx={f(x + 0.6)} cy={f(y - 0.6)} r="2.6" />
          <path
            className={styles.face}
            d={shape(
              [
                [x + 1.8, y - 11],
                [x + 5, y - 11.6],
                [x + 5, y + 6],
                [x + 1.8, y + 6.6],
              ],
              1,
            )}
          />
        </g>
      ))}
      <Post foot={frontPost.foot} top={frontPost.top} />
    </svg>
  );
}

function Post({ foot, top }: { foot: V2; top: V2 }) {
  const r = 4;
  return (
    <g>
      <path
        className={styles.face}
        d={`M${f(foot[0] - r)} ${f(foot[1])}L${f(top[0] - r)} ${f(top[1])}A${r} ${r * 0.6} 0 0 1 ${f(top[0] + r)} ${f(top[1])}L${f(foot[0] + r)} ${f(foot[1])}A${r} ${r * 0.6} 0 0 1 ${f(foot[0] - r)} ${f(foot[1])}Z`}
      />
    </g>
  );
}
```

### inbox-widgets/art.module.css

```css
/* The line drawings (art.tsx): faces in a step off the card's colour, edged with a thin grey
   line; the blue letter in the accent, edged lighter. Their colours are the card's tokens plus
   the four below. */

.art {
  --art-face: #fbfbfc;
  --art-side: #f4f4f6;
  --art-line: #b6b6bc;
  --art-blue: #3b82f6;
  display: block;
  overflow: visible;
}
@supports (color: light-dark(#fff, #000)) {
  .art {
    --art-face: light-dark(#fbfbfc, #262626);
    --art-side: light-dark(#f4f4f6, #2a2a2a);
    --art-line: light-dark(#b6b6bc, #5f5f5f);
    --art-blue: light-dark(#3b82f6, #2d5fbd);
  }
}
.face,
.side,
.blueFace {
  fill: var(--art-face);
  stroke: var(--art-line);
  stroke-width: 1;
  stroke-linejoin: round;
}
.side {
  fill: var(--art-side);
}
.detail,
.blueDetail {
  fill: none;
  stroke: var(--art-line);
  stroke-width: 1;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.blue .face,
.blueFace {
  fill: var(--art-blue);
  stroke: var(--accent-light);
  stroke-width: 1.2;
}
.blue .detail,
.blueDetail {
  stroke: rgb(255 255 255 / 0.85);
  stroke-width: 1.1;
}

/* The rack's rod: a light tube with the card's colour inside. */
.rod {
  fill: none;
  stroke: var(--art-line);
}
.rodCore {
  fill: none;
  stroke: var(--art-face);
}
.hole {
  fill: var(--art-line);
  opacity: 0.6;
}
.holeBlue {
  fill: var(--accent);
}

/* Each letter sways from its peg, on its own phase. */
.letter {
  rotate: var(--tilt);
  animation: sway 3.6s ease-in-out infinite alternate;
  transition:
    translate 700ms cubic-bezier(0.5, 0, 0.75, 0),
    opacity 500ms ease 200ms;
}
@keyframes sway {
  from {
    rotate: calc(var(--tilt) - var(--swing));
  }
  to {
    rotate: calc(var(--tilt) + var(--swing));
  }
}
.letter[data-woken] {
  translate: 0 60px;
  opacity: 0;
}
.peg {
  transition: opacity 300ms ease;
}
.peg[data-woken] {
  opacity: 0.35;
}

@media (prefers-reduced-motion: reduce) {
  .letter {
    animation: none;
    transition: opacity 200ms ease;
  }
  .letter[data-woken] {
    translate: none;
  }
}
```

### inbox-widgets/demo-pointer.tsx

```tsx
'use client';

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

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, 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`, from `start` (the stage's own px; by default its
 * lower right); returns the function that stops it.
 */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
  start?: Point,
): () => 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>();
  let at: Point = start ?? { 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 tokens (card.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 panels themselves. 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>
  );
}
```

### inbox-widgets/use-autoplay.ts

```tsx
'use client';

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

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

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

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

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