# Thermal canvas

> A shared plant board whose press node is wired to a live isometric bed of temperature bars, with teammates’ cursors, a hot spot and a minimap.

A collaborative canvas for a press shop. The selected node, Press 2 · thermal, is wired to its Bed temperature widget: 48 isometric bars coloured by temperature band, drifting live, with a hot spot over the limit. Switch the widget to a flat grid or a line over time, hover any cell for its value, add the suggested alert, comment, zoom, drag the canvas, or fly through the node switcher and the minimap to the line’s other nodes: OEE and andon, tightening and true position, line-side stock and the milk run.

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

Category: Data.

## Anatomy

1. **Tools**: A labelled radio group: Widget, Select, Sticky note and Frame. A picked tool drops its thing where the canvas is pressed, then hands back to Select.
2. **Node switcher**: Fixed on the canvas, wired to its widget by a line. It opens a listbox of the board’s seven nodes, and a pick flies the camera there.
3. **Bed temperature**: The selected widget: 48 isometric bars, painted back to front, as tall and as warm as their cells. Hover or the arrow keys name a cell.
4. **View switch**: Shows the same 48 cells as 3D bars, a flat grid of values, or the hot spot against the bed’s mean over the last 40 s. Its “more” menu pauses the live data.
5. **Hot spot**: The hottest cell, its temperature against the 210 °C limit and a sparkline of its last 40 s. Its cell chip points the bed at it.
6. **Comment pin**: Ben’s initials on the bar he wrote about, threaded by a curve to his comment. It opens and closes the replies.
7. **Suggestion**: An alert suggested for the nodes in view, with a toggle to add it; it changes as the camera moves to another group.
8. **Minimap**: The whole board in small, with a frame for what the camera sees. Each group is a button that flies there; the zoom sits beside it.

## When to use

- A shared operations board where a team watches one machine’s live readings and talks about them in place: a hot spot, a comment on it, an alert.
- Spatial data that reads best in the shape of the thing: a press bed, a tray, a rack of bins, where a cell’s place matters as much as its value.
- One screen that holds several related nodes (a press, its line, a station, a rack) and lets people fly between them without losing their bearings.
- Readings that need three views of the same numbers: the shape at a glance (3D bars), exact values (a grid), and the trend (a line over time).

## When not to use

- A single number or a short list of readings. A metric card or a table says it faster.
- Reports that have to be read exactly or printed. 3D bars hide the back rows; use the grid view or a heatmap alone.
- Hundreds of nodes or a free-form whiteboard. The board is laid out by hand for its seven nodes, with no culling.
- Alerting by itself. The suggested alert is a shortcut on the board; the rule still belongs in a real alerts setting.
- A phone-first tool. Below 540 px it frames one node at a time, which works, but the board is at its best with room.

## Installation

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

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

