# Line figure (Notchset): prompt.md (v2.0.0)

- id: `line-figure` · version 2.0.0 · component · free
- category: Data
- build: one set of files for every shadcn style
- install: `npx shadcn@latest add https://notchset.dev/r/line-figure.json`
- npm dependencies: none
- registry dependencies: utils, https://notchset.dev/r/notchset-foundation.json
- docs: https://notchset.dev/components/line-figure
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

A hairline figure in ink at 70%, its construction lines dotted: nested circles converging (a receipt), two cones meeting (an approval), a wireframe cube (contained), or your own shapes. It draws in once on entering view, then holds still; with less motion, or `still` (a small figure), it is simply there..

## Build it
- Stack: React 18.3 or 19, TypeScript, Tailwind CSS v4 utility classes on the foundation's roles, a components.json (imports use its aliases).
- Packages: none beyond React.
- Files: `components/ui/notchset/line-figure.tsx`; shared code: `lib/notchset/physics.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `notchset-foundation`.
- Builds: one set of files, the same for every shadcn style.
- Exports to keep: `LineFigure`, `LINE_FIGURES`, and every exported type.
- CSS: the install adds the notchset foundation (its roles, keyframes and motion switch, reading your shadcn tokens for colour) to your global stylesheet once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Colours: every part reads the foundation's roles (`--ns-canvas`, `--ns-ink`, `--ns-line`, `--ns-signal` …), which read your shadcn tokens (`--background`, `--foreground`, `--border`, `--destructive` …), plus the optional `--success` and `--warning`. Retheme on `:root` or any container; never add Tailwind colour classes inside the component.
- This item reads `--ns-ink`.
- Retune it (on any ancestor) with the foundation's roles and hooks: `--ns-bad`, `--ns-bad-ink`, `--ns-band`, `--ns-band-edge`, `--ns-band-grade`, `--ns-band-optimal`, `--ns-beat`, `--ns-bezel`, `--ns-canvas`, `--ns-cat-1`, `--ns-cat-2`, `--ns-cat-3`, `--ns-cat-4`, `--ns-cat-5`, `--ns-cat-6`, `--ns-check`, `--ns-chip`, `--ns-control`, `--ns-control-pad`, `--ns-control-sm`, `--ns-deep`, `--ns-deep-ink`, `--ns-deep-ink-2`, `--ns-deep-line`, `--ns-dialog`, `--ns-dialog-lg`, `--ns-dialog-sm`, `--ns-ease`, `--ns-ease-in`, `--ns-ease-panel`, `--ns-ease-pop`, `--ns-field`, `--ns-flood-arc`, `--ns-flood-aurora`, `--ns-flood-behind-1`, `--ns-flood-behind-2`, `--ns-flood-bloom`, `--ns-flood-deep`, `--ns-flood-dial-bloom`, `--ns-flood-glass`, `--ns-flood-grain`, `--ns-flood-image`, `--ns-flood-ink`, `--ns-flood-ink-low`, `--ns-flood-light`, `--ns-flood-mid`, `--ns-flood-pale`, `--ns-flood-pane`, `--ns-flood-pane-edge`, `--ns-flood-pill-ink`, `--ns-flood-ribbon`, `--ns-flood-ribbon-core`, `--ns-flood-shadow`, `--ns-flood-sheen`, `--ns-flood-wash`, `--ns-font-body`, `--ns-font-display`, `--ns-font-mono`, `--ns-font-script`, `--ns-glass`, `--ns-glass-blur`, `--ns-glass-shadow`, `--ns-glow`, `--ns-grain`, `--ns-gutter`, `--ns-header`, `--ns-heat`, `--ns-hover`, `--ns-ink`, `--ns-ink-2`, `--ns-ink-3`, `--ns-ink-4`, `--ns-leading`, `--ns-lift`, `--ns-light-1`, `--ns-light-2`, `--ns-light-3`, `--ns-light-spread`, `--ns-light-strength`, `--ns-line`, `--ns-line-soft`, `--ns-lit-edge`, `--ns-menu-dy`, `--ns-menu-row`, `--ns-mesh`, `--ns-mesh-frost`, `--ns-ok`, `--ns-ok-ink`, `--ns-orb-body`, `--ns-orb-cool`, `--ns-orb-core`, `--ns-orb-dawn`, `--ns-orb-deep`, `--ns-orb-edge`, `--ns-orb-grain`, `--ns-orb-ground`, `--ns-orb-halo`, `--ns-orb-inner`, `--ns-orb-key`, `--ns-orb-lift`, `--ns-orb-limb`, `--ns-orb-limb-from`, `--ns-orb-rim`, `--ns-orb-skin-hi`, `--ns-orb-skin-lo`, `--ns-orb-skin-mid`, `--ns-orb-warm`, `--ns-panel`, `--ns-panel-w`, `--ns-prism-edge`, `--ns-prism-glint`, `--ns-prism-sheen`, `--ns-prism-tint`, `--ns-pulse`, `--ns-radius`, `--ns-radius-card`, `--ns-radius-full`, `--ns-radius-lg`, `--ns-radius-sm`, `--ns-radius-xl`, `--ns-raised`, `--ns-range`, `--ns-row`, `--ns-row-compact`, `--ns-segment`, `--ns-selected`, `--ns-series`, `--ns-series-core`, `--ns-series-lilac`, `--ns-series-tint`, `--ns-shadow-dialog`, `--ns-shadow-float`, `--ns-shadow-panel`, `--ns-sheet`, `--ns-sheet-bottom`, `--ns-side`, `--ns-signal`, `--ns-sleep-awake`, `--ns-sleep-deep`, `--ns-sleep-light`, `--ns-sleep-rem`, `--ns-space-1`, `--ns-space-2`, `--ns-space-3`, `--ns-space-4`, `--ns-space-5`, `--ns-space-6`, `--ns-space-8`, `--ns-space-section`, `--ns-stack-on`, `--ns-standing-critical`, `--ns-standing-in`, `--ns-standing-out`, `--ns-state-doing`, `--ns-state-done`, `--ns-state-review`, `--ns-state-todo`, `--ns-switch-h`, `--ns-switch-w`, `--ns-text-body`, `--ns-text-control`, `--ns-text-display`, `--ns-text-figure`, `--ns-text-headline`, `--ns-text-hero`, `--ns-text-lede`, `--ns-text-meta`, `--ns-text-page`, `--ns-text-record`, `--ns-text-small`, `--ns-text-strong`, `--ns-text-sub`, `--ns-text-title`, `--ns-tile-shadow`, `--ns-time-born`, `--ns-time-color`, `--ns-time-dialog`, `--ns-time-menu`, `--ns-time-page`, `--ns-time-panel`, `--ns-time-toast`, `--ns-tint`, `--ns-tint-ink`, `--ns-toast-bottom`, `--ns-toast-left`, `--ns-touch`, `--ns-tracking-display`, `--ns-tracking-headline`, `--ns-warn`, `--ns-warn-ink`, `--ns-wash-clay`, `--ns-wash-dusk`, `--ns-wash-graphite`, `--ns-wash-ink`, `--ns-wash-sage`, `--ns-wash-sand`, `--ns-weight-light`, `--ns-weight-medium`, `--ns-weight-regular`, `--ns-weight-strong`, `--ns-well`.
- Never add Tailwind colour classes inside the component.

```tsx
import { LineFigure } from "@/components/ui/notchset/line-figure";
```

## Parts
- A single export that takes props.

## Match the original
- Read `components/ui/notchset/line-figure.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when

