# Coverage heatmap

> An analytics panel of AI-tool accounts: one heatmap row per week of each account’s use and of when none was free, with a step chart and per-week bars for the selected week.

Each service with a main account and a back-up gets one row per week, Monday to Sunday in 6-hour cells, in three lanes: the main account’s use, the back-up’s and how many accounts were free. Greys show use by quarter, hatching a locked account waiting for its reset, red the hours none was free (bright in work hours); red dots mark run-outs, white ticks resets, and the week in progress shows its forecast as hollow dots and a dashed outline. Selecting a week fills the detail: a sentence, a step chart of both accounts with the gap hatched, three figures and a bar per week. Hover a cell for its tooltip, or switch to Hand-off for one track per week of which account was serving. Light and dark.

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

Category: Dashboards.

## Anatomy

1. **Headline**: The verdict in one sentence and the line under it, from the `headline` prop.
2. **View tabs**: Weeks and Hand-off: a tablist (← and → move) switching every row between the three lanes and one serving track.
3. **Week lanes**: Three lanes of 28 cells, Mon to Sun in 6-hour steps: the main account’s use, the back-up’s, and how many were free. Dots, ticks and the forecast sit at their exact hour.
4. **Week summary**: The right column, written from the data: the work hours with none free and when, “Covered” with the peaks, or “On pace” for the week in progress.
5. **Legend**: The codes of the current view, the reading line and an info dot with the work hours, under the weeks.
6. **Step chart**: Both accounts’ use through the selected week: the main a white line that turns red while locked, the back-up a grey one, the gap with none free hatched.
7. **Figures**: Three numbers for the week: work hours with none free, how long the back-up covered, and the gap until the reset.
8. **Per-week bars**: The work hours lost each week, stacked on the hours the back-up covered; each bar is a toggle that selects its week.

## When to use

- Answering “was something always available?” over weeks: a pool of accounts, seats, licences or on-call people, where the gaps matter more than the totals.
- A resource with a main supply and a back-up, where the reader needs to see when one handed over to the other and how long the back-up lasted.
- Time that repeats by the week, where a weekday-by-hour grid makes the pattern jump out: the same Thursday run-out, the same red Friday morning.
- Telling the hours that hurt (work hours with nothing free) from the ones that don’t, and forecasting the week in progress from its pace.
- A review screen read row by row, where selecting a week explains it in a sentence, a chart and three figures.

## When not to use

- A single number over time (spend, requests, tokens). Use a line or area chart with a period picker.
- Live status right now: which account is free this minute. Use a status list with badges and a last-updated time.
- More than a handful of services or weeks. Rows of 28 cells stop reading past ten or so; use a table with a sparkline per row.
- Resources with no limit to run out of. Without run-outs and resets, the lanes are just greys; a usage chart says the same in less room.
- Exact hourly figures. The cells are 6-hour buckets of use by quarter; export the readings to a table for that.
- A phone-first screen. Under 940 px the weeks scroll sideways; give a phone a list of weeks with their summaries instead.

## Installation

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

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

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

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

## Usage

```tsx
import { CoverageHeatmap } from './coverage-heatmap';

export function CoveragePage() {
  return (
    <CoverageHeatmap
      defaultTab="weeks"
      defaultSelection={{ service: 'quill', week: 'oct-5' }}
      onSelect={({ service, week }) => console.log('Selected', service, week)}
      autoplay={false}
    />
  );
}
```

## States

- **Week with a gap**: Quill, week of Oct 5: the main account ran out Thursday 17:00, the back-up Friday 11:00, 8 work hours with none free.
- **Covered week**: `defaultSelection={{ service: 'loom', week: 'oct-5' }}`: nothing ran out, so the summary says “Covered” with both peaks.
- **Week in progress**: The current week: cells not read yet are dotted, the run-outs are hollow forecast dots and the figures carry a ~.
- **Cell tooltip**: `defaultTooltip` on Thursday 12:00 – 18:00, main lane: the slot’s three lanes, the hovered one at full strength.
- **None free, work hours**: The tooltip on the free lane of Friday 12:00 – 18:00: no account free during work hours, the cell in bright red.
- **Hand-off**: `defaultTab="handoff"`: one track per week of which account was serving, red dots at the hand-offs.
- **Hand-off tooltip**: A slot on the hand-off track lists who served it, hour range by hour range.
- **Scaled**: From 940 to 1200 px of its own width, the whole panel is drawn smaller with CSS zoom, keeping its two columns.
- **Stacked**: Under 940 px the tabs go under the headline, the detail under the weeks, and the weeks scroll sideways with their labels pinned. Shown at 900 px, cut at its top.
- **Light**: `colorScheme="light"`, or a light page: every colour is a light-dark() token.
- **Dark**: `colorScheme="dark"`, or a dark page: a near-black panel with its own greys, reds and hatching.

## How it works

- A week is three strings of 28 codes, one per 6-hour cell: 1 to 4 for use by quarter, x for locked, o for the cell it ran out in, a dot for no reading; the free lane is all, one, none or none in work hours. Each code is one data attribute, so the stylesheet draws every cell: four greys, a hatch of repeating-linear-gradient, a dotted outline.
- Run-outs, resets, the present moment and the forecast are hours of the week. Each is placed with two custom properties, its day and its share of that day, so a dot sits where its time falls inside its cell and the 4 px gaps between days never shift it.
- The right column of each row is written from the data: the work hours with none free and the hand-off span, “Covered” with the peaks when nothing ran out, or “On pace” for the week in progress.
- Selecting a week (its row, or its bar in the detail) redraws the step chart: an SVG in its own 380-unit box, the main account a white line over a soft fill that turns red while it waits for its reset, the back-up a thin grey one, a red dot and a time over each run-out, work hours as lighter bands and the stretch with no account free hatched with its length written in it. The week in progress is dashed after the present moment.
- Hovering a cell (or ← and → on the focused week) shows one tooltip for the slot: both accounts’ use, locked until when, how many were free and any forecast run-out, the lane under the pointer at full strength.
- Hand-off folds each week into one track of which account was serving, hour by hour, with red dots at the hand-offs; its right column says when the back-up took over and how long it lasted.
- The panel is laid out at 1200 px. Wider, the heatmap grows; from 940 to 1200 px it is scaled whole with CSS zoom, so the two columns and their type keep their proportions; narrower, the detail goes under the weeks and the weeks scroll sideways inside their column with the labels pinned.
- While on screen and untouched, a demo pointer hovers the run-out and the red work hours, selects a week from its row and another from the bars, opens Hand-off and hovers a track, then selects the other service. Any real input hands it over; it comes back after 9 s of quiet.

## API reference

- **services** (`Service[]`, default `two services, five weeks each`): Services with a main account and a back-up: name, glyph, both accounts (name and plan) and their weeks. A week has its three lanes of 28 codes, run-outs and resets in hours from Monday 00:00, the work hours with none free and covered, peaks and the chart’s usage curves.
- **solo** (`SoloService[]`, default `three services`): Services with a single account, one quiet row each: name, glyph and a note.
- **headline** (`{ title: string; lede: string }`): The headline sentence and the line under it.
- **defaultTab** (`'weeks' | 'handoff'`, default `'weeks'`): The view at first: the heatmap, or one hand-off track per week.
- **defaultSelection** (`{ service: string; week: string }`, default `{ service: 'quill', week: 'oct-5' }`): The week whose detail shows at first.
- **onSelect** (`(selection: { service: string; week: string }) => void`): Called when a week is selected, from its row or its bar.
- **defaultTooltip** (`{ service: string; week: string; slot: number; lane?: 0 | 1 | 2 }`): Opens with the tooltip of one slot showing (0 to 27, from Monday 00:00 in 6-hour steps), the lane under the pointer at full strength (0 main, 1 back-up, 2 free; all when left out). The demo stays off.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer shows the main interactions. Any real input stops it.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s; without it, the page’s color-scheme.
- **className** (`string`): Extra class for the root.

## Accessibility

- The headline is a heading; Weeks and Hand-off are a tablist (← and → move) controlling one tabpanel; each service is a heading over a list of its weeks, and the detail is a complementary region named after the selected week.
- Each week is a toggle button stretched over its row, named with the service, the week and its summary (“Quill, week of Oct 5: 8 h none free, Thu 17:00 → Fri 11:00”), with aria-pressed. The weeks are one tab stop: ↑ and ↓ move, Home and End jump, Enter or Space selects, ← and → walk the cells and read each slot out, Escape hides the tooltip.
- The per-week bars are toggle buttons too, each named with its figures. The cells, the chart and the swatches are drawn for the eye (aria-hidden); every figure they show is also in text. A selection and a walked slot are announced in a polite live region.
- Under reduced motion nothing animates and the demo never plays.

## Source

### coverage-heatmap/coverage-heatmap.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { StepChart } from './chart';
import {
  DAY_NAMES,
  HEADLINE,
  SERVICES,
  SOLO,
  WORK,
  slotName,
  span,
  when,
  type Service,
  type SoloService,
  type Week,
} from './data';
import { DemoPointer, runDemo, type Demo, type Point } from './demo-pointer';
import { Glyph, LaneKey } from './glyphs';
import { useAutoplay } from './use-autoplay';
import styles from './coverage-heatmap.module.css';

/*
 * Coverage heatmap: an analytics panel that answers "did we always have an AI-tool account free
 * to work with?". Each service with two accounts (a main one and a back-up) gets one row per
 * week, Mon to Sun in 6-hour cells, in three lanes: the main account's use, the back-up's, and how
 * many accounts were free. Greys show use by quarter, hatching a locked account waiting for its
 * reset, red the hours none was free (bright red in work hours); a red dot is a run-out, a white
 * tick a reset, a hollow dot and a dashed outline the forecast for the week in progress. A column
 * on the right sums each week up.
 *
 * - Selecting a week (click, Enter or Space) fills the detail on the right: what happened in a
 *   sentence, a step chart of both accounts, three figures and a bar per week of the work hours
 *   lost, where a bar selects its week too.
 * - Hovering a cell, or walking the cells of the focused week with ← and →, shows a tooltip with
 *   that slot's three lanes. ↑ and ↓ move between weeks (one tab stop for the whole list).
 * - Weeks / Hand-off switch the rows between the heatmap and a single track per week of which
 *   account was serving, with the hand-offs marked.
 * - Size: the panel is laid out at 1200 px. Wider, its heatmap grows; from 940 px to 1200 px the
 *   whole panel is scaled down (CSS zoom) so it keeps its two columns; narrower, the detail goes
 *   under the weeks and the weeks scroll sideways inside their column, the labels pinned.
 * - While on screen and untouched, a demo pointer hovers a cell, selects weeks from the rows and
 *   from the bars, and visits the Hand-off tab. Any real input hands it over.
 * - Light or dark: every colour is a light-dark() token on the root, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type CoverageTab = 'weeks' | 'handoff';
export type CoverageSelection = { service: string; week: string };
export type CoverageTooltip = CoverageSelection & { slot: number; lane?: 0 | 1 | 2 };

export type CoverageHeatmapProps = {
  /** Services with a main account and a back-up, five weeks each. */
  services?: Service[];
  /** Services with a single account: one quiet row each. */
  solo?: SoloService[];
  /** The headline and the sentence under it. */
  headline?: { title: string; lede: string };
  defaultTab?: CoverageTab;
  /** The week whose detail shows at first. */
  defaultSelection?: CoverageSelection;
  /** Called when a week is selected. */
  onSelect?: (selection: CoverageSelection) => void;
  /**
   * Opens with the tooltip of one slot showing (0 to 27, Mon 00:00 in 6-hour steps), the lane
   * under the pointer at full strength (0 main, 1 back-up, 2 free; all when left out). The demo
   * stays off.
   */
  defaultTooltip?: CoverageTooltip;
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
};

export type { Service, SoloService, Week } from './data';

/** The panel's own width: under it, it is scaled down; under ZOOM_MIN, it stacks. */
const DESIGN = 1200;
const ZOOM_MIN = 940;
const SLOTS = 28;

type Hover = {
  service: string;
  week: string;
  slot: number;
  lane: number | null;
  keyboard?: boolean;
};
type Layout = 'wide' | 'scaled' | 'stacked';

/** Where an hour of the week sits along the lanes: its day, and its share of that day. */
function place(from: number, to?: number): CSSProperties {
  const split = (hour: number) => {
    const day = Math.min(6, Math.floor(hour / 24));
    return [day, (hour - day * 24) / 24];
  };
  const [day, frac] = split(from);
  const style: Record<string, number> = { '--day': day, '--frac': frac };
  if (to !== undefined) {
    const [day2, frac2] = split(to);
    style['--day2'] = day2;
    style['--frac2'] = frac2;
  }
  return style as CSSProperties;
}

/** "18:00", or "24:00" for the end of a day. */
const clock = (hour: number, end = false) => {
  const h = hour % 24 === 0 && end && hour > 0 ? 24 : Math.floor(hour % 24);
  const m = Math.round((hour % 1) * 60);
  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
};

/* Words. */

const USE: Record<string, string> = {
  '1': 'under 25%',
  '2': '25–50%',
  '3': '50–75%',
  '4': '75–100%',
  '.': 'No reading',
  '-': 'Not read yet',
};
const FREE: Record<string, string> = {
  a: 'Both accounts free',
  n: 'One account free',
  z: 'No account free',
  w: 'No account free, work hours',
  '.': 'No reading',
  '-': 'Not read yet',
};