export function PressShop() {
  return (
    <ThermalCanvas
      defaultView="grid"
      onAlertChange={(alert, on) => console.log(alert, on)}
      autoplay={false}
    />
  );
}
```

## States

- **3D bars**: The opening view, held on its first frame here. Live, every cell drifts once a second.
- **Cell hovered**: The bar is outlined and its tooltip gives the cell, its value and how far it is from the limit.
- **Grid**: The same cells flat, in columns A to H and rows 2 to 7, coloured by band and labelled with their values.
- **Line, scrubbed**: The hot spot and the bed’s mean over 40 s, with the limit; a pointer or the arrow keys pick a moment.
- **Node switcher open**: The listbox of nodes, the current one ticked. Arrow keys, Home, End, Enter and Escape work in it.
- **Alert added**: The suggestion’s button turns into “Alert on” and calls `onAlertChange`.
- **Replies open**: Ben’s comment with Ana’s and Cole’s replies under it.
- **Share**: A small panel says who can see the board and copies its link.
- **Another node**: The camera on Station 4: the tightening and true-position cards side by side, with that group’s suggestion.
- **Paused**: With `live={false}`, or Pause in the widget’s menu, the data holds and the header says so.
- **Narrow**: Below 540 px the board stacks the press’s nodes in one column and moves the suggestion under the canvas.

## How it works

- Every 3D picture is plain SVG: a box is three paths (top, left face, right face) projected at 30 degrees, painted back to front so nearer bars cover the ones behind. Their dotted faces are SVG patterns, one per colour and face, skewed with the face.
- The canvas is a world under a camera: one translate-and-scale on the world. Dragging pans it, Ctrl or a trackpad pinch zooms about the pointer, the zoom buttons step it, and the minimap and the node switcher fly it with an eased frame-by-frame tween. The camera lives in a ref, so React does not render while it moves; the grid, the wire to the widget, the zoom readout and the minimap’s frame follow on the same frame.
- The data ticks once a second while the board is on screen and the tab is visible: each cell eases back to its resting temperature with a little noise, the hot spot breathes around 224 °C, the andon’s stop timer runs, the tugger rolls round its loop and the rack’s bins drain. Everything starts from fixed values and a seeded random source, so server and browser draw the same first frame.
- The bed has three views: 3D bars, a flat grid of the same 48 cells, and the hot spot against the bed’s mean over the last 40 s with the 210 °C limit. Hover or the arrow keys name a cell and its value in a tooltip.
- The board is laid out at 600 px and scaled to its container with CSS zoom, so it stays crisp at any size; below 540 px it rearranges into a narrow board whose camera frames one node at a time and whose suggestion moves under the canvas.
- While on screen and untouched, Ana’s cursor shows the board round: it hovers bars, switches views, adds the suggested alert, flies to the other nodes and back. Its hovers are real pointer events dispatched where the cursor stands, so the board reacts exactly as it would to a mouse; any real input hands it to the reader.
- Every colour is a light-dark() token on the root, so the board follows the page’s color-scheme, or the colorScheme prop; the data colours are the same in both.

## API reference

- **defaultView** (`'3d' | 'grid' | 'line'`, default `'3d'`): The widget’s first view: isometric bars, a flat grid of cells, or a line over the last 40 s.
- **defaultNode** (`'thermal' | 'oee' | 'andon' | 'tightening' | 'position' | 'stock' | 'milkrun'`, default `'thermal'`): The node the board opens on; the camera starts there.
- **live** (`boolean`, default `true`): Whether the data drifts, once a second while the board is on screen. The widget’s menu can also pause it.
- **onAlertChange** (`(alert: string, on: boolean) => void`): Called when the suggested alert for the nodes in view is added or removed.
- **onViewChange** (`(view: '3d' | 'grid' | 'line') => void`): Called when the widget’s view changes.
- **onNodeChange** (`(node: NodeId) => void`): Called when a node is picked in the node switcher or the minimap.
- **defaultState** (`ThermalCanvasState`): Opens in this state, held until the reader changes it: hover (a cell drawn hovered with its tooltip, by name, as 'C3', in the 3D or grid view), at (in the line view, the moment scrubbed, in seconds ago from 0 to 39), menu ('nodes' | 'more' | 'share'), alert (the suggested alert already added), replies (Ben’s comment open with its two replies) and tool ('widget' | 'select' | 'note' | 'frame'). Turns the demo off; a menu it opens doesn’t take the focus and ignores presses outside until the board is used.
- **still** (`boolean`, default `false`): Holds the data on its first frame; the board still answers the reader. Turns the demo off.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, Ana’s cursor shows the board round. Any real input hands it to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root.

## Accessibility

- The tools and the widget’s views are labelled radio groups; the node switcher is a button that opens a listbox (arrow keys, Home, End, Enter, Escape), and focus returns to it.
- The bed chart is one focusable group with a spoken summary; arrow keys move between cells (or step through time in the line view) and each step is announced in a polite live region.
- A control that receives focus off screen brings the camera to it, so tabbing through the board never lands on something hidden. Escape closes any menu and drops the current tool.
- Badges, the stop timer and the milk run’s next stop update in polite live regions; adding an alert, copying the link and moving between nodes are announced too.
- Teammates’ cursors, the 3D pictures and the minimap’s drawing are decorative (aria-hidden); their content is in the labels, legends and buttons around them.
- Under reduced motion the camera jumps instead of flying, transitions are off and the demo never plays; the data still updates.

## Source

### thermal-canvas/thermal-canvas.tsx

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

'use client';

import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type FocusEvent as ReactFocusEvent,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import {
  AndonCard,
  arrowKeys,
  Handles,
  MilkRunCard,
  OeeCard,
  PositionCard,
  StockCard,
  TighteningCard,
  type NodeId,
} from './cards';
import { COLS, driftBed, HISTORY, initialBed, ROWS, seeded } from './data';
import { BedTemperature, CommentCard, HotSpotCard, type Cell, type View } from './thermal';
import { useAutoplay } from './use-autoplay';
import styles from './thermal-canvas.module.css';

/*
 * Thermal canvas: a shared plant board. A selected node, "Press 2 · thermal", is wired to its
 * "Bed temperature" widget: 48 isometric bars coloured by temperature band, with a live hot spot,
 * a card for that hot spot and a comment thread on it. Teammates' cursors move about the board.
 * Further out on the same canvas sit the line's other nodes: OEE and andon, tightening and true
 * position, line-side stock and the milk run.
 *
 * - The canvas is a world under a camera: one transform (translate and scale) on the world, set
 *   by hand (drag), by the zoom buttons, by the minimap or by the node switcher, which flies the
 *   camera there. Flights are eased frame by frame with the camera kept in a ref, so React does
 *   not render while the camera moves; the grid, the wire and the minimap follow on the same
 *   frame.
 * - Every 3D picture is plain SVG paths from iso.ts; the widget can also be a flat grid of cells
 *   or a line over the last 40 s. Hover or arrow keys name a cell and its value.
 * - The data drifts once a second while the board is on screen: the bed's cells ease back to their
 *   resting temperatures with some noise, the stop timer runs, the tugger rolls round its loop.
 * - The board is drawn at 600 px wide and scaled (CSS zoom) to its container; below 540 px it
 *   rearranges into a narrow board whose camera frames one node at a time.
 * - While on screen and untouched, Ana's cursor shows the board round: it hovers bars, switches
 *   views, adds the suggested alert and visits the other nodes. Real input hands it to the reader.
 * - Light or dark: every colour is a light-dark() token, so it follows the page's color-scheme,
 *   or the colorScheme prop.
 */

type Cam = { x: number; y: number; s: number };
type Box = { x: number; y: number; w: number; h: number };

/** The board's design width, and the narrowest container that still gets the wide board. */
const DESIGN = 600;
const NARROW_BELOW = 540;
/** The wide board's canvas, in design px. */
const VIEW_W = 592;
const VIEW_H = 400;
/** The zoom readout at camera scale 1: the board opens at 72 %. */
const ZOOM_AT_1 = 72;

const NODES: { id: NodeId; label: string; group: number }[] = [
  { id: 'thermal', label: 'Press 2 · thermal', group: 0 },
  { id: 'oee', label: 'Line 3 · OEE', group: 1 },
  { id: 'andon', label: 'Line 3 · andon', group: 1 },
  { id: 'tightening', label: 'Station 4 · torque', group: 2 },
  { id: 'position', label: 'Hole H7 · position', group: 2 },
  { id: 'stock', label: 'Rack R3 · stock', group: 3 },
  { id: 'milkrun', label: 'Tugger 2 · route', group: 3 },
];
const GROUPS = ['Press 2', 'Line 3', 'Station 4', 'Flow rack R3'];

/** The suggested alert for each group of nodes. */
const SUGGESTIONS = [
  'Alert me when a cell passes 210 °C',
  'Page the supervisor after a 5 min stop',
  'Hold parts when Cpk drops below 1.33',
  'Rush the tugger when a bin runs empty',
];

/** Where everything sits in the world, wide and narrow. Cards are 300 px wide. */
const LAYOUT = {
  wide: {
    widget: { x: 236, y: 66.5, w: 331.5, h: 271 },
    views: { x: 412, y: 22.5 },
    hot: { x: 24.5, y: 82.5, w: 183, h: 112.5 },
    comment: { x: 24.5, y: 208.5, w: 195.5, h: 101.5 },
    pin: { x: 462.5, y: 126.5 },
    cards: {
      oee: { x: 680, y: 0, w: 300, h: 497 },
      andon: { x: 996, y: 0, w: 300, h: 446 },
      tightening: { x: 0, y: 560, w: 300, h: 393 },
      position: { x: 316, y: 560, w: 300, h: 441 },
      stock: { x: 680, y: 560, w: 300, h: 468 },
      milkrun: { x: 996, y: 560, w: 300, h: 471 },
    } as Record<Exclude<NodeId, 'thermal'>, Box>,
    world: { x: -24, y: -24, w: 1344, h: 1080 },
  },
  narrow: {
    widget: { x: 0, y: 44, w: 332, h: 271 },
    views: { x: 176.5, y: 0 },
    hot: { x: 0, y: 331, w: 332, h: 112.5 },
    comment: { x: 0, y: 459, w: 332, h: 101.5 },
    pin: { x: 226.5, y: 104 },
    cards: {
      oee: { x: 400, y: 0, w: 300, h: 497 },
      andon: { x: 400, y: 520, w: 300, h: 446 },
      tightening: { x: 732, y: 0, w: 300, h: 393 },
      position: { x: 732, y: 420, w: 300, h: 441 },
      stock: { x: 1064, y: 0, w: 300, h: 468 },
      milkrun: { x: 1064, y: 490, w: 300, h: 471 },
    } as Record<Exclude<NodeId, 'thermal'>, Box>,
    world: { x: -24, y: -24, w: 1412, h: 1010 },
  },
};
type Layout = (typeof LAYOUT)['wide'];

/** The world box around a group of nodes: the press's own nodes, or a pair of cards. */
function groupBox(layout: Layout, narrow: boolean, g: number): Box {
  if (g === 0) return narrow ? { x: 0, y: 0, w: 332, h: 560 } : { x: 0, y: 0, w: 592, h: 400 };
  const boxes = NODES.filter((n) => n.group === g).map(
    (n) => layout.cards[n.id as Exclude<NodeId, 'thermal'>],
  );
  const x = Math.min(...boxes.map((b) => b.x));
  const y = Math.min(...boxes.map((b) => b.y));
  return {
    x,
    y,
    w: Math.max(...boxes.map((b) => b.x + b.w)) - x,
    h: Math.max(...boxes.map((b) => b.y + b.h)) - y,
  };
}

/** Things dropped on the canvas with the tools. */
type Drop = {
  id: number;
  kind: 'widget' | 'note' | 'frame';
  x: number;
  y: number;
  w: number;
  h: number;
};
type Tool = 'widget' | 'select' | 'note' | 'frame';
const TOOLS: { id: Tool; label: string; icon: string }[] = [
  {
    id: 'widget',
    label: 'Widget',
    icon: 'M8 1.6 13.6 4.8v6.4L8 14.4 2.4 11.2V4.8Z M2.4 4.8 8 8l5.6-3.2M8 8v6.4',
  },
  { id: 'select', label: 'Select', icon: 'M13.6 2.4 2.4 7.2l4.8 1.6 1.6 4.8Z' },
  { id: 'note', label: 'Sticky note', icon: 'M3 3h10v6.5L9.5 13H3Z M13 9.5H9.5V13' },
  {
    id: 'frame',
    label: 'Frame',
    icon: 'M3.5 3.5h9v9h-9Z M3.5 1.5v4M12.5 1.5v4M3.5 10.5v4M12.5 10.5v4M1.5 3.5h4M10.5 3.5h4M1.5 12.5h4M10.5 12.5h4',
  },
];

/** A state the board can open in, held until the reader changes it (for docs and screenshots). */
export type ThermalCanvasState = {
  /** A cell drawn hovered with its tooltip, by name (`'C3'`, `'G7'`), in the 3D or grid view. */
  hover?: string;
  /** In the line view: the moment drawn scrubbed, in seconds ago (0 = now, up to 39). */
  at?: number;
  /** A menu or panel drawn open. One opened this way doesn't take the focus. */
  menu?: 'nodes' | 'more' | 'share';
  /** The suggested alert for the nodes in view, already added. */
  alert?: boolean;
  /** Ben's comment with its two replies open. */
  replies?: boolean;
  /** The tool picked in the header. */
  tool?: Tool;
};

/** A cell's position from its name ('C3'), or null when there is no such cell. */
function cellAt(name: string | undefined) {
  if (!name) return null;
  const col = 'ABCDEFGH'.indexOf(name[0]?.toUpperCase());
  const row = Number(name.slice(1));
  if (col < 0 || !(row >= 2 && row <= ROWS + 1)) return null;
  return { c: COLS - 1 - col, r: ROWS + 1 - row };
}

const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);

export type ThermalCanvasProps = {
  /** The widget's first view: 3D bars, a flat grid, or a line over time. */
  defaultView?: View;
  /** The node the board opens on; the camera starts there. */
  defaultNode?: NodeId;
  /** Whether the data drifts live (once a second, while on screen). */
  live?: boolean;
  /** Called when a suggested alert is added or removed. */
  onAlertChange?: (alert: string, on: boolean) => void;
  /** Called when the widget's view changes. */
  onViewChange?: (view: View) => void;
  /** Called when a node is picked in the node switcher or the minimap. */
  onNodeChange?: (node: NodeId) => void;
  /** Opens in a state (a hovered cell, an open menu, the alert on…), held still; turns the demo off. */
  defaultState?: ThermalCanvasState;
  /** Holds the data on its first frame (the board still reads live); turns the demo off. */
  still?: boolean;
  /** While on screen and untouched, Ana's cursor shows the board round. */
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
};

export function ThermalCanvas({
  defaultView = '3d',
  defaultNode = 'thermal',
  live: liveProp = true,
  onAlertChange,
  onViewChange,
  onNodeChange,
  defaultState,
  still = false,
  autoplay = true,
  colorScheme,
  className,
}: ThermalCanvasProps) {
  const { ref: root, playing: canPlay, visible, reduced, take } = useAutoplay<HTMLDivElement>(8000);
  const playing = autoplay && !defaultState && !still && canPlay;
  const id = 'tc' + useId().replace(/[^a-zA-Z0-9]/g, '');

  /* ---- Size: the wide board scales with its container; narrow ones rearrange. */
  const fit = useRef<HTMLDivElement>(null);
  const [size, setSize] = useState({ width: DESIGN, narrow: false });
  useLayoutEffect(() => {
    const element = fit.current;
    if (!element) return;
    const measure = () => {
      const width = element.clientWidth;
      if (width) setSize({ width, narrow: width < NARROW_BELOW });
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(element);
    return () => observer.disconnect();
  }, []);
  const narrow = size.narrow;
  const zoom = narrow ? 1 : Math.min(size.width / DESIGN, 1.6);
  const layout: Layout = narrow ? LAYOUT.narrow : LAYOUT.wide;
  const viewW = narrow ? size.width - 8 : VIEW_W;
  // A narrow board frames one column at a time, never smaller than 80 % (a phone may crop it a
  // little at the sides; a drag brings the rest in).
  const fitNarrow = Math.max(0.8, (viewW - 24) / 332);
  const viewH = narrow ? Math.round(62 + 562 * fitNarrow + 52) : VIEW_H;

  /* ---- Data. */
  const [paused, setPaused] = useState(false);
  const live = liveProp && !paused;
  const [bed, setBed] = useState(initialBed);
  const [tick, setTick] = useState(0);
  const random = useRef(seeded(11));
  useEffect(() => {
    if (!live || !visible || still) return;
    const timer = window.setInterval(() => {
      setTick((t) => t + 1);
      setBed((b) => driftBed(b, random.current));
    }, 1000);
    return () => window.clearInterval(timer);
  }, [live, visible, still]);

  /* ---- Board state. */
  const [view, setView] = useState<View>(defaultView);
  const [hover, setHover] = useState<Cell | null>(() => cellAt(defaultState?.hover));
  const [selected, setSelected] = useState<NodeId>(defaultNode);
  const [menu, setMenu] = useState<null | 'nodes' | 'more' | 'share'>(defaultState?.menu ?? null);
  const [active, setActive] = useState(() =>
    Math.max(
      0,
      NODES.findIndex((n) => n.id === defaultNode),
    ),
  );
  const [tool, setTool] = useState<Tool>(defaultState?.tool ?? 'select');
  const [drops, setDrops] = useState<Drop[]>([]);
  const [alerts, setAlerts] = useState<boolean[]>(() =>
    [0, 1, 2, 3].map(
      (g) => !!defaultState?.alert && g === NODES.find((n) => n.id === defaultNode)?.group,
    ),
  );
  const [thread, setThread] = useState(!!defaultState?.replies);
  const [copied, setCopied] = useState(false);
  const [say, setSay] = useState('');
  const group = NODES.find((n) => n.id === selected)?.group ?? 0;
  const groupRef = useRef(group);
  groupRef.current = group;

  /* ---- Camera. */
  const viewport = useRef<HTMLDivElement>(null);
  const world = useRef<HTMLDivElement>(null);
  const wire = useRef<SVGPathElement>(null);
  const wireDot = useRef<SVGCircleElement>(null);
  const mapView = useRef<SVGRectElement>(null);
  const zoomText = useRef<HTMLSpanElement>(null);
  const cam = useRef<Cam>({ x: 0, y: 0, s: 1 });
  const flight = useRef(0);

  const apply = useCallback(
    (c: Cam) => {
      cam.current = c;
      if (world.current)
        world.current.style.transform = `translate(${c.x}px, ${c.y}px) scale(${c.s})`;
      if (viewport.current) {
        viewport.current.style.backgroundPosition = `${c.x}px ${c.y}px`;
        viewport.current.style.backgroundSize = `${24 * c.s}px ${24 * c.s}px`;
      }
      if (zoomText.current) zoomText.current.textContent = String(Math.round(ZOOM_AT_1 * c.s));
      // The wire from the node switcher (fixed on the canvas) to its widget (in the world).
      if (wire.current && wireDot.current) {
        const ex = c.x + layout.widget.x * c.s;
        const ey = c.y + (layout.widget.y + 55) * c.s;
        const sx = 212;
        const sy = 44;
        const mx = sx + Math.max(8, (ex - sx) / 2);
        const r = Math.min(10, Math.abs(ey - sy) / 2);
        const down = ey > sy ? 1 : -1;
        wire.current.setAttribute(
          'd',
          `M${sx} ${sy}H${mx - r}Q${mx} ${sy} ${mx} ${sy + r * down}V${ey - r * down}Q${mx} ${ey} ${mx + r} ${ey}H${ex}`,
        );
        wireDot.current.setAttribute('cx', String(ex));
        wireDot.current.setAttribute('cy', String(ey));
      }
      if (mapView.current) {
        const m = layout.world;
        const k = Math.min(64 / m.w, 39.5 / m.h);
        mapView.current.setAttribute('x', String(4 + (-c.x / c.s - m.x) * k));
        mapView.current.setAttribute('y', String(4 + (-c.y / c.s - m.y) * k));
        mapView.current.setAttribute('width', String((viewW / c.s) * k));
        mapView.current.setAttribute('height', String((viewH / c.s) * k));
      }
    },
    [layout, viewW, viewH],
  );

  const fly = useCallback(
    (to: Cam, ms = 700) => {
      cancelAnimationFrame(flight.current);
      const from = { ...cam.current };
      if (reduced || ms === 0) return apply(to);
      const start = performance.now();
      const step = (now: number) => {
        const t = Math.min(1, (now - start) / ms);
        const k = ease(t);
        apply({
          x: from.x + (to.x - from.x) * k,
          y: from.y + (to.y - from.y) * k,
          s: from.s + (to.s - from.s) * k,
        });
        if (t < 1) flight.current = requestAnimationFrame(step);
      };
      flight.current = requestAnimationFrame(step);
    },
    [apply, reduced],
  );

  /** The camera that frames a node. */
  const frameFor = useCallback(
    (node: NodeId): Cam => {
      if (node === 'thermal')
        return narrow
          ? { x: (viewW - 332 * fitNarrow) / 2, y: 62, s: fitNarrow }
          : { x: 0, y: 0, s: 1 };
      const card = layout.cards[node];
      if (narrow) {
        const s = Math.max(0.8, (viewW - 24) / 300);
        return { x: (viewW - 300 * s) / 2 - card.x * s, y: 62 - card.y * s, s };
      }
      // Wide: both cards of its group, side by side, under the node switcher.
      const g = NODES.find((n) => n.id === node)!.group;
      const boxes = NODES.filter((n) => n.group === g).map(
        (n) => layout.cards[n.id as Exclude<NodeId, 'thermal'>],
      );
      const left = Math.min(...boxes.map((b) => b.x));
      const top = Math.min(...boxes.map((b) => b.y));
      const s = 0.78;
      return { x: (VIEW_W - 616 * s) / 2 - left * s, y: 74 - top * s, s };
    },
    [narrow, fitNarrow, layout, viewW],
  );

  const goTo = useCallback(
    (node: NodeId) => {
      setSelected(node);
      setMenu(null);
      fly(frameFor(node));
      setSay(`${NODES.find((n) => n.id === node)?.label} selected`);
      onNodeChange?.(node);
    },
    [fly, frameFor, onNodeChange],
  );

  // Frame the selected node whenever the layout changes (and on the first paint).
  const selectedRef = useRef(selected);
  selectedRef.current = selected;
  useLayoutEffect(() => {
    apply(frameFor(selectedRef.current));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [narrow, viewW]);

  const zoomBy = (factor: number) => {
    const c = cam.current;
    const s = Math.min(2.2, Math.max(0.3, c.s * factor));
    const cx = viewW / 2;
    const cy = viewH / 2;
    fly({ s, x: cx - ((cx - c.x) / c.s) * s, y: cy - ((cy - c.y) / c.s) * s }, 260);
  };

  /* ---- Panning by drag; placing things with the tools. */
  const drag = useRef<{
    x: number;
    y: number;
    cam: Cam;
    id: number;
    moved: boolean;
    frame?: number;
  } | null>(null);
  const toBoard = (clientX: number, clientY: number) => {
    const rect = viewport.current!.getBoundingClientRect();
    return { x: (clientX - rect.left) / zoom, y: (clientY - rect.top) / zoom };
  };
  const toWorld = (p: { x: number; y: number }) => {
    const c = cam.current;
    return { x: (p.x - c.x) / c.s, y: (p.y - c.y) / c.s };
  };
  const onCanvasDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.button !== 0) return;
    const target = event.target as HTMLElement;
    if (
      target.closest(
        'button, input, label, textarea, a, [role="listbox"], [role="menu"], [data-hud]',
      )
    )
      return;
    cancelAnimationFrame(flight.current);
    const p = toBoard(event.clientX, event.clientY);
    if (tool !== 'select') {
      const w = toWorld(p);
      const next: Drop =
        tool === 'note'
          ? { id: Date.now(), kind: 'note', x: w.x - 60, y: w.y - 20, w: 120, h: 84 }
          : tool === 'widget'
            ? { id: Date.now(), kind: 'widget', x: w.x - 70, y: w.y - 18, w: 140, h: 36 }
            : { id: Date.now(), kind: 'frame', x: w.x, y: w.y, w: 1, h: 1 };
      setDrops((list) => [...list, next]);
      if (tool === 'frame') {
        drag.current = {
          x: p.x,
          y: p.y,
          cam: cam.current,
          id: event.pointerId,
          moved: false,
          frame: next.id,
        };
        event.currentTarget.setPointerCapture(event.pointerId);
      } else setTool('select');
      setSay(`${tool === 'note' ? 'Sticky note' : 'Widget'} added`);
      return;
    }
    drag.current = { x: p.x, y: p.y, cam: cam.current, id: event.pointerId, moved: false };
  };
  const onCanvasMove = (event: ReactPointerEvent<HTMLDivElement>) => {
    const d = drag.current;
    if (!d || d.id !== event.pointerId) return;
    const p = toBoard(event.clientX, event.clientY);
    if (d.frame !== undefined) {
      const a = toWorld({ x: d.x, y: d.y });
      const b = toWorld(p);
      setDrops((list) =>
        list.map((item) =>
          item.id === d.frame
            ? {
                ...item,
                x: Math.min(a.x, b.x),
                y: Math.min(a.y, b.y),
                w: Math.abs(b.x - a.x),
                h: Math.abs(b.y - a.y),
              }
            : item,
        ),
      );
      return;
    }
    if (!d.moved && Math.hypot(p.x - d.x, p.y - d.y) < 4) return;
    if (!d.moved) {
      d.moved = true;
      event.currentTarget.setPointerCapture(event.pointerId);
    }
    apply({ ...d.cam, x: d.cam.x + p.x - d.x, y: d.cam.y + p.y - d.y });
  };
  const onCanvasUp = (event: ReactPointerEvent<HTMLDivElement>) => {
    const d = drag.current;
    if (!d || d.id !== event.pointerId) return;
    drag.current = null;
    if (d.frame !== undefined) {
      setDrops((list) =>
        list.map((item) =>
          item.id === d.frame && item.w < 24
            ? { ...item, x: item.x - 80, y: item.y - 50, w: 160, h: 100 }
            : item,
        ),
      );
      setTool('select');
      setSay('Frame added');
    }
  };

  // Ctrl or pinch on a trackpad zooms the canvas about the pointer; a plain wheel scrolls the page.
  useEffect(() => {
    const element = viewport.current;
    if (!element) return;
    const onWheel = (event: WheelEvent) => {
      if (!event.ctrlKey && !event.metaKey) {
        // A plain wheel scrolls through the group in view while it overflows the canvas, and
        // the page once the group's edge is reached.
        const c = cam.current;
        const b = groupBox(layout, narrow, groupRef.current);
        const top = c.y + b.y * c.s;
        const bottom = c.y + (b.y + b.h) * c.s;
        const dy =
          event.deltaY > 0
            ? -Math.min(event.deltaY, Math.max(0, bottom - (viewH - 56)))
            : Math.min(-event.deltaY, Math.max(0, 72 - top));
        if (!dy) return;
        event.preventDefault();
        cancelAnimationFrame(flight.current);
        apply({ ...c, y: c.y + dy });
        return;
      }
      event.preventDefault();
      cancelAnimationFrame(flight.current);
      const rect = element.getBoundingClientRect();
      const px = (event.clientX - rect.left) / zoom;
      const py = (event.clientY - rect.top) / zoom;
      const c = cam.current;
      const s = Math.min(2.2, Math.max(0.3, c.s * Math.exp(-event.deltaY * 0.01)));
      apply({ s, x: px - ((px - c.x) / c.s) * s, y: py - ((py - c.y) / c.s) * s });
    };
    element.addEventListener('wheel', onWheel, { passive: false });
    return () => element.removeEventListener('wheel', onWheel);
  }, [apply, zoom, layout, narrow, viewH]);

  // A control that gets focus off screen brings the camera to it.
  const onWorldFocus = (event: ReactFocusEvent) => {
    const element = event.target as HTMLElement;
    const view = viewport.current;
    if (!view || element === view) return;
    const r = element.getBoundingClientRect();
    const v = view.getBoundingClientRect();
    const top = v.top + 64 * zoom;
    const bottom = v.bottom - 52 * zoom;
    let dx = 0;
    let dy = 0;
    if (r.left < v.left + 8) dx = v.left + 16 - r.left;
    else if (r.right > v.right - 8) dx = v.right - 16 - r.right;
    if (r.top < top) dy = top + 8 - r.top;
    else if (r.bottom > bottom) dy = bottom - 8 - r.bottom;
    if (dx || dy) {
      const c = cam.current;
      fly({ ...c, x: c.x + dx / zoom, y: c.y + dy / zoom }, 300);
    }
  };

  /* ---- Menus: the node switcher, the widget's "more", Share. */
  const pillRef = useRef<HTMLButtonElement>(null);
  const listRef = useRef<HTMLUListElement>(null);
  // A menu opened by defaultState is drawn open without taking the focus.
  const openedByState = useRef(!!defaultState?.menu);
  // …and ignores presses outside the board until the board itself is used.
  const heldByState = useRef(!!defaultState?.menu);
  useEffect(() => {
    if (openedByState.current && menu === defaultState?.menu) return;
    openedByState.current = false;
    if (menu === 'nodes') {
      setActive(
        Math.max(
          0,
          NODES.findIndex((n) => n.id === selected),
        ),
      );
      listRef.current?.focus();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [menu]);
  useEffect(() => {
    if (!menu) return;
    const close = (event: PointerEvent) => {
      if (heldByState.current) return;
      if (!(event.target as HTMLElement).closest('[data-menu]')) setMenu(null);
    };
    document.addEventListener('pointerdown', close);
    return () => document.removeEventListener('pointerdown', close);
  }, [menu]);
  const onListKey = (event: ReactKeyboardEvent) => {
    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      event.preventDefault();
      setActive((i) => (i + (event.key === 'ArrowDown' ? 1 : NODES.length - 1)) % NODES.length);
    } else if (event.key === 'Home' || event.key === 'End') {
      event.preventDefault();
      setActive(event.key === 'Home' ? 0 : NODES.length - 1);
    } else if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      goTo(NODES[active].id);
      pillRef.current?.focus();
    } else if (event.key === 'Escape' || event.key === 'Tab') {
      setMenu(null);
      if (event.key === 'Escape') pillRef.current?.focus();
    }
  };
  const onRootKey = (event: ReactKeyboardEvent) => {
    if (event.key !== 'Escape') return;
    if (menu) setMenu(null);
    if (tool !== 'select') setTool('select');
  };

  const toggleAlert = () => {
    const on = !alerts[group];
    setAlerts((list) => list.map((value, i) => (i === group ? on : value)));
    setSay(on ? `Alert added: ${SUGGESTIONS[group]}` : 'Alert removed');
    onAlertChange?.(SUGGESTIONS[group], on);
  };

  const copyLink = async () => {
    try {
      await navigator.clipboard.writeText(window.location.href);
    } catch {
      // Without clipboard access the link is still shown in the panel.
    }
    setCopied(true);
    setSay('Link copied');
  };

  /* ---- The demo: Ana's cursor shows the board round. */
  const ana = useRef<HTMLDivElement>(null);
  const anaAt = useRef({ x: 261, y: 160 });
  // On a narrow board Ana starts over the widget's left side.
  useLayoutEffect(() => {
    if (narrow && anaAt.current.x === 261) anaAt.current = { x: 40, y: 190 };
    if (ana.current)
      ana.current.style.transform = `translate(${anaAt.current.x}px, ${anaAt.current.y}px)`;
  }, [narrow]);
  useEffect(() => {
    if (!playing) return;
    const element = ana.current;
    const host = root.current;
    const view = viewport.current;
    if (!element || !host || !view) return;
    let alive = true;
    let anim: Animation | null = null;
    let hovered: Element | null = null;
    const timers = new Set<number>();
    const stop = new Error('stopped');
    const wait = (ms: number) =>
      new Promise<void>((resolve, reject) => {
        if (!alive) return reject(stop);
        const t = window.setTimeout(() => {
          timers.delete(t);
          if (alive) resolve();
          else reject(stop);
        }, ms);
        timers.add(t);
      });
    const find = (selector: string) => host.querySelector(selector);
    /** A point on an element, as shares of its box, in the canvas's own px. */
    const pointOn = (target: Element, fx = 0.5, fy = 0.5) => {
      const r = target.getBoundingClientRect();
      const v = view.getBoundingClientRect();
      return {
        x: (r.left + r.width * fx - v.left) / zoom,
        y: (r.top + r.height * fy - v.top) / zoom,
        client: { x: r.left + r.width * fx, y: r.top + r.height * fy },
      };
    };
    const place = (p: { x: number; y: number }) => `translate(${p.x}px, ${p.y}px)`;
    const hoverAt = (client: { x: number; y: number }) => {
      const under = document.elementFromPoint(client.x, client.y);
      if (under === hovered) return under;
      const init = { bubbles: true, clientX: client.x, clientY: client.y, pointerType: 'mouse' };
      if (hovered)
        hovered.dispatchEvent(new PointerEvent('pointerout', { ...init, relatedTarget: under }));
      if (under)
        under.dispatchEvent(new PointerEvent('pointerover', { ...init, relatedTarget: hovered }));
      hovered = under;
      return under;
    };
    const glide = async (selector: string, ms = 800, fx = 0.5, fy = 0.5) => {
      const target = find(selector);
      if (!target || !alive) return null;
      const to = pointOn(target, fx, fy);
      anim = element.animate([{ transform: place(anaAt.current) }, { transform: place(to) }], {
        duration: ms,
        easing: 'cubic-bezier(0.45, 0.05, 0.25, 1)',
        fill: 'forwards',
      });
      await anim.finished.catch(() => undefined);
      if (!alive) throw stop;
      anaAt.current = { x: to.x, y: to.y };
      element.style.transform = place(to);
      anim.cancel();
      anim = null;
      const under = hoverAt(to.client);
      under?.dispatchEvent(
        new PointerEvent('pointermove', {
          bubbles: true,
          clientX: to.client.x,
          clientY: to.client.y,
          pointerType: 'mouse',
        }),
      );
      return target;
    };
    const click = async (selector: string, ms = 800) => {
      const target = await glide(selector, ms);
      if (!target) return;
      element.dataset.press = '';
      await wait(160);
      delete element.dataset.press;
      (target as HTMLElement).click();
    };
    const script = async () => {
      await wait(1200);
      for (;;) {
        await glide('[data-cell="G7"]', 900);
        await wait(500);
        await glide('[data-cell="D5"]', 600);
        await wait(500);
        await glide('[data-cell="C3"]', 700, 0.5, 0.2);
        await wait(1500);
        await click('[data-demo="view-grid"]');
        await wait(700);
        await glide('[data-cell="C3"]', 700);
        await wait(1300);
        await click('[data-demo="view-line"]');
        await wait(500);
        for (const fx of [0.35, 0.6, 0.85]) {
          await glide('[data-part="line-chart"]', 600, fx, 0.55);
          await wait(450);
        }
        await click('[data-demo="view-3d"]');
        await wait(600);
        await click('[data-demo="add-alert"]', 900);
        await wait(1100);
        await click('[data-demo="map-1"]', 900);
        await wait(1100);
        await glide('[data-demo="loss-0"]', 700);
        await wait(800);
        await glide('[data-demo="loss-3"]', 600);
        await wait(700);
        await click('[data-demo="reason-1"]', 800);
        await wait(900);
        await click('[data-demo="reason-0"]', 500);
        await wait(500);
        await click('[data-demo="map-2"]', 900);
        await wait(1100);
        await click('[data-demo="bolt-0"]', 700);
        await wait(800);
        await click('[data-demo="bolt-1"]', 500);
        await wait(500);
        await glide('[data-demo="torque"]', 600, 0.5, 0.45);
        await wait(1300);
        await click('[data-demo="map-3"]', 900);
        await wait(1100);
        await glide('[data-demo="bin-0-0"]', 700, 0.5, 0.6);
        await wait(1000);
        await glide('[data-demo="bin-1-2"]', 600, 0.5, 0.6);
        await wait(1000);
        await click('[data-demo="node-pill"]', 900);
        await wait(700);
        await click('[data-demo="node-thermal"]', 600);
        await wait(1600);
        await click('[data-demo="add-alert"]', 800);
        await wait(1500);
      }
    };
    script().catch(() => undefined);
    return () => {
      alive = false;
      timers.forEach((t) => window.clearTimeout(t));
      if (anim) {
        try {
          anim.commitStyles();
        } catch {
          // A cancelled animation has nothing to commit.
        }
        anim.cancel();
      }
      if (hovered)
        hovered.dispatchEvent(
          new PointerEvent('pointerout', { bubbles: true, relatedTarget: null }),
        );
      delete element.dataset.press;
    };
  }, [playing, zoom, root]);

  // Real input (never the demo's own events) hands the board to the reader.
  const onInput = (event: { nativeEvent: Event }) => {
    if (!event.nativeEvent.isTrusted) return;
    heldByState.current = false;
    take();
  };

  /* ---- Drawing. */
  const label = NODES.find((n) => n.id === selected)!.label;
  const W = layout.widget;
  const c0 = layout.comment;
  const pin = layout.pin;
  const lineStart = narrow ? { x: c0.x + 220, y: c0.y } : { x: c0.x + c0.w, y: c0.y + 21 };
  const lineEnd = { x: pin.x, y: pin.y + 14 };
  const m = layout.world;
  const mk = Math.min(64 / m.w, 39.5 / m.h);
  const mapRect = (b: Box) => ({
    x: 4 + (b.x - m.x) * mk,
    y: 4 + (b.y - m.y) * mk,
    width: Math.max(2, b.w * mk),
    height: Math.max(2, b.h * mk),
  });
  const cardProps = (node: Exclude<NodeId, 'thermal'>) => ({
    id: `${id}${node}`,
    tick,
    selected: selected === node,
    onSelect: setSelected,
  });
  const at = (node: Exclude<NodeId, 'thermal'>) => ({
    left: layout.cards[node].x,
    top: layout.cards[node].y,
  });

  return (
    <div
      ref={root}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      data-narrow={narrow || undefined}
      data-playing={playing || undefined}
      data-reduced={reduced || undefined}
      onPointerDownCapture={onInput}
      onKeyDownCapture={onInput}
      onWheelCapture={onInput}
      onPointerMove={(event) => {
        if (event.nativeEvent.isTrusted && event.pointerType === 'mouse') take();
      }}
      onKeyDown={onRootKey}
      data-part="root"
    >
      <div ref={fit} className={styles.fit}>
        <div className={styles.board} style={{ zoom }} data-part="board">
          <header className={styles.head} data-part="header">
            <nav aria-label="Breadcrumb" className={styles.crumbs}>
              <ol>
                <li>Plant North</li>
                <li aria-current="page">Press Shop</li>
              </ol>
            </nav>
            <div
              className={styles.tools}
              role="radiogroup"
              aria-label="Tool"
              onKeyDown={arrowKeys}
              data-part="tools"
            >
              {TOOLS.map((t) => (
                <button
                  key={t.id}
                  type="button"
                  role="radio"
                  aria-checked={tool === t.id}
                  tabIndex={tool === t.id ? 0 : -1}
                  aria-label={t.label}
                  title={t.label}
                  onClick={() => setTool(t.id)}
                >
                  <svg viewBox="0 0 16 16" aria-hidden="true">
                    <path d={t.icon} />
                  </svg>
                </button>
              ))}
            </div>
            <div className={styles.shareWrap} data-menu>
              <button
                type="button"
                className={styles.share}
                aria-expanded={menu === 'share'}
                aria-haspopup="dialog"
                onClick={() => {
                  setCopied(false);
                  setMenu(menu === 'share' ? null : 'share');
                }}
                data-part="share"
              >
                Share
              </button>
              {menu === 'share' && (
                <div className={styles.popover} role="dialog" aria-label="Share the board">
                  <p>
                    <b>Press Shop</b>
                    <span>Anyone at Plant North can view and comment.</span>
                  </p>
                  <button type="button" onClick={copyLink}>
                    {copied ? 'Link copied' : 'Copy link'}
                  </button>
                </div>
              )}
            </div>
          </header>

          <div
            ref={viewport}
            className={styles.canvas}
            style={{ width: viewW, height: viewH }}
            data-tool={tool}
            onPointerDown={onCanvasDown}
            onPointerMove={onCanvasMove}
            onPointerUp={onCanvasUp}
            onPointerCancel={onCanvasUp}
            role="region"
            aria-label="Canvas. Drag to pan; use the minimap or the node switcher to move between nodes."
            data-part="canvas"
          >
            <svg
              className={styles.wire}
              data-on={(selected === 'thermal' && !narrow) || undefined}
              aria-hidden="true"
            >
              <path ref={wire} />
              <circle ref={wireDot} r="3" />
            </svg>

            <div ref={world} className={styles.world} onFocus={onWorldFocus}>
              <section
                className={styles.frame}
                style={{ left: W.x, top: W.y, width: W.w, height: W.h }}
                data-selected={selected === 'thermal' || undefined}
                onPointerDown={() => setSelected('thermal')}
                onFocus={() => setSelected('thermal')}
                aria-label="Bed temperature widget"
              >
                <BedTemperature
                  id={`${id}bed`}
                  bed={bed}
                  view={view}
                  hover={hover}
                  onHover={setHover}
                  live={live}
                  defaultAt={
                    defaultState?.at === undefined
                      ? undefined
                      : HISTORY - 1 - Math.max(0, Math.min(HISTORY - 1, defaultState.at))
                  }
                />
                {selected === 'thermal' && <Handles />}
              </section>

              <div
                className={styles.views}
                style={{ left: layout.views.x, top: layout.views.y }}
                data-menu
                data-part="view-switch"
              >
                <div role="radiogroup" aria-label="Widget view" onKeyDown={arrowKeys}>
                  {(['3d', 'grid', 'line'] as const).map((v) => (
                    <button
                      key={v}
                      type="button"
                      role="radio"
                      aria-checked={view === v}
                      tabIndex={view === v ? 0 : -1}
                      aria-label={v === '3d' ? '3D bars' : v === 'grid' ? 'Grid' : 'Line'}
                      onClick={() => {
                        setView(v);
                        setHover(null);
                        if (v !== view) onViewChange?.(v);
                      }}
                      data-demo={`view-${v}`}
                    >
                      <svg viewBox="0 0 16 16" aria-hidden="true">
                        <path
                          d={
                            v === '3d'
                              ? 'M8 1.6 13.6 4.8v6.4L8 14.4 2.4 11.2V4.8Z M2.4 4.8 8 8l5.6-3.2M8 8v6.4'
                              : v === 'grid'
                                ? 'M2.5 2.5h4.5v4.5H2.5Z M9 2.5h4.5v4.5H9Z M2.5 9h4.5v4.5H2.5Z M9 9h4.5v4.5H9Z'
                                : 'M1.8 12.5 6 7.5l3 3 5.2-6.5'
                          }
                        />
                      </svg>
                      {v === '3d' && <span>3D</span>}
                    </button>
                  ))}
                </div>
                <button
                  type="button"
                  className={styles.more}
                  aria-label="More"
                  aria-haspopup="menu"
                  aria-expanded={menu === 'more'}
                  onClick={() => setMenu(menu === 'more' ? null : 'more')}
                >
                  <svg viewBox="0 0 16 16" aria-hidden="true">
                    <circle cx="3.5" cy="8" r="1" />
                    <circle cx="8" cy="8" r="1" />
                    <circle cx="12.5" cy="8" r="1" />
                  </svg>
                </button>
                {menu === 'more' && (
                  <div className={styles.menu} role="menu" aria-label="Widget">
                    <button
                      type="button"
                      role="menuitem"
                      onClick={() => {
                        setPaused((p) => !p);
                        setMenu(null);
                      }}
                    >
                      {paused ? 'Resume live data' : 'Pause live data'}
                    </button>
                    <button
                      type="button"
                      role="menuitem"
                      onClick={() => {
                        setMenu(null);
                        goTo('thermal');
                      }}
                    >
                      Reset the view
                    </button>
                  </div>
                )}
              </div>

              <div
                className={styles.at}
                style={{ left: layout.hot.x, top: layout.hot.y, width: layout.hot.w }}
              >
                <HotSpotCard
                  bed={bed}
                  onShow={() => {
                    setView((v) => (v === 'line' ? '3d' : v));
                    setHover({ c: 5, r: 3 });
                    setSelected('thermal');
                  }}
                />
              </div>
              <div className={styles.at} style={{ left: c0.x, top: c0.y, width: c0.w }}>
                <CommentCard open={thread} onToggle={() => setThread((t) => !t)} />
              </div>
              <svg
                className={styles.thread}
                data-off={view !== '3d' || undefined}
                aria-hidden="true"
              >
                <path
                  d={`M${lineStart.x} ${lineStart.y}C${lineStart.x + 80} ${lineStart.y - 30} ${lineEnd.x - 90} ${lineEnd.y + 6} ${lineEnd.x} ${lineEnd.y}`}
                />
              </svg>
              <button
                type="button"
                className={styles.pin}
                style={{ left: pin.x, top: pin.y }}
                hidden={view !== '3d'}
                aria-label="Ben’s comment on C3"
                aria-expanded={thread}
                onClick={() => setThread((t) => !t)}
                data-part="comment-pin"
              >
                BO
              </button>

              {(Object.keys(layout.cards) as Exclude<NodeId, 'thermal'>[]).map((node) => (
                <div key={node} className={styles.at} style={at(node)}>
                  {node === 'oee' && (
                    <OeeCard {...cardProps(node)} onOpenAndon={() => goTo('andon')} />
                  )}
                  {node === 'andon' && <AndonCard {...cardProps(node)} />}
                  {node === 'tightening' && <TighteningCard {...cardProps(node)} />}
                  {node === 'position' && <PositionCard {...cardProps(node)} />}
                  {node === 'stock' && <StockCard {...cardProps(node)} />}
                  {node === 'milkrun' && <MilkRunCard {...cardProps(node)} />}
                </div>
              ))}

              {drops.map((drop) => (
                <div
                  key={drop.id}
                  className={styles.drop}
                  data-kind={drop.kind}
                  style={{ left: drop.x, top: drop.y, width: drop.w, height: drop.h }}
                >
                  {drop.kind === 'note' && (
                    <textarea aria-label="Sticky note" placeholder="Type a note" autoFocus />
                  )}
                  {drop.kind === 'widget' && (
                    <span>
                      <svg viewBox="0 0 16 16" aria-hidden="true">
                        <path d={TOOLS[0].icon} />
                      </svg>
                      Untitled widget
                    </span>
                  )}
                  {drop.kind === 'frame' && <span>Frame</span>}
                  <button
                    type="button"
                    aria-label={`Remove ${drop.kind === 'note' ? 'note' : drop.kind}`}
                    onClick={() => setDrops((list) => list.filter((d) => d.id !== drop.id))}
                  >
                    ×
                  </button>
                </div>
              ))}
            </div>

            {/* Fixed on the canvas: the node switcher, the zoom, the suggestion, the minimap. */}
            <div className={styles.nodeWrap} data-menu data-hud>
              <button
                ref={pillRef}
                type="button"
                className={styles.nodePill}
                aria-haspopup="listbox"
                aria-expanded={menu === 'nodes'}
                aria-label={`Node: ${label}`}
                onClick={() => setMenu(menu === 'nodes' ? null : 'nodes')}
                data-demo="node-pill"
                data-part="node-switcher"
              >
                <svg viewBox="0 0 16 16" aria-hidden="true">
                  <path d={TOOLS[0].icon} />
                </svg>
                <span>{label}</span>
                <i aria-hidden="true">
                  <svg viewBox="0 0 16 16">
                    <path d="m5 6.2 3-3 3 3M5 9.8l3 3 3-3" />
                  </svg>
                </i>
              </button>
              {menu === 'nodes' && (
                <ul
                  ref={listRef}
                  className={styles.nodeList}
                  role="listbox"
                  tabIndex={-1}
                  aria-label="Nodes on this board"
                  aria-activedescendant={`${id}-node-${NODES[active].id}`}
                  onKeyDown={onListKey}
                >
                  {NODES.map((node, i) => (
                    <li
                      key={node.id}
                      id={`${id}-node-${node.id}`}
                      role="option"
                      aria-selected={selected === node.id}
                      data-active={active === i || undefined}
                      onPointerEnter={() => setActive(i)}
                      onClick={() => {
                        goTo(node.id);
                        pillRef.current?.focus();
                      }}
                      data-demo={`node-${node.id}`}
                    >
                      <span>{node.label}</span>
                      {selected === node.id && (
                        <svg viewBox="0 0 16 16" aria-hidden="true">
                          <path d="m3.5 8.5 3 3 6-7" />
                        </svg>
                      )}
                    </li>
                  ))}
                </ul>
              )}
            </div>

            <div className={styles.zoom} data-hud data-part="zoom">
              <button type="button" aria-label="Zoom out" onClick={() => zoomBy(1 / 1.2)}>
                –
              </button>
              <output aria-label="Zoom">
                <span ref={zoomText}>{ZOOM_AT_1}</span> %
              </output>
              <button type="button" aria-label="Zoom in" onClick={() => zoomBy(1.2)}>
                +
              </button>
            </div>

            {!narrow && (
              <Suggestion text={SUGGESTIONS[group]} on={alerts[group]} onToggle={toggleAlert} />
            )}

            <div className={styles.minimap} data-hud data-part="minimap">
              <svg viewBox="0 0 72 47.5" aria-hidden="true">
                {[1, 2, 3].map((g) =>
                  NODES.filter((n) => n.group === g).map((n) => (
                    <rect
                      key={n.id}
                      {...mapRect(layout.cards[n.id as Exclude<NodeId, 'thermal'>])}
                      rx="1"
                      className={styles.mapCard}
                      data-on={selected === n.id || undefined}
                    />
                  )),
                )}
                <rect
                  {...mapRect({ x: 23.5, y: 23.5, w: 188, h: 42 })}
                  rx="1.5"
                  className={styles.mapPill}
                />
                <rect
                  {...mapRect({ ...layout.hot, w: 90, h: 70 })}
                  rx="1.5"
                  className={styles.mapHot}
                />
                <rect {...mapRect(layout.comment)} rx="1.5" className={styles.mapCard} />
                <rect
                  {...mapRect(W)}
                  rx="1.5"
                  className={styles.mapCard}
                  data-on={selected === 'thermal' || undefined}
                />
                <rect ref={mapView} rx="3" className={styles.mapView} />
              </svg>
              {[0, 1, 2, 3].map((g) => (
                <button
                  key={g}
                  type="button"
                  className={styles.mapHit}
                  style={(({ x, y, width, height }) => ({ left: x, top: y, width, height }))(
                    mapRect(groupBox(layout, narrow, g)),
                  )}
                  aria-label={`Go to ${GROUPS[g]}`}
                  onClick={() => goTo(NODES.find((n) => n.group === g)!.id)}
                  data-demo={`map-${g}`}
                />
              ))}
            </div>

            <div className={styles.collabs} aria-hidden="true">
              <div
                ref={ana}
                className={styles.collab}
                data-tone="green"
                style={{ transform: `translate(${anaAt.current.x}px, ${anaAt.current.y}px)` }}
              >
                <svg viewBox="0 0 12 12">
                  <path d="M1 1 11 4.6 6.6 6.2 4.8 10.8Z" />
                </svg>
                <span>Ana</span>
              </div>
              <div
                className={styles.collab}
                data-tone="yellow"
                data-wander
                data-away={group !== 0 || undefined}
              >
                <svg viewBox="0 0 12 12">
                  <path d="M1 1 11 4.6 6.6 6.2 4.8 10.8Z" />
                </svg>
                <span>Cole</span>
              </div>
            </div>
          </div>

          {narrow && (
            <div className={styles.under}>
              <Suggestion text={SUGGESTIONS[group]} on={alerts[group]} onToggle={toggleAlert} />
            </div>
          )}
        </div>
      </div>
      <p className={styles.srOnly} aria-live="polite">
        {say}
      </p>
    </div>
  );
}

/** The assistant's suggested alert for the nodes in view, and the button that adds it. */
function Suggestion({ text, on, onToggle }: { text: string; on: boolean; onToggle: () => void }) {
  return (
    <div className={styles.suggest} data-hud data-part="suggestion">
      <svg viewBox="0 0 16 16" aria-hidden="true">
        <path d="M8 1.5c.5 3.4 2.6 5.6 6 6.5-3.4.9-5.5 3.1-6 6.5-.5-3.4-2.6-5.6-6-6.5 3.4-.9 5.5-3.1 6-6.5Z" />
      </svg>
      <span>{text}</span>
      <button type="button" aria-pressed={on} onClick={onToggle} data-demo="add-alert">
        {on ? (
          <>
            <svg viewBox="0 0 16 16" aria-hidden="true">
              <path d="m3.5 8.5 3 3 6-7" />
            </svg>
            Alert on
          </>
        ) : (
          'Add alert'
        )}
      </button>
    </div>
  );
}
```