### Not when

## Built from
- Physics (shared helper, ships with it)

## Mistakes
- Recreating the component instead of installing it

## Usage

```tsx
"use client";
import { LineFigure } from "@/components/ui/notchset/line-figure";

export default function LineFigureDemo() {
  return (
    <div className="grid w-full max-w-[720px] grid-cols-3 gap-6 py-4 max-[600px]:grid-cols-1">
      <LineFigure figure="receipt" label="Sources narrowing to one answer" className="mx-auto" />
      <LineFigure figure="approval" label="A request meeting an approval" className="mx-auto" />
      <LineFigure figure="contained" label="A cube, contained" className="mx-auto" />
    </div>
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `figure` | `LineFigureName` | `"receipt"` | one of the figures that ship |
| `shapes` | `LineShape[]` |  | your own figure, on a 200 by 160 grid, instead |
| `label` | `string` |  | what it shows, for a screen reader; without one it is decoration |
| `still` | `boolean` | `false` | drawn as it is, no draw-in, strokes 1px at any size (a small figure beside a title) |

Full docs: https://notchset.dev/components/line-figure

## Customising
- Colours: every part reads the foundation's roles (`--ns-canvas`, `--ns-ink`, `--ns-line`, `--ns-signal` …), which read your shadcn tokens (`--background`, `--foreground`, `--border`, `--destructive` …), plus the optional `--success` and `--warning`. Retheme on `:root` or any container; never add Tailwind colour classes inside the component.
- Dark mode follows the `.dark` class or `data-theme="dark"` on an ancestor (the shadcn and next-themes conventions).
- Update later: Commit or stash local changes first (or copy the files), so the update can be undone. Read https://notchset.dev/r/changelog.json for the new versions: a major version, an entry marked breaking or a merge says what to change. Then re-run the install with `--overwrite`, on a branch. It replaces the component's files, the shared helper files they import, and the item's CSS rules in the global stylesheet, so review the whole diff (components, shared files, CSS), re-apply your own edits, and run the app's typecheck, tests and build.

## Accessibility contract (preserve when editing)

## Install

```bash
npx shadcn@latest add https://notchset.dev/r/line-figure.json
```

Or add the `@notchset` registry to components.json and run `npx shadcn@latest add @notchset/line-figure`. Every components.json style gets the same files.

## Continue the workflow

- [Card](https://notchset.dev/components/card)
- [Hover momentum](https://notchset.dev/components/hover-momentum)
- [Knobs](https://notchset.dev/components/knobs)

## Source

### components/ui/notchset/line-figure.tsx

```tsx
// Notchset line-figure 2.0.0 · https://notchset.dev/changelog
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useLessMotion } from "@/lib/notchset/physics";

