# Redline

> Figma-style redlines for any layout. Point at an element to see its size, padding and gaps, with lines and labels that glide from one element to the next. Pin one and hold Alt to measure the distance to another.

An animated React component from KeyframeUI, installed as source with the shadcn CLI. Built with React, Tailwind CSS and Motion.

- Docs and live preview: https://keyframeui.com/components/redline
- Tags: Inspector, Hover, Design
- npm dependencies: motion, clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/redline.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/redline
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/redline.json
```

## Usage

The main demo shows typical usage.

### Details

A studio's "we sweat the details" section around a product card. Left alone, it tours the card's padding, the gap between the title and the price, the button's 44px height and the space around the photo. Point at anything to take over, click to pin, and hold Alt to measure between two parts. With `spec`, text shows its font.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/redline-demo
```

```tsx title="components/redline-demo.tsx"
import { Redline } from "@/components/ui/redline";

const photo =
  "https://images.unsplash.com/photo-1507473885765-e6ed057f782c?auto=format&fit=crop&w=480&h=360&q=80";

export function RedlineDemo() {
  return (
    <section className="w-full max-w-4xl text-zinc-900 dark:text-zinc-50">
      <div className="grid gap-x-10 gap-y-3 sm:grid-cols-2 sm:items-end">
        <div>
          <p className="font-mono text-xs tracking-widest text-zinc-500 uppercase">Craft</p>
          <h3 className="mt-2 text-3xl font-semibold tracking-tight text-balance">
            We sweat the details you&apos;ll never notice
          </h3>
        </div>
        <p className="text-sm/6 text-pretty text-zinc-600 dark:text-zinc-400">
          Every space on this card was decided, not defaulted. Point at anything to see its
          numbers, click to pin it, then hold <span className="font-medium">⌥</span> and point at
          another to measure between them.
        </p>
      </div>

      <Redline
        tour={["card", "details", "button", "photo"]}
        spec
        className="mt-8 flex justify-center rounded-2xl border border-zinc-200 bg-zinc-50 bg-[radial-gradient(circle,rgb(0_0_0/0.07)_1px,transparent_1px)] bg-size-[16px_16px] px-6 py-14 dark:border-zinc-800 dark:bg-zinc-900/60 dark:bg-[radial-gradient(circle,rgb(255_255_255/0.06)_1px,transparent_1px)]"
      >
        <article
          data-redline="card"
          className="flex w-64 flex-col gap-4 rounded-2xl bg-white p-3 shadow-[0_1px_2px_rgb(0_0_0/0.05),0_12px_32px_-16px_rgb(0_0_0/0.25)] ring-1 ring-zinc-950/5 dark:bg-zinc-950 dark:ring-white/10"
        >
          {/* eslint-disable-next-line @next/next/no-img-element -- a remote photo in an installable demo */}
          <img
            data-redline="photo"
            src={photo}
            alt="A gray floor lamp against a white wall"
            draggable={false}
            className="aspect-4/3 w-full rounded-lg bg-zinc-100 object-cover dark:bg-zinc-900"
          />
          <div data-redline="details" className="flex flex-col gap-1">
            <h4 data-redline="title" className="text-base/6 font-medium tracking-tight">
              Kessel Floor Lamp
            </h4>
            <p data-redline="price" className="text-sm/5 text-zinc-500 tabular-nums dark:text-zinc-400">
              $240
            </p>
          </div>
          <button
            data-redline="button"
            type="button"
            className="h-11 rounded-lg bg-zinc-900 px-4 text-sm font-medium text-white dark:bg-zinc-50 dark:text-zinc-900"
          >
            Add to cart
          </button>
        </article>
      </Redline>
    </section>
  );
}
```

## Examples

Finished sections built with Redline. Each one installs whole, with the component included.

### Design System

A settings panel where every spacing label reads as a token through `tokens`, and `selector="[data-redline]"` keeps the pointer on named parts. Pin a row, hold Alt and point at another to see the space between them.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/redline-tokens-demo
```

```tsx title="components/redline-tokens-demo.tsx"
import { Redline } from "@/components/ui/redline";

const tokens = {
  4: "space-1",
  8: "space-2",
  12: "space-3",
  16: "space-4",
  24: "space-6",
  32: "space-8",
};

const rows = [
  {
    name: "Comments",
    note: "Replies and mentions in threads you follow",
    on: true,
    icon: "M4 5h16v11H8l-4 4V5Z",
  },
  {
    name: "Reviews",
    note: "When someone asks you to review a file",
    on: true,
    icon: "M5 12.5 9.5 17 19 7",
  },
  {
    name: "Weekly digest",
    note: "A summary of the week, every Monday",
    on: false,
    icon: "M4 6h16M4 12h16M4 18h10",
  },
];

export function RedlineTokensDemo() {
  return (
    <div className="w-full max-w-md text-zinc-900 dark:text-zinc-50">
      <Redline
        tokens={tokens}
        selector="[data-redline]"
        tour={["panel", ["Comments row", "Reviews row"], "header", "footer"]}
        aria-label="Notification settings, with spacing tokens"
        className="rounded-2xl"
      >
        <div
          data-redline="panel"
          className="flex flex-col gap-6 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10"
        >
          <header data-redline="header" className="flex flex-col gap-1">
            <h3 data-redline="heading" className="text-base/6 font-semibold">
              Notifications
            </h3>
            <p data-redline="summary" className="text-sm/5 text-zinc-500 dark:text-zinc-400">
              Choose what reaches you, and when.
            </p>
          </header>

          <ul data-redline="list" className="flex flex-col gap-2">
            {rows.map((row) => (
              <li
                key={row.name}
                data-redline={`${row.name} row`}
                className="flex items-center gap-3 rounded-xl bg-zinc-50 p-4 ring-1 ring-zinc-950/5 dark:bg-zinc-950/50 dark:ring-white/5"
              >
                <span
                  data-redline={`${row.name} icon`}
                  className="grid size-8 shrink-0 place-items-center rounded-lg bg-white ring-1 ring-zinc-950/10 dark:bg-zinc-800 dark:ring-white/10"
                >
                  <svg
                    viewBox="0 0 24 24"
                    aria-hidden
                    className="size-4 fill-none stroke-current stroke-2 text-zinc-500 dark:text-zinc-400"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  >
                    <path d={row.icon} />
                  </svg>
                </span>
                <span className="min-w-0 flex-1">
                  <span className="block text-sm/5 font-medium">{row.name}</span>
                  <span className="block truncate text-xs/4 text-zinc-500 dark:text-zinc-400">
                    {row.note}
                  </span>
                </span>
                <span
                  data-redline={`${row.name} switch`}
                  role="img"
                  aria-label={row.on ? "On" : "Off"}
                  className={
                    row.on
                      ? "flex h-5 w-9 shrink-0 justify-end rounded-full bg-zinc-900 p-0.5 dark:bg-zinc-50"
                      : "flex h-5 w-9 shrink-0 rounded-full bg-zinc-200 p-0.5 dark:bg-zinc-700"
                  }
                >
                  <span
                    className={
                      row.on
                        ? "size-4 rounded-full bg-white shadow-sm dark:bg-zinc-900"
                        : "size-4 rounded-full bg-white shadow-sm dark:bg-zinc-300"
                    }
                  />
                </span>
              </li>
            ))}
          </ul>

          <footer
            data-redline="footer"
            className="flex justify-end gap-2 border-t border-zinc-950/5 pt-6 dark:border-white/10"
          >
            <button
              data-redline="cancel"
              type="button"
              className="h-9 rounded-lg px-4 text-sm font-medium text-zinc-600 ring-1 ring-zinc-950/10 dark:text-zinc-300 dark:ring-white/15"
            >
              Cancel
            </button>
            <button
              data-redline="save"
              type="button"
              className="h-9 rounded-lg bg-zinc-900 px-4 text-sm font-medium text-white dark:bg-zinc-50 dark:text-zinc-900"
            >
              Save
            </button>
          </footer>
        </div>
      </Redline>

      <p className="mt-10 text-center text-xs/5 text-pretty text-zinc-500">
        Click a row to pin it, then hold{" "}
        <kbd className="rounded bg-zinc-100 px-1.5 py-0.5 font-sans text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
          ⌥ Option
        </kbd>{" "}
        and point at another. On a phone, tap two rows.
      </p>
    </div>
  );
}
```

### Dev Handoff

A design tool's handoff mode. `onSelect` fills the panel on the right with the CSS and the Tailwind classes of whatever layer is selected, and it swaps as you move.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/redline-handoff-demo
```

```tsx title="components/redline-handoff-demo.tsx"
"use client";

import * as React from "react";

import { Redline, type RedlineSelection } from "@/components/ui/redline";

const features = ["Unlimited projects", "Version history", "Priority support"];

type Layer = { name: string; size: string; css: [string, string][]; classes: string[] };

let paint: CanvasRenderingContext2D | null | undefined;

/** Computed colors come back as oklch from Tailwind; handoff wants hex. */
function hex(color: string) {
  paint ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
  if (!paint) return color;
  paint.clearRect(0, 0, 1, 1);
  paint.fillStyle = color;
  paint.fillRect(0, 0, 1, 1);
  const [r, g, b, a] = paint.getImageData(0, 0, 1, 1).data;
  if (a === 0) return null;
  const pair = (n: number) => n.toString(16).padStart(2, "0");
  return `#${pair(r)}${pair(g)}${pair(b)}${a < 255 ? pair(a) : ""}`.toUpperCase();
}