/** The time each account is free again after `hour`, if it resets later in the week. */
const nextReset = (week: Week, lane: 'main' | 'backup', hour: number) => {
  const reset = week.resets[lane];
  return reset !== undefined && reset > hour ? reset : undefined;
};

function laneWords(week: Week, lane: 0 | 1, slot: number) {
  const code = week.lanes[lane][slot];
  const key = lane ? 'backup' : 'main';
  const out = week.outs?.[key];
  if (code === 'o' && out !== undefined) return `Ran out ${when(out)}`;
  if (code === 'x') {
    const reset = nextReset(week, key, slot * 6);
    return reset !== undefined ? `Locked until ${when(reset)}` : 'Locked';
  }
  return USE[code] ?? '';
}

/** What the right column says about a week. */
function summary(service: Service, week: Week, tab: CoverageTab) {
  const [a, b] = service.accounts;
  const outA = week.outs?.main;
  const outB = week.outs?.backup;
  const t = week.forecast ? '~' : '';
  if (tab === 'handoff') {
    if (outA === undefined)
      return { head: 'No hand-off', line: `${a.name} never ran out`, loud: false };
    return {
      head: `Hand-off ${t}${when(outA)}`,
      line:
        outB !== undefined
          ? `${b.name} ${week.forecast ? 'on pace for' : 'lasted'} ${t}${span(outB - outA)}`
          : `${b.name} took over`,
      loud: true,
    };
  }
  if (outA === undefined || outB === undefined)
    return { head: 'Covered', line: `Peaks ${week.peaks[0]}%, ${week.peaks[1]}%`, loud: false };
  const range = `${t}${when(outA)} → ${t}${when(outB)}`;
  if (week.forecast)
    return { head: `~${week.noneWork} h none free`, line: `On pace ${range}`, loud: true };
  if (!week.noneWork) return { head: 'Covered', line: range, loud: false };
  return { head: `${week.noneWork} h none free`, line: range, loud: true };
}

/** The detail's sentence. */
function story(service: Service, week: Week) {
  const [a, b] = service.accounts;
  const outA = week.outs?.main;
  const outB = week.outs?.backup;
  const reset = week.resets.main;
  if (outA !== undefined && outB !== undefined) {
    const until =
      reset !== undefined ? ` until ${a.name} reset${week.forecast ? 's' : ''} ${when(reset)}` : '';
    if (week.forecast)
      return `On pace, ${b.name} takes over at ~${when(outA)} and lasts ~${span(outB - outA)}. Then no account would be free${until}.`;
    return `${b.name} took over at ${when(outA)} and lasted ${span(outB - outA)}. Then no account was free${until}.`;
  }
  return `${week.forecast ? 'So far, ' : ''}${a.name} peaked at ${week.peaks[0]}% and ${b.name} at ${week.peaks[1]}%. Neither ran out, so an account was always free.`;
}

function figures(service: Service, week: Week) {
  const outA = week.outs?.main;
  const outB = week.outs?.backup;
  const both0 = week.outs?.main !== undefined && week.outs?.backup !== undefined;
  const t = week.forecast && both0 ? '~' : '';
  const reset = week.resets.main;
  const both = outA !== undefined && outB !== undefined;
  return [
    { label: 'No account free', value: `${t}${week.noneWork} work h` },
    {
      label: `${service.accounts[1].name} covered`,
      value: both ? `${t}${span(outB - outA)}` : '0 h',
    },
    {
      label: 'Gap until reset',
      value: both && reset !== undefined && reset > outB ? `${t}${span(reset - outB)}` : 'None',
    },
  ];
}

/* The hand-off track: who was serving, hour by hour. */

type Segment = {
  from: number;
  to: number;
  kind: 'main' | 'backup' | 'none' | 'work';
  future: boolean;
};

function serving(week: Week): Segment[] {
  const first = week.lanes[2].search(/[^.]/);
  if (first < 0) return [];
  const start = first * 6;
  const end = 168;
  const outA = week.outs?.main;
  const outB = week.outs?.backup;
  const resetA = week.resets.main;
  const resetB = week.resets.backup;
  const lockedAtStartB = week.lanes[1][first] === 'x';
  const lockedA = (h: number) =>
    outA !== undefined && h >= outA && !(resetA !== undefined && resetA > outA && h >= resetA);
  const lockedB = (h: number) =>
    (lockedAtStartB && resetB !== undefined && h < resetB) ||
    (outB !== undefined && h >= outB && !(resetB !== undefined && resetB > outB && h >= resetB));
  const work = (h: number) => {
    const day = Math.floor(h / 24);
    const hour = h % 24;
    return day < 5 && hour >= WORK[0] && hour < WORK[1];
  };
  const segments: Segment[] = [];
  for (let h = start; h < end; h += 1) {
    const kind = !lockedA(h + 0.5)
      ? 'main'
      : !lockedB(h + 0.5)
        ? 'backup'
        : work(h)
          ? 'work'
          : 'none';
    const future = week.now !== undefined && h + 0.5 > week.now;
    const last = segments[segments.length - 1];
    if (last && last.kind === kind && last.future === future) last.to = h + 1;
    else segments.push({ from: h, to: h + 1, kind, future });
  }
  // The present moment cuts the segment it falls in.
  if (week.now !== undefined)
    for (const segment of segments) {
      if (!segment.future && segment.to > week.now) segment.to = week.now;
      if (segment.future && segment.from < week.now) segment.from = week.now;
    }
  return segments;
}

const KIND: Record<Segment['kind'], (service: Service) => string> = {
  main: (service) => `${service.accounts[0].name} serving`,
  backup: (service) => `${service.accounts[1].name} serving`,
  none: () => 'No account free',
  work: () => 'No account free, work hours',
};

/* The panel. */