/*
 * Line figure: a hairline drawing that means something, in ink at about 70% and 1px, with its construction lines dotted
 * at 0.5px. It draws itself in once when it comes into view (each stroke along its length, about 1.2 s), then holds
 * still: no loop. With less motion it is simply there. Three figures ship (a receipt, an approval, containment); a
 * host draws its own by passing shapes. `still` draws it as it is, with no draw-in, its strokes held at 1px at any
 * size: for a small figure beside a title, where a figure waiting to draw reads as an empty box.
 *
 *   <LineFigure figure="receipt" label="Sources narrowing to one answer" />
 *   <LineFigure figure="approval" still className="w-20" />
 *   <LineFigure shapes={[{ kind: "circle", cx: 100, cy: 80, r: 40 }, { kind: "line", x1: 40, y1: 80, x2: 160, y2: 80, dotted: true }]} />
 */

type Common = { dotted?: boolean | undefined };
export type LineShape =
  | (Common & { kind: "circle"; cx: number; cy: number; r: number })
  | (Common & { kind: "ellipse"; cx: number; cy: number; rx: number; ry: number })
  | (Common & { kind: "line"; x1: number; y1: number; x2: number; y2: number })
  | (Common & { kind: "path"; d: string })
  | { kind: "dot"; cx: number; cy: number };

const dot = (cx: number, cy: number): LineShape => ({ kind: "dot", cx, cy });
const line = (x1: number, y1: number, x2: number, y2: number, dotted = false): LineShape => ({ kind: "line", x1, y1, x2, y2, dotted });