const px = (value: number) => (value ? `${value}px` : "0");

/** The CSS a developer needs for one layer, from its measurements and computed style. */
function layerOf(selection: RedlineSelection): Layer {
  const { element, width, height, padding, gap, text } = selection;
  const cs = getComputedStyle(element);
  const css: [string, string][] = [
    ["width", `${width}px`],
    ["height", `${height}px`],
  ];
  if (gap) {
    css.push(["display", cs.display]);
    if (cs.display.endsWith("flex") && cs.flexDirection !== "row") {
      css.push(["flex-direction", cs.flexDirection]);
    }
    if (cs.alignItems !== "normal" && cs.alignItems !== "stretch") css.push(["align-items", cs.alignItems]);
    const both = gap.row === gap.column ? [gap.row] : [gap.row, gap.column];
    if (gap.row || gap.column) css.push(["gap", both.map(px).join(" ")]);
  }
  const { top, right, bottom, left } = padding;
  if (top || right || bottom || left) {
    const sides =
      top === bottom && left === right
        ? top === left
          ? [top]
          : [top, right]
        : [top, right, bottom, left];
    css.push(["padding", sides.map(px).join(" ")]);
  }
  if (parseFloat(cs.borderTopLeftRadius)) css.push(["border-radius", cs.borderTopLeftRadius]);
  const background = hex(cs.backgroundColor);
  if (background) css.push(["background", background]);
  if (text) {
    const leading = text.lineHeight ? `/${text.lineHeight}px` : "";
    css.push(["font", `${text.weight} ${text.size}px${leading} ${text.font}`]);
    if (text.letterSpacing) css.push(["letter-spacing", `${Math.round(text.letterSpacing * 10) / 1000}em`]);
    css.push(["color", text.color]);
  }
  return {
    name: selection.name ?? element.tagName.toLowerCase(),
    size: `${width} × ${height}`,
    css,
    classes: (element.getAttribute("class") ?? "").split(/\s+/).filter(Boolean),
  };
}