### thermal-canvas/thermal.tsx

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

'use client';

import { useState, type KeyboardEvent } from 'react';
import {
  BANDS,
  BAND_NAMES,
  COLS,
  HISTORY,
  HOT,
  LIMIT,
  ROWS,
  band,
  cellName,
  type Bed,
} from './data';
import { box, floor, patternId, type Frame, type Paint } from './iso';
import styles from './thermal-canvas.module.css';

/*
 * The press's own nodes on the board: the "Bed temperature" widget, drawn three ways (3D bars,
 * a flat grid, a line over time), the hot spot's card and Ben's comment.
 *
 * The 3D view is plain SVG: each of the 48 cells is a box of three paths whose height follows its
 * temperature and whose colour follows its band. They are painted back to front, so nearer bars
 * cover the ones behind. Hovering a bar (or moving with the arrow keys) names its cell and value.
 */

export type View = '3d' | 'grid' | 'line';
export type Cell = { c: number; r: number };

/** The four bands' paints: top, left face, right face. */
export const BAND_PAINTS: Paint[] = [
  { top: '#7febeb', left: '#4ee4e2', right: '#35a8a8' },
  { top: '#81eaaf', left: '#4fe38d', right: '#369b5d' },
  { top: '#ffe073', left: '#f5cf46', right: '#ad9028' },
  { top: '#ff9f6a', left: '#fd7831', right: '#b5521f' },
];
export const BAND_SWATCH = ['#4ee4e2', '#4ee48d', '#ffd84a', '#ff792f'];

/** Dotted fills for a set of paints: one pattern per paint and face. */
export function DotPatterns({ id, paints }: { id: string; paints: Record<string, Paint> }) {
  return (
    <>
      {Object.entries(paints).flatMap(([name, paint]) =>
        (['top', 'left', 'right'] as const).map((face) => (
          <pattern
            key={name + face}
            id={patternId(id, name, face)}
            width="3.4"
            height="3.4"
            patternUnits="userSpaceOnUse"
            patternTransform={
              face === 'left' ? 'skewY(30)' : face === 'right' ? 'skewY(-30)' : undefined
            }
          >
            <rect width="3.4" height="3.4" style={{ fill: paint[face] }} />
            <circle cx="1.7" cy="1.7" r="0.42" fill="rgb(0 0 0 / 0.42)" />
          </pattern>
        )),
      )}
    </>
  );
}

/** The bed's floor: 8.4 × 6.4 units, 18.1 px each, its left corner at (31.9, 161.4). */
const BED: Frame = { ox: 39.1, oy: 161.4, px: 18.1 };
/** A bar's height: the cool cells sit close together, the warm ones climb faster. */
const heightOf = (t: number) =>
  t < 186 ? Math.max(7, 17.3 + (t - 176) * 0.8) : 27 + (t - 186) * 1.21;

const PAINT_NAMES = ['cool', 'warm', 'hot', 'over'];

function signed(n: number) {
  const v = Math.round(n);
  return (v >= 0 ? '+' : '−') + Math.abs(v);
}

/** The tooltip a cell shows when hovered, in any view. */
function CellTip({ cell, t, x, y }: { cell: Cell; t: number; x: number; y: number }) {
  return (
    <div className={styles.tip} style={{ left: x, top: y }} aria-hidden="true" data-part="tooltip">
      <b>
        {cellName(cell.c, cell.r)} · {t.toFixed(1)} °C
      </b>
      <span>
        limit {LIMIT} · {signed(t - LIMIT)}
      </span>
    </div>
  );
}

export function BedTemperature({
  id,
  bed,
  view,
  hover,
  onHover,
  live,
  defaultAt,
}: {
  id: string;
  bed: Bed;
  view: View;
  hover: Cell | null;
  onHover: (cell: Cell | null) => void;
  live: boolean;
  /** In the line view, the sample drawn scrubbed when it opens (0 = oldest). */
  defaultAt?: number;
}) {
  const [focusCell, setFocusCell] = useState<Cell | null>(null);
  const [lineAt, setLineAt] = useState<number | null>(defaultAt ?? null);
  const active = hover ?? focusCell;

  const onKey = (event: KeyboardEvent) => {
    const moves: Record<string, [number, number]> = {
      ArrowLeft: [-1, 0],
      ArrowRight: [1, 0],
      ArrowUp: [0, 1],
      ArrowDown: [0, -1],
    };
    const move = moves[event.key];
    if (view === 'line') {
      if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
        event.preventDefault();
        setLineAt((at) =>
          Math.max(
            0,
            Math.min(HISTORY - 1, (at ?? HISTORY - 1) + (event.key === 'ArrowLeft' ? -1 : 1)),
          ),
        );
      }
      return;
    }
    if (!move) return;
    event.preventDefault();
    // In the flat grid, up and down follow the rows as drawn (the back row at the top).
    const from = active ?? HOT;
    const c = Math.max(0, Math.min(COLS - 1, from.c + (view === 'grid' ? -move[0] : move[0])));
    const r = Math.max(0, Math.min(ROWS - 1, from.r + move[1]));
    setFocusCell({ c, r });
  };

  const order: Cell[] = [];
  for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) order.push({ c, r });
  order.sort((a, b) => a.c - a.r - (b.c - b.r));

  const hot = bed.cells[HOT.r][HOT.c];
  const activeT = active ? bed.cells[active.r][active.c] : 0;
  const lineIndex = lineAt ?? (view === 'line' && focusCell ? HISTORY - 1 : null);
  const announce =
    view === 'line'
      ? lineIndex !== null
        ? `${HISTORY - 1 - lineIndex} s ago: hot spot ${bed.hot[lineIndex].toFixed(1)} °C, mean ${bed.mean[lineIndex].toFixed(1)} °C`
        : ''
      : focusCell
        ? `${cellName(focusCell.c, focusCell.r)}, ${bed.cells[focusCell.r][focusCell.c].toFixed(1)} °C, ${BAND_NAMES[band(bed.cells[focusCell.r][focusCell.c])]}`
        : '';

  return (
    <div className={styles.bed} data-view={view} data-part="widget">
      <header className={styles.bedHead}>
        <svg viewBox="0 0 16 16" className={styles.bedIcon} aria-hidden="true">
          <path d="M8 1.6 13.6 4.8v6.4L8 14.4 2.4 11.2V4.8Z M2.4 4.8 8 8l5.6-3.2M8 8v6.4" />
        </svg>
        <h3>Bed temperature</h3>
        <span className={styles.mono}>°C · {live ? 'live' : 'paused'}</span>
      </header>

      <div
        className={styles.chart}
        tabIndex={0}
        role="group"
        aria-roledescription="chart"
        aria-label={
          view === 'line'
            ? `Bed temperature over the last ${HISTORY} seconds. Hot spot ${cellName(HOT.c, HOT.r)} at ${hot.toFixed(1)} °C, limit ${LIMIT}. Left and right arrows step through time.`
            : `Bed temperature, ${COLS * ROWS} cells. Hot spot ${cellName(HOT.c, HOT.r)} at ${hot.toFixed(1)} °C, limit ${LIMIT}. Arrow keys move between cells.`
        }
        onKeyDown={onKey}
        onBlur={() => {
          setFocusCell(null);
          setLineAt(null);
        }}
        data-part="chart"
      >
        {view === '3d' && (
          <svg viewBox="0 0 331.5 271" className={styles.bedSvg} aria-hidden="true">
            <defs>
              <DotPatterns
                id={id}
                paints={Object.fromEntries(BAND_PAINTS.map((p, i) => [PAINT_NAMES[i], p]))}
              />
            </defs>
            <path d={floor(BED, -0.2, -0.2, 8.2, 6.2)} className={styles.plate} />
            {order.map(({ c, r }) => {
              const t = bed.cells[r][c];
              const name = PAINT_NAMES[band(t)];
              const faces = box(BED, c + 0.16, r + 0.16, c + 0.84, r + 0.84, 0, heightOf(t));
              const on = active?.c === c && active?.r === r;
              return (
                <g
                  key={`${c}-${r}`}
                  className={styles.bar}
                  data-on={on || undefined}
                  data-cell={cellName(c, r)}
                  onPointerEnter={() => onHover({ c, r })}
                  onPointerLeave={() => onHover(null)}
                >
                  <path d={faces.left} fill={`url(#${patternId(id, name, 'left')})`} />
                  <path d={faces.right} fill={`url(#${patternId(id, name, 'right')})`} />
                  <path d={faces.top} fill={`url(#${patternId(id, name, 'top')})`} />
                </g>
              );
            })}
            {(() => {
              const top = box(
                BED,
                HOT.c + 0.16,
                HOT.r + 0.16,
                HOT.c + 0.84,
                HOT.r + 0.84,
                0,
                heightOf(hot),
              ).topCenter;
              return (
                <circle cx={top.x} cy={top.y} r="7" className={styles.hotRing} data-part="hot" />
              );
            })()}
          </svg>
        )}

        {view === 'grid' && (
          <div className={styles.heat} data-part="heatmap">
            <span />
            {Array.from({ length: COLS }, (_, i) => (
              <span key={i} className={styles.heatLabel}>
                {'ABCDEFGH'[i]}
              </span>
            ))}
            {Array.from({ length: ROWS }, (_, row) => {
              const r = ROWS - 1 - row;
              return [
                <span key={`l${r}`} className={styles.heatLabel}>
                  {row + 2}
                </span>,
                ...Array.from({ length: COLS }, (_, col) => {
                  const c = COLS - 1 - col;
                  const t = bed.cells[r][c];
                  const on = active?.c === c && active?.r === r;
                  return (
                    <span
                      key={`${c}-${r}`}
                      className={styles.heatCell}
                      data-band={band(t)}
                      data-on={on || undefined}
                      data-cell={cellName(c, r)}
                      onPointerEnter={() => onHover({ c, r })}
                      onPointerLeave={() => onHover(null)}
                    >
                      {Math.round(t)}
                    </span>
                  );
                }),
              ];
            })}
          </div>
        )}

        {view === 'line' && <LineView bed={bed} at={lineIndex} onAt={setLineAt} />}

        {active && view === '3d' && (
          <CellTip
            cell={active}
            t={activeT}
            {...(() => {
              const top = box(
                BED,
                active.c + 0.16,
                active.r + 0.16,
                active.c + 0.84,
                active.r + 0.84,
                0,
                heightOf(activeT),
              ).topCenter;
              return { x: top.x, y: top.y - 10 };
            })()}
          />
        )}
        {active && view === 'grid' && (
          <CellTip
            cell={active}
            t={activeT}
            x={GRID.x + (COLS - 1 - active.c + 0.5) * GRID.w}
            y={GRID.y + (ROWS - 1 - active.r) * GRID.h - 4}
          />
        )}
      </div>

      <footer className={styles.legend} data-part="legend">
        {BANDS.map((label, i) => (
          <span key={label} className={styles.mono}>
            <i style={{ background: BAND_SWATCH[i] }} />
            {label}
          </span>
        ))}
        <span className={styles.cells}>{view === 'line' ? `${HISTORY} s` : '48 cells'}</span>
      </footer>
      <span className={styles.srOnly} aria-live="polite">
        {announce}
      </span>
    </div>
  );
}