/** The figures that ship, each on a 200 by 160 grid. */
export const LINE_FIGURES = {
  /** nested circles converging on one point: sources narrowing to an answer */
  receipt: [
    line(100, 10, 100, 150, true),
    line(30, 140, 170, 140, true),
    ...[62, 48, 34, 20].map((r): LineShape => ({ kind: "circle", cx: 100, cy: 140 - r, r })),
    dot(100, 140),
  ],
  /** two cones meeting at a point on dotted axes: a request meets an approval */
  approval: [
    line(10, 80, 190, 80, true),
    line(100, 18, 100, 142, true),
    { kind: "ellipse", cx: 38, cy: 80, rx: 11, ry: 40 },
    line(38, 40, 100, 80),
    line(38, 120, 100, 80),
    { kind: "ellipse", cx: 162, cy: 80, rx: 11, ry: 40 },
    line(162, 40, 100, 80),
    line(162, 120, 100, 80),
    dot(100, 80),
  ],
  /** a wireframe cube with dotted diagonals: contained */
  contained: [
    { kind: "path", d: "M60 58 H128 V126 H60 Z" },
    { kind: "path", d: "M88 30 H156 V98 H88 Z" },
    line(60, 58, 88, 30),
    line(128, 58, 156, 30),
    line(128, 126, 156, 98),
    line(60, 126, 88, 98),
    line(60, 58, 156, 98, true),
    line(60, 126, 156, 30, true),
    line(128, 58, 88, 98, true),
    line(128, 126, 88, 30, true),
  ],
} satisfies Record<string, LineShape[]>;
export type LineFigureName = keyof typeof LINE_FIGURES;

type LineFigureProps = {
  /** one of the figures that ship */
  figure?: LineFigureName | undefined;
  /** your own figure, on a 200 by 160 grid, instead */
  shapes?: LineShape[] | undefined;
  /** what it shows, for a screen reader; without one it is decoration */
  label?: string | undefined;
  /** drawn as it is, no draw-in, strokes 1px at any size (a small figure beside a title) */
  still?: boolean | undefined;
  className?: string | undefined;
};