export function RedlineHandoffDemo() {
  const [layer, setLayer] = React.useState<Layer | null>(null);
  const [copied, setCopied] = React.useState(false);

  React.useEffect(() => {
    if (!copied) return;
    const timer = window.setTimeout(() => setCopied(false), 1400);
    return () => window.clearTimeout(timer);
  }, [copied]);

  const copy = () => {
    if (!layer) return;
    const text = layer.css.map(([property, value]) => `${property}: ${value};`).join("\n");
    navigator.clipboard?.writeText(text).then(() => setCopied(true), () => {});
  };

  return (
    <div className="w-full max-w-4xl overflow-hidden rounded-2xl border border-zinc-200 bg-white text-zinc-900 shadow-sm dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50">
      <div className="flex h-11 items-center justify-between gap-4 border-b border-zinc-200 px-4 text-xs dark:border-zinc-800">
        <p className="truncate text-zinc-500">
          Pricing <span className="px-1 text-zinc-300 dark:text-zinc-700">/</span>
          <span className="font-medium text-zinc-900 dark:text-zinc-100">Pro plan</span>
        </p>
        <div className="flex shrink-0 rounded-md bg-zinc-100 p-0.5 font-medium dark:bg-zinc-900">
          <span className="px-2.5 py-1 text-zinc-500">Design</span>
          <span className="rounded-[5px] bg-white px-2.5 py-1 shadow-sm dark:bg-zinc-800">Dev Mode</span>
        </div>
      </div>

      <div className="grid md:grid-cols-[minmax(0,1fr)_18rem]">
        <Redline
          tour={["Plan", "Price", "Features", "Button"]}
          selector="[data-redline]"
          onSelect={(selection) => selection && setLayer(layerOf(selection))}
          aria-label="Pro plan card"
          className="flex items-center justify-center bg-zinc-100 bg-[radial-gradient(circle,rgb(0_0_0/0.07)_1px,transparent_1px)] bg-size-[16px_16px] px-6 py-12 dark:bg-zinc-900/60 dark:bg-[radial-gradient(circle,rgb(255_255_255/0.06)_1px,transparent_1px)]"
        >
          <div
            data-redline="Plan"
            className="flex w-64 flex-col gap-6 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-zinc-950/5 dark:bg-zinc-950 dark:ring-white/10"
          >
            <div data-redline="Header" className="flex flex-col gap-2">
              <span
                data-redline="Badge"
                className="w-fit rounded-full bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700 dark:bg-violet-500/15 dark:text-violet-300"
              >
                Pro
              </span>
              <p data-redline="Price" className="text-4xl/10 font-semibold tracking-tight">
                $24
                <span className="text-sm font-normal tracking-normal text-zinc-500"> / month</span>
              </p>
              <p data-redline="Blurb" className="text-sm/5 text-zinc-500 dark:text-zinc-400">
                For teams that ship every week.
              </p>
            </div>
            <ul data-redline="Features" className="flex flex-col gap-2.5 text-sm/5">
              {features.map((feature) => (
                <li key={feature} data-redline={feature} className="flex items-center gap-2">
                  <svg
                    viewBox="0 0 16 16"
                    aria-hidden
                    className="size-4 fill-none stroke-violet-600 stroke-2 dark:stroke-violet-400"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  >
                    <path d="m3.5 8.5 3 3 6-7" />
                  </svg>
                  {feature}
                </li>
              ))}
            </ul>
            <button
              data-redline="Button"
              type="button"
              className="h-11 rounded-[10px] bg-zinc-900 px-4 text-sm font-medium text-white dark:bg-zinc-50 dark:text-zinc-900"
            >
              Start free trial
            </button>
          </div>
        </Redline>

        <aside className="flex min-h-80 flex-col border-t border-zinc-200 text-xs md:border-t-0 md:border-l dark:border-zinc-800">
          {layer ? (
            <>
              <div className="flex items-baseline justify-between gap-3 border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
                <p className="truncate text-sm font-medium">{layer.name}</p>
                <p className="shrink-0 font-mono text-zinc-500 tabular-nums">{layer.size}</p>
              </div>

              <div className="border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
                <div className="flex items-center justify-between">
                  <p className="font-medium text-zinc-500">CSS</p>
                  <button
                    type="button"
                    onClick={copy}
                    className="rounded px-1.5 py-0.5 font-medium text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:outline-none dark:hover:bg-zinc-900 dark:hover:text-zinc-100"
                  >
                    {copied ? "Copied" : "Copy"}
                  </button>
                </div>
                <pre className="mt-2 overflow-x-auto font-mono text-[12px]/5">
                  {layer.css.map(([property, value]) => (
                    <div key={property}>
                      <span className="text-sky-700 dark:text-sky-300">{property}</span>
                      <span className="text-zinc-400">: </span>
                      <span>{value}</span>
                      <span className="text-zinc-400">;</span>
                    </div>
                  ))}
                </pre>
              </div>

              <div className="px-4 py-3">
                <p className="font-medium text-zinc-500">Tailwind</p>
                <div className="mt-2 flex flex-wrap gap-1">
                  {layer.classes.map((name) => (
                    <code
                      key={name}
                      className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-[11px] text-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
                    >
                      {name}
                    </code>
                  ))}
                </div>
              </div>
            </>
          ) : (
            <p className="m-auto px-6 text-center text-zinc-500">
              Point at a layer to see its CSS and Tailwind classes.
            </p>
          )}
        </aside>
      </div>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `React.ReactNode` |  | The layout to measure. Measurements are relative to the `Redline` box and keep up with resizes and with scrolling inside it. |
| `data-redline` | `string` |  | Put it on any element inside to name it, for `tour` steps, for `onSelect`'s `name` and for the announcement when it's selected. |
| `tour` | `(string \| [string, string])[]` |  | Elements to walk through on their own while nobody is pointing, by `data-redline` name, 2.5s each. A pair of names measures between the two. It loops while on screen, stops when the pointer comes in and picks up 2s after it leaves. |
| `tokens` | `Record<number, string>` |  | Names for pixel values, so padding, gap and distance labels read `space-4` instead of `16`: `{ 16: "space-4" }`. Sizes stay in pixels. |
| `spec` | `boolean` | `false` | Show a card beside text elements with their font, size and line height, weight, letter spacing and color. |
| `selector` | `string` | `"*"` | Which elements can be measured. `"[data-redline]"` limits it to named elements, like layers in a design tool; the pointer then picks the nearest named element under it. |
| `onSelect` | `(selection: RedlineSelection \| null) => void` |  | Called when the selected element changes, with the element, its `name`, `width`, `height`, `padding`, flex or grid `gap`, the `spacing` to its neighbors and, for text, its `text` style. Called with `null` when the redlines fade out. |
| `colors` | `{ selection?: string; distance?: string; spacing?: string }` | `{ selection: "#0C8CE9", distance: "#F24822", spacing: "#C026D3" }` | Colors for the selection box and size pill, the distance lines and their labels, and the padding and gap hatching. |
| `className` | `string` |  | Classes for the measured area. It takes keyboard focus: arrow keys walk the tree, Enter pins, Esc clears. |

## Source

```tsx title="components/ui/redline.tsx"
"use client";

import * as React from "react";
import {
  AnimatePresence,
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
  type MotionValue,
} from "motion/react";

import { cn } from "@/lib/utils";

/* ─────────────────────────────────────────────────────────
 * REDLINE STORYBOARD
 *
 *  point    once the pointer settles on an element, a 1px box
 *           with corner handles lands on it, its size in a pill
 *           underneath. Padding is hatched and labeled per side,
 *           gaps between flex and grid children are hatched and
 *           labeled once, and red lines measure the space to the
 *           nearest neighbor or the parent's edge
 *  glide    pointing at another element carries all of it there
 *           on one quick spring: the box resizes on the way, the
 *           lines and hatches follow, and their numbers tick to
 *           the new values. A line that wasn't there before draws
 *           outward from the edge it measures. Sweeping across
 *           nested elements doesn't flicker: the box moves once
 *           the pointer slows or settles
 *  text     with spec, a card beside text gives its font, size
 *           and line height, weight, letter spacing and color
 *  pin      a click pins the element, instead of pressing it.
 *           Hold Alt (Option) and point at another to measure
 *           between the two; when they don't overlap on an axis,
 *           dashed guides carry its edges out to the lines
 *  leave    everything fades out quickly, unless something is
 *           pinned
 *  tour     with nobody pointing, it walks a list of elements on
 *           its own while on screen. The pointer coming in stops
 *           it, and it picks up again 2s after the pointer leaves
 *  keys     ↑ parent, ↓ first child, ←/→ siblings. Enter pins;
 *           moving on then measures between the two. Esc clears
 *  touch    a tap selects, a tap on another element measures
 *           between them, a tap outside clears
 *  reduced  no glides or drawing: everything moves at once
 * ───────────────────────────────────────────────────────── */

const REDLINE = {
  glide: { type: "spring", visualDuration: 0.2, bounce: 0 },
  draw: { duration: 0.3, ease: [0.22, 1, 0.36, 1] }, // a line drawing outward the first time
  fade: { duration: 0.12, ease: "easeOut" },
  dwell: 90, // ms the pointer rests on an element before the box moves to it
  slow: 0.3, // px/ms; a pointer moving slower than this moves the box at once
  step: 2500, // ms per tour stop
  resume: 2000, // ms after the pointer leaves before the tour picks up again
  pill: 24, // px a size pill needs below or above its box
  card: 12, // px between a box and the spec card beside it
} as const;

const COLORS = {
  selection: "#0C8CE9",
  distance: "#F24822",
  spacing: "#C026D3",
};

const PILL =
  "rounded-[3px] px-1 py-px text-[11px]/[14px] font-medium whitespace-nowrap text-white tabular-nums";

const HATCH =
  "repeating-linear-gradient(-45deg, color-mix(in srgb, var(--redline-space) 34%, transparent) 0 1px, transparent 1px 5px), linear-gradient(color-mix(in srgb, var(--redline-space) 6%, transparent), color-mix(in srgb, var(--redline-space) 6%, transparent))";

// Each line is revealed from the edge it measures, outward.
const DRAW_FROM = {
  top: "inset(100% 0% 0% 0%)",
  right: "inset(0% 100% 0% 0%)",
  bottom: "inset(0% 0% 100% 0%)",
  left: "inset(0% 0% 0% 100%)",
} as const;
const DRAWN = "inset(0% 0% 0% 0%)";

type Side = "top" | "right" | "bottom" | "left";
const SIDES: Side[] = ["top", "right", "bottom", "left"];

type Rect = { x: number; y: number; w: number; h: number };
/** A hatched area: padding or a gap. `axis` is the dimension its value measures. */
type Strip = {
  key: string;
  rect: Rect;
  value: number;
  axis: "x" | "y";
  label: boolean;
  /** A column gap too narrow for its label puts it above. */
  above?: boolean;
};
/**
 * A distance line, starting at (x, y) and running `length` down (top, bottom) or across (left, right).
 * `span` is the range it can slide along to keep clear of other labels; `edge` means it ends at the
 * parent's edge. Its label sits beside it (`side`), on the other side (`flip`), or past its outer end.
 */
type Line = {
  key: string;
  side: Side;
  x: number;
  y: number;
  length: number;
  span: [number, number];
  edge: boolean;
  tag: "side" | "flip" | "end";
};
type Guide = { key: string; axis: "x" | "y"; x: number; y: number; length: number };

type Scene = {
  main: Rect;
  other: Rect | null;
  strips: Strip[];
  lines: Line[];
  guides: Guide[];
  text: RedlineText | null;
  pill: "below" | "above" | "inside";
  /** Where the text spec card goes, and how far from the box. */
  card: { side: "right" | "left" | "below"; offset: number } | null;
};

type Point = { el: Element; via: "mouse" | "key" | "touch" | "tour"; to?: Element | null };

type Tokens = Record<number, string>;

/** The font of a text element, as `RedlineSelection.text` reports it. */
export interface RedlineText {
  /** The first family in the font stack, e.g. "Inter". */
  font: string;
  size: number;
  /** In px, or null for `line-height: normal`. */
  lineHeight: number | null;
  weight: number;
  /** As a percentage of the font size, the way Figma shows it: -0.16px on 16px text is -1. */
  letterSpacing: number;
  /** Hex, with an alpha pair when it isn't opaque. */
  color: string;
}

/** What `onSelect` reports about the selected element. Sizes are CSS pixels. */
export interface RedlineSelection {
  element: Element;
  /** The element's `data-redline` name, if it has one. */
  name: string | null;
  width: number;
  height: number;
  padding: Record<Side, number>;
  /** The row and column gap of a flex or grid container, otherwise null. */
  gap: { row: number; column: number } | null;
  /** Space to the nearest neighbor, or to the parent's edge, on each side that has any. */
  spacing: Partial<Record<Side, number>>;
  /** The font, for elements with text of their own; otherwise null. */
  text: RedlineText | null;
}

/** A tour stop: one element's `data-redline` name, or two names to measure between. */
export type RedlineTourStep = string | [string, string];

/* ── Formatting ─────────────────────────────────────────── */

/** Whole pixels, or one decimal when the layout isn't on whole pixels. */
const px = (value: number) => {
  const rounded = Math.round(value * 10) / 10;
  return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
};

const named = (value: number, tokens?: Tokens) => tokens?.[Number(px(value))] ?? px(value);

const percent = (value: number) => {
  const rounded = Math.round(value * 10) / 10;
  return rounded === 0 ? "0%" : `${rounded < 0 ? "−" : ""}${Math.abs(rounded)}%`;
};

let paint: CanvasRenderingContext2D | null | undefined;
const hexes = new Map<string, string>();

/** Any CSS color (Tailwind's oklch included) as hex. */
function hexOf(color: string) {
  const known = hexes.get(color);
  if (known) return known;
  paint ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
  if (!paint) return color;
  paint.clearRect(0, 0, 1, 1);
  paint.fillStyle = color;
  paint.fillRect(0, 0, 1, 1);
  const [r, g, b, a] = paint.getImageData(0, 0, 1, 1).data;
  const pair = (n: number) => n.toString(16).padStart(2, "0");
  const hex = `#${pair(r)}${pair(g)}${pair(b)}${a < 255 ? pair(a) : ""}`.toUpperCase();
  hexes.set(color, hex);
  return hex;
}

function fontOf(family: string) {
  const first = family.split(",")[0].trim().replace(/^["']|["']$/g, "");
  const hashed = /^__(.+?)_[\da-f]{5,}$/i.exec(first); // next/font's "__Inter_a1b2c3"
  return (hashed ? hashed[1].replace(/_/g, " ") : first) || "sans-serif";
}

const specOf = (text: RedlineText) => [
  text.font,
  `${px(text.size)}/${text.lineHeight == null ? "Auto" : px(text.lineHeight)}`,
  String(text.weight),
  percent(text.letterSpacing),
  text.color,
];

function nameOf(el: Element) {
  const label =
    el.getAttribute("data-redline") ?? el.getAttribute("aria-label") ?? el.getAttribute("alt");
  if (label) return label;
  const text = el.textContent?.trim().replace(/\s+/g, " ");
  if (text) return text.length > 32 ? `${text.slice(0, 31)}…` : text;
  return el.tagName.toLowerCase();
}

/* ── Measuring ──────────────────────────────────────────── */

const right = (r: Rect) => r.x + r.w;
const bottom = (r: Rect) => r.y + r.h;
const overlap = (a0: number, a1: number, b0: number, b1: number) =>
  Math.min(a1, b1) - Math.max(a0, b0);

/**
 * The root's padding box in the viewport, so rects can be measured in its own coordinates.
 * `clips` when it scrolls or hides overflow, so labels have to stay inside it.
 */
type Frame = {
  left: number;
  top: number;
  scale: number;
  x: number;
  y: number;
  width: number;
  height: number;
  clips: boolean;
};

function frameOf(root: HTMLElement): Frame {
  const r = root.getBoundingClientRect();
  const scale = root.offsetWidth > 0 ? r.width / root.offsetWidth || 1 : 1;
  return {
    left: r.left + root.clientLeft * scale,
    top: r.top + root.clientTop * scale,
    scale,
    x: root.scrollLeft,
    y: root.scrollTop,
    width: root.clientWidth,
    height: root.clientHeight,
    clips: getComputedStyle(root).overflow !== "visible",
  };
}

function rectOf(frame: Frame, el: Element): Rect {
  const r = el.getBoundingClientRect();
  return {
    x: (r.left - frame.left) / frame.scale + frame.x,
    y: (r.top - frame.top) / frame.scale + frame.y,
    w: r.width / frame.scale,
    h: r.height / frame.scale,
  };
}

const sides = (cs: CSSStyleDeclaration, name: (side: Side) => string) =>
  SIDES.map((side) => parseFloat(cs.getPropertyValue(name(side))) || 0) as [
    number,
    number,
    number,
    number,
  ];

const inset = (r: Rect, [t, ri, b, l]: number[]): Rect => ({
  x: r.x + l,
  y: r.y + t,
  w: Math.max(0, r.w - l - ri),
  h: Math.max(0, r.h - t - b),
});

/** Whether an element can be measured: inside the root, matching the selector, and not empty. */
function isPart(el: Element, root: HTMLElement, selector: string) {
  if (el === root || el.closest("[data-redline-ui]") || !el.matches(selector)) return false;
  const r = el.getBoundingClientRect();
  return r.width > 0 || r.height > 0;
}

/** The measurable element at a pointer target. An icon counts as one piece, not its paths. */
function partAt(target: EventTarget | null, root: HTMLElement, selector: string) {
  let el = target instanceof Element ? target : null;
  if (el instanceof SVGElement) {
    let svg = el instanceof SVGSVGElement ? el : el.ownerSVGElement;
    while (svg?.ownerSVGElement) svg = svg.ownerSVGElement;
    el = svg ?? el;
  }
  for (; el && el !== root && root.contains(el); el = el.parentElement) {
    if (isPart(el, root, selector)) return el;
  }
  return null;
}

function parentPart(el: Element, root: HTMLElement, selector: string) {
  for (let p = el.parentElement; p && p !== root; p = p.parentElement) {
    if (isPart(p, root, selector)) return p;
  }
  return null;
}

/** The nearest measurable descendants: through wrappers that don't match, never into an icon. */
function childParts(of: Element, root: HTMLElement, selector: string) {
  const parts: Element[] = [];
  const walk = (node: Element) => {
    for (const child of Array.from(node.children)) {
      if (child.hasAttribute("data-redline-ui")) continue;
      if (isPart(child, root, selector)) parts.push(child);
      else if (!(child instanceof SVGElement)) walk(child);
    }
  };
  walk(of);
  return parts;
}

/** A flex container's items, in order, without absolutely positioned or hidden ones. */
function flexItems(el: Element): Element[] {
  return Array.from(el.children).flatMap((child) => {
    if (child.hasAttribute("data-redline-ui")) return [];
    const cs = getComputedStyle(child);
    if (cs.display === "contents") return flexItems(child);
    if (cs.display === "none" || cs.position === "absolute" || cs.position === "fixed") return [];
    return [child];
  });
}

function gapsOf(frame: Frame, el: Element, cs: CSSStyleDeclaration, content: Rect): Strip[] {
  if (cs.display.endsWith("grid")) {
    const strips: Strip[] = [];
    const lanes = (tracks: string, gap: number, start: number, room: number, align: string, axis: "x" | "y") => {
      const sizes = Array.from(tracks.matchAll(/(-?[\d.]+)px/g), (m) => parseFloat(m[1]));
      if (sizes.length < 2 || !(gap >= 0.5)) return;
      const total = sizes.reduce((sum, size) => sum + size, 0) + gap * (sizes.length - 1);
      let at = start + (align.includes("center") ? (room - total) / 2 : align.includes("end") ? room - total : 0);
      for (let i = 0; i < sizes.length - 1; i++) {
        at += sizes[i];
        strips.push({
          key: `${axis === "x" ? "col" : "row"}-${i}`,
          rect:
            axis === "x"
              ? { x: at, y: content.y, w: gap, h: content.h }
              : { x: content.x, y: at, w: content.w, h: gap },
          value: gap,
          axis,
          label: i === 0,
        });
        at += gap;
      }
    };
    lanes(cs.gridTemplateColumns, parseFloat(cs.columnGap), content.x, content.w, cs.justifyContent, "x");
    lanes(cs.gridTemplateRows, parseFloat(cs.rowGap), content.y, content.h, cs.alignContent, "y");
    return strips;
  }

  if (!cs.display.endsWith("flex")) return [];
  const row = cs.flexDirection.startsWith("row");
  const wraps = cs.flexWrap !== "nowrap";
  const items = flexItems(el)
    .map((item) => rectOf(frame, item))
    .filter((r) => r.w > 0 || r.h > 0);
  const strips: Strip[] = [];
  for (let i = 1; i < items.length; i++) {
    const a = items[i - 1];
    const b = items[i];
    // Main axis start and end, then cross axis start and end.
    const span = (r: Rect) =>
      row ? [r.x, right(r), r.y, bottom(r)] : [r.y, bottom(r), r.x, right(r)];
    const [a0, a1, ac0, ac1] = span(a);
    const [b0, b1, bc0, bc1] = span(b);
    if (overlap(ac0, ac1, bc0, bc1) <= 0) continue; // a new flex line
    const [from, to] = b0 >= a1 - 0.5 ? [a1, b0] : a0 >= b1 - 0.5 ? [b1, a0] : [0, 0];
    if (to - from < 0.5) continue;
    const c0 = wraps ? Math.min(ac0, bc0) : row ? content.y : content.x;
    const c1 = wraps ? Math.max(ac1, bc1) : row ? bottom(content) : right(content);
    strips.push({
      key: `gap-${strips.length}`,
      rect: row ? { x: from, y: c0, w: to - from, h: c1 - c0 } : { x: c0, y: from, w: c1 - c0, h: to - from },
      value: to - from,
      axis: row ? "x" : "y",
      label: true,
    });
  }
  // Equal gaps are labeled once.
  const even = strips.every((s) => Math.abs(s.value - strips[0].value) < 0.05);
  return strips.map((s, i) => ({ ...s, label: !even || i === 0 }));
}

/** Space from each side of a box to its nearest neighbor across from it, or else the parent's edge. */
function spaceAround(box: Rect, siblings: Rect[], within: Rect): Line[] {
  const lines: Line[] = [];
  for (const side of SIDES) {
    const vertical = side === "top" || side === "bottom";
    const before = side === "top" || side === "left";
    const lo = (r: Rect) => (vertical ? r.y : r.x);
    const hi = (r: Rect) => (vertical ? bottom(r) : right(r));
    const across = (r: Rect): [number, number] => (vertical ? [r.x, right(r)] : [r.y, bottom(r)]);
    const [b0, b1] = across(box);
    let edge = before ? lo(within) : hi(within);
    let span: [number, number] = [b0, b1];
    let neighbor = false;
    for (const s of siblings) {
      const [s0, s1] = across(s);
      const shared: [number, number] = [Math.max(b0, s0), Math.min(b1, s1)];
      if (shared[1] - shared[0] < 0.5) continue;
      const nearer = before
        ? hi(s) <= lo(box) + 0.5 && hi(s) > edge
        : lo(s) >= hi(box) - 0.5 && lo(s) < edge;
      if (!nearer) continue;
      edge = before ? hi(s) : lo(s);
      span = shared;
      neighbor = true;
    }
    const from = before ? edge : hi(box);
    const length = before ? lo(box) - edge : edge - hi(box);
    if (length < 0.5) continue;
    const at = (span[0] + span[1]) / 2;
    lines.push({
      key: side,
      side,
      x: vertical ? at : from,
      y: vertical ? from : at,
      length,
      span,
      edge: !neighbor,
      tag: "side",
    });
  }
  return lines;
}

/** Figma's Alt measurement from a (pinned) to b. Guides are added once the lines are placed. */
function between(a: Rect, b: Rect): Line[] {
  const lines: Line[] = [];
  const ox = overlap(a.x, right(a), b.x, right(b));
  const oy = overlap(a.y, bottom(a), b.y, bottom(b));
  // Lines run through the shared range, or from a's middle when there's none.
  const spanX: [number, number] = ox > 0 ? [Math.max(a.x, b.x), Math.min(right(a), right(b))] : [a.x, right(a)];
  const spanY: [number, number] = oy > 0 ? [Math.max(a.y, b.y), Math.min(bottom(a), bottom(b))] : [a.y, bottom(a)];
  const add = (side: Side, from: number, to: number) => {
    const length = to - from;
    if (length < 0.5) return;
    const vertical = side === "top" || side === "bottom";
    const span = vertical ? spanX : spanY;
    const at = (span[0] + span[1]) / 2;
    lines.push({
      key: `m-${side}`,
      side,
      x: vertical ? at : from,
      y: vertical ? from : at,
      length,
      span,
      edge: false,
      tag: "side",
    });
  };

  if (ox > 0 && oy > 0) {
    // Overlapping or nested: the space between matching edges.
    add("left", Math.min(a.x, b.x), Math.max(a.x, b.x));
    add("right", Math.min(right(a), right(b)), Math.max(right(a), right(b)));
    add("top", Math.min(a.y, b.y), Math.max(a.y, b.y));
    add("bottom", Math.min(bottom(a), bottom(b)), Math.max(bottom(a), bottom(b)));
    return lines;
  }
  if (b.x >= right(a)) add("right", right(a), b.x);
  else if (right(b) <= a.x) add("left", right(b), a.x);
  if (b.y >= bottom(a)) add("bottom", bottom(a), b.y);
  else if (bottom(b) <= a.y) add("top", bottom(b), a.y);
  return lines;
}

/** Where a line misses b on the other axis, a dashed guide carries b's edge out to it. */
function guidesTo(b: Rect, lines: Line[]): Guide[] {
  return lines.flatMap((line): Guide[] => {
    const vertical = line.side === "top" || line.side === "bottom";
    const at = vertical ? line.x : line.y;
    const [b0, b1] = vertical ? [b.x, right(b)] : [b.y, bottom(b)];
    if (at >= b0 - 0.5 && at <= b1 + 0.5) return [];
    const end = line.side === "right" || line.side === "bottom" ? (vertical ? line.y : line.x) + line.length : vertical ? line.y : line.x;
    const near = at < b0 ? b0 : b1;
    const from = Math.min(at, near);
    const length = Math.abs(near - at);
    return [
      vertical
        ? { key: `g-${line.side}`, axis: "x", x: from, y: end, length }
        : { key: `g-${line.side}`, axis: "y", x: end, y: from, length },
    ];
  });
}

const labelWidth = (text: string) => text.length * 6.5 + 8;

/**
 * Keep labels off each other: a line slides along its span to clear the padding label on its
 * side and the size pill, and a short line ending at the parent's edge puts its label past it.
 */
function placeLines(lines: Line[], box: Rect, strips: Strip[], pill: Scene["pill"], tokens?: Tokens) {
  const pillHalf = labelWidth(`${px(box.w)} × ${px(box.h)}`) / 2 + 6;
  return lines.map((line): Line => {
    const vertical = line.side === "top" || line.side === "bottom";
    const center = vertical ? box.x + box.w / 2 : box.y + box.h / 2;
    let keep = strips.some((strip) => strip.key === `pad-${line.side}` && strip.label)
      ? vertical
        ? 16
        : 12
      : 0;
    if ((line.side === "bottom" && pill === "below") || (line.side === "top" && pill === "above")) {
      keep = Math.max(keep, pillHalf);
    }
    let at = vertical ? line.x : line.y;
    let tag: Line["tag"] = "side";
    const [s0, s1] = line.span;
    if (keep && Math.abs(at - center) < keep) {
      if (center + keep <= s1 - 1) at = center + keep;
      else if (center - keep >= s0 + 1) {
        at = center - keep;
        tag = "flip";
      }
    }
    if (!vertical && line.edge && line.length < labelWidth(named(line.length, tokens)) + 12) tag = "end";
    return vertical ? { ...line, x: at, tag } : { ...line, y: at, tag };
  });
}

function textOf(el: Element, cs: CSSStyleDeclaration): RedlineText | null {
  const own = Array.from(el.childNodes).some(
    (node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim(),
  );
  if (!own) return null;
  const size = parseFloat(cs.fontSize) || 0;
  const tracking = cs.letterSpacing === "normal" ? 0 : parseFloat(cs.letterSpacing) || 0;
  return {
    font: fontOf(cs.fontFamily),
    size,
    lineHeight: cs.lineHeight === "normal" ? null : parseFloat(cs.lineHeight),
    weight: Number(cs.fontWeight) || 400,
    letterSpacing: size ? (tracking / size) * 100 : 0,
    color: hexOf(cs.color),
  };
}

/** Everything about one element: its box, padding, gaps and the space around it. */
function inspect(root: HTMLElement, frame: Frame, el: Element, selector: string) {
  const box = rectOf(frame, el);
  const cs = getComputedStyle(el);
  const border = sides(cs, (side) => `border-${side}-width`);
  const [pt, pr, pb, pl] = sides(cs, (side) => `padding-${side}`);
  const inner = inset(box, border);
  const content = inset(inner, [pt, pr, pb, pl]);

  // Top and bottom padding run the full width; left and right fit between them.
  const pads: [Side, Rect, number][] = [
    ["top", { ...inner, h: pt }, pt],
    ["right", { x: right(inner) - pr, y: inner.y + pt, w: pr, h: inner.h - pt - pb }, pr],
    ["bottom", { ...inner, y: bottom(inner) - pb, h: pb }, pb],
    ["left", { x: inner.x, y: inner.y + pt, w: pl, h: inner.h - pt - pb }, pl],
  ];
  const strips: Strip[] = pads
    .filter(([, rect, value]) => value >= 0.5 && rect.w > 0 && rect.h > 0)
    .map(([side, rect, value]) => ({
      key: `pad-${side}`,
      rect,
      value,
      axis: side === "top" || side === "bottom" ? "y" : "x",
      label: true,
    }));
  strips.push(...gapsOf(frame, el, cs, content));

  const parent = parentPart(el, root, selector);
  const siblings = childParts(parent ?? root, root, selector)
    .filter((sibling) => sibling !== el)
    .map((sibling) => rectOf(frame, sibling));
  const within = parent
    ? inset(rectOf(frame, parent), sides(getComputedStyle(parent), (side) => `border-${side}-width`))
    : { x: frame.x, y: frame.y, w: frame.width, h: frame.height }; // the root's visible padding box

  const flowing = cs.display.endsWith("flex") || cs.display.endsWith("grid");
  return {
    box,
    parent,
    strips,
    lines: spaceAround(box, siblings, within),
    padding: { top: pt, right: pr, bottom: pb, left: pl },
    gap: flowing ? { row: parseFloat(cs.rowGap) || 0, column: parseFloat(cs.columnGap) || 0 } : null,
    text: textOf(el, cs),
  };
}

type Look = ReturnType<typeof inspect>;

function sceneOf(
  frame: Frame,
  look: Look,
  other: Rect | null,
  measuring: boolean,
  spec: boolean,
  tokens?: Tokens,
): Scene {
  const box = look.box;
  const view = { x: frame.x, y: frame.y, r: frame.x + frame.width, b: frame.y + frame.height };
  const pill =
    !frame.clips || bottom(box) + REDLINE.pill <= view.b
      ? "below"
      : box.y - REDLINE.pill >= view.y
        ? "above"
        : "inside";
  if (other && measuring) {
    const lines = placeLines(between(box, other), box, [], pill, tokens);
    return { main: box, other, strips: [], lines, guides: guidesTo(other, lines), text: null, pill, card: null };
  }

  const strips = look.strips.map((strip) =>
    strip.axis === "x" && !strip.key.startsWith("pad") && labelWidth(named(strip.value, tokens)) > strip.rect.w + 4
      ? { ...strip, above: true }
      : strip,
  );
  const lines = placeLines(look.lines, box, strips, pill, tokens);
  // The spec card goes beside the box, past the distance line on that side, or else under it.
  const text = spec ? look.text : null;
  let card: Scene["card"] = null;
  if (text) {
    const width = labelWidth(specOf(text).join(" · ")) + 32;
    const past = (side: Side) => {
      const line = lines.find((l) => l.side === side);
      if (!line) return REDLINE.card;
      return line.length + (line.tag === "end" ? labelWidth(named(line.length, tokens)) + 6 : 0) + REDLINE.card;
    };
    const r = past("right");
    const l = past("left");
    card =
      view.r - right(box) - r >= width
        ? { side: "right", offset: r }
        : box.x - view.x - l >= width
          ? { side: "left", offset: l }
          : { side: "below", offset: 0 };
  }
  return { main: box, other, strips, lines, guides: [], text, pill, card };
}

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

function selectionOf(el: Element, look: Look): RedlineSelection {
  return {
    element: el,
    name: el.getAttribute("data-redline"),
    width: round(look.box.w),
    height: round(look.box.h),
    padding: look.padding,
    gap: look.gap,
    spacing: Object.fromEntries(look.lines.map((line) => [line.side, round(line.length)])),
    text: look.text,
  };
}

/* ── Redline ────────────────────────────────────────────── */

export interface RedlineColors {
  /** The selection box, its handles and size pill. */
  selection?: string;
  /** Distance lines, guides and their labels. */
  distance?: string;
  /** Padding and gap hatching and their labels. */
  spacing?: string;
}

export interface RedlineProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
  /** Elements to walk through on their own while nobody is pointing, by `data-redline` name. A pair of names measures between the two. */
  tour?: RedlineTourStep[];
  /** Names for pixel values, so spacing labels read "space-4" instead of "16". */
  tokens?: Tokens;
  /** Show a card beside text elements with their font, size, line height, weight, letter spacing and color. */
  spec?: boolean;
  /** Which elements can be measured. Any element inside by default. */
  selector?: string;
  /** Called when the selected element changes, with its measurements, or with null when nothing is selected. */
  onSelect?: (selection: RedlineSelection | null) => void;
  /** Overrides for the selection, distance and spacing colors. */
  colors?: RedlineColors;
}

type Live = {
  point: Point | null;
  pinned: Element | null;
  alt: boolean;
  inside: boolean;
  focused: boolean;
  /** The element last reported through onSelect. */
  shown: Element | null;
  scene: string;
  observer: ResizeObserver | null;
  observed: Element[];
  pointer: { x: number; y: number; t: number; speed: number; timer: number; kind: string };
  tour: { step: number; inView: boolean; idle: boolean; timer: number; resume: number };
};

/**
 * Figma-style redlines for any layout. Point at an element to see its size,
 * padding and gaps; pin one and hold Alt to measure the distance to another.
 */
export function Redline({
  tour,
  tokens,
  spec = false,
  selector = "*",
  onSelect,
  colors,
  className,
  style,
  children,
  "aria-label": ariaLabel = "Measurements",
  onPointerEnter,
  onPointerMove,
  onPointerLeave,
  onPointerDown,
  onMouseDownCapture,
  onClickCapture,
  onKeyDown,
  onFocus,
  onBlur,
  ...props
}: RedlineProps) {
  const rootRef = React.useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion() ?? false;
  const [scene, setScene] = React.useState<(Scene & { instant: boolean }) | null>(null);
  const [message, setMessage] = React.useState("");
  const hintId = React.useId();

  const latest = React.useRef({ tour, spec, selector, tokens, onSelect });
  React.useLayoutEffect(() => {
    latest.current = { tour, spec, selector, tokens, onSelect };
  });

  const live = React.useRef<Live>({
    point: null,
    pinned: null,
    alt: false,
    inside: false,
    focused: false,
    shown: null,
    scene: "null",
    observer: null,
    observed: [],
    pointer: { x: NaN, y: NaN, t: 0, speed: Infinity, timer: 0, kind: "mouse" },
    tour: { step: 0, inView: false, idle: true, timer: 0, resume: 0 },
  });

  /** Measure what's pointed at and redraw. `instant` skips the glide, for scrolling and resizing. */
  const refresh = (instant = false) => {
    const root = rootRef.current;
    if (!root) return;
    const s = live.current;
    const { spec, selector, tokens, onSelect } = latest.current;
    const alive = (el: Element | null | undefined) => (el?.isConnected && root.contains(el) ? el : null);
    s.pinned = alive(s.pinned);
    if (s.point && !alive(s.point.el)) s.point = null;

    // What gets the box, and what it's measured against.
    const p = s.point;
    let main = p?.el ?? null;
    let other = p?.via === "tour" ? alive(p.to) : null;
    let measuring = !!other;
    if (s.pinned) {
      main = s.pinned;
      other = p && p.el !== s.pinned ? p.el : null;
      measuring = !!other && (p?.via !== "mouse" || s.alt);
    }

    let next: Scene | null = null;
    let look: Look | null = null;
    if (main) {
      const frame = frameOf(root);
      look = inspect(root, frame, main, selector);
      next = sceneOf(frame, look, other && rectOf(frame, other), measuring, spec, tokens);
    }
    if (main !== s.shown) {
      s.shown = main;
      onSelect?.(main && look ? selectionOf(main, look) : null);
    }
    const json = JSON.stringify(next);
    if (json !== s.scene) {
      s.scene = json;
      setScene(next && { ...next, instant });
    }

    // Follow the size of everything the scene depends on.
    const watch = [root, main, look?.parent, other].filter((el): el is Element => !!el);
    const moved = watch.length !== s.observed.length || watch.some((el, i) => el !== s.observed[i]);
    if (s.observer && moved) {
      s.observer.disconnect();
      for (const el of watch) s.observer.observe(el);
      s.observed = watch;
    }
  };

  const describe = (el: Element) => {
    const root = rootRef.current;
    if (!root) return "";
    const s = live.current;
    const frame = frameOf(root);
    const r = rectOf(frame, el);
    let text = `${nameOf(el)}, ${px(r.w)} by ${px(r.h)}`;
    if (s.pinned && s.pinned !== el) {
      const lines = between(rectOf(frame, s.pinned), r);
      const parts = lines.map((line) => `${line.side} ${px(line.length)}`).join(", ");
      if (parts) text += `. From ${nameOf(s.pinned)}: ${parts}`;
    }
    return text;
  };

  const announce = (text: string) =>
    setMessage((last) => (last === text ? `${text} ` : text)); // repeat the same words too

  /* Tour: steps on its own while on screen and nobody's using it. */
  const syncTour = () => {
    const s = live.current;
    const t = s.tour;
    const steps = latest.current.tour ?? [];
    const running = steps.length > 0 && t.inView && t.idle && !s.pinned && !s.inside && !s.focused;
    if (!running) {
      window.clearTimeout(t.timer);
      t.timer = 0;
      return;
    }
    if (t.timer) return;
    const root = rootRef.current;
    if (!root) return;
    const find = (name: string) => root.querySelector(`[data-redline="${CSS.escape(name)}"]`);
    for (let i = 0; i < steps.length; i++) {
      const index = (t.step + i) % steps.length;
      const [first, second] = Array.isArray(steps[index]) ? steps[index] : [steps[index] as string, null];
      const el = find(first);
      if (!el) continue;
      t.step = index;
      s.point = { el, via: "tour", to: second ? find(second) : null };
      break;
    }
    refresh();
    t.timer = window.setTimeout(() => {
      t.timer = 0;
      t.step += 1;
      actions.current.syncTour();
    }, REDLINE.step);
  };

  /** Someone's using it: the tour stops where it is. */
  const engage = () => {
    const t = live.current.tour;
    t.idle = false;
    window.clearTimeout(t.resume);
    syncTour();
  };

  /** Nobody is: the tour picks up again after a moment. */
  const release = () => {
    const s = live.current;
    const t = s.tour;
    window.clearTimeout(t.resume);
    if (s.inside || s.focused || s.pinned) return;
    t.resume = window.setTimeout(() => {
      t.idle = true;
      if (s.point?.via !== "tour") t.step += 1;
      actions.current.syncTour();
    }, REDLINE.resume);
  };

  const clear = () => {
    const s = live.current;
    s.pinned = null;
    s.point = null;
    refresh();
    release();
  };

  // Global listeners, observers and timers call the latest versions of these.
  const actions = React.useRef({ refresh, syncTour, clear });
  React.useLayoutEffect(() => {
    actions.current = { refresh, syncTour, clear };
  });

  React.useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const s = live.current;
    let frame = 0;
    const remeasure = () => {
      cancelAnimationFrame(frame);
      frame = requestAnimationFrame(() => actions.current.refresh(true));
    };
    const observer = new ResizeObserver(remeasure);
    observer.observe(root);
    s.observer = observer;
    s.observed = [root];
    root.addEventListener("scroll", remeasure, { capture: true, passive: true });
    window.addEventListener("resize", remeasure);
    document.fonts?.ready.then(remeasure);

    const view = new IntersectionObserver(([entry]) => {
      s.tour.inView = entry.isIntersecting;
      actions.current.syncTour();
    });
    view.observe(root);

    // A tap outside clears a touch selection.
    const outside = (event: PointerEvent) => {
      if (event.pointerType === "mouse" || root.contains(event.target as Node)) return;
      if (s.pinned || s.point?.via === "touch") actions.current.clear();
    };
    document.addEventListener("pointerdown", outside, true);

    const setAlt = (alt: boolean) => {
      if (s.alt === alt) return;
      s.alt = alt;
      if (s.pinned && s.point?.via === "mouse") actions.current.refresh();
    };
    const keyDown = (event: KeyboardEvent) => event.key === "Alt" && setAlt(true);
    const keyUp = (event: KeyboardEvent) => {
      if (event.key !== "Alt") return;
      setAlt(false);
      if (s.inside) event.preventDefault(); // keep Windows from focusing the menu bar
    };
    const blur = () => setAlt(false);
    window.addEventListener("keydown", keyDown);
    window.addEventListener("keyup", keyUp);
    window.addEventListener("blur", blur);

    return () => {
      cancelAnimationFrame(frame);
      observer.disconnect();
      s.observer = null;
      view.disconnect();
      root.removeEventListener("scroll", remeasure, { capture: true });
      window.removeEventListener("resize", remeasure);
      document.removeEventListener("pointerdown", outside, true);
      window.removeEventListener("keydown", keyDown);
      window.removeEventListener("keyup", keyUp);
      window.removeEventListener("blur", blur);
      window.clearTimeout(s.pointer.timer);
      window.clearTimeout(s.tour.timer);
      window.clearTimeout(s.tour.resume);
      s.tour.timer = 0;
    };
  }, []);

  // New props: remeasure, and start, restart or drop the tour.
  const tourKey = JSON.stringify(tour ?? []);
  const tokensKey = JSON.stringify(tokens ?? {});
  React.useLayoutEffect(() => {
    const s = live.current;
    if (!JSON.parse(tourKey).length && s.point?.via === "tour") s.point = null;
    window.clearTimeout(s.tour.timer);
    s.tour.timer = 0;
    actions.current.syncTour();
    actions.current.refresh(true);
  }, [tourKey, tokensKey, spec, selector]);

  /* Pointer: the box follows the mouse once it settles. Taps are handled on click. */
  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    onPointerMove?.(event);
    const root = rootRef.current;
    if (event.pointerType !== "mouse" || !root) return;
    const s = live.current;
    const p = s.pointer;
    if (s.alt !== event.altKey) {
      s.alt = event.altKey;
      if (s.pinned) refresh();
    }
    const dx = event.clientX - p.x;
    const dy = event.clientY - p.y;
    if (dx === 0 && dy === 0) return;
    const dt = event.timeStamp - p.t;
    const speed = Number.isNaN(dx) ? Infinity : dt > 0 ? Math.hypot(dx, dy) / dt : p.speed;
    p.speed = Number.isFinite(p.speed) && Number.isFinite(speed) ? (p.speed + speed) / 2 : speed;
    p.x = event.clientX;
    p.y = event.clientY;
    p.t = event.timeStamp;

    window.clearTimeout(p.timer);
    // Over the root's own background the box stays where it was.
    const next = partAt(event.target, root, latest.current.selector);
    if (!next || (next === s.point?.el && s.point.via === "mouse")) return;
    const go = () => {
      s.point = { el: next, via: "mouse" };
      refresh();
    };
    if (p.speed < REDLINE.slow) go();
    else p.timer = window.setTimeout(go, REDLINE.dwell);
  };

  const handlePointerLeave = (event: React.PointerEvent<HTMLDivElement>) => {
    onPointerLeave?.(event);
    if (event.pointerType !== "mouse") return;
    const s = live.current;
    window.clearTimeout(s.pointer.timer);
    s.pointer.x = NaN;
    s.pointer.speed = Infinity;
    s.inside = false;
    if (s.point?.via === "mouse" || s.point?.via === "tour") s.point = null;
    refresh();
    release();
  };

  /** Touch: tap to select, tap another to measure, tap the selected one again to clear. */
  const tap = (el: Element | null) => {
    const s = live.current;
    engage();
    if (!el || el === s.pinned) {
      if (s.pinned || s.point) announce("Cleared");
      clear();
      return;
    }
    if (!s.pinned) s.pinned = el;
    s.point = { el, via: "touch" };
    announce(describe(el));
    refresh();
  };

  const handleClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {
    onClickCapture?.(event);
    const root = rootRef.current;
    if (!root) return;
    // Clicks pin instead of pressing whatever is underneath.
    event.preventDefault();
    event.stopPropagation();
    const s = live.current;
    const el = partAt(event.target, root, latest.current.selector);
    const keyboard = event.detail === 0;
    if (!keyboard && s.pointer.kind !== "mouse") {
      tap(el);
      return;
    }
    window.clearTimeout(s.pointer.timer);
    if (!el || el === s.pinned) {
      if (s.pinned) announce("Unpinned");
      s.pinned = null;
      if (el) s.point = { el, via: keyboard ? "key" : "mouse" };
      refresh();
      return;
    }
    s.pinned = el;
    s.point = { el, via: keyboard ? "key" : "mouse" };
    announce(`Pinned ${describe(el)}`);
    refresh();
  };

  /* Keys: walk the tree, pin, clear. Only while the area itself has focus. */
  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(event);
    const root = rootRef.current;
    if (!root || event.defaultPrevented || event.target !== root) return;
    if (event.metaKey || event.ctrlKey) return;
    const s = live.current;
    const { selector } = latest.current;
    const at = s.point?.el ?? s.pinned ?? null;

    if (event.key === "Escape") {
      if (!s.pinned && !s.point) return;
      event.preventDefault();
      if (s.pinned) s.pinned = null;
      else s.point = null;
      announce(s.point ? "Unpinned" : "Cleared");
      refresh();
      release();
      return;
    }

    if (event.key === "Enter") {
      if (!at) return;
      event.preventDefault();
      s.pinned = s.pinned === at ? null : at;
      s.point = { el: at, via: "key" };
      announce(s.pinned ? `Pinned ${describe(at)}` : "Unpinned");
      refresh();
      return;
    }

    const arrows = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
    if (!arrows.includes(event.key)) return;
    event.preventDefault();
    s.focused = true;
    engage();
    let to: Element | null = null;
    if (!at) to = childParts(root, root, selector)[0] ?? null;
    else if (event.key === "ArrowUp") to = parentPart(at, root, selector);
    else if (event.key === "ArrowDown") to = childParts(at, root, selector)[0] ?? null;
    else {
      const siblings = childParts(parentPart(at, root, selector) ?? root, root, selector);
      to = siblings[siblings.indexOf(at) + (event.key === "ArrowRight" ? 1 : -1)] ?? null;
    }
    if (!to) return;
    s.point = { el: to, via: "key" };
    announce(describe(to));
    refresh();
  };

  return (
    <div
      {...props}
      ref={rootRef}
      tabIndex={0}
      role="group"
      aria-label={ariaLabel}
      aria-describedby={hintId}
      className={cn(
        "relative isolate outline-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-(--redline-select)",
        className,
      )}
      style={
        {
          "--redline-select": colors?.selection ?? COLORS.selection,
          "--redline-distance": colors?.distance ?? COLORS.distance,
          "--redline-space": colors?.spacing ?? COLORS.spacing,
          ...style,
        } as React.CSSProperties
      }
      onPointerEnter={(event) => {
        onPointerEnter?.(event);
        if (event.pointerType !== "mouse") return;
        live.current.inside = true;
        engage();
      }}
      onPointerMove={handlePointerMove}
      onPointerLeave={handlePointerLeave}
      onPointerDown={(event) => {
        onPointerDown?.(event);
        live.current.pointer.kind = event.pointerType;
      }}
      onMouseDownCapture={(event) => {
        onMouseDownCapture?.(event);
        if (event.button !== 0) return;
        // No focusing, dragging or text selection underneath; the area takes focus for Esc.
        event.preventDefault();
        rootRef.current?.focus({ preventScroll: true });
      }}
      onClickCapture={handleClickCapture}
      onKeyDown={handleKeyDown}
      onFocus={(event) => {
        onFocus?.(event);
        const root = rootRef.current;
        if (!root || event.target !== root || !root.matches(":focus-visible")) return;
        const s = live.current;
        s.focused = true;
        engage();
        // Keyboard focus starts where the box already is, or on the first element.
        const at = s.point?.el ?? s.pinned ?? childParts(root, root, latest.current.selector)[0];
        if (!at) return;
        s.point = { el: at, via: "key" };
        announce(describe(at));
        refresh();
      }}
      onBlur={(event) => {
        onBlur?.(event);
        if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
        const s = live.current;
        if (!s.focused) return;
        s.focused = false;
        if (s.point?.via === "key") s.point = null;
        refresh();
        release();
      }}
    >
      {children}

      <OverlayContext.Provider value={{ instant: scene?.instant ?? true, reduced, tokens }}>
        <div
          data-redline-ui=""
          aria-hidden
          className="pointer-events-none absolute top-0 left-0 z-50 size-0 overflow-visible font-sans select-none"
        >
          <AnimatePresence>
            {scene?.strips.map((strip) => <Hatch key={strip.key} strip={strip} />)}
            {scene?.other && <Outline key="other" rect={scene.other} />}
            {scene?.guides.map((guide) => <GuideLine key={guide.key} guide={guide} />)}
            {scene?.lines.map((line) => <Distance key={line.key} line={line} />)}
            {scene && (
              <SelectionBox key="main" rect={scene.main} pill={scene.pill} card={scene.card} text={scene.text} />
            )}
          </AnimatePresence>
        </div>
      </OverlayContext.Provider>

      <div data-redline-ui="" className="sr-only">
        <span id={hintId}>
          Arrow keys move between elements: up to the parent, down to the first child, left and right
          to siblings. Enter pins one, and moving on then measures the distance from it. Escape clears.
        </span>
        <span role="status" aria-live="polite">
          {message}
        </span>
      </div>
    </div>
  );
}