/** Where the flat grid's cells sit in the widget, for its tooltip. */
const GRID = { x: 47.5, y: 65, w: 33, h: 27.5 };

/** The line view: the hot spot and the bed's mean over the last 40 s, with the limit. */
function LineView({
  bed,
  at,
  onAt,
}: {
  bed: Bed;
  at: number | null;
  onAt: (index: number | null) => void;
}) {
  const W = 331.5;
  const left = 44;
  const right = W - 18;
  const top = 58;
  const bottom = 226;
  const lo = 170;
  const hi = 232;
  const x = (i: number) => left + (i / (HISTORY - 1)) * (right - left);
  const y = (t: number) => bottom - ((t - lo) / (hi - lo)) * (bottom - top);
  const line = (values: number[]) =>
    values.map((t, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)} ${y(t).toFixed(1)}`).join('');
  return (
    <>
      <svg
        viewBox={`0 0 ${W} 271`}
        className={styles.bedSvg}
        aria-hidden="true"
        onPointerMove={(event) => {
          const rect = event.currentTarget.getBoundingClientRect();
          const px = ((event.clientX - rect.left) / rect.width) * W;
          const i = Math.round(((px - left) / (right - left)) * (HISTORY - 1));
          onAt(Math.max(0, Math.min(HISTORY - 1, i)));
        }}
        onPointerLeave={() => onAt(null)}
        data-part="line-chart"
      >
        {[180, 200, 220].map((t) => (
          <g key={t}>
            <line x1={left} x2={right} y1={y(t)} y2={y(t)} className={styles.gridLine} />
            <text x={left - 8} y={y(t) + 3.5} textAnchor="end" className={styles.axis}>
              {t}
            </text>
          </g>
        ))}
        {['−40 s', '−20 s', 'now'].map((label, i) => (
          <text
            key={label}
            x={left + (i / 2) * (right - left)}
            y={bottom + 16}
            textAnchor={i === 0 ? 'start' : i === 2 ? 'end' : 'middle'}
            className={styles.axis}
          >
            {label}
          </text>
        ))}
        <line x1={left} x2={right} y1={y(LIMIT)} y2={y(LIMIT)} className={styles.limitLine} />
        <text x={right} y={y(LIMIT) - 5} textAnchor="end" className={styles.limitText}>
          limit {LIMIT}
        </text>
        <path d={line(bed.mean)} className={styles.meanLine} />
        <path d={line(bed.hot)} className={styles.hotLine} />
        <circle cx={x(HISTORY - 1)} cy={y(bed.hot[HISTORY - 1])} r="4" className={styles.hotDot} />
        {at !== null && (
          <g>
            <line x1={x(at)} x2={x(at)} y1={top - 6} y2={bottom} className={styles.cross} />
            <circle cx={x(at)} cy={y(bed.hot[at])} r="3.5" className={styles.hotDot} />
            <circle cx={x(at)} cy={y(bed.mean[at])} r="3" className={styles.meanDot} />
          </g>
        )}
      </svg>
      {at !== null && (
        <div
          className={styles.tip}
          style={{ left: Math.min(Math.max(x(at), 80), W - 80), top: y(bed.hot[at]) - 12 }}
          aria-hidden="true"
        >
          <b>
            {cellName(HOT.c, HOT.r)} · {bed.hot[at].toFixed(1)} °C
          </b>
          <span>
            mean {bed.mean[at].toFixed(1)} ·{' '}
            {at === HISTORY - 1 ? 'now' : `${HISTORY - 1 - at} s ago`}
          </span>
        </div>
      )}
    </>
  );
}

/** The hot spot's card: which cell, how hot, and its last 40 s against the limit. */
export function HotSpotCard({ bed, onShow }: { bed: Bed; onShow: () => void }) {
  const t = bed.cells[HOT.r][HOT.c];
  const W = 160;
  const H = 25;
  const y = (v: number) => H - ((v - 194) / (230 - 194)) * H;
  // Every other sample, ending on the newest: a calmer line at this size.
  const points = bed.hot.filter((_, i) => (HISTORY - 1 - i) % 2 === 0);
  const d = points
    .map(
      (v, n) => `${n ? 'L' : 'M'}${((n / (points.length - 1)) * W).toFixed(1)} ${y(v).toFixed(1)}`,
    )
    .join('');
  return (
    <section className={styles.hotCard} aria-label="Hot spot" data-part="hot-spot">
      <h3>Hot spot</h3>
      <button type="button" className={styles.cellChip} onClick={onShow} data-demo="cell-chip">
        cell {cellName(HOT.c, HOT.r)}
      </button>
      <p className={styles.hotValue}>
        <span className={styles.mono}>{Math.round(t)}</span>
        <span className={styles.mono}>°C</span>
        <small>limit {LIMIT}</small>
      </p>
      <svg
        viewBox={`0 0 ${W} ${H}`}
        preserveAspectRatio="none"
        className={styles.spark}
        aria-hidden="true"
      >
        <line x1="0" x2={W} y1={y(LIMIT)} y2={y(LIMIT)} className={styles.sparkLimit} />
        <path d={d} className={styles.sparkLine} />
      </svg>
    </section>
  );
}

const REPLIES = [
  {
    who: 'Ana',
    initials: 'AN',
    tone: 'cyan',
    when: '1 min',
    text: 'Spare insert is on the cart by Press 2.',
  },
  {
    who: 'Cole',
    initials: 'CW',
    tone: 'yellow',
    when: 'now',
    text: 'I can swap it at the 13:40 break.',
  },
];

/** Ben's comment on the hot spot, with its two replies behind a disclosure. */
export function CommentCard({ open, onToggle }: { open: boolean; onToggle: () => void }) {
  return (
    <section className={styles.comment} aria-label="Comment from Ben" data-part="comment">
      <header>
        <span className={styles.avatar} data-tone="purple" aria-hidden="true">
          BO
        </span>
        <b>Ben</b>
        <span>2 min</span>
      </header>
      <p>{cellName(HOT.c, HOT.r)} is hot again. Swap the die insert before shift B?</p>
      {open && (
        <ul className={styles.replies}>
          {REPLIES.map((reply) => (
            <li key={reply.who}>
              <span className={styles.avatar} data-tone={reply.tone} aria-hidden="true">
                {reply.initials}
              </span>
              <div>
                <b>{reply.who}</b> <span>{reply.when}</span>
                <p>{reply.text}</p>
              </div>
            </li>
          ))}
        </ul>
      )}
      <button
        type="button"
        className={styles.repliesButton}
        aria-expanded={open}
        onClick={onToggle}
        data-demo="replies"
      >
        <span className={styles.stack} aria-hidden="true">
          <span className={styles.avatar} data-tone="cyan">
            AN
          </span>
          <span className={styles.avatar} data-tone="yellow">
            CW
          </span>
        </span>
        {open ? 'Hide replies' : '2 replies'}
      </button>
    </section>
  );
}
```

### thermal-canvas/cards.tsx

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

'use client';

import { useEffect, useState, type KeyboardEvent, type ReactNode } from 'react';
import { seeded } from './data';
import { box, floor, patternId, project, wireBox, type Frame, type Paint } from './iso';
import { DotPatterns } from './thermal';
import styles from './thermal-canvas.module.css';

/*
 * The other nodes on the board, two to a group: the line's OEE and its andon, a station's
 * tightening curves and a hole's true position, a flow rack's stock and the tugger's milk run.
 * Every number is invented; the ones that tick (the stop timer, the tugger, the bins) follow the
 * board's one-second clock, `tick`, so they all pause together.
 */

export type NodeId = 'thermal' | 'oee' | 'andon' | 'tightening' | 'position' | 'stock' | 'milkrun';

const PAINTS: Record<string, Paint> = {
  gray: { top: 'var(--gray-top)', left: 'var(--gray-left)', right: 'var(--gray-right)' },
  dark: { top: 'var(--column-left)', left: 'var(--column-left)', right: 'var(--column-right)' },
  orange: { top: '#ff9f6a', left: '#fd7831', right: '#b5521f' },
  yellow: { top: '#ffe073', left: '#f5cf46', right: '#ad9028' },
  purple: { top: '#c79bff', left: '#a66bff', right: '#7646c4' },
  cyan: { top: '#7febeb', left: '#4ee4e2', right: '#35a8a8' },
  pink: { top: '#ffb7d0', left: '#ff93b9', right: '#c0647f' },
  green: { top: '#81eaaf', left: '#4fe38d', right: '#369b5d' },
  blue: { top: '#74b0ff', left: '#3a8cff', right: '#2462c2' },
  slab: { top: 'var(--slab-top)', left: 'var(--slab-left)', right: 'var(--slab-right)' },
};

/** A box with dotted faces, from `box()`. */
function Box({ id, paint, faces }: { id: string; paint: string; faces: ReturnType<typeof box> }) {
  return (
    <g className={styles.box}>
      <path d={faces.left} fill={`url(#${patternId(id, paint, 'left')})`} />
      <path d={faces.right} fill={`url(#${patternId(id, paint, 'right')})`} />
      <path d={faces.top} fill={`url(#${patternId(id, paint, 'top')})`} />
    </g>
  );
}

/**
 * Arrow keys in a radio group or a tab list: move to the next or previous choice and pick it,
 * as its role promises. Each group keeps one tab stop, on its chosen item.
 */
export function arrowKeys(event: KeyboardEvent<HTMLElement>) {
  const step =
    event.key === 'ArrowRight' || event.key === 'ArrowDown'
      ? 1
      : event.key === 'ArrowLeft' || event.key === 'ArrowUp'
        ? -1
        : 0;
  if (!step) return;
  const items = [
    ...event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'),
  ];
  const at = items.indexOf(document.activeElement as HTMLButtonElement);
  if (at < 0) return;
  event.preventDefault();
  const next = items[(at + step + items.length) % items.length];
  next.focus();
  next.click();
}

/** The shell every card shares: a title, a line under it, a badge, and an inset panel. */
function Card({
  node,
  title,
  sub,
  badge,
  tone,
  selected,
  onSelect,
  children,
}: {
  node: NodeId;
  title: string;
  sub: string;
  badge?: string;
  tone?: string;
  selected: boolean;
  onSelect: (node: NodeId) => void;
  children: ReactNode;
}) {
  return (
    <section
      className={styles.card}
      data-node={node}
      data-selected={selected || undefined}
      aria-label={title}
      onPointerDown={() => onSelect(node)}
      onFocus={() => onSelect(node)}
      data-part="card"
    >
      <header className={styles.cardHead}>
        <div>
          <h3>{title}</h3>
          <p>{sub}</p>
        </div>
        {badge && (
          <span className={styles.badge} data-tone={tone} aria-live="polite">
            {badge}
          </span>
        )}
      </header>
      <div className={styles.panel}>{children}</div>
      {selected && <Handles />}
    </section>
  );
}

/** The four little squares at a selected node's corners. */
export function Handles() {
  return (
    <span className={styles.handles} aria-hidden="true">
      <i />
      <i />
      <i />
      <i />
    </span>
  );
}

type CardProps = {
  id: string;
  tick: number;
  selected: boolean;
  onSelect: (node: NodeId) => void;
};

/* ---------------------------------------------------------------- OEE */

const LOSSES = [
  { name: 'Breakdowns', min: 38, paint: 'orange', swatch: '#ff792f' },
  { name: 'Changeover', min: 24, paint: 'yellow', swatch: '#ffd84a' },
  { name: 'Small stops', min: 21, paint: 'purple', swatch: '#a66bff' },
  { name: 'Slow cycles', min: 39, paint: 'cyan', swatch: '#4ee4e2' },
  { name: 'Scrap', min: 15, paint: 'pink', swatch: '#ff8fb8' },
];
const OEE_FRAME: Frame = { ox: 53.3, oy: 152, px: 24 };
/** px of height per minute lost, under the 120 px of planned time. */
const PER_MIN = 29 / 137;

export function OeeCard({
  id,
  selected,
  onSelect,
  onOpenAndon,
}: CardProps & { onOpenAndon: () => void }) {
  const [focus, setFocus] = useState(3);
  let level = 120;
  const bars = LOSSES.map((loss, i) => {
    const top = level;
    level -= loss.min * PER_MIN;
    return { ...loss, i: i + 1, top, bottom: level };
  });
  const faces = (k: number, z0: number, z1: number) => box(OEE_FRAME, k, 0, k + 0.8, 0.8, z0, z1);
  const focused = bars[focus];
  const label = faces(focused.i, focused.bottom, focused.top);
  const lx = 222;
  const ly = 95;
  return (
    <Card node="oee" title="OEE" sub="Line 3 · today" selected={selected} onSelect={onSelect}>
      <div className={styles.kpiRow}>
        <p className={styles.kpi}>
          <span className={styles.mono}>71.4 %</span>
          <small>OEE</small>
        </p>
        <span className={styles.pill} data-tone="green">
          +3.2 pts
        </span>
      </div>
      <svg viewBox="0 0 292 242" className={styles.oeeSvg} aria-hidden="true">
        <defs>
          <DotPatterns id={id} paints={PAINTS} />
        </defs>
        <path d={floor(OEE_FRAME, -0.25, -0.25, 7.05, 1.05)} className={styles.oeeFloor} />
        <Box id={id} paint="gray" faces={faces(0, 0, 120)} />
        {bars.map((bar) => (
          <g
            key={bar.name}
            className={styles.lossBar}
            data-on={focus === bar.i - 1 || undefined}
            onPointerEnter={() => setFocus(bar.i - 1)}
          >
            <Box id={id} paint="dark" faces={faces(bar.i, 0, bar.bottom)} />
            <Box id={id} paint={bar.paint} faces={faces(bar.i, bar.bottom, bar.top)} />
          </g>
        ))}
        <Box id={id} paint="green" faces={faces(6, 0, level)} />
        <path
          d={`M${label.topCenter.x + 10} ${label.topCenter.y + 6} L${lx - 22} ${ly + 1} L${lx - 4} ${ly + 1}`}
          className={styles.leader}
        />
        <text x={lx} y={ly + 5} className={styles.leaderText}>
          −{focused.min} min
        </text>
      </svg>
      <ul className={styles.lossLegend} aria-label="Minutes lost today">
        {LOSSES.map((loss, i) => (
          <li key={loss.name}>
            <button
              type="button"
              aria-pressed={focus === i}
              onClick={() => setFocus(i)}
              onPointerEnter={() => setFocus(i)}
              data-demo={`loss-${i}`}
            >
              <i style={{ background: loss.swatch }} />
              {loss.name} <b className={styles.mono}>{loss.min}</b>
            </button>
          </li>
        ))}
      </ul>
      <button
        type="button"
        className={styles.rowLink}
        onClick={onOpenAndon}
        data-demo="slow-cycles"
      >
        <span>
          <b>Slow cycles on Weld-1</b>
          <small>49 s vs 45 s takt</small>
        </span>
        <svg viewBox="0 0 16 16" aria-hidden="true">
          <path d="m6 3.5 4.5 4.5L6 12.5" />
        </svg>
      </button>
    </Card>
  );
}

/* ---------------------------------------------------------------- Andon */

const STATIONS = [
  { name: 'Cut', value: '38 s', state: 'run' },
  { name: 'Bend', value: '41 s', state: 'run' },
  { name: 'Weld-1', value: '', state: 'stop' },
  { name: 'Weld-2', value: 'idle', state: 'idle' },
  { name: 'Paint', value: '34 s', state: 'run' },
  { name: 'Assy', value: 'QC hold', state: 'hold' },
  { name: 'QC', value: '29 s', state: 'run' },
  { name: 'Pack', value: 'idle', state: 'idle' },
];
const REASONS = ['Maintenance', 'Material', 'Quality'];
const clock = (s: number) =>
  `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
const short = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;

export function AndonCard({ selected, onSelect, tick }: CardProps) {
  const [reason, setReason] = useState(0);
  const [resolvedAt, setResolvedAt] = useState<number | null>(null);
  const [escalatedAt, setEscalatedAt] = useState<number | null>(null);
  const resolved = resolvedAt !== null;
  const stopped = 252 + (resolved ? resolvedAt : tick);
  const page = Math.max(0, 108 - tick);
  const paged = escalatedAt !== null || page === 0;
  // The page goes out 12 minutes into a stop.
  const pageShare = paged ? 1 : 1 - page / 720;
  return (
    <Card
      node="andon"
      title="Andon"
      sub="Line 3 · 8 stations"
      badge={resolved ? 'All running' : '1 stopped'}
      tone={resolved ? 'green' : 'red'}
      selected={selected}
      onSelect={onSelect}
    >
      <ul className={styles.stations} aria-label="Stations">
        {STATIONS.map((station) => {
          const state = station.state === 'stop' && resolved ? 'run' : station.state;
          const value =
            station.state === 'stop' ? (resolved ? '45 s' : clock(stopped)) : station.value;
          return (
            <li key={station.name} className={styles.station} data-state={state}>
              <i aria-hidden="true" />
              <b className={styles.mono}>{station.name}</b>
              <span className={styles.mono}>{value}</span>
            </li>
          );
        })}
      </ul>
      <div className={styles.stop}>
        <header>
          <h4>{resolved ? 'Weld-1 running' : 'Weld-1 stopped'}</h4>
          <span className={styles.mono} data-tone={resolved ? 'green' : 'red'}>
            {clock(stopped)}
          </span>
        </header>
        <div className={styles.reasons} role="radiogroup" aria-label="Reason" onKeyDown={arrowKeys}>
          {REASONS.map((name, i) => (
            <button
              key={name}
              type="button"
              role="radio"
              aria-checked={reason === i}
              tabIndex={reason === i ? 0 : -1}
              onClick={() => setReason(i)}
              disabled={resolved}
              data-demo={`reason-${i}`}
            >
              {name}
            </button>
          ))}
        </div>
        <div className={styles.person}>
          <span className={styles.avatar} data-tone="purple" data-size="l" aria-hidden="true">
            BO
          </span>
          <span>
            <b>Ben Okafor</b>
            <small>
              {REASONS[reason].toLowerCase()} · {resolved ? 'done' : 'on the way'}
            </small>
          </span>
          <span className={styles.mono} data-tone="green">
            {resolved ? 'done' : '2 min'}
          </span>
        </div>
        <p className={styles.page}>
          <svg viewBox="0 0 20 20" aria-hidden="true">
            <circle cx="10" cy="10" r="7" className={styles.pageTrack} />
            <circle
              cx="10"
              cy="10"
              r="7"
              pathLength="1"
              strokeDasharray={`${pageShare} 1`}
              className={styles.pageArc}
            />
          </svg>
          {resolved ? (
            'Supervisor not needed'
          ) : paged ? (
            'Supervisor paged'
          ) : (
            <>
              Supervisor paged in <b className={styles.mono}>{short(page)}</b>
            </>
          )}
        </p>
      </div>
      <div className={styles.actions}>
        {resolved ? (
          <button type="button" onClick={() => setResolvedAt(null)} data-wide>
            Reopen the stop
          </button>
        ) : (
          <>
            <button type="button" onClick={() => setEscalatedAt(tick)} disabled={paged}>
              {paged ? 'Escalated' : 'Escalate now'}
            </button>
            <button
              type="button"
              data-primary
              onClick={() => setResolvedAt(tick)}
              data-demo="resolve"
            >
              Resolve
            </button>
          </>
        )}
      </div>
    </Card>
  );
}

/* ---------------------------------------------------------------- Tightening */

type Curve = { snug: number; end: number; torque: number; part: number };
/** Torque at angle `a` along a curve: a slow run-down, then the rise once the joint is snug. */
const torqueAt = (curve: Curve, a: number) =>
  a <= curve.snug
    ? (a / curve.snug) * 1.6
    : 1.6 + (curve.torque - 1.6) * Math.pow((a - curve.snug) / (curve.end - curve.snug), 1.45);

function curves(seed: number, last: Curve): Curve[] {
  const random = seeded(seed);
  const set: Curve[] = [];
  for (let i = 0; i < 9; i++)
    set.push({
      snug: 13 + random() * 4,
      end: 35 + random() * 5,
      torque: 23.5 + random() * 3,
      part: 100 + i,
    });
  return [...set, last];
}
const BOLTS = [
  { ok: curves(3, { snug: 14, end: 37, torque: 25.6, part: 119 }) },
  {
    ok: curves(5, { snug: 15, end: 38, torque: 24.8, part: 119 }),
    nok: { snug: 18, end: 47, torque: 21.4, part: 118 },
  },
  { ok: curves(9, { snug: 13.5, end: 40, torque: 23.9, part: 119 }) },
];
const T = { left: 26, right: 255.5, top: 21, bottom: 165 };
const ax = (a: number) => T.left + (a / 60) * (T.right - T.left);
const ty = (t: number) => T.bottom - (t / 30) * (T.bottom - T.top);
const curvePath = (curve: Curve) => {
  let d = '';
  for (let a = 0; a <= curve.end + 0.01; a += 1)
    d += `${d ? 'L' : 'M'}${ax(a).toFixed(1)} ${ty(torqueAt(curve, a)).toFixed(1)}`;
  return d;
};

export function TighteningCard({ selected, onSelect }: CardProps) {
  const [bolt, setBolt] = useState(1);
  const [hover, setHover] = useState<number | null>(null);
  const set = BOLTS[bolt];
  const last = set.ok[set.ok.length - 1];
  const all = set.nok ? [...set.ok, set.nok] : set.ok;
  // The tooltip names the hovered curve, or the failed one when nothing is hovered.
  const shown = hover !== null ? all[hover] : set.nok;
  const shownNok = shown === set.nok;
  return (
    <Card
      node="tightening"
      title="Tightening"
      sub="Station 4 · M8 bolt · last 20"
      badge="1 NOK"
      tone="red"
      selected={selected}
      onSelect={onSelect}
    >
      <div className={styles.segmented} role="tablist" aria-label="Bolt" onKeyDown={arrowKeys}>
        {BOLTS.map((_, i) => (
          <button
            key={i}
            type="button"
            role="tab"
            aria-selected={bolt === i}
            tabIndex={bolt === i ? 0 : -1}
            onClick={() => {
              setBolt(i);
              setHover(null);
            }}
            data-demo={`bolt-${i}`}
          >
            Bolt {i + 1}
          </button>
        ))}
      </div>
      <div className={styles.kpiRow} data-tight>
        <p className={styles.kpi}>
          <span className={styles.mono}>{last.torque.toFixed(1)}</span>
          <small>Nm at {Math.round(last.end)}°</small>
        </p>
        <span className={styles.pill} data-tone="green">
          OK
        </span>
      </div>
      <div className={styles.torque}>
        <svg
          viewBox="0 0 260 186"
          aria-hidden="true"
          onPointerMove={(event) => {
            const rect = event.currentTarget.getBoundingClientRect();
            const x = ((event.clientX - rect.left) / rect.width) * 260;
            const y = ((event.clientY - rect.top) / rect.height) * 186;
            const a = ((x - T.left) / (T.right - T.left)) * 60;
            let best: number | null = null;
            let gap = 14;
            all.forEach((curve, i) => {
              const ca = Math.min(a, curve.end);
              const d = Math.hypot(ax(ca) - x, ty(torqueAt(curve, ca)) - y);
              if (d < gap) {
                gap = d;
                best = i;
              }
            });
            setHover(best);
          }}
          onPointerLeave={() => setHover(null)}
          data-demo="torque"
        >
          {[30, 20, 10].map((t) => (
            <g key={t}>
              <line x1={T.left} x2={T.right} y1={ty(t)} y2={ty(t)} className={styles.gridLine} />
              <text x="0" y={ty(t) + 4} className={styles.axisL}>
                {t}
              </text>
            </g>
          ))}
          <line x1={T.left} x2={T.right} y1={T.bottom} y2={T.bottom} className={styles.baseLine} />
          {[0, 20, 40, 60].map((a) => (
            <text key={a} x={ax(a)} y={T.bottom + 18} className={styles.axisL} data-x>
              {a}°
            </text>
          ))}
          <rect
            x={ax(32)}
            y={ty(27.5)}
            width={ax(44) - ax(32)}
            height={ty(22.5) - ty(27.5)}
            rx="3"
            className={styles.window}
          />
          {set.ok.slice(0, -1).map((curve, i) => (
            <path
              key={i}
              d={curvePath(curve)}
              className={styles.okCurve}
              data-on={hover === i || undefined}
            />
          ))}
          {set.nok && <path d={curvePath(set.nok)} className={styles.nokCurve} />}
          <path d={curvePath(last)} className={styles.lastCurve} />
          <circle cx={ax(last.end)} cy={ty(last.torque)} r="3.6" className={styles.lastDot} />
          {set.nok && (
            <g>
              <circle
                cx={ax(set.nok.end)}
                cy={ty(set.nok.torque)}
                r="5.6"
                className={styles.nokRing}
              />
              <circle
                cx={ax(set.nok.end)}
                cy={ty(set.nok.torque)}
                r="2"
                className={styles.nokDot}
              />
            </g>
          )}
          {shown && (
            <path
              d={`M143 24 H${ax(shown.end)} V${ty(shown.torque) - (shownNok ? 6 : 4)}`}
              className={styles.leaderGray}
            />
          )}
        </svg>
        {shown && (
          <div className={styles.tip} data-static>
            <b>
              Part {shown.part} · {shown.torque.toFixed(1)} Nm
            </b>
            <span>
              {shownNok ? 'soft joint' : shown === last ? 'last, in window' : 'in window'} at{' '}
              {Math.round(shown.end)}°
            </span>
          </div>
        )}
      </div>
      <ul className={styles.lineLegend} aria-hidden="true">
        <li>
          <i data-k="ok" />
          OK
        </li>
        <li>
          <i data-k="last" />
          Last
        </li>
        <li>
          <i data-k="nok" />
          NOK
        </li>
        <li>
          <i data-k="window" />
          Window
        </li>
      </ul>
    </Card>
  );
}

/* ---------------------------------------------------------------- True position */

const POS_FRAME: Frame = { ox: 14, oy: 147, px: 16.5 };
type Bin = { u: number; v: number; n: number; kind: 'older' | 'recent' | 'out' };
/** Hits binned on a 0.8-unit grid around (cu, cv). */
function cluster(seed: number, cu: number, cv: number, spread: number, parts: number) {
  const random = seeded(seed);
  const bins = new Map<string, { u: number; v: number; n: number }>();
  for (let i = 0; i < parts; i++) {
    // A rough normal draw: the sum of three uniforms.
    const g = () => (random() + random() + random() - 1.5) * spread;
    const u = Math.round((cu + g()) / 0.8) * 0.8;
    const v = Math.round((cv + g()) / 0.8) * 0.8;
    const key = `${u},${v}`;
    const bin = bins.get(key) ?? { u, v, n: 0 };
    bin.n++;
    bins.set(key, bin);
  }
  return [...bins.values()];
}
const OLDER = cluster(21, 3.6, 3.9, 1.9, 34);
const RECENT_DRIFT = cluster(4, 5.5, 4.8, 1.3, 29);
const RECENT_CENTRED = cluster(4, 4, 4, 1.3, 29);

export function PositionCard({ id, selected, onSelect }: CardProps) {
  const [applied, setApplied] = useState(false);
  const recent = applied ? RECENT_CENTRED : RECENT_DRIFT;
  const bins: Bin[] = [
    ...OLDER.map((b) => ({ ...b, kind: 'older' as const })),
    ...recent.map((b) => ({ ...b, kind: 'recent' as const })),
    ...(applied ? [] : [{ u: 7.1, v: 5.6, n: 0.5, kind: 'out' as const }]),
  ];
  // Two bins of different ages on the same spot: the newer one stands on the older.
  const stack = new Map<string, number>();
  const boxes = bins
    .map((b) => {
      const key = `${b.u},${b.v}`;
      const z0 = stack.get(key) ?? 0;
      const z1 = z0 + b.n * 8.5;
      stack.set(key, z1);
      return { ...b, z0, z1 };
    })
    .sort((a, b) => a.u - a.v - (b.u - b.v) || a.z0 - b.z0);
  const c = project(POS_FRAME, 4, 4);
  const gridLines: string[] = [];
  for (let k = 1; k < 8; k++) {
    const a = project(POS_FRAME, k, 0);
    const b = project(POS_FRAME, k, 8);
    const e = project(POS_FRAME, 0, k);
    const f = project(POS_FRAME, 8, k);
    gridLines.push(`M${a.x} ${a.y}L${b.x} ${b.y}M${e.x} ${e.y}L${f.x} ${f.y}`);
  }
  const xAxis = [project(POS_FRAME, 0.6, 4), project(POS_FRAME, 7.4, 4)];
  const yAxis = [project(POS_FRAME, 4, 0.6), project(POS_FRAME, 4, 7.4)];
  return (
    <Card
      node="position"
      title="True position"
      sub="Hole Ø 8 H7 · 64 parts"
      badge={applied ? 'Centred' : 'Drifting'}
      tone={applied ? 'green' : 'yellow'}
      selected={selected}
      onSelect={onSelect}
    >
      <div className={styles.kpiRow}>
        <p className={styles.kpi}>
          <span className={styles.mono}>{applied ? '0.006' : '0.031'}</span>
          <small>mm offset · last 30</small>
        </p>
        <span className={styles.mono} data-tone={applied ? 'green' : 'yellow'}>
          Cpk {applied ? '1.71' : '1.12'}
        </span>
      </div>
      <svg viewBox="0 0 292 234" className={styles.posSvg} aria-hidden="true">
        <defs>
          <DotPatterns id={id} paints={PAINTS} />
        </defs>
        <text x="146" y="43" className={styles.posLabel}>
          Ø 0.10
        </text>
        <path d={floor(POS_FRAME, 0, 0, 8, 8)} className={styles.posFloor} />
        <path d={gridLines.join('')} className={styles.posGrid} />
        <ellipse cx={c.x} cy={c.y} rx="81" ry="46.8" className={styles.posCircle} />
        <path
          d={`M${xAxis[0].x} ${xAxis[0].y}L${xAxis[1].x} ${xAxis[1].y}M${yAxis[0].x} ${yAxis[0].y}L${yAxis[1].x} ${yAxis[1].y}`}
          className={styles.posAxis}
        />
        <text x="219.5" y="104" className={styles.posAxisText}>
          Y
        </text>
        <text x="219.5" y="193.5" className={styles.posAxisText}>
          X
        </text>
        {boxes.map((b, i) => (
          <Box
            key={i}
            id={id}
            paint={b.kind === 'older' ? 'gray' : b.kind === 'recent' ? 'blue' : 'orange'}
            faces={box(POS_FRAME, b.u, b.v, b.u + 0.62, b.v + 0.62, b.z0, b.z1)}
          />
        ))}
      </svg>
      <ul className={styles.dotLegend} aria-hidden="true">
        <li>
          <i data-k="older" />
          Older
        </li>
        <li>
          <i data-k="recent" />
          Recent
        </li>
        <li>
          <i data-k="out" />
          Out · {applied ? 0 : 1}
        </li>
        <li>
          <span aria-hidden="true">→</span> Drift
        </li>
      </ul>
      <div className={styles.offset}>
        <span>
          <small>Tool offset</small>
          <b className={styles.mono}>{applied ? 'X 0.000   Y 0.000' : 'X −0.026   Y −0.017'}</b>
        </span>
        <button type="button" onClick={() => setApplied((a) => !a)} data-demo="apply">
          {applied ? 'Undo' : 'Apply'}
        </button>
      </div>
    </Card>
  );
}

/* ---------------------------------------------------------------- Line-side stock */

type Slot = {
  name: string;
  part: string;
  paint: string;
  size: number;
  perMin: number;
  count0: number;
};
const SLOTS: Slot[][] = [
  // The back tier, left to right.
  [
    { name: 'Brackets', part: 'B-7', paint: 'yellow', size: 40, perMin: 0.9, count0: 40 },
    { name: 'Brackets', part: 'B-9', paint: 'yellow', size: 40, perMin: 0.7, count0: 29 },
    { name: 'Caps', part: 'C-12', paint: 'purple', size: 40, perMin: 0.43, count0: 6 },
    { name: 'Housings', part: 'H-2', paint: 'cyan', size: 24, perMin: 0.3, count0: 22 },
  ],
  // The front tier.
  [
    { name: 'Housings', part: 'H-4', paint: 'cyan', size: 24, perMin: 0.3, count0: 23 },
    { name: 'Seals', part: 'S-3', paint: 'pink', size: 60, perMin: 1.2, count0: 0 },
    { name: 'Seals', part: 'S-5', paint: 'pink', size: 60, perMin: 1.1, count0: 47 },
    { name: 'Seals', part: 'S-6', paint: 'pink', size: 60, perMin: 1.0, count0: 28 },
  ],
];
const RACK: Frame = { ox: 49.4, oy: 135, px: 32 };

export function StockCard({ id, selected, onSelect, tick }: CardProps) {
  const [hover, setHover] = useState<string | null>(null);
  const [auto, setAuto] = useState(true);
  // One minute of the line passes every 6 s of the board's clock.
  const minutes = (tick / 6) % 12;
  const slots = SLOTS.flatMap((tier, t) =>
    tier.map((slot, k) => {
      const count = Math.max(0, Math.round(slot.count0 - slot.perMin * minutes));
      const share = count / slot.size;
      const low = count > 0 && share < 0.25;
      return { ...slot, t, k, count, share, low, key: `${t}-${k}` };
    }),
  );
  const toRefill = slots.filter((s) => s.count === 0 || s.low).length;
  // The back tier stands higher and further back; each slot is a glass box of 0.86 units.
  const place = (t: number, k: number) => ({
    u: 0.15 + k * 1.02 + (t === 0 ? 0.32 : 0),
    v: t === 0 ? 1.12 : 0.02,
    z: t === 0 ? 20 : 0,
  });
  const shown = slots.find((s) => s.key === hover) ?? slots.find((s) => s.low) ?? null;
  const shownTop = shown
    ? (() => {
        const p = place(shown.t, shown.k);
        const h = Math.max(4, shown.share * 30);
        return project(RACK, p.u + 0.43, p.v + 0.43, p.z + h);
      })()
    : null;
  const order = [...slots].sort((a, b) => a.t - b.t || a.k - b.k);
  return (
    <Card
      node="stock"
      title="Line-side stock"
      sub="Flow rack R3 · 8 bins"
      badge={`${toRefill} low`}
      tone="orange"
      selected={selected}
      onSelect={onSelect}
    >
      <div className={styles.kpiRow}>
        <p className={styles.kpi} data-sans>
          <span>{toRefill}</span>
          <small>bins to refill</small>
        </p>
        <span className={styles.mono} data-tone="muted">
          run in {Math.max(1, 6 - (Math.floor(minutes) % 6))} min
        </span>
      </div>
      <div className={styles.rack}>
        <svg viewBox="0 0 292 232" aria-hidden="true">
          <defs>
            <DotPatterns id={id} paints={PAINTS} />
          </defs>
          {/* The rack: a low shelf in front, a raised one behind. */}
          <Box id={id} paint="slab" faces={box(RACK, 0.02, 1.0, 4.62, 2.12, -8, 20)} />
          <Box id={id} paint="slab" faces={box(RACK, -0.1, -0.1, 4.2, 0.98, -8, 0)} />
          {order.map((slot) => {
            const p = place(slot.t, slot.k);
            const h = slot.count === 0 ? 0 : Math.max(4, slot.share * 30);
            const paint = slot.low ? 'orange' : slot.paint;
            const on = shown?.key === slot.key;
            return (
              <g
                key={slot.key}
                className={styles.slot}
                data-on={on || undefined}
                onPointerEnter={() => setHover(slot.key)}
                onPointerLeave={() => setHover(null)}
                data-demo={`bin-${slot.key}`}
              >
                <path
                  d={floor(RACK, p.u - 0.03, p.v - 0.03, p.u + 0.89, p.v + 0.89, p.z)}
                  className={styles.slotBase}
                />
                {slot.count === 0 ? (
                  <>
                    <path
                      d={floor(RACK, p.u, p.v, p.u + 0.86, p.v + 0.86, p.z)}
                      className={styles.emptyFloor}
                    />
                    <path
                      d={wireBox(
                        RACK,
                        p.u + 0.04,
                        p.v + 0.04,
                        p.u + 0.82,
                        p.v + 0.82,
                        p.z,
                        p.z + 26,
                      )}
                      className={styles.emptyWire}
                    />
                  </>
                ) : (
                  <Box
                    id={id}
                    paint={paint}
                    faces={box(RACK, p.u + 0.04, p.v + 0.04, p.u + 0.82, p.v + 0.82, p.z, p.z + h)}
                  />
                )}
                <path
                  d={wireBox(RACK, p.u, p.v, p.u + 0.86, p.v + 0.86, p.z, p.z + 38)}
                  className={styles.glass}
                />
              </g>
            );
          })}
          {shown && shownTop && (
            <g>
              <path d={`M${shownTop.x} ${shownTop.y}V52`} className={styles.leaderGray} />
              <circle cx={shownTop.x} cy={shownTop.y} r="3" className={styles.whiteDot} />
            </g>
          )}
        </svg>
        {shown && shownTop && (
          <div
            className={styles.tip}
            data-static
            style={{ left: Math.max(8, Math.min(shownTop.x - 24, 160)) }}
          >
            <b>
              {shown.name} {shown.part} · {shown.count} left
            </b>
            <span>
              {shown.count === 0
                ? 'empty now'
                : `empty in ~${Math.max(1, Math.round(shown.count / shown.perMin))} min`}
            </span>
          </div>
        )}
      </div>
      <ul className={styles.dotLegend} data-square aria-hidden="true">
        <li>
          <i style={{ background: '#4ee4e2' }} />
          Housings
        </li>
        <li>
          <i style={{ background: '#ffd84a' }} />
          Brackets
        </li>
        <li>
          <i style={{ background: '#a66bff' }} />
          Caps
        </li>
        <li>
          <i style={{ background: '#ff8fb8' }} />
          Seals
        </li>
        <li>
          <i style={{ background: '#ff792f' }} />
          Low
        </li>
        <li>
          <i data-k="empty" />
          Empty
        </li>
      </ul>
      <label className={styles.toggleRow}>
        <span>
          <b>Auto-call the tugger</b>
          <small>when a bin drops below 25 %</small>
        </span>
        <input
          type="checkbox"
          role="switch"
          checked={auto}
          onChange={(event) => setAuto(event.target.checked)}
          data-demo="auto-call"
        />
      </label>
    </Card>
  );
}

/* ---------------------------------------------------------------- Milk run */

/** The loop's centre line: a rounded rectangle, walked clockwise from the Store. */
const LOOP = { x0: 29.5, y0: 29.5, x1: 262, y1: 198, r: 34 };
const straight = {
  top: LOOP.x1 - LOOP.x0 - 2 * LOOP.r,
  side: LOOP.y1 - LOOP.y0 - 2 * LOOP.r,
};
const ARC = (Math.PI / 2) * LOOP.r;
const PERIMETER = 2 * straight.top + 2 * straight.side + 4 * ARC;
const START = 78; // the Store, x on the top edge
/** The point at share `s` of the loop, from the Store. */
function loopPoint(s: number) {
  let d = (((s % 1) + 1) % 1) * PERIMETER + (START - LOOP.x0 - LOOP.r);
  const { x0, y0, x1, y1, r } = LOOP;
  const segs: ((t: number) => { x: number; y: number })[] = [
    (t) => ({ x: x0 + r + t, y: y0 }),
    (t) => ({ x: x1 - r + Math.sin(t / r) * r, y: y0 + r - Math.cos(t / r) * r }),
    (t) => ({ x: x1, y: y0 + r + t }),
    (t) => ({ x: x1 - r + Math.cos(t / r) * r, y: y1 - r + Math.sin(t / r) * r }),
    (t) => ({ x: x1 - r - t, y: y1 }),
    (t) => ({ x: x0 + r - Math.sin(t / r) * r, y: y1 - r + Math.cos(t / r) * r }),
    (t) => ({ x: x0, y: y1 - r - t }),
    (t) => ({ x: x0 + r - Math.cos(t / r) * r, y: y0 + r - Math.sin(t / r) * r }),
  ];
  const lengths = [straight.top, ARC, straight.side, ARC, straight.top, ARC, straight.side, ARC];
  d %= PERIMETER;
  for (let i = 0; i < 8; i++) {
    if (d <= lengths[i]) return segs[i](d);
    d -= lengths[i];
  }
  return segs[0](0);
}
/**
 * The stops, by their distance along the loop's centre line from the Store, in px: Cut on the top
 * edge, Weld-1 in the top right bend, Weld-2 where the bottom edge starts, Paint on it, Assy on
 * the left side. The loop is 743.6 px round.
 */
const STOPS = [
  { name: 'Store', d: 0, bins: 0 },
  { name: 'Cut', d: 97, bins: 0 },
  { name: 'Weld-1', d: 193.2, bins: 0 },
  { name: 'Weld-2', d: 357.3, bins: 2 },
  { name: 'Paint', d: 476.3, bins: 1 },
  { name: 'Assy', d: 625.2, bins: 4 },
].map((stop) => ({ ...stop, s: stop.d / PERIMETER }));
/** One loop is 18 minutes of the line, and three minutes of the board's clock. */
const LOOP_TICKS = 180;
/** Where the tugger starts: on the right side, heading down to Weld-2. */
const S0 = 0.36;

export function MilkRunCard({ selected, onSelect, tick }: CardProps) {
  const [skipped, setSkipped] = useState<string[]>([]);
  // Rushing doubles the tugger's pace until the next stop: the extra ticks it gained so far.
  const [bonus, setBonus] = useState(0);
  const [rushFrom, setRushFrom] = useState<number | null>(null);
  const rushing = rushFrom !== null ? tick - rushFrom : 0;
  const travelled = S0 + (tick + bonus + rushing) / LOOP_TICKS;
  const loop = Math.floor(travelled);
  const s = travelled - loop;
  // Minutes to each stop still ahead: the way there, plus a minute at each stop on the way.
  const ahead = STOPS.slice(3)
    .filter((stop) => !skipped.includes(`${loop}:${stop.name}`))
    .map((stop) => ({ ...stop, left: stop.s >= s ? stop.s - s : stop.s + 1 - s }))
    .sort((a, b) => a.left - b.left)
    .map((stop, i) => ({ ...stop, minutes: Math.max(1, Math.round(stop.left * 18 + i + 0.8)) }));
  const next = ahead[0];
  const arrived = rushFrom !== null && (!next || next.left < 0.02);
  useEffect(() => {
    if (!arrived || rushFrom === null) return;
    setBonus((b) => b + (tick - rushFrom));
    setRushFrom(null);
  }, [arrived, rushFrom, tick]);
  const trail = [0.006, 0.018, 0.03].map((k) => loopPoint(s - k));
  const onBoard =
    7 -
    STOPS.slice(3)
      .filter((stop) => stop.s < s)
      .reduce((n, stop) => n + stop.bins, 0);
  const loopPath = `M${START} ${LOOP.y0}H${LOOP.x1 - LOOP.r}A${LOOP.r} ${LOOP.r} 0 0 1 ${LOOP.x1} ${LOOP.y0 + LOOP.r}V${LOOP.y1 - LOOP.r}A${LOOP.r} ${LOOP.r} 0 0 1 ${LOOP.x1 - LOOP.r} ${LOOP.y1}H${LOOP.x0 + LOOP.r}A${LOOP.r} ${LOOP.r} 0 0 1 ${LOOP.x0} ${LOOP.y1 - LOOP.r}V${LOOP.y0 + LOOP.r}A${LOOP.r} ${LOOP.r} 0 0 1 ${LOOP.x0 + LOOP.r} ${LOOP.y0}Z`;
  const stopState = (stop: (typeof STOPS)[number]) =>
    stop.s <= s ? 'done' : stop.name === next?.name ? 'next' : 'ahead';
  return (
    <Card
      node="milkrun"
      title="Milk run"
      sub="Tugger 2 · 18 min loop"
      badge={rushFrom !== null ? 'Rushing' : 'On route'}
      tone={rushFrom !== null ? 'blue' : 'green'}
      selected={selected}
      onSelect={onSelect}
    >
      <div className={styles.route}>
        <svg viewBox="0 0 292 228" aria-hidden="true">
          <path d={loopPath} className={styles.track} />
          <path d={loopPath} className={styles.trackDots} />
          <path
            d={loopPath}
            pathLength="1"
            strokeDasharray={`${s.toFixed(4)} 1`}
            className={styles.trackDone}
          />
          {STOPS.map((stop) => {
            const p = loopPoint(stop.s);
            const state = stopState(stop);
            return stop.name === 'Store' ? (
              <rect
                key={stop.name}
                x={p.x - 4.5}
                y={p.y - 4.5}
                width="9"
                height="9"
                rx="1"
                className={styles.store}
              />
            ) : (
              <g key={stop.name} className={styles.routeStop} data-state={state}>
                {state === 'next' && <circle cx={p.x} cy={p.y} r="11" className={styles.halo} />}
                <circle cx={p.x} cy={p.y} r={state === 'done' ? 5.2 : 5.6} />
              </g>
            );
          })}
          {trail.map((p, i) => (
            <rect
              key={i}
              x="-3.2"
              y="-3.2"
              width="6.4"
              height="6.4"
              className={styles.cart}
              data-k={i}
              style={{ transform: `translate(${p.x}px, ${p.y}px)` }}
            />
          ))}
        </svg>
        <span className={styles.routeLabel} style={{ left: 87.5, top: 50 }}>
          Store
        </span>
        <span className={styles.routeLabel} style={{ left: 174.5, top: 50 }}>
          Cut
        </span>
        <span className={styles.routeLabel} style={{ left: 226, top: 54 }}>
          Weld-1
        </span>
        <span className={styles.routeLabel} data-left style={{ left: 44, top: 114 }}>
          Assy
        </span>
        <span className={styles.routeLabel} style={{ left: 108.5, top: 178 }}>
          Paint
        </span>
        <div className={styles.routeCenter} aria-live="polite">
          <small>Loop {((loop + 2) % 8) + 1} of 8</small>
          <b className={styles.mono}>{onBoard} bins on board</b>
          {next && (
            <span>
              {next.name} <em className={styles.mono}>{next.minutes} min</em>
            </span>
          )}
        </div>
      </div>
      <ol className={styles.stopsList} aria-label="Next stops">
        {ahead.map((stop, i) => (
          <li key={stop.name} data-next={i === 0 || undefined}>
            <i aria-hidden="true" />
            <b>{stop.name}</b>
            <span className={styles.chip}>
              {stop.bins} {stop.bins === 1 ? 'bin' : 'bins'}
            </span>
            <span className={styles.mono}>{stop.minutes} min</span>
          </li>
        ))}
      </ol>
      <div className={styles.actions}>
        <button
          type="button"
          disabled={!next}
          onClick={() => next && setSkipped((list) => [...list, `${loop}:${next.name}`])}
          data-demo="skip"
        >
          Skip stop
        </button>
        <button
          type="button"
          data-primary
          disabled={rushFrom !== null}
          onClick={() => setRushFrom(tick)}
        >
          {rushFrom !== null ? 'Rushing…' : 'Rush delivery'}
        </button>
      </div>
    </Card>
  );
}
```

### thermal-canvas/iso.ts

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

/*
 * Isometric drawing in plain SVG paths, shared by every 3D picture on the board (the bed's bars,
 * the losses waterfall, the hole's histogram, the flow rack's bins).
 *
 * A point on the floor has two coordinates: u runs down and to the right on the screen, v up and
 * to the right, both at 30 degrees. z is height in px. A box is seen from the front, so three of
 * its faces show: the top, the face towards the reader on the left (v at its smallest) and the
 * face towards the reader on the right (u at its largest).
 */

/** tan(30°): how far a step along u or v drops (or climbs) for each px it moves across. */
export const ISO = Math.tan(Math.PI / 6);

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

/** A projection: `px` screen px across per unit of u or v, the floor's origin at `ox`, `oy`. */
export type Frame = { ox: number; oy: number; px: number };

/** Where floor point (u, v), lifted by z px, lands on the screen. */
export function project(frame: Frame, u: number, v: number, z = 0): Pt {
  return {
    x: frame.ox + (u + v) * frame.px,
    y: frame.oy + (u - v) * frame.px * ISO - z,
  };
}

const path = (points: Pt[]) =>
  'M' + points.map((p) => `${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join('L') + 'Z';

export type BoxFaces = { top: string; left: string; right: string; topCenter: Pt };

/** The three faces of a box over floor rectangle u0..u1 × v0..v1, from height z0 up to z1. */
export function box(
  frame: Frame,
  u0: number,
  v0: number,
  u1: number,
  v1: number,
  z0: number,
  z1: number,
): BoxFaces {
  const p = (u: number, v: number, z: number) => project(frame, u, v, z);
  return {
    top: path([p(u0, v0, z1), p(u1, v0, z1), p(u1, v1, z1), p(u0, v1, z1)]),
    left: path([p(u0, v0, z0), p(u1, v0, z0), p(u1, v0, z1), p(u0, v0, z1)]),
    right: path([p(u1, v0, z0), p(u1, v1, z0), p(u1, v1, z1), p(u1, v0, z1)]),
    topCenter: p((u0 + u1) / 2, (v0 + v1) / 2, z1),
  };
}

/** The edges of a box a reader would see through glass: its outline and its three near edges. */
export function wireBox(
  frame: Frame,
  u0: number,
  v0: number,
  u1: number,
  v1: number,
  z0: number,
  z1: number,
): string {
  const p = (u: number, v: number, z: number) => project(frame, u, v, z);
  const line = (a: Pt, b: Pt) =>
    `M${a.x.toFixed(2)} ${a.y.toFixed(2)}L${b.x.toFixed(2)} ${b.y.toFixed(2)}`;
  return [
    path([p(u0, v0, z1), p(u1, v0, z1), p(u1, v1, z1), p(u0, v1, z1)]),
    line(p(u0, v0, z0), p(u0, v0, z1)),
    line(p(u1, v0, z0), p(u1, v0, z1)),
    line(p(u1, v1, z0), p(u1, v1, z1)),
    line(p(u0, v1, z0), p(u0, v1, z1)),
    line(p(u0, v0, z0), p(u1, v0, z0)),
    line(p(u1, v0, z0), p(u1, v1, z0)),
  ].join('');
}

/** A flat outline on the floor (or at height z): the four corners of u0..u1 × v0..v1. */
export function floor(frame: Frame, u0: number, v0: number, u1: number, v1: number, z = 0): string {
  const p = (u: number, v: number) => project(frame, u, v, z);
  return path([p(u0, v0), p(u1, v0), p(u1, v1), p(u0, v1)]);
}

/** The colours of one kind of box: its top, its left face, its right face. */
export type Paint = { top: string; left: string; right: string };

/**
 * The dotted texture of every box face, as SVG pattern ids: one pattern per paint and face, so a
 * face is a single path. `id` prefixes the ids, which must be unique on the page.
 */
export function patternId(id: string, paint: string, face: 'top' | 'left' | 'right') {
  return `${id}-${paint}-${face}`;
}
```

### thermal-canvas/data.ts

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

/*
 * The press bed's temperatures: 48 cells, 8 across (columns A–H, A on the right) by 6 deep (rows
 * 2–7 from the back; row 1 is the bed's clamp strip, which has no sensors), and how they drift
 * while the board is live. All of it is invented.
 *
 * Every cell eases back towards its own resting temperature with a little noise on top, and the
 * hot spot breathes around 224 °C, so the picture moves without ever wandering off. The numbers
 * start from fixed values and a seeded random source, so the server and the browser draw the same
 * first frame.
 */

export const COLS = 8;
export const ROWS = 6;
/** The bed's limit, °C: any cell above it is an alert. */
export const LIMIT = 210;

/** Resting temperatures, °C, by row (r: 0 at the front edge) and column (c: 0 at the left). */
const REST: number[][] = [
  [175, 181, 178, 178, 172, 178, 175, 174],
  [184, 184, 180, 177, 183, 183, 183, 182],
  [183, 184, 182, 179, 190, 195, 194, 187],
  [171, 177, 178, 184, 198, 219, 216, 194],
  [167, 172, 175, 180, 184, 224, 221, 199],
  [166, 171, 171, 181, 185, 212, 207, 190],
];

/** The hot cell: column C, row 3. */
export const HOT = { c: 5, r: 4 };

/** A cell's name: its column letter (A on the right) and row number (2 at the back, after the clamp strip). */
export function cellName(c: number, r: number) {
  return 'ABCDEFGH'[COLS - 1 - c] + (ROWS + 1 - r);
}

/** Which colour band a temperature falls in: under 186, 186–195, 196–207, 208 and up. */
export function band(t: number): 0 | 1 | 2 | 3 {
  return t < 186 ? 0 : t < 196 ? 1 : t < 208 ? 2 : 3;
}
export const BANDS = ['<186', '186', '196', '208+'] as const;
export const BAND_NAMES = ['cool', 'warm', 'hot', 'over the limit'] as const;

/** A small seeded random source (mulberry32), so every run starts the same. */
export function seeded(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** How many samples the charts keep: one per tick. */
export const HISTORY = 40;

export type Bed = {
  /** Temperatures by row, then column. */
  cells: number[][];
  /** The hot cell's temperature, oldest first. */
  hot: number[];
  /** The bed's mean temperature, oldest first. */
  mean: number[];
  /** Ticks since the start, for the hot spot's slow breathing. */
  tick: number;
};

const mean = (cells: number[][]) =>
  cells.reduce((sum, row) => sum + row.reduce((s, t) => s + t, 0), 0) / (ROWS * COLS);

/** The first frame: resting temperatures, and a history that climbs to them. */
export function initialBed(): Bed {
  const random = seeded(7);
  const cells = REST.map((row) => [...row]);
  const restMean = mean(cells);
  const hot: number[] = [];
  const means: number[] = [];
  for (let i = 0; i < HISTORY; i++) {
    const k = i / (HISTORY - 1);
    hot.push(194 + 30 * Math.pow(k, 1.15) + (random() - 0.5) * 3);
    means.push(restMean - 4 * (1 - k) + (random() - 0.5) * 0.8);
  }
  hot[HISTORY - 1] = cells[HOT.r][HOT.c];
  means[HISTORY - 1] = restMean;
  return { cells, hot, mean: means, tick: 0 };
}

/** One tick later: every cell eases to its rest with some noise; the hot spot breathes. */
export function driftBed(bed: Bed, random: () => number): Bed {
  const tick = bed.tick + 1;
  const breath = 2.6 * Math.sin(tick / 5) + 1.2 * Math.sin(tick / 2.3);
  const cells = bed.cells.map((row, r) =>
    row.map((t, c) => {
      const rest = REST[r][c] + (r === HOT.r && c === HOT.c ? breath : 0);
      const noise = (random() - 0.5) * (rest > 195 ? 2.4 : 1.4);
      return Math.round((t + (rest - t) * 0.35 + noise) * 10) / 10;
    }),
  );
  return {
    cells,
    hot: [...bed.hot.slice(1), cells[HOT.r][HOT.c]],
    mean: [...bed.mean.slice(1), mean(cells)],
    tick,
  };
}

/** The hottest cell right now. */
export function hottest(cells: number[][]) {
  let best = { c: 0, r: 0, t: -Infinity };
  cells.forEach((row, r) =>
    row.forEach((t, c) => {
      if (t > best.t) best = { c, r, t };
    }),
  );
  return best;
}
```

### thermal-canvas/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. `visible` alone says whether it is on screen in
 * a visible tab, for work that runs whether or not the demo plays (live data).
 */
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, visible, reduced, playing: visible && !reduced && !taken };
}
```

### thermal-canvas/thermal-canvas.module.css

```css
/* Thermal canvas (thermal-canvas.tsx): a plant board on a stage. The board is laid out at 600 px
   wide and scaled to its container with CSS zoom (set from script), so every size below is in the
   board's own px; under 540 px the narrow board (data-narrow) lays itself out at the real width.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page, or the one its colorScheme prop sets
   (data-color-scheme). The data colours (bands, losses, bins) are the same on both.

   Memory. The stage is one layout boundary; the canvas clips its world, and the world moves by one
   transform. Bars, curves and bins are SVG paths, redrawn once a second at most; the cursors move
   by transforms. */

.root {
  --page: #111312;
  --board: #1e201f;
  --canvas: #262827;
  --grid: rgb(255 255 255 / 0.045);
  --panel: #161817;
  --hud: #0e100f;
  --hud-2: #2d2f2e;
  --tool-on: #3a3c3b;
  --comment: #2c2e2d;
  --card: #1e201f;
  --card-panel: #262827;
  --tile: #1e201f;
  --chip: #303231;
  --seg: #181a19;
  --seg-on: #4d4f4e;
  --plate: #1c1e1d;
  --ink: #ececec;
  --ink-2: #c6c8c7;
  --muted: #8a8c8b;
  --faint: #5c5e5d;
  --line: rgb(255 255 255 / 0.08);
  --edge: rgb(0 0 0 / 0.55);
  --primary: #f0f0f0;
  --primary-ink: #121413;
  --secondary: #2e302f;
  --tip: #171717;
  --tip-ink: #f4f4f4;
  --shadow: rgb(0 0 0 / 0.45);
  --slab-top: #2e302f;
  --slab-left: #252726;
  --slab-right: #1c1e1d;
  --column-left: #323433;
  --column-right: #292b2a;
  --gray-top: #8e908f;
  --gray-left: #6b6d6c;
  --gray-right: #4b4d4c;
  /* The same on both schemes. */
  --blue: #308bff;
  --blue-ink: #4a9dff;
  --blue-ring: #8cc4ff;
  --orange: #ff7a2e;
  --red: #fc5b5e;
  --green: #4ee48d;
  --yellow: #ffd84a;
  --purple: #a66bff;
  --cyan: #4ee4e2;
  --sans: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  --mono: JetBrains, ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
  position: relative;
  container-type: inline-size;
  contain: layout;
  width: 100%;
  min-height: 300px;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  place-items: center;
  padding: clamp(8px, 4cqi, 44px) clamp(8px, 4cqi, 44px);
  box-sizing: border-box;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--page);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 13px;
  line-height: 1.25;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  user-select: none;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --page: light-dark(#e7e8e6, #111312);
    --board: light-dark(#f8f8f6, #1e201f);
    --canvas: light-dark(#efefed, #262827);
    --grid: light-dark(rgb(0 0 0 / 0.055), rgb(255 255 255 / 0.045));
    --panel: light-dark(#ffffff, #161817);
    --hud: light-dark(#ffffff, #0e100f);
    --hud-2: light-dark(#efefed, #2d2f2e);
    --tool-on: light-dark(#e6e6e4, #3a3c3b);
    --comment: light-dark(#ffffff, #2c2e2d);
    --card: light-dark(#ffffff, #1e201f);
    --card-panel: light-dark(#f3f3f1, #262827);
    --tile: light-dark(#ffffff, #1e201f);
    --chip: light-dark(#e8e8e6, #303231);
    --seg: light-dark(#e7e7e5, #181a19);
    --seg-on: light-dark(#ffffff, #4d4f4e);
    --plate: light-dark(#e6e6e3, #1c1e1d);
    --ink: light-dark(#17191a, #ececec);
    --ink-2: light-dark(#3a3c3b, #c6c8c7);
    --muted: light-dark(#76787a, #8a8c8b);
    --faint: light-dark(#a7a9a8, #5c5e5d);
    --line: light-dark(rgb(0 0 0 / 0.09), rgb(255 255 255 / 0.08));
    --edge: light-dark(rgb(0 0 0 / 0.35), rgb(0 0 0 / 0.55));
    --primary: light-dark(#17191a, #f0f0f0);
    --primary-ink: light-dark(#ffffff, #121413);
    --secondary: light-dark(#e6e6e4, #2e302f);
    --shadow: light-dark(rgb(20 24 22 / 0.14), rgb(0 0 0 / 0.45));
    --slab-top: light-dark(#d9d9d6, #2e302f);
    --slab-left: light-dark(#c9c9c6, #252726);
    --slab-right: light-dark(#b4b4b1, #1c1e1d);
    --column-left: light-dark(#d4d5d3, #323433);
    --column-right: light-dark(#bfc0be, #292b2a);
    --gray-top: light-dark(#b9bbba, #8e908f);
    --gray-left: light-dark(#9a9c9b, #6b6d6c);
    --gray-right: light-dark(#7b7d7c, #4b4d4c);
    --blue-ink: light-dark(#1f6fe0, #4a9dff);
    --orange: light-dark(#e8620f, #ff7a2e);
    --red: light-dark(#e23b3f, #fc5b5e);
    --green: light-dark(#18a85a, #4ee48d);
    --yellow: light-dark(#b88a00, #ffd84a);
  }
}
.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 :where(button, input, textarea, output) {
  font: inherit;
  color: inherit;
}
.root :where(h3, h4, p, ul, ol) {
  margin: 0;
  padding: 0;
  font: inherit;
}
.root :where(ul, ol) {
  list-style: none;
}
.root :where(button) {
  border: 0;
  background: none;
  padding: 0;
  cursor: pointer;
}
.root :where(button:disabled) {
  cursor: default;
}
.root :where(svg) {
  display: block;
}
.root :focus-visible {
  outline: 2px solid var(--blue);
  outline-offset: 2px;
}
.mono {
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
}
.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ---------------------------------------------------------------- The board */

.fit {
  width: 100%;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  justify-items: center;
}
.board {
  position: relative;
  width: 600px;
  border-radius: 28px;
  background: var(--board);
  box-shadow:
    0 1px 0 var(--line) inset,
    0 30px 60px -20px var(--shadow);
  padding: 0 4px 4px;
  box-sizing: border-box;
}
.root[data-narrow] .board {
  width: 100%;
  border-radius: 22px;
}
.head {
  position: relative;
  z-index: 3;
  height: 56px;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 8px 0 16px;
}
.crumbs {
  flex: 1;
  min-width: 0;
}
.crumbs ol {
  display: flex;
  gap: 9px;
  white-space: nowrap;
  font-size: 13.5px;
  letter-spacing: -0.01em;
}
.crumbs li {
  overflow: hidden;
  text-overflow: ellipsis;
}
.crumbs li:first-child {
  color: var(--muted);
}
.crumbs li + li::before {
  content: '/';
  margin-right: 9px;
  color: var(--faint);
}
.root[data-narrow] .crumbs li:first-child {
  display: none;
}
.root[data-narrow] .crumbs li + li::before {
  display: none;
}
.tools {
  display: flex;
  gap: 2px;
  padding: 2px;
  height: 32px;
  box-sizing: border-box;
  border-radius: 16px;
  background: var(--hud);
}
.tools button {
  width: 36px;
  height: 28px;
  border-radius: 10px;
  display: grid;
  place-items: center;
  transition: background-color 160ms;
}
.root[data-narrow] .tools button {
  width: 26px;
}
.root[data-narrow] .tools {
  gap: 0;
}
.root[data-narrow] .head {
  gap: 6px;
  padding-left: 14px;
}
.root[data-narrow] .share {
  padding: 0 12px;
}
.tools button[aria-checked='true'] {
  background: var(--tool-on);
}
.tools svg,
.views svg,
.more svg {
  width: 16px;
  height: 16px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.shareWrap {
  position: relative;
}
.share {
  height: 32px;
  padding: 0 14px;
  border-radius: 16px;
  background: var(--primary);
  color: var(--primary-ink);
  font-size: 13.5px;
  font-weight: 500;
}
.root .share {
  color: var(--primary-ink);
}
.popover,
.menu {
  position: absolute;
  z-index: 6;
  border-radius: 14px;
  background: var(--hud);
  box-shadow:
    0 0 0 1px var(--line),
    0 12px 30px var(--shadow);
  padding: 6px;
}
.popover {
  right: 0;
  top: 38px;
  width: 220px;
  display: grid;
  gap: 8px;
  padding: 12px;
}
.popover p {
  display: grid;
  gap: 3px;
}
.popover p span {
  color: var(--muted);
  font-size: 12px;
}
.popover button,
.menu button {
  height: 30px;
  border-radius: 9px;
  padding: 0 10px;
  text-align: left;
}
.popover button {
  background: var(--hud-2);
  text-align: center;
}
.popover button:hover,
.menu button:hover {
  background: var(--hud-2);
}

/* ---------------------------------------------------------------- The canvas */

.canvas {
  position: relative;
  overflow: hidden;
  border-radius: 24px;
  background-color: var(--canvas);
  background-image:
    linear-gradient(var(--grid) 1px, transparent 1px),
    linear-gradient(90deg, var(--grid) 1px, transparent 1px);
  background-size: 24px 24px;
  touch-action: pan-y;
  cursor: default;
  isolation: isolate;
}
.canvas[data-tool='note'],
.canvas[data-tool='widget'],
.canvas[data-tool='frame'] {
  cursor: crosshair;
}
.world {
  position: absolute;
  inset: 0 auto auto 0;
  width: 0;
  height: 0;
  transform-origin: 0 0;
  will-change: transform;
}
.at {
  position: absolute;
}
.wire {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
  opacity: 0;
  transition: opacity 200ms;
  z-index: 1;
}
.wire[data-on] {
  opacity: 1;
}
.wire path {
  fill: none;
  stroke: var(--blue);
  stroke-width: 1.5;
}
.wire circle {
  fill: var(--blue);
}
.thread {
  position: absolute;
  inset: 0;
  width: 1px;
  height: 1px;
  overflow: visible;
  pointer-events: none;
  z-index: 2;
}
.thread path {
  fill: none;
  stroke: var(--muted);
  stroke-width: 1.2;
  stroke-dasharray: 2.4 3.2;
  opacity: 0.8;
}
.pin {
  position: absolute;
  z-index: 3;
  width: 25.5px;
  height: 25.5px;
  box-sizing: border-box;
  border-radius: 13px 13px 13px 3px;
  border: 2px solid #fff;
  background: var(--purple);
  color: #fff;
  font-size: 10.5px;
  font-weight: 500;
  display: grid;
  place-items: center;
  box-shadow: 0 4px 10px rgb(0 0 0 / 0.3);
}
.root .pin {
  color: #fff;
}

/* The selected node's outline and corner handles. */
.frame {
  position: absolute;
  box-sizing: border-box;
  border-radius: 18px;
  background: var(--panel);
  border: 1px solid transparent;
}
.frame[data-selected],
.card[data-selected] {
  border-color: var(--blue);
}
.handles i {
  position: absolute;
  width: 6px;
  height: 6px;
  box-sizing: border-box;
  border: 1.5px solid var(--blue);
  background: #fff;
  border-radius: 1.5px;
  transform: translate(-50%, -50%);
  z-index: 2;
}
.handles i:nth-child(1) {
  left: -0.5px;
  top: -0.5px;
}
.handles i:nth-child(2) {
  left: calc(100% + 0.5px);
  top: -0.5px;
}
.handles i:nth-child(3) {
  left: -0.5px;
  top: calc(100% + 0.5px);
}
.handles i:nth-child(4) {
  left: calc(100% + 0.5px);
  top: calc(100% + 0.5px);
}

/* The node switcher, fixed at the canvas's top left. */
.nodeWrap {
  position: absolute;
  left: 23.5px;
  top: 23.5px;
  z-index: 5;
}
.root[data-narrow] .nodeWrap {
  left: 12px;
  top: 12px;
}
.nodePill {
  height: 41.5px;
  min-width: 188.5px;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 5px 0 12px;
  border-radius: 16px;
  background: var(--blue);
  border: 2px solid var(--blue-ring);
  box-shadow:
    0 0 0 0.5px rgb(48 139 255 / 0.4),
    0 10px 26px rgb(48 139 255 / 0.25);
  font-size: 13.5px;
  font-weight: 500;
  letter-spacing: -0.005em;
  white-space: nowrap;
}
.root .nodePill {
  color: #fff;
}
.nodePill > svg {
  width: 17px;
  height: 17px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linejoin: round;
}
.nodePill > span {
  flex: 1;
  text-align: left;
}
.nodePill i {
  width: 28px;
  height: 28px;
  border-radius: 9px;
  background: rgb(255 255 255 / 0.2);
  display: grid;
  place-items: center;
}
.nodePill i svg {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.nodeList {
  position: absolute;
  top: 48px;
  left: 0;
  width: 210px;
  border-radius: 14px;
  background: var(--hud);
  box-shadow:
    0 0 0 1px var(--line),
    0 12px 30px var(--shadow);
  padding: 5px;
  outline: none;
}
.nodeList li {
  height: 30px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 10px;
  border-radius: 9px;
  cursor: pointer;
}
.nodeList li[data-active] {
  background: var(--hud-2);
}
.nodeList:focus-visible li[data-active] {
  box-shadow: inset 0 0 0 1.5px var(--blue);
}
.nodeList li[aria-selected='true'] {
  color: var(--blue-ink);
}
.nodeList li svg {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The widget's view switch, above its top right corner. */
.views {
  position: absolute;
  z-index: 4;
  display: flex;
  align-items: center;
  height: 35.5px;
  padding: 0 2px 0 3px;
  box-sizing: border-box;
  border-radius: 18px;
  background: var(--hud);
  box-shadow: 0 8px 20px rgb(0 0 0 / 0.18);
}
.views [role='radiogroup'] {
  display: flex;
}
.views [role='radio'] {
  height: 28px;
  min-width: 32px;
  border-radius: 14px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  color: var(--ink-2);
  transition:
    background-color 180ms,
    color 180ms;
}
.views [role='radio']:first-child {
  margin-right: 2.5px;
}
.views [role='radio'][aria-checked='true'] {
  background: var(--blue);
  color: #fff;
  padding: 0 8px 0 9px;
}
.views [role='radio'] span {
  font-size: 12.25px;
  font-weight: 500;
}
.views [role='radio']:first-child:not([aria-checked='true']) {
  padding: 0 8px 0 9px;
}
.views [role='radio'] svg {
  width: 14px;
  height: 14px;
}
.more {
  width: 32px;
  height: 28px;
  display: grid;
  place-items: center;
  color: var(--ink-2);
}
.more svg {
  fill: currentColor;
  stroke: none;
}
.menu {
  top: 40px;
  right: 0;
  width: 170px;
  display: grid;
}

/* ---------------------------------------------------------------- Bed temperature */

.bed {
  position: absolute;
  inset: 0;
}
.bedHead {
  position: absolute;
  left: 15.5px;
  right: 15.5px;
  top: 12px;
  display: flex;
  align-items: center;
  gap: 6px;
  height: 22px;
}
.bedHead h3 {
  flex: 1;
  color: var(--ink-2);
  font-size: 11.5px;
}
.bedHead > span {
  color: var(--muted);
  font-size: 11.5px;
}
.bedIcon {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: var(--green);
  stroke-width: 1.6;
  stroke-linejoin: round;
}
.chart {
  position: absolute;
  inset: 0;
  border-radius: 18px;
}
.chart:focus-visible {
  outline-offset: -3px;
}
.bedSvg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.plate {
  fill: var(--plate);
  stroke: var(--line);
  stroke-width: 0.8;
}
.bar path {
  stroke: var(--edge);
  stroke-width: 0.5;
  stroke-linejoin: round;
}
.bar[data-on] path {
  stroke: #fff;
  stroke-width: 0.9;
}
.box path {
  stroke: var(--edge);
  stroke-width: 0.5;
  stroke-linejoin: round;
}
.hotRing {
  fill: #ff9f6a;
  stroke: #fff;
  stroke-width: 1.6;
  pointer-events: none;
}
.tip {
  position: absolute;
  z-index: 4;
  transform: translate(-50%, -100%);
  display: grid;
  gap: 2px;
  padding: 6px 10px 7px;
  border-radius: 10px;
  background: var(--tip);
  color: var(--tip-ink);
  font-size: 12.5px;
  white-space: nowrap;
  pointer-events: none;
  box-shadow: 0 8px 20px rgb(0 0 0 / 0.3);
}
.tip b {
  font-weight: 500;
}
.tip span {
  color: #b9bbba;
}
.tip[data-static] {
  transform: none;
}
.heat {
  position: absolute;
  left: 30px;
  top: 50px;
  display: grid;
  grid-template-columns: 16px repeat(8, 30px);
  grid-template-rows: 12px repeat(6, 24.5px);
  gap: 3px;
}
.heatLabel {
  display: grid;
  place-items: center;
  color: var(--muted);
  font-family: var(--mono);
  font-size: 10px;
}
.heatCell {
  display: grid;
  place-items: center;
  border-radius: 5px;
  font-family: var(--mono);
  font-size: 10.5px;
  color: rgb(0 0 0 / 0.72);
  background-image: radial-gradient(rgb(0 0 0 / 0.22) 0.45px, transparent 0.6px);
  background-size: 3.4px 3.4px;
  box-shadow: inset 0 0 0 0.5px rgb(0 0 0 / 0.4);
  transition: transform 160ms;
}
.heatCell[data-band='0'] {
  background-color: var(--cyan);
}
.heatCell[data-band='1'] {
  background-color: #4ee48d;
}
.heatCell[data-band='2'] {
  background-color: #ffd84a;
}
.heatCell[data-band='3'] {
  background-color: #ff792f;
}
.heatCell[data-on] {
  box-shadow: 0 0 0 1.5px #fff;
  transform: scale(1.08);
}
.legend {
  position: absolute;
  left: 15.5px;
  right: 15.5px;
  bottom: 6px;
  height: 18px;
  display: flex;
  align-items: center;
  gap: 12px;
  color: var(--muted);
  font-size: 11.5px;
  pointer-events: none;
}
.legend span {
  display: flex;
  align-items: center;
  gap: 6px;
}
.legend i {
  width: 7.5px;
  height: 7.5px;
  border-radius: 2px;
}
.legend .cells {
  margin-left: auto;
  font-family: var(--sans);
}
.gridLine {
  stroke: var(--line);
  stroke-width: 1;
}
.baseLine {
  stroke: color-mix(in srgb, var(--muted) 45%, transparent);
  stroke-width: 1;
}
.axis,
.axisL {
  fill: var(--muted);
  font-family: var(--mono);
  font-size: 10.5px;
}
.axisL {
  font-size: 11.5px;
}
.axisL[data-x] {
  text-anchor: middle;
}
.limitLine {
  stroke: var(--orange);
  stroke-width: 1;
  stroke-dasharray: 3 3;
  opacity: 0.8;
}
.limitText {
  fill: var(--orange);
  font-family: var(--mono);
  font-size: 10px;
}
.meanLine,
.hotLine {
  fill: none;
  stroke-width: 1.8;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.meanLine {
  stroke: var(--cyan);
}
.hotLine {
  stroke: #ff792f;
}
.hotDot {
  fill: #ff792f;
  stroke: #fff;
  stroke-width: 1.4;
}
.meanDot {
  fill: var(--cyan);
  stroke: #fff;
  stroke-width: 1.2;
}
.cross {
  stroke: var(--muted);
  stroke-width: 1;
  stroke-dasharray: 2 2;
}

/* ---------------------------------------------------------------- Hot spot and comment */

.hotCard {
  position: relative;
  height: 112.5px;
  border-radius: 20px;
  background: var(--panel);
}
.hotCard h3 {
  position: absolute;
  left: 11.5px;
  top: 14px;
  color: var(--ink-2);
  font-size: 11.5px;
}
.cellChip {
  position: absolute;
  right: 11.5px;
  top: 11.5px;
  height: 20px;
  padding: 0 7px;
  border-radius: 7px;
  background: color-mix(in srgb, var(--orange) 16%, var(--panel));
  font-family: var(--mono);
  font-size: 11.5px;
}
.root .cellChip {
  color: var(--orange);
}
.hotValue {
  position: absolute;
  left: 11.5px;
  top: 39px;
  display: flex;
  align-items: baseline;
  gap: 0;
  color: var(--orange);
}
.hotValue span {
  font-size: 21px;
  letter-spacing: -0.02em;
}
.hotValue span + span {
  margin-left: 10px;
}
.hotValue small {
  margin-left: 8px;
  color: var(--muted);
  font-size: 11.5px;
}
.spark {
  position: absolute;
  left: 11.5px;
  right: 11.5px;
  top: 72.5px;
  width: calc(100% - 23px);
  height: 25px;
  overflow: visible;
}
.root[data-narrow] .spark {
  width: calc(100% - 23px);
}
.sparkLimit {
  stroke: var(--faint);
  stroke-width: 1;
  stroke-dasharray: 2 2.5;
  vector-effect: non-scaling-stroke;
}
.sparkLine {
  fill: none;
  stroke: #ff792f;
  stroke-width: 1.5;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}
.root:not([data-narrow]) .spark {
  width: 160px;
}
.comment {
  display: grid;
  gap: 6px;
  padding: 9px 12px 10px 11.5px;
  border-radius: 16px;
  background: var(--comment);
  box-shadow: 0 1px 0 var(--line) inset;
}
.comment header {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 11.5px;
}
.comment header b {
  font-weight: 500;
}
.comment header > span:last-child {
  color: var(--muted);
}
.comment > p {
  font-size: 11.5px;
  line-height: 16px;
  color: var(--ink);
  user-select: text;
}
.avatar {
  flex: none;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  font-size: 9.5px;
  font-weight: 500;
  letter-spacing: -0.02em;
  color: #17121f;
}
.avatar[data-size='l'] {
  width: 28px;
  height: 28px;
  font-size: 12px;
}
.avatar[data-tone='purple'] {
  background: var(--purple);
}
.avatar[data-tone='cyan'] {
  background: var(--cyan);
}
.avatar[data-tone='yellow'] {
  background: #ffd84a;
}
.replies {
  display: grid;
  gap: 8px;
  padding: 2px 0 2px;
  border-top: 1px solid var(--line);
  padding-top: 8px;
}
.replies li {
  display: flex;
  gap: 7px;
  font-size: 12px;
}
.replies li > div > span {
  color: var(--muted);
}
.replies li b {
  font-weight: 500;
}
.replies li p {
  margin-top: 2px;
  line-height: 15.5px;
}
.repliesButton {
  justify-self: start;
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 11.5px;
  border-radius: 9px;
}
.root .repliesButton {
  color: var(--blue-ink);
}
.stack {
  display: flex;
}
.stack .avatar {
  width: 18px;
  height: 18px;
  font-size: 8.5px;
  box-shadow: 0 0 0 1.5px var(--comment);
}
.stack .avatar + .avatar {
  margin-left: -4px;
}

/* ---------------------------------------------------------------- The HUD along the bottom */

.zoom {
  position: absolute;
  left: 16px;
  bottom: 16px;
  z-index: 5;
  height: 31px;
  width: 110.5px;
  box-sizing: border-box;
  display: grid;
  grid-template-columns: 1fr 48.5px 1fr;
  align-items: center;
  border-radius: 16px;
  background: var(--hud);
}
.zoom button {
  height: 31px;
  color: var(--muted);
  font-size: 15px;
}
.zoom output {
  height: 23.5px;
  border-radius: 12px;
  background: var(--hud-2);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  white-space: nowrap;
  font-size: 12.5px;
  font-variant-numeric: tabular-nums;
}
.suggest {
  position: absolute;
  left: 140px;
  bottom: 16.5px;
  z-index: 5;
  height: 31.5px;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 4px 0 12px;
  border-radius: 16px;
  background: var(--hud);
  box-shadow: 0 10px 24px rgb(0 0 0 / 0.22);
  font-size: 11.75px;
  white-space: nowrap;
}
.suggest > svg {
  width: 13px;
  height: 13px;
  fill: var(--yellow);
  stroke: var(--yellow);
  stroke-width: 0.6;
  stroke-linejoin: round;
}
.suggest button {
  height: 24px;
  padding: 0 10px;
  border-radius: 12px;
  background: var(--primary);
  font-size: 11.75px;
  display: flex;
  align-items: center;
  gap: 4px;
  transition: background-color 180ms;
}
.root .suggest button {
  color: var(--primary-ink);
}
.root .suggest button[aria-pressed='true'] {
  background: var(--green);
  color: #08240f;
}
.suggest button svg {
  width: 12px;
  height: 12px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.under {
  padding: 8px 0 4px;
}
.under .suggest {
  position: static;
  height: auto;
  min-height: 36px;
  white-space: normal;
  padding: 6px 4px 6px 12px;
  box-shadow: none;
}
.under .suggest span {
  flex: 1;
  font-size: 12.5px;
}
.under .suggest button {
  flex: none;
  height: 28px;
  border-radius: 14px;
}
.minimap {
  position: absolute;
  right: 16px;
  bottom: 12.5px;
  z-index: 5;
  width: 72px;
  height: 47.5px;
  border-radius: 10px;
  background: var(--hud);
}
.minimap svg {
  width: 72px;
  height: 47.5px;
}
.mapCard {
  fill: color-mix(in srgb, var(--muted) 55%, transparent);
}
.mapCard[data-on] {
  fill: var(--blue);
}
.mapPill {
  fill: var(--blue);
}
.mapHot {
  fill: #ffd84a;
}
.mapView {
  fill: rgb(255 255 255 / 0.04);
  stroke: var(--ink);
  stroke-width: 1.2;
}
.mapHit {
  position: absolute;
  border-radius: 2px;
}
.mapHit:hover {
  background: rgb(255 255 255 / 0.1);
}

/* ---------------------------------------------------------------- Teammates' cursors */

.collabs {
  position: absolute;
  inset: 0;
  z-index: 7;
  pointer-events: none;
}
.collab {
  position: absolute;
  left: 0;
  top: 0;
  will-change: transform;
}
.collab svg {
  position: absolute;
  left: -1px;
  top: -1px;
  width: 11px;
  height: 11px;
  fill: currentColor;
  stroke: rgb(0 0 0 / 0.35);
  stroke-width: 0.6;
  transition: transform 120ms;
}
.collab span {
  position: absolute;
  left: 2px;
  top: 3px;
  padding: 2px 8px 3px;
  border-radius: 7px;
  font-size: 12.5px;
  color: #0a1c0f;
  white-space: nowrap;
  transition: transform 120ms;
}
.collab[data-tone='green'] {
  color: #33fb63;
}
.collab[data-tone='green'] span {
  background: #33fb63;
}
.collab[data-tone='yellow'] {
  color: #ffd84a;
}
.collab[data-tone='yellow'] span {
  background: #ffd84a;
  color: #241c00;
}
.collab[data-press] svg {
  transform: scale(0.82);
}
.collab[data-press] span {
  transform: scale(0.94);
}
.collab[data-wander] {
  transform: translate(104px, 330px);
  opacity: 0;
  transition: opacity 400ms;
}
.thread[data-off],
.pin[hidden] {
  display: none;
}
.root[data-playing] .collab[data-wander]:not([data-away]) {
  opacity: 1;
}
.root[data-playing] .collab[data-wander] {
  animation: wander 14s cubic-bezier(0.45, 0.05, 0.3, 1) infinite;
}
.root[data-narrow] .collab[data-wander],
.root[data-narrow] .thread {
  display: none;
}
@keyframes wander {
  0%,
  100% {
    transform: translate(104px, 330px);
  }
  22% {
    transform: translate(176px, 286px);
  }
  40% {
    transform: translate(150px, 300px);
  }
  62% {
    transform: translate(60px, 250px);
  }
  80% {
    transform: translate(96px, 316px);
  }
}

/* ---------------------------------------------------------------- Things dropped with the tools */

.drop {
  position: absolute;
  z-index: 3;
  box-sizing: border-box;
  border-radius: 10px;
}
.drop[data-kind='note'] {
  background: #ffe073;
  box-shadow: 0 10px 24px rgb(0 0 0 / 0.25);
  padding: 8px;
}
.drop[data-kind='note'] textarea {
  width: 100%;
  height: 100%;
  border: 0;
  background: none;
  resize: none;
  color: #2a2100;
  font-size: 12.5px;
  outline: none;
  user-select: text;
}
.root .drop[data-kind='note'] textarea {
  color: #2a2100;
}
.drop[data-kind='widget'] {
  background: var(--panel);
  border: 1px solid var(--line);
  display: flex;
  align-items: center;
  padding: 0 10px;
  font-size: 12.5px;
}
.drop[data-kind='widget'] span {
  display: flex;
  gap: 6px;
  align-items: center;
}
.drop[data-kind='widget'] svg {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: var(--green);
  stroke-width: 1.5;
}
.drop[data-kind='frame'] {
  border: 1.5px dashed var(--blue);
  background: rgb(48 139 255 / 0.06);
}
.drop[data-kind='frame'] > span {
  position: absolute;
  left: 0;
  top: -18px;
  font-size: 11px;
  color: var(--blue-ink);
}
.drop > button {
  position: absolute;
  right: -8px;
  top: -8px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--hud);
  box-shadow: 0 0 0 1px var(--line);
  font-size: 12px;
  line-height: 1;
}

/* ---------------------------------------------------------------- Cards */

.card {
  position: relative;
  width: 300px;
  box-sizing: border-box;
  border-radius: 28px;
  background: var(--card);
  border: 1px solid transparent;
  padding: 0 3px 3px;
  box-shadow: 0 20px 40px -18px var(--shadow);
}
.cardHead {
  height: 63px;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  padding: 18px 12px 0 19px;
  box-sizing: border-box;
}
.cardHead h3 {
  font-size: 14.5px;
  line-height: 18px;
}
.cardHead p {
  margin-top: 1px;
  color: var(--muted);
  font-size: 12.5px;
}
.badge {
  height: 24px;
  margin-top: 3px;
  padding: 0 9px;
  border-radius: 12px;
  display: grid;
  place-items: center;
  font-size: 12.5px;
  white-space: nowrap;
}
.badge[data-tone='red'] {
  color: var(--red);
  background: color-mix(in srgb, var(--red) 16%, var(--card));
}
.badge[data-tone='green'] {
  color: var(--green);
  background: color-mix(in srgb, var(--green) 14%, var(--card));
}
.badge[data-tone='yellow'] {
  color: var(--yellow);
  background: color-mix(in srgb, var(--yellow) 14%, var(--card));
}
.badge[data-tone='orange'] {
  color: var(--orange);
  background: color-mix(in srgb, var(--orange) 16%, var(--card));
}
.badge[data-tone='blue'] {
  color: var(--blue-ink);
  background: color-mix(in srgb, var(--blue) 18%, var(--card));
}
.panel {
  position: relative;
  border-radius: 24px;
  background: var(--card-panel);
  padding: 16px;
}
.kpiRow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 29px;
}
.kpiRow[data-tight] {
  margin-top: 14px;
}
.kpi {
  display: flex;
  align-items: baseline;
  gap: 9px;
}
.kpi span {
  font-size: 24px;
  letter-spacing: -0.02em;
}
.kpi[data-sans] span {
  font-family: var(--sans);
  font-size: 22px;
}
.kpi small {
  color: var(--muted);
  font-size: 12.5px;
}
.kpiRow > .mono {
  font-size: 13px;
}
.root [data-tone='muted'] {
  color: var(--muted);
}
.kpiRow [data-tone='yellow'],
.stop [data-tone='red'] {
  color: var(--yellow);
}
.stop [data-tone='red'] {
  color: var(--red);
}
.root .kpiRow [data-tone='green'].mono,
.stop [data-tone='green'],
.person [data-tone='green'] {
  color: var(--green);
}
.pill {
  height: 23.5px;
  padding: 0 8px;
  border-radius: 12px;
  display: grid;
  place-items: center;
  font-family: var(--mono);
  font-size: 12.5px;
}
.pill[data-tone='green'] {
  color: var(--green);
  background: color-mix(in srgb, var(--green) 14%, var(--card-panel));
}

/* OEE */
.oeeSvg {
  display: block;
  width: 292px;
  height: 242px;
  margin: 0 -16px;
}
.oeeFloor {
  fill: color-mix(in srgb, var(--plate) 70%, #000 30%);
  opacity: 0.6;
}
.lossBar {
  cursor: default;
}
.leader {
  fill: none;
  stroke: var(--muted);
  stroke-width: 1;
  stroke-dasharray: 2 2;
}
.leaderText {
  fill: var(--ink);
  font-family: var(--mono);
  font-size: 13px;
}
.lossLegend {
  display: flex;
  flex-wrap: wrap;
  gap: 0 14px;
  margin-top: 0;
}
.lossLegend button {
  height: 22px;
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 12px;
  border-radius: 6px;
}
.root .lossLegend button {
  color: var(--muted);
}
.lossLegend button b {
  color: var(--ink);
  font-weight: 400;
  margin-left: 1px;
}
.root .lossLegend button[aria-pressed='true'] {
  color: var(--ink);
}
.lossLegend i,
.dotLegend i {
  width: 8px;
  height: 8px;
  border-radius: 2px;
}
.rowLink {
  margin-top: 13px;
  width: 100%;
  height: 51px;
  border-radius: 14px;
  background: var(--tile);
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 14px 0 12px;
  text-align: left;
}
.rowLink span {
  display: grid;
  gap: 1px;
}
.rowLink b {
  font-weight: 400;
  font-size: 12.5px;
}
.rowLink small {
  color: var(--muted);
  font-size: 12.5px;
}
.rowLink svg {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: var(--ink-2);
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Andon */
.stations {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 6.5px;
}
.station {
  position: relative;
  height: 57.5px;
  box-sizing: border-box;
  border-radius: 12px;
  background: var(--tile);
  padding: 13px 0 0 6px;
  display: grid;
  align-content: start;
  gap: 5px;
  font-size: 12.5px;
}
.station i {
  position: absolute;
  left: 6px;
  top: 7.5px;
  width: 12px;
  height: 2.5px;
  border-radius: 2px;
  background: var(--green);
}
.station b {
  font-weight: 400;
}
.station span {
  color: var(--muted);
}
.station[data-state='idle'] i {
  background: var(--faint);
}
.station[data-state='hold'] i {
  background: var(--yellow);
}
.station[data-state='hold'] span {
  color: var(--yellow);
  font-family: var(--sans);
}
.station[data-state='idle'] span {
  font-family: var(--sans);
}
.station[data-state='stop'] {
  background: color-mix(in srgb, var(--red) 13%, var(--tile));
  box-shadow: inset 0 0 0 1.5px var(--red);
}
.station[data-state='stop'] i {
  background: var(--red);
}
.station[data-state='stop'] span {
  color: var(--red);
}
.stop {
  margin-top: 13px;
  border-radius: 18px;
  background: var(--tile);
  padding: 12px 12px 14px;
  display: grid;
  gap: 12px;
}
.stop header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.stop h4 {
  font-size: 14px;
}
.reasons {
  display: flex;
  gap: 6px;
}
.reasons button {
  height: 28px;
  padding: 0 10px;
  border-radius: 12px;
  background: var(--chip);
  font-size: 12.5px;
  transition:
    background-color 160ms,
    color 160ms;
}
.root .reasons button {
  color: var(--muted);
}
.root .reasons button[aria-checked='true'] {
  background: var(--primary);
  color: var(--primary-ink);
}
.person {
  display: flex;
  align-items: center;
  gap: 10px;
}
.person > span:nth-child(2) {
  flex: 1;
  display: grid;
  gap: 1px;
}
.person b {
  font-weight: 400;
  font-size: 14px;
}
.person small {
  color: var(--muted);
  font-size: 12px;
}
.person > .mono {
  font-size: 13px;
}
.page {
  display: flex;
  align-items: center;
  gap: 9px;
  color: var(--muted);
  font-size: 12.5px;
}
.page b {
  color: var(--orange);
  font-weight: 400;
}
.page svg {
  width: 18px;
  height: 18px;
  transform: rotate(-90deg);
}
.pageTrack {
  fill: none;
  stroke: var(--chip);
  stroke-width: 3;
}
.pageArc {
  fill: none;
  stroke: var(--orange);
  stroke-width: 3;
  transition: stroke-dasharray 1s linear;
}
.actions {
  margin-top: 12px;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 8px;
}
.actions button {
  height: 36px;
  border-radius: 14px;
  background: var(--secondary);
  font-size: 13px;
}
.actions button[data-primary] {
  background: var(--primary);
}
.root .actions button[data-primary] {
  color: var(--primary-ink);
}
.actions button[data-wide] {
  grid-column: 1 / -1;
}
.actions button:disabled {
  opacity: 0.55;
}

/* Tightening */
.segmented {
  height: 36px;
  border-radius: 18px;
  background: var(--seg);
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  padding: 4px;
  box-sizing: border-box;
}
.segmented button {
  border-radius: 14px;
  font-size: 12.5px;
  transition:
    background-color 180ms,
    color 180ms;
}
.root .segmented button {
  color: var(--muted);
}
.root .segmented button[aria-selected='true'] {
  background: var(--seg-on);
  color: var(--ink);
}
.torque {
  position: relative;
  margin-top: 0;
}
.torque svg {
  width: 260px;
  height: 186px;
  overflow: visible;
}
.torque .tip {
  left: 30px;
  top: 15px;
}
.window {
  fill: rgb(48 139 255 / 0.16);
  stroke: var(--blue);
  stroke-width: 1;
  stroke-dasharray: 3 2;
}
.okCurve {
  fill: none;
  stroke: var(--muted);
  stroke-width: 1;
  opacity: 0.75;
}
.okCurve[data-on] {
  stroke: var(--ink);
  opacity: 1;
  stroke-width: 1.4;
}
.nokCurve {
  fill: none;
  stroke: #ff792f;
  stroke-width: 1.6;
}
.lastCurve {
  fill: none;
  stroke: var(--blue);
  stroke-width: 2;
}
.lastDot {
  fill: #fff;
  stroke: var(--blue);
  stroke-width: 2.2;
}
.nokRing {
  fill: none;
  stroke: #ff792f;
  stroke-width: 1.6;
}
.nokDot {
  fill: #ff792f;
}
.leaderGray {
  fill: none;
  stroke: var(--muted);
  stroke-width: 1;
  stroke-dasharray: 2 2;
}
.lineLegend,
.dotLegend {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0 11px;
  font-size: 12px;
  color: var(--muted);
}
.lineLegend {
  margin-top: 18px;
}
.lineLegend li,
.dotLegend li {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 22px;
}
.lineLegend i {
  width: 11px;
  height: 2px;
  border-radius: 1px;
  background: var(--ink);
}
.lineLegend i[data-k='last'] {
  background: var(--blue);
}
.lineLegend i[data-k='nok'] {
  background: #ff792f;
}
.lineLegend i[data-k='window'] {
  width: 9px;
  height: 7px;
  background: none;
  border: 1px dashed var(--green);
}

/* True position */
.posSvg {
  display: block;
  width: 292px;
  height: 234px;
  margin: 0 -16px;
}
.posLabel {
  fill: var(--ink-2);
  font-family: var(--mono);
  font-size: 12px;
  text-anchor: middle;
}
.posFloor {
  fill: color-mix(in srgb, var(--card-panel) 70%, #000 30%);
  opacity: 0.55;
}
.posGrid {
  fill: none;
  stroke: var(--line);
  stroke-width: 0.6;
}
.posCircle {
  fill: rgb(255 255 255 / 0.07);
  stroke: color-mix(in srgb, var(--ink) 30%, transparent);
  stroke-width: 0.8;
}
.posAxis {
  fill: none;
  stroke: var(--muted);
  stroke-width: 0.8;
  stroke-dasharray: 2 2.5;
  opacity: 0.6;
}
.posAxisText {
  fill: var(--muted);
  font-family: var(--sans);
  font-size: 12px;
}
.dotLegend {
  margin-top: 4px;
}
.dotLegend i {
  border-radius: 50%;
}
.dotLegend[data-square] i {
  border-radius: 2px;
}
.dotLegend i[data-k='older'] {
  background: var(--gray-left);
}
.dotLegend i[data-k='recent'] {
  background: var(--blue);
}
.dotLegend i[data-k='out'] {
  background: #ff792f;
}
.dotLegend i[data-k='empty'] {
  border: 1px dashed var(--red);
  box-sizing: border-box;
}
.offset {
  margin-top: 10px;
  height: 47px;
  border-radius: 16px;
  background: var(--tile);
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 8px 0 12px;
}
.offset > span {
  display: grid;
  gap: 2px;
}
.offset small {
  color: var(--muted);
  font-size: 12px;
}
.offset b {
  font-weight: 400;
  font-size: 12.5px;
  white-space: pre;
}
.offset button {
  height: 31.5px;
  padding: 0 14px;
  border-radius: 16px;
  background: var(--primary);
  font-size: 13px;
}
.root .offset button {
  color: var(--primary-ink);
}

/* Line-side stock */
.rack {
  position: relative;
  margin: 0 -16px;
}
.rack svg {
  width: 292px;
  height: 232px;
}
.rack .tip {
  top: 8px;
}
.slotBase {
  fill: rgb(0 0 0 / 0.25);
}
.glass {
  fill: none;
  stroke: color-mix(in srgb, var(--ink) 22%, transparent);
  stroke-width: 0.7;
}
.slot[data-on] .glass {
  stroke: color-mix(in srgb, var(--ink) 45%, transparent);
}
.emptyFloor {
  fill: rgb(252 91 94 / 0.2);
}
.emptyWire {
  fill: none;
  stroke: var(--red);
  stroke-width: 0.9;
  stroke-dasharray: 2.5 2;
}
.whiteDot {
  fill: #fff;
}
.toggleRow {
  margin-top: 10px;
  height: 51px;
  border-radius: 16px;
  background: var(--tile);
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 12px;
  cursor: pointer;
}
.toggleRow > span {
  display: grid;
  gap: 1px;
}
.toggleRow b {
  font-weight: 400;
  font-size: 13px;
}
.toggleRow small {
  color: var(--muted);
  font-size: 12px;
}
.toggleRow input {
  appearance: none;
  margin: 0;
  width: 36px;
  height: 20px;
  border-radius: 10px;
  background: var(--chip);
  position: relative;
  cursor: pointer;
  transition: background-color 200ms;
}
.toggleRow input::after {
  content: '';
  position: absolute;
  left: 2.5px;
  top: 2.5px;
  width: 15px;
  height: 15px;
  border-radius: 50%;
  background: #121413;
  transition: transform 200ms;
}
.toggleRow input:checked {
  background: #47e598;
}
.toggleRow input:checked::after {
  transform: translateX(16px);
}
.toggleRow input:not(:checked)::after {
  background: var(--muted);
}

/* Milk run */
.route {
  position: relative;
  height: 212px;
  margin: -16px -16px 0;
}
.route svg {
  width: 292px;
  height: 228px;
}
.track {
  fill: none;
  stroke: color-mix(in srgb, var(--page) 80%, #000 20%);
  stroke-width: 14;
}
.trackDots {
  fill: none;
  stroke: color-mix(in srgb, var(--ink) 28%, transparent);
  stroke-width: 0.8;
  stroke-dasharray: 1 3;
}
.trackDone {
  fill: none;
  stroke: #4ee48d;
  stroke-width: 2.5;
  stroke-linecap: round;
  transition: stroke-dasharray 1s linear;
}
.store {
  fill: #f2f2f2;
}
.routeStop circle {
  fill: var(--card-panel);
  stroke: var(--muted);
  stroke-width: 1.8;
}
.routeStop[data-state='done'] circle {
  fill: #4ee48d;
  stroke: none;
}
.routeStop[data-state='next'] circle {
  stroke: var(--blue);
  stroke-width: 2.4;
}
.routeStop .halo {
  fill: rgb(48 139 255 / 0.25);
  stroke: none;
}
.cart {
  transition: transform 1s linear;
}
.cart[data-k='0'] {
  fill: #fff;
}
.cart[data-k='1'] {
  fill: #ffd84a;
}
.cart[data-k='2'] {
  fill: var(--cyan);
}
.routeLabel {
  position: absolute;
  transform: translate(-50%, -50%);
  color: var(--ink-2);
  font-size: 12.5px;
  white-space: nowrap;
}
.routeLabel[data-left] {
  transform: translate(0, -50%);
  color: var(--muted);
}
.routeCenter {
  position: absolute;
  left: 50%;
  top: 92px;
  transform: translateX(-50%);
  display: grid;
  justify-items: center;
  gap: 2px;
  white-space: nowrap;
}
.routeCenter small {
  color: var(--muted);
  font-size: 12.5px;
}
.routeCenter b {
  font-weight: 400;
  font-size: 13px;
}
.routeCenter > span {
  margin-top: 12px;
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 12.5px;
}
.routeCenter em {
  font-style: normal;
  height: 20px;
  padding: 0 8px;
  border-radius: 10px;
  background: var(--blue);
  color: #fff;
  display: grid;
  place-items: center;
  font-size: 13px;
}
.stopsList {
  margin-top: 6px;
  display: grid;
  gap: 5px;
}
.stopsList li {
  height: 35px;
  border-radius: 14px;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 12px 0 10px;
  font-size: 12.5px;
  color: var(--muted);
}
.stopsList li[data-next] {
  background: var(--tile);
  color: var(--ink);
}
.stopsList li i {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  border: 1.5px solid var(--muted);
  box-sizing: border-box;
}
.stopsList li[data-next] i {
  background: var(--blue);
  border: 0;
}
.stopsList b {
  font-weight: 400;
  min-width: 42px;
}
.stopsList .mono {
  margin-left: auto;
  font-size: 13px;
}
.stopsList li[data-next] .mono {
  color: var(--blue-ink);
}
.chip {
  height: 20px;
  padding: 0 6px;
  border-radius: 8px;
  background: var(--chip);
  display: grid;
  place-items: center;
  color: var(--ink-2);
}

/* ---------------------------------------------------------------- Narrow boards */

.root[data-narrow] .views {
  box-shadow: none;
}

/* ---------------------------------------------------------------- Reduced motion */

.root[data-reduced] *,
.root[data-reduced] *::before,
.root[data-reduced] *::after {
  transition: none !important;
  animation: none !important;
}
```