function LineFigure({ figure = "receipt", shapes, label, still = false, className }: LineFigureProps) {
  const ref = React.useRef<SVGSVGElement>(null);
  const less = useLessMotion() || still;
  /* drawn: "rest" until it is seen (the server and a first paint show it whole), then "ready" (hidden), then "drawn" */
  const [phase, setPhase] = React.useState<"rest" | "ready" | "drawn">("rest");
  /* once drawn, the dashes that drew it are taken off (a dashed circle keeps a hairline gap where it starts) */
  React.useEffect(() => {
    if (phase !== "drawn") return;
    const t = setTimeout(() => setPhase("rest"), 1800);
    return () => clearTimeout(t);
  }, [phase]);
  React.useEffect(() => {
    const el = ref.current;
    if (!el || less || typeof IntersectionObserver === "undefined") return;
    const box = el.getBoundingClientRect();
    /* already on screen when it mounts: it stays as it is, nothing redraws in front of you */
    if (box.top < innerHeight && box.bottom > 0) return;
    setPhase("ready");
    let raf = 0;
    const io = new IntersectionObserver((es) => {
      if (!es.some((e) => e.isIntersecting)) return;
      io.disconnect();
      raf = requestAnimationFrame(() => (raf = requestAnimationFrame(() => setPhase("drawn"))));
    }, { threshold: 0.4 });
    io.observe(el);
    return () => (io.disconnect(), cancelAnimationFrame(raf));
  }, [less]);
  const list = shapes ?? LINE_FIGURES[figure];
  /* less motion shows it whole at once, whatever it was waiting to draw */
  const view = less ? "rest" : phase;
  const hidden = view === "ready";
  let i = 0;
  return (
    <svg
      ref={ref}
      data-slot="line-figure"
      data-phase={view}
      viewBox="0 0 200 160"
      role={label ? "img" : undefined}
      aria-label={label}
      aria-hidden={label ? undefined : true}
      className={cn("block h-auto w-full max-w-[240px] text-[color-mix(in_oklab,var(--ns-ink)_70%,transparent)]", className)}
      fill="none"
      stroke="currentColor"
    >
      {list.map((s, k) => {
        if (s.kind === "dot") return <circle key={k} cx={s.cx} cy={s.cy} r={still ? 4 : 2.2} fill="currentColor" stroke="none" style={{ opacity: hidden ? 0 : 1, transition: "opacity 400ms ease 1000ms" }} />;
        const dotted = !!s.dotted;
        const delay = dotted ? 0 : (i++ * 90);
        /* a solid stroke draws along its length; a construction line fades in under it */
        const style: React.CSSProperties = dotted
          ? { opacity: hidden ? 0 : 1, transition: "opacity 600ms ease" }
          : view === "rest"
            ? {}
            : { strokeDasharray: 1, strokeDashoffset: hidden ? 1 : 0, transition: `stroke-dashoffset 1200ms cubic-bezier(0.22, 1, 0.36, 1) ${delay}ms` };
        const props = { strokeWidth: dotted ? (still ? 0.75 : 0.5) : 1, vectorEffect: still ? "non-scaling-stroke" : undefined, strokeDasharray: dotted ? "1 2.5" : undefined, pathLength: dotted || view === "rest" ? undefined : 1, style };
        if (s.kind === "circle") return <circle key={k} cx={s.cx} cy={s.cy} r={s.r} {...props} />;
        if (s.kind === "ellipse") return <ellipse key={k} cx={s.cx} cy={s.cy} rx={s.rx} ry={s.ry} {...props} />;
        if (s.kind === "line") return <line key={k} x1={s.x1} y1={s.y1} x2={s.x2} y2={s.y2} {...props} />;
        return <path key={k} d={s.d} {...props} />;
      })}
    </svg>
  );
}

export { LineFigure, type LineFigureProps };
```

### lib/notchset/physics.ts

```tsx
// Notchset line-figure 2.0.0 · https://notchset.dev/changelog
"use client";
/*
 * Physics: what every showcase piece moves by (SHOWCASE-RULES.md, "Real physics"), so a spring settles the same at 60
 * and at 120 Hz and only one piece on a screen is ever running. Plain hooks and functions, no markup.
 *
 * - Time-based: a frame gets the seconds since the last one (capped, so a hidden tab never lands as one huge step),
 *   and simulations that collide take fixed steps of 1/120 s from an accumulator.
 * - One running loop per viewport: the latest piece that asked and is on screen moves; every other one shows its
 *   still until it is the latest again.
 * - Less motion, from the app (data-motion="reduced") or the device, which always wins: a piece shows its still.
 * - Self-play hands over for good at the first pointer press, key or wheel inside the piece.
 */
import * as React from "react";

/** Less motion, asked by the device or by the app on the root. */
export function lessMotion(): boolean {
  if (typeof document === "undefined") return true;
  return document.documentElement.dataset.motion === "reduced" || (typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches);
}

/** Less motion, kept current as the device's setting or the root's data-motion changes. */
export function useLessMotion(): boolean {
  /* read at once in the browser, so a piece never starts in its still and then jumps; true on a server */
  const [less, setLess] = React.useState(() => lessMotion());
  React.useEffect(() => {
    const read = () => setLess(lessMotion());
    read();
    const q = typeof matchMedia !== "undefined" ? matchMedia("(prefers-reduced-motion: reduce)") : null;
    q?.addEventListener("change", read);
    const mo = typeof MutationObserver !== "undefined" ? new MutationObserver(read) : null;
    mo?.observe(document.documentElement, { attributes: true, attributeFilter: ["data-motion"] });
    return () => {
      q?.removeEventListener("change", read);
      mo?.disconnect();
    };
  }, []);
  return less;
}