/* ── Overlay pieces ─────────────────────────────────────── */

type Overlay = { instant: boolean; reduced: boolean; tokens?: Tokens };

const OverlayContext = React.createContext<Overlay>({ instant: true, reduced: false });

/** Lines stay crisp: positions land on whole device pixels. */
const snap = (value: number) => {
  const dpr = window.devicePixelRatio || 1;
  return Math.round(value * dpr) / dpr;
};

/** A number that glides to each new value, or jumps there when scrolling, resizing or reduced. */
function useGlide(value: number) {
  const { instant, reduced } = React.useContext(OverlayContext);
  const mv = useMotionValue(value);
  const jump = instant || reduced;
  React.useEffect(() => {
    if (jump) {
      mv.jump(value);
      return;
    }
    const controls = animate(mv, value, REDLINE.glide);
    return () => controls.stop();
  }, [mv, value, jump]);
  return mv;
}

function useRect(rect: Rect) {
  const x = useGlide(rect.x);
  const y = useGlide(rect.y);
  const w = useGlide(rect.w);
  const h = useGlide(rect.h);
  const style = {
    x: useTransform(() => snap(x.get())),
    y: useTransform(() => snap(y.get())),
    width: useTransform(() => snap(x.get() + w.get()) - snap(x.get())),
    height: useTransform(() => snap(y.get() + h.get()) - snap(y.get())),
  };
  return { w, h, style };
}