export function CoverageHeatmap({
  services = SERVICES,
  solo = SOLO,
  headline = HEADLINE,
  defaultTab = 'weeks',
  defaultSelection = { service: 'quill', week: 'oct-5' },
  onSelect,
  defaultTooltip,
  autoplay = true,
  colorScheme,
  className,
}: CoverageHeatmapProps) {
  const uid = useId().replace(/:/g, '');
  const { ref: rootRef, take, playing } = useAutoplay<HTMLDivElement>(9000);
  const cardRef = useRef<HTMLDivElement>(null);
  const pointerRef = useRef<HTMLSpanElement>(null);
  const rowRefs = useRef(new Map<string, HTMLLIElement>());
  const [tab, setTab] = useState<CoverageTab>(defaultTab);
  const [selection, setSelection] = useState<CoverageSelection>(defaultSelection);
  const [hover, setHover] = useState<Hover | null>(() =>
    defaultTooltip
      ? {
          service: defaultTooltip.service,
          week: defaultTooltip.week,
          slot: Math.max(0, Math.min(SLOTS - 1, Math.round(defaultTooltip.slot))),
          lane: defaultTooltip.lane ?? null,
        }
      : null,
  );
  // Opening on a tooltip is a still picture: the demo stays off.
  const demoOn = autoplay && !defaultTooltip;
  const [focusKey, setFocusKey] = useState(`${defaultSelection.service}:${defaultSelection.week}`);
  const [layout, setLayout] = useState<{ mode: Layout; zoom: number }>({ mode: 'wide', zoom: 1 });
  const [announcement, setAnnouncement] = useState('');

  const rows = useMemo(
    () =>
      services.flatMap((service) =>
        service.weeks.map((week) => ({ service, week, key: `${service.id}:${week.id}` })),
      ),
    [services],
  );
  const selectedService =
    services.find((service) => service.id === selection.service) ?? services[0];
  const selectedWeek =
    selectedService.weeks.find((week) => week.id === selection.week) ??
    selectedService.weeks[selectedService.weeks.length - 1];

  // The layout follows the component's own width.
  useLayoutEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const measure = () => {
      const width = root.clientWidth;
      if (width >= DESIGN) setLayout({ mode: 'wide', zoom: 1 });
      else if (width >= ZOOM_MIN) setLayout({ mode: 'scaled', zoom: width / DESIGN });
      else setLayout({ mode: 'stacked', zoom: 1 });
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(root);
    return () => observer.disconnect();
  }, [rootRef]);

  const select = useCallback(
    (service: Service, week: Week, announce = true) => {
      setSelection({ service: service.id, week: week.id });
      setFocusKey(`${service.id}:${week.id}`);
      if (announce) {
        const words = summary(service, week, 'weeks');
        setAnnouncement(`${service.name}, week of ${week.label}: ${words.head}, ${words.line}.`);
      }
      onSelect?.({ service: service.id, week: week.id });
    },
    [onSelect],
  );

  /* The demo. */

  const centre = (element: Element | null | undefined, dx = 0.5, dy = 0.5): Point => {
    const root = rootRef.current;
    if (!root || !element) return { x: 0, y: 0 };
    const box = element.getBoundingClientRect();
    const frame = root.getBoundingClientRect();
    return { x: box.left - frame.left + box.width * dx, y: box.top - frame.top + box.height * dy };
  };
  const play = useEffectEvent(async (demo: Demo) => {
    const card = cardRef.current;
    if (!card) return;
    const find = (selector: string) => card.querySelector(selector);
    const quill = services[0];
    const loom = services[1] ?? services[0];
    const pick = (service: Service, index: number) =>
      service.weeks[Math.min(index, service.weeks.length - 1)];
    const grid = (service: Service, week: Week) =>
      rowRefs.current.get(`${service.id}:${week.id}`)?.querySelector('[data-part="lanes"]');
    for (;;) {
      await demo.wait(700);
      // Hover the cell where the main account ran out, then the red work hours.
      const last = pick(quill, 3);
      const lanes = grid(quill, last);
      for (const [slot, lane] of [
        [14, 0],
        [18, 2],
      ] as const) {
        demo.glyph('arrow');
        await demo.glide(centre(lanes, (slot + 0.5) / SLOTS, lane === 0 ? 0.15 : 0.85), 750);
        setHover({ service: quill.id, week: last.id, slot, lane });
        await demo.wait(1500);
      }
      setHover(null);
      // Select an earlier week from its row.
      const earlier = pick(quill, 2);
      demo.glyph('hand');
      await demo.glide(
        centre(rowRefs.current.get(`${quill.id}:${earlier.id}`)?.firstElementChild, 0.1, 0.5),
        800,
      );
      select(quill, earlier, false);
      await demo.wait(1700);
      // And another from the bars.
      const bar = find(`[data-part="bars"] button:nth-child(2)`);
      await demo.glide(centre(bar, 0.5, 0.6), 850);
      select(quill, pick(quill, 1), false);
      await demo.wait(1700);
      // The hand-off view.
      await demo.glide(centre(find('[data-tab="handoff"]')), 850);
      setTab('handoff');
      await demo.wait(900);
      demo.glyph('arrow');
      const track = grid(quill, pick(quill, 3));
      await demo.glide(centre(track, 16.5 / SLOTS, 0.5), 800);
      setHover({ service: quill.id, week: pick(quill, 3).id, slot: 16, lane: null });
      await demo.wait(1700);
      setHover(null);
      demo.glyph('hand');
      await demo.glide(centre(find('[data-tab="weeks"]')), 750);
      setTab('weeks');
      await demo.wait(800);
      // The other service, then back to the start.
      await demo.glide(
        centre(rowRefs.current.get(`${loom.id}:${pick(loom, 3).id}`)?.firstElementChild, 0.1, 0.5),
        850,
      );
      select(loom, pick(loom, 3), false);
      await demo.wait(1900);
      await demo.glide(
        centre(rowRefs.current.get(`${quill.id}:${last.id}`)?.firstElementChild, 0.1, 0.5),
        850,
      );
      select(quill, last, false);
      await demo.wait(2200);
    }
  });
  const running = demoOn && playing;
  useEffect(() => {
    const root = rootRef.current;
    const pointer = pointerRef.current;
    if (!running || !root || !pointer) return;
    const stop = runDemo(root, pointer, (demo) => play(demo));
    return () => {
      stop();
      setHover(null);
    };
  }, [running, rootRef]);

  const takeOver = () => {
    if (demoOn) take();
  };

  /* Keyboard: one tab stop for the weeks; ↑ ↓ move, ← → walk the cells, Enter selects. */

  const onRowKey = (event: ReactKeyboardEvent, index: number) => {
    const { service, week } = rows[index];
    const move = (to: number) => {
      const next = rows[(to + rows.length) % rows.length];
      setFocusKey(next.key);
      rowRefs.current.get(next.key)?.querySelector('button')?.focus();
      if (hover?.keyboard)
        setHover({
          service: next.service.id,
          week: next.week.id,
          slot: hover.slot,
          lane: null,
          keyboard: true,
        });
    };
    if (event.key === 'ArrowDown') move(index + 1);
    else if (event.key === 'ArrowUp') move(index - 1);
    else if (event.key === 'Home') move(0);
    else if (event.key === 'End') move(rows.length - 1);
    else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
      const here =
        hover?.keyboard && hover.week === week.id && hover.service === service.id
          ? hover.slot
          : event.key === 'ArrowLeft'
            ? SLOTS
            : -1;
      const slot = Math.max(0, Math.min(SLOTS - 1, here + (event.key === 'ArrowLeft' ? -1 : 1)));
      setHover({ service: service.id, week: week.id, slot, lane: null, keyboard: true });
      setAnnouncement(tipText(service, week, slot, tab));
    } else if (event.key === 'Escape') setHover(null);
    else return;
    event.preventDefault();
  };

  const onLanesMove = (event: ReactPointerEvent<HTMLElement>, service: Service, week: Week) => {
    const box = event.currentTarget.getBoundingClientRect();
    const slot = Math.max(
      0,
      Math.min(SLOTS - 1, Math.floor(((event.clientX - box.left) / box.width) * SLOTS)),
    );
    const lane =
      tab === 'weeks'
        ? Math.max(0, Math.min(2, Math.floor(((event.clientY - box.top) / box.height) * 3)))
        : null;
    if (
      hover?.slot !== slot ||
      hover.lane !== lane ||
      hover.week !== week.id ||
      hover.service !== service.id
    )
      setHover({ service: service.id, week: week.id, slot, lane });
  };

  const zoomed: CSSProperties | undefined =
    layout.mode === 'scaled' ? ({ width: DESIGN, zoom: layout.zoom } as CSSProperties) : undefined;
  const panelId = `${uid}-panel`;

  return (
    <div
      ref={rootRef}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      data-layout={layout.mode}
      onPointerDown={takeOver}
      onKeyDown={takeOver}
      onWheel={takeOver}
      onPointerMove={(event) => {
        if (event.pointerType === 'mouse' && (event.movementX || event.movementY)) takeOver();
      }}
    >
      <div ref={cardRef} className={styles.card} style={zoomed} data-part="panel">
        <header className={styles.head} data-part="header">
          <div className={styles.headText}>
            <h2 className={styles.title}>{headline.title}</h2>
            <p className={styles.lede}>{headline.lede}</p>
          </div>
          <div className={styles.tabs} role="tablist" aria-label="View" data-part="tabs">
            {(['weeks', 'handoff'] as const).map((name) => (
              <button
                key={name}
                type="button"
                role="tab"
                id={`${uid}-${name}`}
                className={styles.tab}
                data-tab={name}
                aria-selected={tab === name}
                aria-controls={panelId}
                tabIndex={tab === name ? 0 : -1}
                onClick={() => setTab(name)}
                onKeyDown={(event) => {
                  if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
                  event.preventDefault();
                  const next = name === 'weeks' ? 'handoff' : 'weeks';
                  setTab(next);
                  document.getElementById(`${uid}-${next}`)?.focus();
                }}
              >
                {name === 'weeks' ? 'Weeks' : 'Hand-off'}
              </button>
            ))}
          </div>
        </header>

        <div className={styles.body}>
          <section
            className={styles.board}
            id={panelId}
            role="tabpanel"
            aria-labelledby={`${uid}-${tab}`}
            data-part="board"
            data-tab={tab}
          >
            <div className={styles.scroller}>
              <div className={styles.table}>
                <div className={styles.columns} aria-hidden="true">
                  <span className={styles.sticky}>Week of</span>
                  <span className={styles.days}>
                    {DAY_NAMES.map((day) => (
                      <span key={day}>{day}</span>
                    ))}
                  </span>
                  <span>{tab === 'weeks' ? 'Work hours' : 'Hand-off'}</span>
                </div>

                {services.map((service, serviceIndex) => (
                  <div key={service.id} className={styles.section} data-part="service">
                    <h3 className={styles.serviceHead} data-first={serviceIndex === 0 || undefined}>
                      <span className={`${styles.serviceName} ${styles.sticky}`}>
                        <Glyph name={service.glyph} className={styles.glyph} />
                        {service.name}
                      </span>
                      <span className={styles.keys} aria-hidden="true">
                        {tab === 'weeks' ? (
                          <>
                            {[
                              service.accounts[0].name,
                              service.accounts[1].name,
                              'Accounts free',
                            ].map((name, lane) => (
                              <span key={name} className={styles.key}>
                                <LaneKey lane={lane} className={styles.keyIcon} />
                                {name}
                              </span>
                            ))}
                          </>
                        ) : (
                          <>
                            {[
                              ['main', service.accounts[0].name],
                              ['backup', service.accounts[1].name],
                              ['work', 'None free'],
                            ].map(([kind, name]) => (
                              <span key={kind} className={styles.key}>
                                <span className={styles.keySwatch} data-kind={kind} />
                                {name}
                              </span>
                            ))}
                          </>
                        )}
                      </span>
                    </h3>
                    <ul className={styles.rows}>
                      {service.weeks.map((week, weekIndex) => {
                        const key = `${service.id}:${week.id}`;
                        const index = rows.findIndex((row) => row.key === key);
                        const selected =
                          selection.service === service.id && selection.week === week.id;
                        const words = summary(service, week, tab);
                        const tip =
                          hover && hover.service === service.id && hover.week === week.id
                            ? hover
                            : null;
                        return (
                          <li
                            key={week.id}
                            ref={(element) => {
                              if (element) rowRefs.current.set(key, element);
                              else rowRefs.current.delete(key);
                            }}
                            className={styles.row}
                            data-selected={selected || undefined}
                            data-part="week"
                            onClick={() => select(service, week)}
                          >
                            <button
                              type="button"
                              className={styles.rowButton}
                              aria-pressed={selected}
                              tabIndex={focusKey === key ? 0 : -1}
                              aria-label={`${service.name}, week of ${week.label}${week.note ? ` (${week.note})` : ''}: ${words.head}, ${words.line}`}
                              onFocus={() => setFocusKey(key)}
                              onBlur={() => {
                                if (hover?.keyboard) setHover(null);
                              }}
                              onKeyDown={(event) => onRowKey(event, index)}
                            />
                            <span className={`${styles.label} ${styles.sticky}`} aria-hidden="true">
                              <span className={styles.labelMain}>{week.label}</span>
                              {week.note && <span className={styles.labelNote}>{week.note}</span>}
                            </span>
                            <span
                              className={styles.lanes}
                              data-part="lanes"
                              aria-hidden="true"
                              onPointerMove={(event) => onLanesMove(event, service, week)}
                              onPointerLeave={() => setHover(null)}
                            >
                              {tab === 'weeks' ? (
                                <WeekLanes week={week} />
                              ) : (
                                <HandoffTrack week={week} />
                              )}
                              {tip && (
                                <Tooltip
                                  service={service}
                                  week={week}
                                  slot={tip.slot}
                                  lane={tip.lane}
                                  tab={tab}
                                  below={weekIndex === 0}
                                />
                              )}
                            </span>
                            <span
                              className={styles.summary}
                              data-part="summary"
                              data-loud={words.loud || undefined}
                              aria-hidden="true"
                            >
                              <span className={styles.summaryHead}>{words.head}</span>
                              <span className={styles.summaryLine}>{words.line}</span>
                            </span>
                          </li>
                        );
                      })}
                    </ul>
                  </div>
                ))}

                <ul className={styles.soloRows} aria-label="Services with one account">
                  {solo.map((service) => (
                    <li key={service.id} className={styles.solo} data-part="solo">
                      <span className={`${styles.serviceName} ${styles.sticky}`}>
                        <Glyph name={service.glyph} className={styles.soloGlyph} />
                        {service.name}
                      </span>
                      <span className={styles.soloNote}>{service.note}</span>
                      <span className={styles.soloSummary}>
                        <span>One account</span>
                        <span>No cover</span>
                      </span>
                    </li>
                  ))}
                </ul>
              </div>
            </div>

            <Legend tab={tab} />
          </section>

          <div className={styles.rule} aria-hidden="true" />

          <Detail
            service={selectedService}
            week={selectedWeek}
            onPick={(week) => select(selectedService, week)}
          />
        </div>
      </div>
      <p className={styles.live} aria-live="polite">
        {announcement}
      </p>
      {demoOn && (
        <DemoPointer
          ref={pointerRef}
          className={styles.pointer}
          arrowClass={styles.pointerArrow}
          handClass={styles.pointerHand}
        />
      )}
    </div>
  );
}

/* The three lanes of a week. */

function WeekLanes({ week }: { week: Week }) {
  const ticks: { lane: number; at: number }[] = [];
  if (week.resets.main !== undefined && (week.now === undefined || week.resets.main <= week.now))
    ticks.push({ lane: 0, at: week.resets.main });
  if (
    week.resets.backup !== undefined &&
    (week.now === undefined || week.resets.backup <= week.now)
  )
    ticks.push({ lane: 1, at: week.resets.backup });
  const outs = [
    week.outs?.main !== undefined ? { lane: 0, at: week.outs.main } : null,
    week.outs?.backup !== undefined ? { lane: 1, at: week.outs.backup } : null,
  ].filter((out) => out !== null);
  const pace = week.forecast && week.outs?.backup !== undefined ? week.outs.backup : undefined;
  const paceEnd = week.resets.main ?? 168;
  return (
    <>
      {week.lanes.map((codes, lane) => (
        <span key={lane} className={styles.lane}>
          {DAY_NAMES.map((day, index) => (
            <span key={day} className={styles.day}>
              {Array.from(codes.slice(index * 4, index * 4 + 4)).map((code, slot) => (
                <span key={slot} className={styles.cell} data-code={code} />
              ))}
            </span>
          ))}
        </span>
      ))}
      {ticks.map((tick) => (
        <span
          key={`t${tick.lane}`}
          className={styles.tick}
          data-part="reset"
          data-lane={tick.lane}
          style={place(tick.at)}
        />
      ))}
      {pace !== undefined && <span className={styles.paceGap} style={place(pace, paceEnd)} />}
      {outs.map((out) =>
        out ? (
          <span
            key={`o${out.lane}`}
            className={styles.dot}
            data-part="run-out"
            data-lane={out.lane}
            data-forecast={week.forecast || undefined}
            style={place(out.at)}
          />
        ) : null,
      )}
      {week.now !== undefined && week.outs && (
        <span className={styles.now} style={place(week.now)} />
      )}
    </>
  );
}

/* The hand-off view's track. */

function HandoffTrack({ week }: { week: Week }) {
  const segments = serving(week);
  const first = week.lanes[2].search(/[^.]/);
  const handoffs = [week.outs?.main, week.outs?.backup].filter(
    (hour): hour is number => hour !== undefined,
  );
  return (
    <>
      <span className={styles.track}>
        {first > 0 && <span className={styles.trackVoid} style={place(0, first * 6)} />}
        {segments.map((segment) => (
          <span
            key={`${segment.from}-${segment.kind}`}
            className={styles.segment}
            data-part="segment"
            data-kind={segment.kind}
            data-future={segment.future || undefined}
            style={place(segment.from, segment.to)}
          />
        ))}
      </span>
      {[week.resets.main, week.resets.backup]
        .filter(
          (hour): hour is number =>
            hour !== undefined && (week.now === undefined || hour <= week.now),
        )
        .map((hour) => (
          <span
            key={`r${hour}`}
            className={styles.tick}
            data-part="reset"
            data-lane="track"
            style={place(hour)}
          />
        ))}
      {handoffs.map((hour) => (
        <span
          key={hour}
          className={styles.dot}
          data-part="run-out"
          data-lane="track"
          data-forecast={week.forecast || undefined}
          style={place(hour)}
        />
      ))}
    </>
  );
}

/* The tooltip of a slot. */

function tipText(service: Service, week: Week, slot: number, tab: CoverageTab) {
  const [a, b] = service.accounts;
  if (tab === 'handoff') {
    const hour = slot * 6 + 3;
    const segment = serving(week).find((s) => hour >= s.from && hour < s.to);
    return `${service.name}, ${slotName(slot)}: ${segment ? KIND[segment.kind](service) : 'No reading'}`;
  }
  return `${service.name}, ${slotName(slot)}: ${a.name} ${laneWords(week, 0, slot)}; ${b.name} ${laneWords(week, 1, slot)}; ${FREE[week.lanes[2][slot]] ?? ''}`;
}

function Tooltip({
  service,
  week,
  slot,
  lane,
  tab,
  below,
}: {
  service: Service;
  week: Week;
  slot: number;
  lane: number | null;
  tab: CoverageTab;
  below: boolean;
}) {
  const [a, b] = service.accounts;
  const edge = slot < 4 ? 'start' : slot > SLOTS - 5 ? 'end' : undefined;
  const style = place(slot * 6 + 3);
  let lines: { swatch: string; name: string; value: string; on: boolean }[];
  if (tab === 'handoff') {
    const from = slot * 6;
    const parts = serving(week).filter((s) => s.to > from && s.from < from + 6);
    lines = parts.length
      ? parts.map((part) => ({
          swatch: `k-${part.kind}`,
          name: KIND[part.kind](service),
          value: `${clock(Math.max(part.from, from))}–${clock(Math.min(part.to, from + 6), true)}${part.future ? ', forecast' : ''}`,
          on: true,
        }))
      : [{ swatch: 'c-.', name: 'No reading', value: '', on: true }];
  } else {
    lines = [
      {
        swatch: `c-${week.lanes[0][slot]}`,
        name: a.name,
        value: laneWords(week, 0, slot),
        on: lane === 0,
      },
      {
        swatch: `c-${week.lanes[1][slot]}`,
        name: b.name,
        value: laneWords(week, 1, slot),
        on: lane === 1,
      },
      {
        swatch: `c-${week.lanes[2][slot]}`,
        name: FREE[week.lanes[2][slot]] ?? '',
        value: '',
        on: lane === 2,
      },
    ];
    const pace = week.forecast
      ? (['main', 'backup'] as const).find((key) => {
          const out = week.outs?.[key];
          return out !== undefined && out >= slot * 6 && out < slot * 6 + 6;
        })
      : undefined;
    if (pace)
      lines.push({
        swatch: 'pace',
        name: `On pace to run out ~${when(week.outs![pace]!)}`,
        value: '',
        on: false,
      });
  }
  return (
    <span
      className={styles.tip}
      data-edge={edge}
      data-below={below || undefined}
      style={style}
      data-part="tooltip"
    >
      <span className={styles.tipHead}>
        {service.name} · {slotName(slot)}
      </span>
      {lines.map((line, index) => (
        <span
          key={index}
          className={styles.tipLine}
          data-on={line.on || lane === null || undefined}
        >
          <span className={styles.tipSwatch} data-swatch={line.swatch} />
          <span className={styles.tipName}>{line.name}</span>
          {line.value && <span className={styles.tipValue}>{line.value}</span>}
        </span>
      ))}
    </span>
  );
}