/** Whether the element is on screen (without an IntersectionObserver, as in a test, it counts as seen). */
export function useSeen(el: React.RefObject<Element | null>): boolean {
  const [seen, setSeen] = React.useState(typeof IntersectionObserver === "undefined");
  React.useEffect(() => {
    const node = el.current;
    if (!node || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver((es) => setSeen(es.some((e) => e.isIntersecting)), { threshold: 0.15 });
    io.observe(node);
    const vis = () => document.hidden && setSeen(false);
    document.addEventListener("visibilitychange", vis);
    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", vis);
    };
  }, [el]);
  return seen;
}

/* ---------- one running loop per viewport ---------- */
const askers: string[] = [];
const listeners = new Set<() => void>();
let ownerNow: string | null = null;
function publish() {
  ownerNow = askers[askers.length - 1] ?? null;
  for (const l of listeners) l();
}
function subscribe(l: () => void) {
  listeners.add(l);
  return () => listeners.delete(l);
}

/**
 * Whether this piece may run its loop now: it asks while `wants` (on screen, motion allowed, something to play), and
 * the latest that asked runs; when it stops asking, the one before it takes over.
 */
export function useLoopTurn(wants: boolean): boolean {
  const id = React.useId();
  React.useEffect(() => {
    if (!wants) return;
    askers.push(id);
    publish();
    return () => {
      const i = askers.lastIndexOf(id);
      if (i >= 0) askers.splice(i, 1);
      publish();
    };
  }, [id, wants]);
  const owner = React.useSyncExternalStore(subscribe, () => ownerNow, () => null);
  return wants && owner === id;
}