/** A label that ticks through whole numbers on its way to the new value. Token names swap instead. */
function useTicker(value: MotionValue<number>, target: number, tokens?: Tokens) {
  const final = named(target, tokens);
  const isToken = final !== px(target);
  const text = useMotionValue(final);
  React.useEffect(() => {
    const show = (v: number) =>
      text.set(isToken || Math.abs(v - target) < 0.05 ? final : String(Math.round(v)));
    show(value.get());
    return value.on("change", show);
  }, [value, target, final, isToken, text]);
  return text;
}

const fade = {
  initial: { opacity: 0 },
  animate: { opacity: 1 },
  exit: { opacity: 0 },
  transition: REDLINE.fade,
};

const HANDLES = ["-top-1 -left-1", "-top-1 -right-1", "-bottom-1 -left-1", "-bottom-1 -right-1"];

function SelectionBox({
  rect,
  pill,
  card,
  text,
}: {
  rect: Rect;
  pill: Scene["pill"];
  card: Scene["card"];
  text: RedlineText | null;
}) {
  const { w, h, style } = useRect(rect);
  const width = useTicker(w, rect.w);
  const height = useTicker(h, rect.h);
  return (
    <motion.div className="absolute top-0 left-0" style={style} {...fade}>
      <div className="absolute -inset-px border border-(--redline-select)" />
      {HANDLES.map((place) => (
        <div
          key={place}
          className={cn("absolute size-[7px] border border-(--redline-select) bg-white", place)}
        />
      ))}
      <div
        className={cn(
          "absolute inset-x-0 flex justify-center",
          pill === "below" ? "top-full pt-2" : pill === "above" ? "bottom-full pb-2" : "bottom-2",
        )}
      >
        <span className={cn(PILL, "bg-(--redline-select)")}>
          <motion.span>{width}</motion.span>
          {" × "}
          <motion.span>{height}</motion.span>
        </span>
      </div>
      <AnimatePresence>
        {text && card && <SpecCard key="spec" text={text} place={card} below={pill === "below"} />}
      </AnimatePresence>
    </motion.div>
  );
}