/* The legend under the weeks. */

function Legend({ tab }: { tab: CoverageTab }) {
  return (
    <div className={styles.legend} data-part="legend">
      {tab === 'weeks' ? (
        <>
          <p className={styles.legendLine}>
            <span>Use</span>
            <span className={styles.legendGroup}>
              {['1', '2', '3', '4'].map((code) => (
                <span key={code} className={styles.swatch} data-code={code} />
              ))}
              <span>under 25, 50, 75, 100%</span>
            </span>
            <span className={styles.legendItem}>
              <span className={styles.swatch} data-code="o" />
              Runs out
            </span>
            <span className={styles.legendItem}>
              <span className={styles.swatch} data-code="x" />
              Locked
            </span>
            <span className={styles.legendItem}>
              <span className={styles.legendTick} />
              Reset
            </span>
          </p>
          <p className={styles.legendLine}>
            <span>Accounts free</span>
            <span className={styles.legendFree}>
              {[
                ['a', 'all'],
                ['n', 'one'],
                ['z', 'none'],
                ['w', 'none, work hours'],
              ].map(([code, name]) => (
                <span key={code} className={styles.legendItem}>
                  <span className={styles.swatch} data-code={code} />
                  {name}
                </span>
              ))}
            </span>
            <span className={styles.legendItem} data-near="">
              <span className={styles.legendRing} />
              On pace
            </span>
            <span className={styles.legendItem}>
              <span className={styles.swatch} data-code="." />
              No reading or not yet
            </span>
          </p>
        </>
      ) : (
        <>
          <p className={styles.legendLine}>
            <span>Serving</span>
            {[
              ['main', 'main account'],
              ['backup', 'back-up'],
              ['none', 'none'],
              ['work', 'none, work hours'],
            ].map(([kind, name]) => (
              <span key={kind} className={styles.legendItem}>
                <span className={styles.swatch} data-kind={kind} />
                {name}
              </span>
            ))}
          </p>
          <p className={styles.legendLine}>
            <span className={styles.legendItem}>
              <span className={styles.legendDot} />
              Hand-off
            </span>
            <span className={styles.legendItem}>
              <span className={styles.legendRing} />
              On pace
            </span>
            <span className={styles.legendItem}>
              <span className={styles.legendTick} />
              Reset
            </span>
          </p>
        </>
      )}
      <p className={styles.legendLine}>
        {tab === 'weeks'
          ? 'One row per week, Mon to Sun, 6 h cells. Select a row for detail.'
          : 'One track per week, Mon to Sun. Select a row for detail.'}
        <span
          className={styles.info}
          tabIndex={0}
          role="note"
          aria-label="Work hours are 09:00 to 19:00, Monday to Friday. Each account resets on its own weekly window."
          data-tip="Work hours are 09:00–19:00, Mon to Fri. Each account resets on its own weekly window."
        >
          <svg viewBox="0 0 16 16" aria-hidden="true">
            <circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" strokeWidth="1.1" />
            <path d="M8 7.2v4.2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
            <circle cx="8" cy="4.9" r="0.8" fill="currentColor" />
          </svg>
        </span>
      </p>
    </div>
  );
}

/* The detail of the selected week. */

function Detail({
  service,
  week,
  onPick,
}: {
  service: Service;
  week: Week;
  onPick: (week: Week) => void;
}) {
  const [a, b] = service.accounts;
  const stats = figures(service, week);
  const max = Math.max(12, ...service.weeks.map((w) => w.noneWork + w.covered));
  const scale = Math.min(6.4, 84 / max);
  const byWeek = service.weeks
    .map((w) => `${w.forecast && w.noneWork ? '~' : ''}${w.noneWork}`)
    .join(', ');
  return (
    <aside
      className={styles.detail}
      aria-label={`${service.name}, week of ${week.label}`}
      data-part="detail"
    >
      <div className={styles.detailHead}>
        <Glyph name={service.glyph} className={styles.detailGlyph} />
        <div>
          <h3 className={styles.detailTitle}>{service.name}</h3>
          <p className={styles.detailSub}>
            {a.name} ({a.plan}) and {b.name} ({b.plan}) · week of {week.label}
          </p>
        </div>
      </div>
      <p className={styles.story} aria-live="polite">
        {story(service, week)}
      </p>
      <StepChart service={service} week={week} />
      <div className={styles.chartKeys} aria-hidden="true">
        <span className={styles.chartKey}>
          <span className={styles.keyLine} data-main="" />
          {a.name}
        </span>
        <span className={styles.chartKey}>
          <span className={styles.keyLine} />
          {b.name}
        </span>
        <span className={styles.chartKey}>
          <span className={styles.legendDot} />
          Runs out
        </span>
        <span className={styles.chartKey}>
          <span className={styles.swatch} data-code="x" data-wide="" />
          No account free
        </span>
        <span className={styles.chartKey}>
          <span className={styles.swatch} data-code="band" data-wide="" />
          Work hours
        </span>
      </div>
      <dl className={styles.stats} data-part="stats">
        {stats.map((stat) => (
          <div key={stat.label} className={styles.stat}>
            <dt>{stat.label}</dt>
            <dd>{stat.value}</dd>
          </div>
        ))}
      </dl>
      <div className={styles.perWeek} data-part="per-week">
        <h4 className={styles.perWeekTitle}>
          Work hours with no {service.name} account <span>· per week</span>
        </h4>
        <p className={styles.perWeekGoal}>Goal 0. By week: {byWeek} h.</p>
        <div className={styles.bars} data-part="bars">
          {service.weeks.map((w) => {
            const on = w.id === week.id;
            return (
              <button
                key={w.id}
                type="button"
                className={styles.bar}
                data-part="bar"
                aria-pressed={on}
                aria-label={`Week of ${w.label}: ${w.forecast ? 'about ' : ''}${w.noneWork} work hours with none free, ${w.covered} covered by ${b.name}`}
                data-forecast={w.forecast || undefined}
                onClick={() => onPick(w)}
              >
                <span className={styles.barStack}>
                  <span className={styles.barValue} data-zero={!w.noneWork || undefined}>
                    {w.forecast && w.noneWork ? '~' : ''}
                    {w.noneWork}
                  </span>
                  {w.noneWork > 0 && (
                    <span className={styles.barNone} style={{ height: w.noneWork * scale - 1.5 }} />
                  )}
                  {w.covered > 0 && (
                    <span className={styles.barCovered} style={{ height: w.covered * scale }} />
                  )}
                </span>
                <span className={styles.barLabel}>{w.short}</span>
              </button>
            );
          })}
        </div>
        <div className={styles.barKeys} aria-hidden="true">
          <span className={styles.chartKey}>
            <span className={styles.swatch} data-code="n" />
            Covered by {b.name}
          </span>
          <span className={styles.chartKey}>
            <span className={styles.swatch} data-code="w" />
            None free
          </span>
          <span className={styles.chartKey}>
            <span className={styles.swatch} data-code="pace" />
            On pace
          </span>
        </div>
      </div>
    </aside>
  );
}
```

### coverage-heatmap/coverage-heatmap.module.css

```css
/* Coverage heatmap (coverage-heatmap.tsx): a near-black analytics panel by night, white by day.
   Use is drawn in four greys, a locked account in red hatching, the hours no account was free in
   a dark red (a bright one in work hours), run-outs as red dots and resets as white ticks.

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

   Sizes are those of the panel at 1200 px: a 720 px column of weeks (135 px of labels, the
   heatmap, 155 px of summaries), a 380 px detail column, 46 px week rows, 10 px lanes. From 940 to
   1200 px the component scales the panel as a whole (zoom, set by the script); under 940 px the
   detail goes under the weeks and the weeks scroll sideways, their labels pinned.

   Memory. The cells are plain boxes with a background; the tooltip, the dots and the ticks sit
   over them by position. A selection or a tab switch only restyles and repaints. */