/** Calls `frame(dt, now)` every animation frame while `on`, with dt in seconds (at most 1/20 s); paused in a hidden tab. */
export function useFrame(frame: (dt: number, now: number) => void, on: boolean): void {
  const cb = React.useRef(frame);
  React.useEffect(() => {
    cb.current = frame;
  });
  React.useEffect(() => {
    if (!on) return;
    let raf = 0, last = performance.now();
    const tick = (now: number) => {
      const dt = Math.min(0.05, Math.max(0, (now - last) / 1000));
      last = now;
      if (!document.hidden) cb.current(dt, now);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [on]);
}

/** A damped spring's state: where it is and how fast it is going. */
export type SpringState = { x: number; v: number };

/**
 * One step of a damped spring toward `target` (semi-implicit Euler, stable at a frame's dt): `stiffness` pulls, and
 * `damping` is the share of critical damping (1 settles without overshoot, under 1 overshoots and rings).
 */
export function springStep(s: SpringState, target: number, stiffness: number, damping: number, dt: number): SpringState {
  const c = 2 * Math.sqrt(stiffness) * damping;
  const v = s.v + (stiffness * (target - s.x) - c * s.v) * dt;
  return { x: s.x + v * dt, v };
}

/** Whether a spring has come to rest at its target. */
export function springAtRest(s: SpringState, target: number, eps = 0.0005): boolean {
  return Math.abs(s.x - target) < eps && Math.abs(s.v) < eps * 10;
}

/** Runs `step(h)` in fixed steps of `h` seconds for the time that passed; returns the time left over for next frame. */
export function fixedSteps(carry: number, dt: number, step: (h: number) => void, h = 1 / 120, max = 12): number {
  let acc = carry + dt, n = 0;
  while (acc >= h && n < max) {
    step(h);
    acc -= h;
    n++;
  }
  return n >= max ? 0 : acc;
}

/**
 * Self-play until the first touch: `mine` turns true for good at the first pointer press, key or wheel inside the
 * root, so every queued step can be dropped and the visitor holds it from there. Bind `handOver` to the root's events.
 */
export function useHandover(start = false): [boolean, () => void] {
  const [mine, setMine] = React.useState(start);
  const handOver = React.useCallback(() => setMine(true), []);
  return [mine, handOver];
}

/** A ghost cursor's script: where it is (0–1 of the stage) at each moment (ms), and whether it presses. */
export type GhostKey = { t: number; x: number; y: number; down?: boolean | undefined };

/** Where the ghost is at `t` ms: eased between its keys (a hand's narration, not physics), pressing as its key says. */
export function ghostAt(script: GhostKey[], t: number): { x: number; y: number; down: boolean; done: boolean } {
  const last = script[script.length - 1]!;
  if (t >= last.t) return { x: last.x, y: last.y, down: !!last.down, done: true };
  let i = 0;
  while (i < script.length - 1 && script[i + 1]!.t <= t) i++;
  const a = script[i]!, b = script[i + 1] ?? a;
  const u = b.t === a.t ? 1 : (t - a.t) / (b.t - a.t);
  const e = u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2;
  return { x: a.x + (b.x - a.x) * e, y: a.y + (b.y - a.y) * e, down: !!a.down, done: false };
}

/** A colour role read from the page (for a canvas, which can't read a CSS variable), as an [r, g, b, a] in 0–255. */
export function readColor(el: Element, role: string): [number, number, number, number] {
  const probe = document.createElement("i");
  probe.style.cssText = `position:absolute;visibility:hidden;color:var(${role})`;
  el.appendChild(probe);
  const c = getComputedStyle(probe).color;
  probe.remove();
  /* whatever space the browser serialises it in (rgb, color(srgb …), oklab …), a pixel says it in sRGB */
  try {
    const cv = document.createElement("canvas");
    cv.width = cv.height = 1;
    const g = cv.getContext("2d", { willReadFrequently: true });
    if (g) {
      g.clearRect(0, 0, 1, 1);
      g.fillStyle = c;
      g.fillRect(0, 0, 1, 1);
      const d = g.getImageData(0, 0, 1, 1).data;
      return [d[0]!, d[1]!, d[2]!, d[3]!];
    }
  } catch {}
  const m = c.match(/[\d.]+/g)?.map(Number) ?? [0, 0, 0, 1];
  return [m[0] ?? 0, m[1] ?? 0, m[2] ?? 0, (m[3] ?? 1) * 255];
}

/** Mixes two [r, g, b, a] colours by t (0–1). */
export function mixColor(a: readonly number[], b: readonly number[], t: number): string {
  const k = Math.max(0, Math.min(1, t));
  const ch = (i: number) => a[i]! + (b[i]! - a[i]!) * k;
  return `rgba(${Math.round(ch(0))},${Math.round(ch(1))},${Math.round(ch(2))},${(ch(3) / 255).toFixed(3)})`;
}

/** The width an element is given, kept current (a callback ref, so a replaced element is followed). */
export function useWidth<T extends HTMLElement>(fallback = 520): [React.RefCallback<T>, number] {
  const [el, setEl] = React.useState<T | null>(null);
  const [w, setW] = React.useState(fallback);
  React.useEffect(() => {
    if (!el) return;
    const read = () => setW(Math.max(200, Math.round(el.clientWidth || fallback)));
    read();
    if (typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(read);
    ro.observe(el);
    return () => ro.disconnect();
  }, [el, fallback]);
  return [setEl, w];
}

/**
 * A copy of a simulation's state for drawing: the frame loop changes the live state (a ref) in place, and the piece
 * draws from the copy it last painted, so rendering never reads a ref. Plain objects, arrays and typed arrays.
 */
export function snapshot<T>(x: T): T {
  if (Array.isArray(x)) return x.map(snapshot) as T;
  if (ArrayBuffer.isView(x)) return (x as unknown as Float32Array).slice() as unknown as T;
  if (x && typeof x === "object") {
    const out: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(x)) out[k] = snapshot(v);
    return out as T;
  }
  return x;
}
```

The foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://notchset.dev/r/notchset-foundation.json.