function SpecCard({
  text,
  place,
  below,
}: {
  text: RedlineText;
  place: NonNullable<Scene["card"]>;
  below: boolean;
}) {
  const [font, size, weight, tracking] = specOf(text);
  const dot = <span className="text-zinc-500">·</span>;
  const { side, offset } = place;
  return (
    <motion.div
      {...fade}
      className={cn(
        "absolute flex",
        side === "below" ? "inset-x-0 justify-center" : "-top-px",
        side === "below" && (below ? "top-[calc(100%+34px)]" : "top-[calc(100%+10px)]"),
      )}
      style={
        side === "right"
          ? { left: `calc(100% + ${offset}px)` }
          : side === "left"
            ? { right: `calc(100% + ${offset}px)` }
            : undefined
      }
    >
      <div className="flex items-center gap-1.5 rounded-[4px] bg-zinc-900 px-2 py-1 text-[11px]/4 font-medium whitespace-nowrap text-zinc-50 tabular-nums shadow-md ring-1 ring-white/10">
      <span className="max-w-32 truncate">{font}</span>
      {dot}
      <span>{size}</span>
      {dot}
      <span>{weight}</span>
      {dot}
      <span>{tracking}</span>
      {dot}
      <span className="flex items-center gap-1">
        <span className="size-2.5 rounded-[2px] ring-1 ring-white/30" style={{ background: text.color }} />
        {text.color}
      </span>
      </div>
    </motion.div>
  );
}