.root {
  --card: #fff;
  --edge: #e2e2e2;
  --line: #ececec;
  --rule: #ececec;
  --row-hover: #fafafa;
  --row-on: #f5f5f5;
  --ink: #141414;
  --ink-2: #3c3c3c;
  --ink-3: #727272;
  --ink-4: #a5a5a5;
  --ink-solo: #8a8a8a;
  --c1: #ededed;
  --c2: #d6d6d6;
  --c3: #b3b3b3;
  --c4: #888;
  --dotted: #c8c8c8;
  --runs: #f1cbc6;
  --none: #f4d9d5;
  --work: #e8766c;
  --red: #e2574b;
  --red-ink: #cf4136;
  --pace: #e0675c;
  --hatch-base: #fbe9e7;
  --hatch-line: #eb9a92;
  --tick: #1d1d1d;
  --dot-ring: #fff;
  --pill: #f1f1f1;
  --pill-on: #fff;
  --pill-drop: rgb(0 0 0 / 0.08);
  --pill-ring: rgb(0 0 0 / 0.05);
  --key-on: #4d4d4d;
  --key-off: #d4d4d4;
  --spark: #d4693f;
  --glyph: #222;
  --glyph-solo: #8d8d8d;
  --ridge-a: #3c6fd4;
  --ridge-b: #8d67d6;
  --band: rgb(0 0 0 / 0.035);
  --under: rgb(0 0 0 / 0.045);
  --grid: rgb(0 0 0 / 0.08);
  --grid-strong: rgb(0 0 0 / 0.18);
  --dash: #c6c6c6;
  --gap: rgb(226 87 75 / 0.07);
  --hatch: rgb(226 87 75 / 0.42);
  --main-line: #151515;
  --backup-line: #9b9b9b;
  --halo: #fff;
  --bar-on: #f3f3f3;
  --bar-grey: #a8a8a8;
  --proj: #fbe1dd;
  --proj-edge: #c8c8c8;
  --tip: #fff;
  --tip-edge: #e1e1e1;
  --tip-drop: rgb(0 0 0 / 0.12);
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  container-type: inline-size;
  width: 100%;
  min-width: 0;
  color: var(--ink);
  font-family:
    Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    system-ui,
    sans-serif;
  font-size: 14px;
  line-height: 1.3;
  font-feature-settings: 'cv11' 0;
  text-align: left;
  letter-spacing: normal;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --card: light-dark(#fff, #181818);
    --edge: light-dark(#e2e2e2, #2f2f2f);
    --line: light-dark(#ececec, #252525);
    --rule: light-dark(#ececec, #232323);
    --row-hover: light-dark(#fafafa, #1b1b1b);
    --row-on: light-dark(#f5f5f5, #1f1f1f);
    --ink: light-dark(#141414, #fafafa);
    --ink-2: light-dark(#3c3c3c, #c7c7c7);
    --ink-3: light-dark(#727272, #9a9a9a);
    --ink-4: light-dark(#a5a5a5, #636363);
    --ink-solo: light-dark(#8a8a8a, #7a7a7a);
    --c1: light-dark(#ededed, #2a2a2a);
    --c2: light-dark(#d6d6d6, #454545);
    --c3: light-dark(#b3b3b3, #666);
    --c4: light-dark(#888, #8d8d8d);
    --dotted: light-dark(#c8c8c8, #505050);
    --runs: light-dark(#f1cbc6, #4f312e);
    --none: light-dark(#f4d9d5, #573535);
    --work: light-dark(#e8766c, #c9665e);
    --red: light-dark(#e2574b, #f07c72);
    --red-ink: light-dark(#cf4136, #f38c8e);
    --pace: light-dark(#e0675c, #d9645a);
    --hatch-base: light-dark(#fbe9e7, #2c1f1e);
    --hatch-line: light-dark(#eb9a92, #b9625b);
    --tick: light-dark(#1d1d1d, #e8e8e8);
    --dot-ring: light-dark(#fff, #120c0c);
    --pill: light-dark(#f1f1f1, #202020);
    --pill-on: light-dark(#fff, #262626);
    --pill-drop: light-dark(rgb(0 0 0 / 0.08), transparent);
    --pill-ring: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.035));
    --key-on: light-dark(#4d4d4d, #c9c9c9);
    --key-off: light-dark(#d4d4d4, #474747);
    --spark: light-dark(#d4693f, #db7b55);
    --glyph: light-dark(#222, #ececec);
    --glyph-solo: light-dark(#8d8d8d, #8b8b8b);
    --ridge-a: light-dark(#3c6fd4, #3d72d8);
    --ridge-b: light-dark(#8d67d6, #8e6bd9);
    --band: light-dark(rgb(0 0 0 / 0.035), rgb(255 255 255 / 0.05));
    --under: light-dark(rgb(0 0 0 / 0.045), rgb(255 255 255 / 0.066));
    --grid: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.085));
    --grid-strong: light-dark(rgb(0 0 0 / 0.18), rgb(255 255 255 / 0.16));
    --dash: light-dark(#c6c6c6, #4b4b4b);
    --gap: light-dark(rgb(226 87 75 / 0.07), rgb(240 124 114 / 0.1));
    --hatch: light-dark(rgb(226 87 75 / 0.42), rgb(240 124 114 / 0.5));
    --main-line: light-dark(#151515, #f7f7f7);
    --backup-line: light-dark(#9b9b9b, #8b8b8b);
    --halo: light-dark(#fff, #181818);
    --bar-on: light-dark(#f3f3f3, #222);
    --bar-grey: light-dark(#a8a8a8, #707070);
    --proj: light-dark(#fbe1dd, #382624);
    --proj-edge: light-dark(#c8c8c8, #4e4e4e);
    --tip: light-dark(#fff, #262626);
    --tip-edge: light-dark(#e1e1e1, #363636);
    --tip-drop: light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.5));
    --focus: light-dark(#387bff, #6ea2ff);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}
.root :where(h2, h3, h4, p, ul, dl, dd) {
  margin: 0;
  padding: 0;
  font: inherit;
}
.root :where(ul) {
  list-style: none;
}
.root :where(button) {
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
  cursor: pointer;
  border: 0;
  background: none;
  padding: 0;
  text-align: inherit;
}
.root :where(svg) {
  flex: none;
  display: block;
}
.root :where(button, [tabindex]):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}

/* The panel. */

.card {
  border: 1px solid var(--edge);
  border-radius: 24px;
  padding: 20.4px 29px 27px;
  background: var(--card);
}

.head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px 24px;
}
.headText {
  min-width: 0;
}
.title {
  font-size: 24px;
  line-height: 36px;
  font-weight: 500;
  letter-spacing: -0.012em;
  color: var(--ink);
  text-wrap: balance;
}
.lede {
  margin-top: 1.7px;
  font-size: 15px;
  line-height: 20px;
  color: var(--ink-3);
  text-wrap: pretty;
}
.tabs {
  display: flex;
  flex: none;
  height: 35.6px;
  padding: 2px;
  border-radius: 999px;
  background: var(--pill);
}
.tab {
  height: 100%;
  padding: 0 13.3px;
  border-radius: 999px;
  font-size: 14px;
  font-weight: 500;
  color: var(--ink-2);
  transition:
    background-color 0.18s ease,
    color 0.18s ease,
    box-shadow 0.18s ease;
}
.tab[aria-selected='true'] {
  background: var(--pill-on);
  color: var(--ink);
  box-shadow:
    0 1px 2px var(--pill-drop),
    inset 0 0 0 1px var(--pill-ring);
}
.tab:hover {
  color: var(--ink);
}

.body {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 1px 380px;
  column-gap: 20px;
  margin-top: 16.6px;
}
.rule {
  background: var(--rule);
}

/* The weeks. */

.board {
  min-width: 0;
  padding-top: 21.4px;
}
.scroller {
  min-width: 0;
}
.table {
  min-width: 0;
}
.columns,
.serviceHead,
.row {
  display: grid;
  grid-template-columns: 135px minmax(0, 1fr) 155px;
  align-items: center;
}
.columns {
  height: 16px;
  font-size: 12.5px;
  line-height: 16px;
  color: var(--ink-3);
}
.columns > :first-child {
  padding-left: 5px;
}
.columns > :last-child {
  padding-left: 13px;
}
.days > * {
  padding-left: 1.5px;
}
.days {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  column-gap: 4px;
  color: var(--ink-2);
}

.serviceHead {
  height: 48px;
  padding-top: 19px;
  padding-bottom: 9px;
  border-bottom: 1px solid var(--line);
}
.serviceHead[data-first] {
  height: 41px;
  padding-top: 11.4px;
}
.serviceName {
  display: flex;
  align-items: center;
  height: 20px;
  padding-left: 34px;
  position: relative;
  font-size: 16px;
  line-height: 20px;
  font-weight: 500;
  color: var(--ink);
  white-space: nowrap;
}
.glyph {
  position: absolute;
  left: 4px;
  top: 50%;
  width: 19.5px;
  height: 19.5px;
  translate: 0 -50%;
}
.keys {
  display: flex;
  gap: 14.5px;
  font-size: 13px;
  line-height: 16px;
  color: var(--ink-3);
  white-space: nowrap;
  grid-column: 2 / 4;
}
.key {
  display: inline-flex;
  align-items: center;
  gap: 7.6px;
}
.keyIcon {
  width: 10px;
  height: 10px;
}
.keySwatch {
  width: 14px;
  height: 6px;
  border-radius: 2px;
  background: var(--c4);
}
.keySwatch[data-kind='backup'] {
  background: var(--c3);
}
.keySwatch[data-kind='work'] {
  background: var(--work);
}

.rows {
  border-bottom: 1px solid var(--line);
}
.row {
  position: relative;
  height: 46px;
  cursor: pointer;
}
.row + .row {
  border-top: 1px solid var(--line);
}
.row:hover {
  background: var(--row-hover);
}
.row[data-selected] {
  background: var(--row-on);
}
.rowButton {
  position: absolute;
  inset: 0;
  z-index: 0;
  border-radius: 2px;
}
.rowButton:focus-visible {
  outline-offset: -2px;
}
.label {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-self: stretch;
  padding-left: 33px;
  pointer-events: none;
}
.labelMain {
  font-size: 15px;
  line-height: 19px;
  color: var(--ink-2);
}
.row[data-selected] .labelMain {
  color: var(--ink);
  font-weight: 500;
}
.labelNote {
  font-size: 12.5px;
  line-height: 16px;
  color: var(--ink-3);
  white-space: nowrap;
}
.summary {
  display: flex;
  flex-direction: column;
  min-width: 0;
  padding-left: 13px;
  padding-bottom: 3.5px;
  pointer-events: none;
}
.summaryHead,
.summaryLine {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.summaryHead {
  font-size: 14px;
  line-height: 20px;
  color: var(--ink-3);
}
.summary[data-loud] .summaryHead {
  color: var(--ink);
  font-weight: 500;
}
.summaryLine {
  font-size: 13px;
  line-height: 17px;
  color: var(--ink-3);
}

/* A week's three lanes: 7 days of 4 cells each. Times are placed with --day and --frac. */

.lanes {
  --gap: 4px;
  position: relative;
  z-index: 1;
  display: grid;
  grid-template-rows: repeat(3, 10px);
  row-gap: 2px;
  align-self: start;
  margin-top: 6px;
}
.lane {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  column-gap: var(--gap);
}
.day {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  column-gap: 1.5px;
}
.cell {
  border-radius: 2.6px;
  background: var(--c1);
}
.cell[data-code='2'] {
  background: var(--c2);
}
.cell[data-code='3'] {
  background: var(--c3);
}
.cell[data-code='4'] {
  background: var(--c4);
}
.cell[data-code='o'] {
  background: var(--runs);
}
.cell[data-code='x'] {
  background: repeating-linear-gradient(
    45deg,
    var(--hatch-line) 0 1.1px,
    var(--hatch-base) 1.1px 4.6px
  );
}
.cell[data-code='.'],
.cell[data-code='-'] {
  background: none;
  border: 1px dotted var(--dotted);
}
.cell[data-code='n'] {
  background: var(--c3);
}
.cell[data-code='z'] {
  background: var(--none);
}
.cell[data-code='w'] {
  background: var(--work);
}

/* Where an hour of the week falls along the lanes: its day, then its share of that day. */
.tick,
.dot,
.now,
.paceGap,
.segment,
.trackVoid,
.tip {
  --x: calc(
    (100% + var(--gap)) / 7 * var(--day) + ((100% + var(--gap)) / 7 - var(--gap)) * var(--frac)
  );
  --x2: calc(
    (100% + var(--gap)) / 7 * var(--day2) + ((100% + var(--gap)) / 7 - var(--gap)) * var(--frac2)
  );
  position: absolute;
  pointer-events: none;
}
.tick {
  left: var(--x);
  top: -2px;
  width: 1.6px;
  height: 14px;
  margin-left: -0.8px;
  border-radius: 1px;
  background: var(--tick);
}
.tick[data-lane='1'] {
  top: 10px;
}
.tick[data-lane='track'] {
  top: 7px;
  height: 20px;
}
.dot {
  left: var(--x);
  top: 5px;
  width: 7.4px;
  height: 7.4px;
  translate: -50% -50%;
  border-radius: 50%;
  background: var(--red);
  box-shadow: 0 0 0 1.2px var(--dot-ring);
}
.dot[data-lane='1'] {
  top: 17px;
}
.dot[data-lane='track'] {
  top: 17px;
}
.dot[data-forecast] {
  width: 9px;
  height: 9px;
  background: var(--card);
  box-shadow: inset 0 0 0 1.2px var(--pace);
}
.now {
  left: var(--x);
  top: -6px;
  bottom: -5px;
  width: 1.5px;
  margin-left: -0.75px;
  background: repeating-linear-gradient(to bottom, var(--tick) 0 3.4px, transparent 3.4px 6px);
}
.paceGap {
  left: var(--x);
  width: calc(var(--x2) - var(--x));
  top: 23.3px;
  height: 11.4px;
  border: 1.3px dashed var(--pace);
  border-radius: 2.6px;
}

/* The hand-off view's track. */

.track {
  position: absolute;
  inset: 10px 0;
}
.segment,
.trackVoid {
  left: var(--x);
  width: calc(var(--x2) - var(--x));
  top: 0;
  bottom: 0;
  border-radius: 2.6px;
  background: var(--c4);
}
.segment[data-kind='backup'] {
  background: var(--c3);
}
.segment[data-kind='none'] {
  background: var(--none);
}
.segment[data-kind='work'] {
  background: var(--work);
}
.segment[data-future] {
  background: none;
  border: 1.3px dashed var(--c3);
}
.segment[data-future][data-kind='none'],
.segment[data-future][data-kind='work'] {
  border-color: var(--pace);
}
.trackVoid {
  background: none;
  border: 1px dotted var(--dotted);
}

/* The tooltip. */

.tip {
  left: var(--x);
  bottom: calc(100% + 8px);
  z-index: 5;
  display: grid;
  gap: 4px;
  width: max-content;
  max-width: 280px;
  padding: 8px 10px 9px;
  border: 1px solid var(--tip-edge);
  border-radius: 9px;
  background: var(--tip);
  box-shadow: 0 8px 24px var(--tip-drop);
  translate: -50% 0;
  font-size: 12.5px;
  line-height: 16px;
  color: var(--ink-2);
  animation: tipIn 0.14s ease-out;
}
.tip[data-below] {
  bottom: auto;
  top: calc(100% + 8px);
}
.tip[data-edge='start'] {
  translate: -14% 0;
}
.tip[data-edge='end'] {
  translate: -86% 0;
}
@keyframes tipIn {
  from {
    opacity: 0;
    transform: translateY(3px);
  }
}
.tipHead {
  font-weight: 500;
  color: var(--ink);
  white-space: nowrap;
}
.tipLine {
  display: flex;
  align-items: center;
  gap: 7px;
  white-space: nowrap;
  opacity: 0.6;
}
.tipLine[data-on] {
  opacity: 1;
}
.tipSwatch {
  width: 12px;
  height: 8px;
  border-radius: 2px;
  background: var(--c1);
}
.tipSwatch[data-swatch='c-2'] {
  background: var(--c2);
}
.tipSwatch[data-swatch='c-3'],
.tipSwatch[data-swatch='c-n'],
.tipSwatch[data-swatch='k-backup'] {
  background: var(--c3);
}
.tipSwatch[data-swatch='c-4'],
.tipSwatch[data-swatch='k-main'] {
  background: var(--c4);
}
.tipSwatch[data-swatch='c-o'] {
  background: var(--runs);
}
.tipSwatch[data-swatch='c-z'],
.tipSwatch[data-swatch='k-none'] {
  background: var(--none);
}
.tipSwatch[data-swatch='c-w'],
.tipSwatch[data-swatch='k-work'] {
  background: var(--work);
}
.tipSwatch[data-swatch='c-x'] {
  background: repeating-linear-gradient(
    45deg,
    var(--hatch-line) 0 1.1px,
    var(--hatch-base) 1.1px 4.6px
  );
}
.tipSwatch[data-swatch='c-.'],
.tipSwatch[data-swatch='c--'] {
  background: none;
  border: 1px dotted var(--dotted);
}
.tipSwatch[data-swatch='pace'] {
  width: 9px;
  height: 9px;
  margin: 0 1.5px;
  border-radius: 50%;
  background: none;
  box-shadow: inset 0 0 0 1.3px var(--pace);
}
.tipName {
  color: var(--ink);
}
.tipValue {
  color: var(--ink-3);
}

/* Services with one account. */

.solo {
  display: grid;
  grid-template-columns: 135px minmax(0, 1fr) 155px;
  align-items: center;
  height: 37px;
  padding-bottom: 6px;
  border-bottom: 1px solid var(--line);
}
.solo .serviceName {
  font-size: 14px;
  font-weight: 400;
  color: var(--ink-solo);
  padding-left: 31.5px;
}
.soloGlyph {
  position: absolute;
  left: 4.3px;
  top: 50%;
  width: 16.5px;
  height: 16.5px;
  translate: 0 -50%;
}
.soloNote {
  font-size: 13.6px;
  color: var(--ink-4);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.soloSummary {
  display: flex;
  flex-direction: column;
  padding-left: 13px;
  font-size: 13px;
  line-height: 19px;
  color: var(--ink-4);
  white-space: nowrap;
}
.soloSummary > :first-child {
  font-size: 14px;
}

/* The legend. */

.legend {
  margin-top: 11px;
  font-size: 14px;
  line-height: 26px;
  color: var(--ink-3);
}
.legendLine {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
}
.legendLine > * + * {
  margin-left: 16px;
}
.legendLine > :nth-child(2) {
  margin-left: 11.6px;
}
.legendLine > :nth-child(3) {
  margin-left: 22px;
}
.legendLine > [data-near] {
  margin-left: 16.8px;
}
.legendLine:last-child {
  font-size: 13.75px;
}
.legendGroup,
.legendItem {
  display: inline-flex;
  align-items: center;
  gap: 7.5px;
}
.legendLine:first-child > :nth-child(4) {
  margin-left: 14.4px;
}
.legendLine:nth-child(2) > :nth-child(4) {
  margin-left: 21.5px;
}
.legendGroup {
  gap: 7.2px;
}
.legendFree {
  display: inline-flex;
  flex-wrap: wrap;
  column-gap: 9px;
}
.legendFree > .legendItem {
  margin-left: 0;
}
.swatch {
  flex: none;
  width: 14.4px;
  height: 9.6px;
  border-radius: 2.6px;
  background: var(--c1);
}
.swatch[data-wide] {
  width: 14px;
  height: 10px;
}
.swatch[data-code='2'] {
  background: var(--c2);
}
.swatch[data-code='3'],
.swatch[data-code='n'],
.swatch[data-kind='backup'] {
  background: var(--c3);
}
.swatch[data-code='4'],
.swatch[data-kind='main'] {
  background: var(--c4);
}
.swatch[data-code='o'] {
  background: var(--runs);
}
.swatch[data-code='z'],
.swatch[data-kind='none'] {
  background: var(--none);
}
.swatch[data-code='w'],
.swatch[data-kind='work'] {
  background: var(--work);
}
.swatch[data-code='x'] {
  background: repeating-linear-gradient(
    45deg,
    var(--hatch-line) 0 1.1px,
    var(--hatch-base) 1.1px 4.6px
  );
}
.swatch[data-code='.'] {
  background: none;
  border: 1px dotted var(--dotted);
}
.swatch[data-code='band'] {
  background: var(--c1);
}
.swatch[data-code='pace'] {
  background: var(--proj);
  border: 1.2px dashed var(--pace);
}
.legendTick {
  width: 1.6px;
  height: 12px;
  border-radius: 1px;
  background: var(--tick);
}
.legendRing,
.legendDot {
  width: 9.4px;
  height: 9.4px;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1.3px var(--pace);
}
.legendDot {
  background: var(--red);
  box-shadow: none;
}
.legendLine > .info {
  display: inline-grid;
  margin-left: 9px;
  color: var(--ink-3);
  border-radius: 50%;
  cursor: help;
}
.info svg {
  width: 17.6px;
  height: 17.6px;
}

/* The detail. */

.detail {
  min-width: 0;
  padding-top: 2.6px;
}
.detailHead {
  position: relative;
  padding-left: 35px;
}
.detailGlyph {
  position: absolute;
  left: 0.6px;
  top: 9px;
  width: 23px;
  height: 23px;
}
.detailTitle {
  font-size: 16px;
  line-height: 20px;
  font-weight: 500;
  color: var(--ink);
}
.detailSub {
  margin-top: 2.6px;
  font-size: 14px;
  line-height: 18px;
  color: var(--ink-3);
}
.story {
  margin-top: 12.5px;
  font-size: 14px;
  line-height: 20.5px;
  color: var(--ink-2);
  min-height: 41px;
}
.chart {
  width: 100%;
  height: auto;
  margin-top: 9.4px;
  overflow: visible;
}
.chart text {
  font-family: inherit;
}
.mainLine {
  fill: none;
  stroke: var(--main-line);
  stroke-width: 2.1;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.backupLine {
  fill: none;
  stroke: var(--backup-line);
  stroke-width: 1.3;
  stroke-linejoin: round;
}
.lockLine {
  fill: none;
  stroke: var(--red);
  stroke-width: 2.1;
}
.nowLine {
  stroke: var(--tick);
  stroke-width: 1.2;
  stroke-dasharray: 3 2.6;
}
.chartDot {
  fill: var(--red);
  stroke: var(--card);
  stroke-width: 1.3;
}
.chartDot[data-forecast] {
  fill: var(--card);
  stroke: var(--pace);
}
.callout {
  font-size: 13.4px;
  font-weight: 500;
  fill: var(--ink);
}
.axisText {
  font-size: 12px;
  fill: var(--ink-3);
}
.lineName {
  font-size: 12.5px;
  fill: var(--ink);
  paint-order: stroke;
  stroke: var(--halo);
  stroke-width: 3px;
  stroke-linejoin: round;
}
.lineName[data-backup] {
  fill: var(--ink-2);
}
.gapText {
  font-size: 13px;
  font-weight: 500;
  fill: var(--red-ink);
  paint-order: stroke;
  stroke: var(--halo);
  stroke-width: 3px;
  stroke-linejoin: round;
}
.resetText {
  font-size: 12px;
  fill: var(--ink-2);
  paint-order: stroke;
  stroke: var(--halo);
  stroke-width: 3px;
  stroke-linejoin: round;
}
.gap[data-forecast] {
  opacity: 0.7;
}

.chartKeys,
.barKeys {
  display: flex;
  flex-wrap: wrap;
  column-gap: 20px;
  font-size: 14px;
  line-height: 26px;
  color: var(--ink-3);
}
.chartKeys {
  margin-top: 7.1px;
  max-width: 300px;
}
.chartKey {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  white-space: nowrap;
}
.keyLine {
  width: 14px;
  height: 1px;
  border-radius: 1px;
  background: var(--backup-line);
}
.keyLine[data-main] {
  height: 2.2px;
  background: var(--main-line);
}

.stats {
  display: grid;
  grid-template-columns: 131.5px 131.5px minmax(0, 1fr);
  margin-top: 14.4px;
}
.stat dt {
  font-size: 12.5px;
  line-height: 17px;
  color: var(--ink-3);
}
.stat dd {
  margin-top: 4.6px;
  font-size: 17px;
  line-height: 22px;
  font-weight: 500;
  color: var(--ink);
  white-space: nowrap;
}

.perWeek {
  margin-top: 20.8px;
  padding-top: 13px;
  border-top: 1px solid var(--rule);
}
.perWeekTitle {
  font-size: 14px;
  line-height: 20px;
  font-weight: 500;
  color: var(--ink);
}
.perWeekTitle span {
  font-weight: 400;
  color: var(--ink-3);
}
.perWeekGoal {
  margin-top: 4.3px;
  font-size: 13.4px;
  line-height: 20px;
  color: var(--ink-2);
}
.bars {
  position: relative;
  display: grid;
  grid-template-columns: repeat(5, 76px);
  height: 128.2px;
  margin-top: 16.7px;
}
.bars::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 106.2px;
  height: 1px;
  background: var(--rule);
  pointer-events: none;
}
.bar {
  position: relative;
  display: grid;
  grid-template-rows: 106.2px 1fr;
  justify-items: center;
  margin: 0 3px;
  border-radius: 9px;
  transition: background-color 0.18s ease;
}
.bar:hover {
  background: var(--row-hover);
}
.bar[aria-pressed='true'] {
  background: var(--bar-on);
}
.barStack {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: center;
  gap: 1.5px;
  width: 27.4px;
  padding-bottom: 0;
}
.barValue {
  margin-bottom: 2px;
  font-size: 12px;
  line-height: 14px;
  font-weight: 600;
  color: var(--red-ink);
}
.barValue[data-zero] {
  color: var(--ink-3);
  font-weight: 500;
}
.barNone,
.barCovered {
  width: 100%;
  border-radius: 3px;
  background: var(--red);
  transition: height 0.3s ease;
}
.barCovered {
  background: var(--bar-grey);
}
.bar[data-forecast] .barNone {
  background: var(--proj);
  border: 1.3px dashed var(--pace);
}
.bar[data-forecast] .barCovered {
  background: none;
  border: 1.3px dashed var(--proj-edge);
}
.barLabel {
  padding-top: 5.7px;
  font-size: 13px;
  line-height: 16px;
  color: var(--ink-3);
  white-space: nowrap;
}
.bar[aria-pressed='true'] .barLabel {
  color: var(--ink);
  font-weight: 500;
}
.barKeys {
  margin-top: -1.7px;
}

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

/* The demo pointer: sized here, moved by the script. */
.pointer {
  width: 22px;
  height: 22px;
  z-index: 10;
}
.pointerHand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .pointerArrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .pointerHand {
  visibility: visible;
}

/* Narrow: the detail under the weeks, the weeks scrolling sideways with their labels pinned. */

.root[data-layout='stacked'] .card {
  padding: 18px 16px 22px;
  border-radius: 20px;
}
.root[data-layout='stacked'] .head {
  flex-direction: column;
}
.root[data-layout='stacked'] .title {
  font-size: 20px;
  line-height: 27px;
}
.root[data-layout='stacked'] .lede {
  font-size: 14px;
}
.root[data-layout='stacked'] .body {
  grid-template-columns: minmax(0, 1fr);
  row-gap: 24px;
  margin-top: 8px;
}
.root[data-layout='stacked'] .rule {
  height: 1px;
}
.root[data-layout='stacked'] .scroller {
  overflow-x: auto;
  overscroll-behavior-x: contain;
  margin: 0 -16px;
  padding: 0 16px 6px;
  scrollbar-width: thin;
}
.root[data-layout='stacked'] .table {
  min-width: 700px;
  padding-bottom: 2px;
}
.root[data-layout='stacked'] .sticky {
  position: sticky;
  left: -16px;
  z-index: 2;
  background: var(--card);
  align-self: stretch;
  display: flex;
  align-items: center;
}
.root[data-layout='stacked'] .row[data-selected] .sticky {
  background: var(--row-on);
}
.root[data-layout='stacked'] .label {
  flex-direction: column;
  align-items: flex-start;
}
.root[data-layout='stacked'] .detail {
  max-width: 560px;
}
.root[data-layout='stacked'] .chartKeys {
  max-width: none;
}
.root[data-layout='stacked'] .stats {
  grid-template-columns: repeat(3, minmax(0, 1fr));
  column-gap: 12px;
}
.root[data-layout='stacked'] :is(.columns, .serviceHead, .row, .solo) {
  grid-template-columns: 116px minmax(0, 1fr) 155px;
}
.root[data-layout='stacked'] .label {
  padding-left: 22px;
}
.root[data-layout='stacked'] .serviceName {
  padding-left: 26px;
}
.root[data-layout='stacked'] .glyph {
  left: 0;
}
@container (max-width: 520px) {
  .legendLine {
    column-gap: 16px;
  }
  .stat dd {
    font-size: 15px;
  }
  .stat dt {
    font-size: 12px;
  }
  .bars {
    grid-template-columns: repeat(5, minmax(0, 1fr));
    margin-inline: -3px;
  }
  .barLabel {
    font-size: 12px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .tab,
  .bar,
  .barNone,
  .barCovered {
    transition: none;
  }
  .tip {
    animation: none;
  }
}
```

### coverage-heatmap/chart.tsx

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

import { useId } from 'react';
import { DAY_NAMES, WINDOW, WORK, span, when, type Knot, type Service, type Week } from './data';
import styles from './coverage-heatmap.module.css';

/*
 * The detail's step chart: both accounts' use from Wednesday to Sunday noon. The main account's
 * line is white over a soft fill, the back-up's a thin grey one; where the main account sits at
 * 100 % waiting for its reset the line turns red, a red dot marks each run-out with its time above,
 * and the stretch with no account free is hatched red with its length written in it. Work hours
 * are lighter bands. For the week in progress the lines after the present moment are dashed and
 * the dots hollow: a forecast.
 *
 * It is drawn in a 380 × 224 box of its own units (the panel's px at full size) and scales with
 * its column; the figures it shows are all in the text around it, so it is aria-hidden.
 */

const W = 380;
const H = 224;
const X0 = 41;
const X1 = 372.8;
const Y100 = 35.3;
const Y0 = 197.6;

const x = (hour: number) => X0 + ((hour - WINDOW[0]) / (WINDOW[1] - WINDOW[0])) * (X1 - X0);
const y = (percent: number) => Y0 - (percent / 100) * (Y0 - Y100);
const at = (knots: Knot[], hour: number) => {
  for (let i = 1; i < knots.length; i += 1) {
    const [h0, p0] = knots[i - 1];
    const [h1, p1] = knots[i];
    if (hour >= h0 && hour <= h1)
      return h1 === h0 ? p1 : p0 + ((p1 - p0) * (hour - h0)) / (h1 - h0);
  }
  return knots[knots.length - 1][1];
};

/** The part of `knots` between two hours, as an SVG path. */
function trace(knots: Knot[], from: number, to: number) {
  const points: [number, number][] = [[from, at(knots, from)]];
  // A vertical step (a reset) is two knots at the same hour: both are kept.
  for (const [h, p] of knots) {
    const last = points[points.length - 1];
    if (h >= from && h <= to && !(h === last[0] && p === last[1])) points.push([h, p]);
  }
  const last = points[points.length - 1];
  if (last[0] < to) points.push([to, at(knots, to)]);
  return points
    .map(([h, p], i) => `${i ? 'L' : 'M'}${x(h).toFixed(2)} ${y(p).toFixed(2)}`)
    .join('');
}

export function StepChart({ service, week }: { service: Service; week: Week }) {
  const id = useId().replace(/:/g, '');
  const [main, backup] = week.usage;
  const [first, second] = service.accounts;
  const outA = week.outs?.main;
  const outB = week.outs?.backup;
  const reset = week.resets.main;
  const start = Math.max(WINDOW[0], main[0][0]);
  const now = week.forecast ? (week.now ?? WINDOW[0]) : undefined;
  const cut = now !== undefined ? Math.max(start, Math.min(now, WINDOW[1])) : WINDOW[1];
  // Where the main line is red: from its run-out to its reset, inside the window.
  const lockFrom = outA !== undefined && outA < WINDOW[1] ? outA : undefined;
  const lockTo =
    lockFrom !== undefined
      ? Math.min(reset && reset > lockFrom ? reset : WINDOW[1], WINDOW[1])
      : undefined;
  const gap =
    outA !== undefined && outB !== undefined ? [Math.max(outA, outB), lockTo ?? WINDOW[1]] : null;

  // The main line in its parts: before the run-out (white), locked (red), after the reset (white).
  const mainSolid = (from: number, to: number) => (to > from ? trace(main, from, to) : '');
  const before = lockFrom ?? WINDOW[1];
  const after = lockTo !== undefined && lockTo < WINDOW[1] ? lockTo : undefined;
  const fill = `${trace(main, start, WINDOW[1])}L${x(WINDOW[1])} ${Y0}L${x(start)} ${Y0}Z`;

  const days = [2, 3, 4, 5, 6].map((day) => {
    const from = Math.max(day * 24, WINDOW[0]);
    const to = Math.min(day * 24 + 24, WINDOW[1]);
    return { name: DAY_NAMES[day], middle: x((from + to) / 2), edge: x(day * 24) };
  });
  const bands = [2, 3, 4].map((day) => [x(day * 24 + WORK[0]), x(day * 24 + WORK[1])]);

  const dots = [
    outA !== undefined ? { hour: outA, anchor: 'end' as const } : null,
    outB !== undefined ? { hour: outB, anchor: 'start' as const } : null,
  ].filter((dot) => dot !== null && dot.hour >= WINDOW[0] && dot.hour <= WINDOW[1]);
  if (dots.length === 1 && dots[0]) dots[0].anchor = 'middle' as never;

  // Each line's name sits just above its start; when the two would touch, the lower one goes under
  // its line.
  const label = (knots: Knot[]) => y(at(knots, start + 0.5)) - 6;
  let mainLabel = label(main);
  let backupLabel = label(backup);
  if (Math.abs(mainLabel - backupLabel) < 13) {
    if (mainLabel > backupLabel) mainLabel += 18;
    else backupLabel += 18;
  }
  const tilde = week.forecast ? '~' : '';

  return (
    <svg
      className={styles.chart}
      viewBox={`0 0 ${W} ${H}`}
      aria-hidden="true"
      data-part="step-chart"
    >
      <defs>
        <pattern
          id={`${id}h`}
          width="6"
          height="6"
          patternUnits="userSpaceOnUse"
          patternTransform="rotate(45)"
        >
          <rect width="1.6" height="6" fill="var(--hatch)" />
        </pattern>
      </defs>
      {bands.map(([a, b], index) => (
        <rect key={index} x={a} y={Y100} width={b - a} height={Y0 - Y100} fill="var(--band)" />
      ))}
      <path d={fill} fill="var(--under)" />
      <line x1={X0} x2={X1} y1={y(50)} y2={y(50)} stroke="var(--grid)" />
      <line x1={X0} x2={X1} y1={Y0 + 0.5} y2={Y0 + 0.5} stroke="var(--grid)" />
      {days.slice(1).map((day) => (
        <line
          key={day.name}
          x1={day.edge}
          x2={day.edge}
          y1={Y0}
          y2={Y0 + 5}
          stroke="var(--grid-strong)"
        />
      ))}
      <line
        x1={X0}
        x2={lockFrom !== undefined ? x(lockFrom) : X1}
        y1={Y100}
        y2={Y100}
        stroke="var(--dash)"
        strokeDasharray="2.2 2.4"
      />
      {gap && gap[1] > gap[0] && (
        <g data-forecast={week.forecast || undefined} className={styles.gap}>
          <rect
            x={x(gap[0])}
            y={Y100}
            width={x(gap[1]) - x(gap[0])}
            height={Y0 - Y100}
            fill="var(--gap)"
          />
          <rect
            x={x(gap[0])}
            y={Y100}
            width={x(gap[1]) - x(gap[0])}
            height={Y0 - Y100}
            fill={`url(#${id}h)`}
          />
          {x(gap[1]) - x(gap[0]) > 76 && (
            <text
              className={styles.gapText}
              x={(x(gap[0]) + x(gap[1])) / 2}
              y="103.2"
              textAnchor="middle"
            >
              <tspan x={(x(gap[0]) + x(gap[1])) / 2}>No account free</tspan>
              <tspan x={(x(gap[0]) + x(gap[1])) / 2} dy="16.4">
                {tilde}
                {span(gap[1] - gap[0])}
              </tspan>
            </text>
          )}
          {reset !== undefined && reset <= WINDOW[1] && (
            <text className={styles.resetText} x={x(reset) - 3.5} y="192.4" textAnchor="end">
              Reset {when(reset).slice(4)}
            </text>
          )}
        </g>
      )}
      {/* The back-up's line, under the main one. */}
      <path
        d={trace(backup, Math.max(WINDOW[0], backup[0][0]), cut)}
        className={styles.backupLine}
      />
      {cut < WINDOW[1] && (
        <path
          d={trace(backup, cut, WINDOW[1])}
          className={styles.backupLine}
          strokeDasharray="3 3"
        />
      )}
      {/* The main line: white, red while it is locked, white again after its reset. */}
      {[
        [start, Math.min(before, cut)],
        [Math.max(start, cut), before],
      ].map(([from, to], index) =>
        to > from ? (
          <path
            key={index}
            d={mainSolid(from, to)}
            className={styles.mainLine}
            strokeDasharray={index ? '3 3' : undefined}
          />
        ) : null,
      )}
      {lockFrom !== undefined && lockTo !== undefined && (
        <path
          d={`M${x(lockFrom)} ${Y100}L${x(lockTo)} ${Y100}`}
          className={styles.lockLine}
          strokeDasharray={week.forecast ? '3 3' : undefined}
        />
      )}
      {after !== undefined && (
        <path
          d={trace(main, after, WINDOW[1])}
          className={styles.mainLine}
          strokeDasharray={week.forecast ? '3 3' : undefined}
        />
      )}
      {now !== undefined && now > WINDOW[0] && now < WINDOW[1] && (
        <line x1={x(now)} x2={x(now)} y1={Y100 - 4} y2={Y0} className={styles.nowLine} />
      )}
      <text className={styles.lineName} x={x(start) + 4.3} y={mainLabel}>
        {first.name}
      </text>
      <text className={styles.lineName} data-backup="" x={x(start) + 4.3} y={backupLabel}>
        {second.name}
      </text>
      {dots.map((dot) =>
        dot ? (
          <g key={dot.hour}>
            <circle
              cx={x(dot.hour)}
              cy={Y100}
              r={week.forecast ? 4.3 : 4.9}
              className={styles.chartDot}
              data-forecast={week.forecast || undefined}
            />
            <text
              className={styles.callout}
              x={x(dot.hour) + (dot.anchor === 'end' ? 4 : dot.anchor === 'start' ? -4 : 0)}
              y="23.2"
              textAnchor={dot.anchor}
            >
              {tilde}
              {when(dot.hour)}
            </text>
          </g>
        ) : null,
      )}
      {[100, 50, 0].map((percent) => (
        <text
          key={percent}
          className={styles.axisText}
          x="33.6"
          y={y(percent)}
          textAnchor="end"
          dominantBaseline="central"
        >
          {percent}%
        </text>
      ))}
      {days.map((day) => (
        <text
          key={day.name}
          className={styles.axisText}
          x={day.middle}
          y="216.2"
          textAnchor="middle"
        >
          {day.name}
        </text>
      ))}
    </svg>
  );
}
```

### coverage-heatmap/data.ts

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

/*
 * The panel's data: services with two accounts (a main one and a back-up), five weeks each, and
 * services with a single account. Every time is in hours from Monday 00:00 of its week (0–168).
 *
 * A week's heatmap is three lanes of 28 cells (Mon to Sun, 6 h each), written as strings of codes:
 * the two accounts' use, then how many accounts were free.
 *   use:   1 2 3 4  under 25, 50, 75, 100 %      o  the cell where it ran out
 *          x        locked (ran out, waiting for its reset)
 *          .        no reading                    -  not yet (later this week)
 *   free:  a all    n one    z none    w none, during work hours
 *
 * Run-outs, resets, the present moment and the forecast are times, drawn where they fall inside
 * their cell. The usage curves of the detail chart are knots [hour, percent] of each account from
 * Wednesday 00:00 to Sunday 12:00 (the chart's window); `curve()` writes them for the weeks that
 * are not drawn by hand.
 */

export type Knot = [hour: number, percent: number];

export type Account = {
  /** Its name in the panel, e.g. "Max". */
  name: string;
  /** Its plan, shown beside it in the detail. */
  plan: string;
};

export type Week = {
  id: string;
  /** "Sep 14": the Monday it starts on. */
  label: string;
  /** The bar chart's shorter label: "21", "This wk". */
  short: string;
  /** A second line under the label: "from Wed 15:00", "this week". */
  note?: string;
  /** The three lanes: first account, second account, accounts free (see the codes above). */
  lanes: [string, string, string];
  /** When each account ran out (or is on pace to, for the week in progress). */
  outs?: { main?: number; backup?: number };
  /** When each account's window reset this week. */
  resets: { main?: number; backup?: number };
  /** The present moment, for the week in progress: later cells are not read yet. */
  now?: number;
  /** The week in progress: its run-outs are a forecast. */
  forecast?: boolean;
  /** Work hours with no account free, and work hours the back-up covered. */
  noneWork: number;
  covered: number;
  /** Each account's highest use, in percent. */
  peaks: [number, number];
  /** The detail chart's curves. */
  usage: [Knot[], Knot[]];
};

export type Service = {
  id: string;
  name: string;
  glyph: GlyphName;
  accounts: [main: Account, backup: Account];
  weeks: Week[];
};

export type SoloService = {
  id: string;
  name: string;
  glyph: GlyphName;
  /** What the row says about it. */
  note: string;
};

export type GlyphName = 'spark' | 'rings' | 'facet' | 'burrow' | 'ridge';

/** The chart's window: Wednesday 00:00 to Sunday 12:00. */
export const WINDOW: [number, number] = [48, 156];
/** Work hours, each weekday. */
export const WORK: [number, number] = [9, 19];

/**
 * A usage curve over the chart's window, rising in work hours. `from` is the use at its start
 * (or at `start`, when readings began later), `out` when it reaches 100 % (or `peak`, its highest,
 * reached by Friday evening), `reset` when it drops back to 0. `rush` is a time from which it
 * rises at any hour (a back-up taking over); `seed` varies the little steps.
 */
export function curve({
  from,
  start = WINDOW[0],
  out,
  peak = 100,
  reset,
  rush,
  seed = 1,
}: {
  from: number;
  start?: number;
  out?: number;
  peak?: number;
  reset?: number;
  rush?: number;
  seed?: number;
}): Knot[] {
  const end = out ?? 115;
  const top = out === undefined ? peak : 100;
  // The stretches of time it is used in: work hours, and every hour once it is rushed.
  const spans: [number, number][] = [];
  for (let day = 2; day < 7; day += 1) {
    const a = Math.max(start, day * 24 + WORK[0]);
    const b = Math.min(end, day * 24 + WORK[1], rush ?? Infinity);
    if (day < 5 && b > a) spans.push([a, b]);
  }
  if (rush !== undefined && end > rush) spans.push([Math.max(rush, start), end]);
  const total = spans.reduce((sum, [a, b]) => sum + (b - a), 0) || 1;
  let random = seed * 9301 + 49297;
  const jitter = () => {
    random = (random * 9301 + 49297) % 233280;
    return random / 233280;
  };
  const knots: Knot[] = [[start, from]];
  let value = from;
  for (const [a, b] of spans) {
    knots.push([a, value]);
    // Each span rises by its share of what is left, in a few uneven steps.
    const gain = ((top - from) * (b - a)) / total;
    const steps = Math.max(2, Math.round((b - a) / 2.5));
    let done = 0;
    const weights = Array.from({ length: steps }, () => 0.4 + jitter());
    const sum = weights.reduce((s, w) => s + w, 0);
    weights.forEach((weight, index) => {
      done += (gain * weight) / sum;
      knots.push([a + ((b - a) * (index + 1)) / steps, round(value + done)]);
    });
    value = round(value + gain);
  }
  knots.push([end, top]);
  if (reset !== undefined && reset > end) {
    knots.push([reset, top], [reset, 0], [WINDOW[1], 0]);
  } else knots.push([WINDOW[1], top]);
  return knots;
}

const round = (value: number) => Math.round(value * 10) / 10;

/** The week of Oct 5, drawn by hand: the main account ran out Thu 17:00, the back-up Fri 11:00. */
const OCT_5_MAIN: Knot[] = [
  [48, 55],
  [57, 55],
  [58, 59],
  [59, 61.5],
  [60, 65],
  [61.5, 67],
  [62.5, 69.5],
  [64, 74],
  [65, 77.5],
  [66, 80],
  [67, 82],
  [81, 82.5],
  [82, 85],
  [84, 91],
  [86, 95],
  [89, 100],
  [150, 100],
  [150, 0],
  [156, 0],
];
const OCT_5_BACKUP: Knot[] = [
  [48, 5.7],
  [57, 5.7],
  [59, 8],
  [61, 8.5],
  [62, 10.3],
  [65, 11.2],
  [66.5, 12],
  [67.5, 12.7],
  [81, 13],
  [83, 14],
  [84.5, 15.4],
  [86.5, 16],
  [89, 17.2],
  [90.9, 46],
  [92.6, 49.6],
  [93.5, 53],
  [94.4, 59],
  [95.3, 63.5],
  [104.3, 67],
  [107, 100],
  [156, 100],
];

/** Sunday 06:00, when the main account's weekly window resets. */
const SUN_06 = 150;

export const SERVICES: Service[] = [
  {
    id: 'quill',
    name: 'Quill',
    glyph: 'spark',
    accounts: [
      { name: 'Max', plan: 'Studio' },
      { name: 'Pro Side', plan: 'Pro' },
    ],
    weeks: [
      {
        id: 'sep-14',
        label: 'Sep 14',
        short: 'Sep 14',
        note: 'from Wed 15:00',
        lanes: [
          '..........33334444oxxxxxx111',
          '..........111111113oxxxxxxxx',
          '..........aaaaaaaanzzzzzznnn',
        ],
        outs: { main: 111, backup: 119 },
        resets: { main: SUN_06 },
        noneWork: 0,
        covered: 4,
        peaks: [100, 100],
        usage: [
          curve({ from: 58, start: 63, out: 111, reset: SUN_06, seed: 3 }),
          curve({ from: 4, start: 63, out: 119, rush: 111, seed: 5 }),
        ],
      },
      {
        id: 'sep-21',
        label: 'Sep 21',
        short: '21',
        lanes: [
          '11122223333334444oxxxxxxx111',
          'xxxxxx111111111111oxxxxxxxxx',
          'nnnnnnaaaaaaaaaaanwwzzzzznnn',
        ],
        outs: { main: 107, backup: 113 },
        resets: { main: SUN_06, backup: 33 },
        noneWork: 2,
        covered: 6,
        peaks: [100, 100],
        usage: [
          curve({ from: 58, out: 107, reset: SUN_06, seed: 7 }),
          curve({ from: 4, out: 113, rush: 107, seed: 2 }),
        ],
      },
      {
        id: 'sep-28',
        label: 'Sep 28',
        short: '28',
        lanes: [
          '111222233334444oxxxxxxxxx111',
          'xxxxxx111111111224oxxxxxxxxx',
          'nnnnnnaaaaaaaaannnwwzzzzznnn',
        ],
        outs: { main: 90, backup: 109 },
        resets: { main: SUN_06, backup: 33 },
        noneWork: 6,
        covered: 5,
        peaks: [100, 100],
        usage: [
          curve({ from: 61, out: 90, reset: SUN_06, seed: 4 }),
          curve({ from: 5, out: 109, rush: 90, seed: 8 }),
        ],
      },
      {
        id: 'oct-5',
        label: 'Oct 5',
        short: 'Oct 5',
        lanes: [
          '11122223334444oxxxxxxxxxx111',
          'xxxxxx11111111133oxxxxxxxxxx',
          'nnnnnnaaaaaaaannnwwwzzzzznnn',
        ],
        outs: { main: 89, backup: 107 },
        resets: { main: SUN_06, backup: 33 },
        noneWork: 8,
        covered: 4,
        peaks: [100, 100],
        usage: [OCT_5_MAIN, OCT_5_BACKUP],
      },
      {
        id: 'oct-12',
        label: 'Oct 12',
        short: 'This wk',
        note: 'this week',
        lanes: [
          '11122233333-----------------',
          'xxxxxx11111-----------------',
          'nnnnnnaaaa------------------',
        ],
        outs: { main: 88, backup: 106 },
        resets: { main: SUN_06, backup: 33 },
        now: 62.5,
        forecast: true,
        noneWork: 9,
        covered: 4,
        peaks: [70, 9],
        usage: [
          curve({ from: 57, out: 88, reset: SUN_06, seed: 6 }),
          curve({ from: 5, out: 106, rush: 88, seed: 9 }),
        ],
      },
    ],
  },
  {
    id: 'loom',
    name: 'Loom',
    glyph: 'rings',
    accounts: [
      { name: 'Pro Work', plan: 'Pro' },
      { name: 'Plus', plan: 'Plus' },
    ],
    weeks: [
      {
        id: 'sep-14',
        label: 'Sep 14',
        short: 'Sep 14',
        note: 'from Wed 15:00',
        lanes: [
          '..........111111112223111111',
          '..........222222222333333333',
          '..........aaaaaaaaaaaaaaaaaa',
        ],
        resets: { main: 129 },
        noneWork: 0,
        covered: 0,
        peaks: [28, 60],
        usage: [
          curve({ from: 6, start: 63, peak: 28, reset: 129, seed: 2 }),
          curve({ from: 31, start: 63, peak: 60, seed: 4 }),
        ],
      },
      {
        id: 'sep-21',
        label: 'Sep 21',
        short: '21',
        lanes: [
          '1111111111111111122223111111',
          '3111111111222222223333333333',
          'aaaaaaaaaaaaaaaaaaaaaaaaaaaa',
        ],
        resets: { main: 129, backup: 3 },
        noneWork: 0,
        covered: 0,
        peaks: [30, 64],
        usage: [
          curve({ from: 4, peak: 30, reset: 129, seed: 6 }),
          curve({ from: 12, peak: 64, seed: 3 }),
        ],
      },
      {
        id: 'sep-28',
        label: 'Sep 28',
        short: '28',
        lanes: [
          '11111111111111222222.3111111',
          '3111111112222222223333333333',
          'aaaaaaaaaaaaaaaaaaaa..aaaaaa',
        ],
        resets: { main: 129, backup: 3 },
        noneWork: 0,
        covered: 0,
        peaks: [32, 68],
        usage: [
          curve({ from: 5, peak: 32, reset: 129, seed: 8 }),
          curve({ from: 14, peak: 68, seed: 5 }),
        ],
      },
      {
        id: 'oct-5',
        label: 'Oct 5',
        short: 'Oct 5',
        lanes: [
          '1111111111111122222223111111',
          '3111111112222222233333333333',
          'aaaaaaaaaaaaaaaaaaaaaaaaaaaa',
        ],
        resets: { main: 129, backup: 3 },
        noneWork: 0,
        covered: 0,
        peaks: [33, 70],
        usage: [
          curve({ from: 5, peak: 33, reset: 129, seed: 1 }),
          curve({ from: 15, peak: 70, seed: 7 }),
        ],
      },
      {
        id: 'oct-12',
        label: 'Oct 12',
        short: 'This wk',
        note: 'this week',
        lanes: [
          '11111111111-----------------',
          '31111112222-----------------',
          'aaaaaaaaaaa-----------------',
        ],
        resets: { main: 129, backup: 3 },
        now: 62.5,
        forecast: true,
        noneWork: 0,
        covered: 0,
        peaks: [17, 70],
        usage: [
          curve({ from: 4, peak: 34, reset: 129, seed: 3 }),
          curve({ from: 14, peak: 70, seed: 2 }),
        ],
      },
    ],
  },
];

export const SOLO: SoloService[] = [
  { id: 'facet', name: 'Facet Pro', glyph: 'facet', note: 'One account, no cover. Never ran out' },
  {
    id: 'burrow',
    name: 'Burrow Pro',
    glyph: 'burrow',
    note: 'One account, no cover. Never ran out',
  },
  { id: 'ridge', name: 'Ridge Pro', glyph: 'ridge', note: 'One account, no cover. Never ran out' },
];

export const HEADLINE = {
  title: 'Quill had no account free for 8 working hours last week',
  lede: 'Friday from 11:00, after Quill Max and then Quill Pro Side ran out. Loom always had an account free.',
};

/* Time words. */

const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
export const DAY_NAMES = DAYS;

/** "Thu 17:00". */
export function when(hour: number) {
  const day = Math.floor(hour / 24);
  const minutes = Math.round((hour - day * 24) * 60);
  const h = Math.floor(minutes / 60);
  const m = minutes % 60;
  return `${DAYS[Math.min(6, day)]} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}

/** "18 h", "1 day 19 h". */
export function span(hours: number) {
  const whole = Math.round(hours);
  const days = Math.floor(whole / 24);
  const rest = whole % 24;
  if (!days) return `${rest} h`;
  return `${days} day${days > 1 ? 's' : ''}${rest ? ` ${rest} h` : ''}`;
}

/** "Fri 12:00 – 18:00" for a cell. */
export function slotName(slot: number) {
  const day = Math.floor(slot / 4);
  const from = (slot % 4) * 6;
  const pad = (h: number) => `${String(h).padStart(2, '0')}:00`;
  return `${DAYS[day]} ${pad(from)} – ${pad(from + 6)}`;
}
```

### coverage-heatmap/glyphs.tsx

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

import type { GlyphName } from './data';

/*
 * The services' marks: small original glyphs, drawn on a 20-unit square. They are decorative (the
 * service's name is always beside them), so each is aria-hidden. Their size comes from the class
 * the panel gives them.
 */

export function Glyph({ name, className }: { name: GlyphName; className?: string }) {
  return (
    <svg viewBox="0 0 20 20" className={className} aria-hidden="true" data-glyph={name}>
      {name === 'spark' && (
        // An eight-ray asterisk with alternating long and short rays.
        <g stroke="var(--spark)" strokeWidth="1.7" strokeLinecap="round">
          <path d="M10 1.6v16.8M1.6 10h16.8M4.1 4.1l11.8 11.8M15.9 4.1 4.1 15.9" />
          <path d="M6.6 2.6 7.6 5.6M13.4 17.4l-1-3M2.6 13.4l3-1M17.4 6.6l-3 1M13.4 2.6l-1 3M6.6 17.4l1-3M2.6 6.6l3 1M17.4 13.4l-3-1" />
        </g>
      )}
      {name === 'rings' && (
        // Two loops woven through each other.
        <g fill="none" stroke="var(--glyph)" strokeWidth="1.6">
          <ellipse cx="7.6" cy="10" rx="5.4" ry="6.6" />
          <ellipse cx="12.4" cy="10" rx="5.4" ry="6.6" />
          <path d="M10 4.6v10.8" strokeWidth="1.2" strokeLinecap="round" />
        </g>
      )}
      {name === 'facet' && (
        // A cut gem seen from the side.
        <g fill="none" stroke="var(--glyph-solo)" strokeWidth="1.6" strokeLinejoin="round">
          <path d="M5.2 3.5h9.6l3.4 4.6L10 17.2 1.8 8.1z" />
          <path d="M1.8 8.1h16.4M7.6 3.5 6 8.1l4 9.1 4-9.1-1.6-4.6" strokeWidth="1.1" />
        </g>
      )}
      {name === 'burrow' && (
        // A burrow's mouth: an arch with its shadow.
        <g>
          <path
            d="M2.5 16.5V10a7.5 7.5 0 0 1 15 0v6.5z"
            fill="none"
            stroke="var(--glyph-solo)"
            strokeWidth="1.6"
            strokeLinejoin="round"
          />
          <path d="M6.3 16.5v-5a3.7 3.7 0 0 1 7.4 0v5z" fill="var(--glyph-solo)" />
        </g>
      )}
      {name === 'ridge' && (
        // Two ridges, cool to warm.
        <g fill="none" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
          <path d="M1.8 16.5 8 5l3.4 6.4" stroke="var(--ridge-a)" />
          <path d="M9.4 16.5l4-7 4.8 7.9" stroke="var(--ridge-b)" />
        </g>
      )}
    </svg>
  );
}

/** A lane's key in a section's legend: three stacked bars, one of them lit. */
export function LaneKey({ lane, className }: { lane: number; className?: string }) {
  return (
    <svg viewBox="0 0 10 10" className={className} aria-hidden="true">
      {[0, 1, 2].map((index) => (
        <rect
          key={index}
          x="0"
          y={index * 3.6}
          width="10"
          height="2.8"
          rx="0.9"
          fill={index === lane ? 'var(--key-on)' : 'var(--key-off)'}
        />
      ))}
    </svg>
  );
}
```

### coverage-heatmap/demo-pointer.tsx

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

'use client';

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### coverage-heatmap/use-autoplay.ts

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

'use client';

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

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

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

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

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