function Outline({ rect }: { rect: Rect }) {
  const { style } = useRect(rect);
  return (
    <motion.div className="absolute top-0 left-0" style={style} {...fade}>
      <div className="absolute -inset-px border border-(--redline-select) opacity-70" />
    </motion.div>
  );
}

function Hatch({ strip }: { strip: Strip }) {
  const { tokens } = React.useContext(OverlayContext);
  const { w, h, style } = useRect(strip.rect);
  const label = useTicker(strip.axis === "x" ? w : h, strip.value, tokens);
  return (
    <motion.div className="absolute top-0 left-0" style={{ ...style, backgroundImage: HATCH }} {...fade}>
      {strip.label && (
        <div
          className={cn(
            "absolute flex justify-center",
            strip.above ? "inset-x-0 bottom-full pb-1" : "inset-0 items-center",
          )}
        >
          <motion.span className={cn(PILL, "bg-(--redline-space)")}>{label}</motion.span>
        </div>
      )}
    </motion.div>
  );
}

const BESIDE = "inset-y-0 left-full items-center pl-1.5";
const BEFORE = "inset-y-0 right-full items-center pr-1.5";

/** Where a line's label goes: beside it, on its other side, or past its outer end. */
function labelPlace({ side, tag }: Line) {
  if (side === "top" || side === "bottom") return tag === "flip" ? BEFORE : BESIDE;
  if (tag === "end") return side === "left" ? BEFORE : BESIDE;
  return tag === "flip"
    ? "inset-x-0 bottom-full justify-center pb-1.5"
    : "inset-x-0 top-full justify-center pt-1.5";
}

function Distance({ line }: { line: Line }) {
  const { reduced, tokens } = React.useContext(OverlayContext);
  const vertical = line.side === "top" || line.side === "bottom";
  const x = useGlide(line.x);
  const y = useGlide(line.y);
  const length = useGlide(line.length);
  const label = useTicker(length, line.length, tokens);
  const sx = useTransform(() => snap(x.get()));
  const sy = useTransform(() => snap(y.get()));
  const span = useTransform(() =>
    vertical ? snap(y.get() + length.get()) - snap(y.get()) : snap(x.get() + length.get()) - snap(x.get()),
  );
  return (
    <motion.div
      className="absolute top-0 left-0"
      style={vertical ? { x: sx, y: sy, width: 1, height: span } : { x: sx, y: sy, width: span, height: 1 }}
      exit={{ opacity: 0 }}
      transition={REDLINE.fade}
    >
      <motion.div
        className={cn("absolute", vertical ? "inset-y-0 -left-[3px] w-[7px]" : "inset-x-0 -top-[3px] h-[7px]")}
        initial={reduced ? false : { clipPath: DRAW_FROM[line.side] }}
        animate={{ clipPath: DRAWN }}
        transition={REDLINE.draw}
      >
        <div
          className={cn(
            "absolute bg-(--redline-distance)",
            vertical ? "inset-y-0 left-[3px] w-px" : "inset-x-0 top-[3px] h-px",
          )}
        />
        {/* End ticks */}
        <div className={cn("absolute bg-(--redline-distance)", vertical ? "inset-x-0 top-0 h-px" : "inset-y-0 left-0 w-px")} />
        <div className={cn("absolute bg-(--redline-distance)", vertical ? "inset-x-0 bottom-0 h-px" : "inset-y-0 right-0 w-px")} />
      </motion.div>
      {/* Beside the line, not on it */}
      <motion.div
        className={cn("absolute flex", labelPlace(line))}
        initial={reduced ? false : { opacity: 0 }}
        animate={{ opacity: 1 }}
        transition={{ ...REDLINE.fade, delay: reduced ? 0 : 0.14 }}
      >
        <motion.span className={cn(PILL, "bg-(--redline-distance)")}>{label}</motion.span>
      </motion.div>
    </motion.div>
  );
}

function GuideLine({ guide }: { guide: Guide }) {
  const vertical = guide.axis === "y";
  const x = useGlide(guide.x);
  const y = useGlide(guide.y);
  const length = useGlide(guide.length);
  const sx = useTransform(() => snap(x.get()));
  const sy = useTransform(() => snap(y.get()));
  const span = useTransform(() =>
    vertical ? snap(y.get() + length.get()) - snap(y.get()) : snap(x.get() + length.get()) - snap(x.get()),
  );
  return (
    <motion.div
      className="absolute top-0 left-0"
      style={{
        x: sx,
        y: sy,
        width: vertical ? 1 : span,
        height: vertical ? span : 1,
        backgroundImage: `repeating-linear-gradient(${vertical ? "to bottom" : "to right"}, var(--redline-distance) 0 3px, transparent 3px 6px)`,
      }}
      {...fade}
    />
  );
}
```
