# Thinking

Part of the Dovetail design system (https://graham-goebel.github.io/Dovetail/), in the Feedback family. Files: [Thinking.jsx](https://graham-goebel.github.io/Dovetail/system/components/feedback/Thinking.jsx), [Thinking.d.ts](https://graham-goebel.github.io/Dovetail/system/components/feedback/Thinking.d.ts), [Thinking.md](https://graham-goebel.github.io/Dovetail/system/components/feedback/Thinking.md).

Live page: https://graham-goebel.github.io/Dovetail/components/Thinking.html

## Guidelines

What an assistant is doing while you wait: connecting, listening, thinking, searching or speaking. Five states, drawn as a fluid or as a texture of small marks, all read from the system's tokens, that sit inline in a chat or fill the screen for a voice session.

### Use it when
- A chat is waiting on a reply. Put it inline where the reply will appear.
- A voice session is live. Use `mode="overlay"` and pass the microphone or reply amplitude as `level`.

### Don't use it when
- The wait has a known length or a known amount of work. Use `Progress`.
- It is a page or a button loading. Use `Spinner` or the button's own `loading`. This component speaks for an assistant, not for the interface.

### States
| state | motion | when |
| --- | --- | --- |
| `connecting` | satellites gather and breathe | before the session is live |
| `listening` | swells and wobbles with `level` | the person is speaking |
| `thinking` | bodies orbit and merge | the reply is being prepared |
| `searching` | a comet orbits the centre | tools or sources are being consulted |
| `speaking` | pulses outward with `level` | the reply is being spoken or streamed |

### Example
```jsx
{/* Chat */}
<Thinking state="thinking" />
<Thinking state="thinking" shape="ascii" label="Drafting" />
<Thinking state="searching" shape="dots" label="Checking three sources" />

{/* Voice */}
<Thinking mode="overlay" state="listening" level={micLevel} tone="duotone" onDismiss={end}>
  {transcript}
</Thinking>
<Thinking mode="overlay" screen="light" state="speaking" shape="particles" onDismiss={end} />
```

### Inputs
- **shape**: `blob` is free fluid. `orb` and `tile` hold the fluid in a well tinted with `--dt-thinking-surface`; the tile follows `--dt-radius-container`, so it changes with the Shape setting. `dots` and `bars` are the chat and voice conventions, animated the same five ways.
- **Textural shapes** build the figure from many small marks instead of a liquid, and animate the same five states:
  - `matrix`: a dot display. Every cell keeps a faint dot so the grid shows, and the figure swells the dots.
  - `ascii`: the same figure as glyphs of rising density, ` .·:-=+*#%@`, set in `--dt-font-family-mono`, so it changes with the Code font.
  - `particles`: a point cloud on a sphere, turning in perspective. Nearer points are larger and take the end colour; searching flattens it into a disc with a bright arc sweeping round.
  - `sequence`: a ring of dots lit in order, like a loader on a device panel.

  matrix and ascii share one field per state: a shrinking ring for connecting, a lit sphere that swells with `level` for listening, a spiral for thinking, a radar sweep for searching and ripples for speaking. The sphere is shaded as if lit from above left, which is what gives the flat marks their depth. These shapes draw on a canvas and read the tone colours back from the page about twice a second, so Configure and dark mode reach them without a re-render.
- **tone** or **colors**: the gradient the fluid sweeps. `brand` reads `--dt-thinking-color-start` and `-end`, which point at the primary ramp; `duotone` sweeps primary into secondary. `colors` takes any two CSS colours, token references included.
- **speed** multiplies the pace set by `--dt-thinking-duration`; **intensity** sets how far the fluid travels and deforms.
- **screen** (overlay only): `dark` dims the page behind the scrim and sets the label in white. `light` is a bright frosted screen with dark text, for light products and daytime voice UIs. It stays light on a dark page, because it points the figure's gradient stops and the text roles at light values inside the overlay.
- **level** drives listening and speaking from a real signal. Without it they follow a built-in rhythm.

### Accessibility
The container is a polite live region, and the label is announced when the state changes, whether or not it is shown. The animation itself is hidden from assistive technology. Under `prefers-reduced-motion` the figure holds still; it still follows `level`, because that is information rather than decoration. The overlay closes on Escape when `onDismiss` is set.

### Tokens
`--dt-thinking-*` (Tier 3): the start and end colour of each tone, the brand pair, the well surface, the label colour, the overlay scrim, the light screen and its text and gradient stops (`--dt-thinking-screen-light*`, `--dt-thinking-light-*`), the duration, and the inline and overlay sizes. Colour aliases are repeated under `.dark`, so the fluid keeps its contrast in a dark band and in the overlay.

## Props

```ts
import * as React from "react";

export type ThinkingState = "connecting" | "listening" | "thinking" | "searching" | "speaking";

/**
 * An assistant's loading and thinking states, as a fluid or textural
 * animation that reads the system's tokens. One component, two placements: inline beside a
 * chat message, or as a full-screen overlay for voice.
 */
export interface ThinkingProps extends Omit<React.HTMLAttributes<HTMLElement>, "color"> {
  /**
   * connecting: gathering, before a session is live. listening: follows the
   * person's voice. thinking: a merging swirl while a reply is prepared.
   * searching: an orbit, while tools or sources are consulted. speaking:
   * pulses with the reply. @default "thinking"
   */
  state?: ThinkingState;
  /** inline sits in a line of text; overlay covers the screen for a voice session. @default "inline" */
  mode?: "inline" | "overlay";
  /**
   * Fluid shapes. blob: free fluid. orb and tile: fluid inside a well, tile
   * rounded by --dt-radius-container. dots: the chat convention. bars: the
   * voice convention.
   *
   * Textural shapes, drawn from many small marks. matrix: a dot display.
   * ascii: glyphs of rising density in --dt-font-family-mono. particles: a
   * point cloud turning in 3D. sequence: a ring of dots lit in order.
   * @default "blob"
   */
  shape?: "blob" | "orb" | "tile" | "dots" | "bars" | "matrix" | "ascii" | "particles" | "sequence";
  /** Which gradient the fluid sweeps. brand reads --dt-thinking-color-start and -end. @default "brand" */
  tone?: "brand" | "primary" | "secondary" | "duotone" | "neutral";
  /** Two CSS colours, start and end, overriding tone. Token references work: ["var(--dt-color-green-400)", "var(--dt-color-green-700)"]. */
  colors?: [string, string];
  /** sm is the inline icon size, xl the overlay size; or any CSS length. @default "sm" inline, "xl" overlay */
  size?: "sm" | "md" | "lg" | "xl" | string;
  /** Multiplies the pace set by --dt-thinking-duration. @default 1 */
  speed?: number;
  /** How far the figure is allowed to travel and deform, 0 to 1. @default 0.6 */
  intensity?: number;
  /**
   * A live level from 0 to 1, a microphone's or the reply's amplitude. Drives
   * listening and speaking; omit it and they follow a built-in rhythm.
   */
  level?: number;
  /** Announced to assistive technology, and shown unless showLabel is false. Defaults to the state's name. */
  label?: string;
  /** Inline only: show the label beside the animation. It is announced either way. @default true */
  showLabel?: boolean;
  /**
   * Overlay only. dark dims the page with the scrim and sets white text on
   * it. light is a bright frosted screen with dark text, light in either
   * colour mode. @default "dark"
   */
  screen?: "dark" | "light";
  /** Overlay only: adds a close button and closes on Escape. */
  onDismiss?: () => void;
  /** Overlay: a caption or live transcript under the label. Inline: content after the label. */
  children?: React.ReactNode;
}

export declare function Thinking(props: ThinkingProps): React.JSX.Element;
```

## Tokens it reads

| Token | Tier | Declared as |
| --- | --- | --- |
| `--dt-dialog-scrim-blur` | component | `2px` |
| `--dt-thinking-color-end` | component | `var(--dt-thinking-primary-end)` |
| `--dt-thinking-color-start` | component | `var(--dt-thinking-primary-start)` |
| `--dt-thinking-duration` | component | `var(--dt-duration-600)` |
| `--dt-thinking-label-color` | component | `var(--dt-text-secondary)` |
| `--dt-thinking-light-color-end` | component | `var(--dt-thinking-light-primary-end)` |
| `--dt-thinking-light-color-start` | component | `var(--dt-thinking-light-primary-start)` |
| `--dt-thinking-light-neutral-end` | component | `var(--dt-color-neutral-800)` |
| `--dt-thinking-light-neutral-start` | component | `var(--dt-color-neutral-400)` |
| `--dt-thinking-light-primary-end` | component | `var(--dt-color-primary-700)` |
| `--dt-thinking-light-primary-start` | component | `var(--dt-color-primary-400)` |
| `--dt-thinking-light-secondary-end` | component | `var(--dt-color-secondary-700)` |
| `--dt-thinking-light-secondary-start` | component | `var(--dt-color-secondary-400)` |
| `--dt-thinking-light-surface` | component | `var(--dt-color-primary-100)` |
| `--dt-thinking-neutral-end` | component | `var(--dt-color-neutral-800)` |
| `--dt-thinking-neutral-start` | component | `var(--dt-color-neutral-400)` |
| `--dt-thinking-primary-end` | component | `var(--dt-color-primary-700)` |
| `--dt-thinking-primary-start` | component | `var(--dt-color-primary-400)` |
| `--dt-thinking-screen-light` | component | `color-mix(in oklab, var(--dt-color-neutral-050) 92%, transparent)` |
| `--dt-thinking-screen-light-text` | component | `var(--dt-color-neutral-900)` |
| `--dt-thinking-screen-light-text-secondary` | component | `var(--dt-color-neutral-600)` |
| `--dt-thinking-scrim` | component | `var(--dt-surface-scrim)` |
| `--dt-thinking-secondary-end` | component | `var(--dt-color-secondary-700)` |
| `--dt-thinking-secondary-start` | component | `var(--dt-color-secondary-400)` |
| `--dt-thinking-size-inline` | component | `var(--dt-size-icon-md)` |
| `--dt-thinking-size-overlay` | component | `var(--dt-dim-40)` |
| `--dt-thinking-surface` | component | `var(--dt-surface-brand-muted)` |
| `--dt-backdrop-glass` | semantic | `saturate(1.6) blur(var(--dt-blur-glass))` |
| `--dt-border-default` | semantic | `var(--dt-color-neutral-200)` |
| `--dt-border-strong` | semantic | `var(--dt-color-neutral-400)` |
| `--dt-border-subtle` | semantic | `var(--dt-color-neutral-100)` |
| `--dt-radius-container` | semantic | `var(--dt-radius-raw-16)` |
| `--dt-radius-pill` | semantic | `var(--dt-radius-raw-full)` |
| `--dt-size-control-md` | semantic | `var(--dt-dim-10)` |
| `--dt-size-icon-md` | semantic | `var(--dt-dim-5)` |
| `--dt-size-icon-xl` | semantic | `var(--dt-dim-8)` |
| `--dt-space-inline-xs` | semantic | `var(--dt-dim-2)` |
| `--dt-space-inset-lg` | semantic | `var(--dt-dim-6)` |
| `--dt-space-inset-xl` | semantic | `var(--dt-dim-8)` |
| `--dt-space-stack-lg` | semantic | `var(--dt-dim-6)` |
| `--dt-surface-base` | semantic | `var(--dt-color-white)` |
| `--dt-surface-brand-muted` | semantic | `var(--dt-color-primary-050)` |
| `--dt-surface-overlay` | semantic | `var(--dt-color-white)` |
| `--dt-surface-raised` | semantic | `var(--dt-color-white)` |
| `--dt-surface-scrim` | semantic | `oklch(0.145 0.005 264 / 0.5)` |
| `--dt-surface-subtle` | semantic | `var(--dt-color-neutral-050)` |
| `--dt-surface-sunken` | semantic | `var(--dt-color-neutral-100)` |
| `--dt-text-body-md-family` | semantic | `var(--dt-font-family-sans)` |
| `--dt-text-body-md-line` | semantic | `var(--dt-line-height-md)` |
| `--dt-text-body-md-size` | semantic | `var(--dt-font-size-md)` |
| `--dt-text-body-sm-family` | semantic | `var(--dt-font-family-sans)` |
| `--dt-text-body-sm-line` | semantic | `var(--dt-line-height-sm)` |
| `--dt-text-body-sm-size` | semantic | `var(--dt-font-size-sm)` |
| `--dt-text-heading-sm-family` | semantic | `var(--dt-font-family-sans)` |
| `--dt-text-heading-sm-line` | semantic | `var(--dt-line-height-xl)` |
| `--dt-text-heading-sm-size` | semantic | `var(--dt-font-size-xl)` |
| `--dt-text-heading-sm-weight` | semantic | `var(--dt-font-weight-medium)` |
| `--dt-text-on-scrim` | semantic | `var(--dt-color-neutral-050)` |
| `--dt-text-on-scrim-secondary` | semantic | `var(--dt-color-neutral-200)` |
| `--dt-text-primary` | semantic | `var(--dt-color-neutral-900)` |
| `--dt-text-secondary` | semantic | `var(--dt-color-neutral-600)` |
| `--dt-text-tertiary` | semantic | `var(--dt-color-neutral-500)` |
| `--dt-z-overlay` | semantic | `300` |
| `--dt-color-neutral-050` | primitive | `oklch(0.979 0 0)` |
| `--dt-color-neutral-100` | primitive | `oklch(0.958 0 0)` |
| `--dt-color-neutral-200` | primitive | `oklch(0.928 0 0)` |
| `--dt-color-neutral-400` | primitive | `oklch(0.715 0 0)` |
| `--dt-color-neutral-800` | primitive | `oklch(0.285 0 0)` |
| `--dt-color-primary-400` | primitive | `oklch(0.735 0.148 45)` |
| `--dt-color-primary-700` | primitive | `oklch(0.505 0.140 45)` |
| `--dt-color-secondary-400` | primitive | `oklch(0.740 0.163 305)` |
| `--dt-color-secondary-700` | primitive | `oklch(0.518 0.190 305)` |
| `--dt-color-white` | primitive | `oklch(1 0 0)` |
| `--dt-dim-16` | primitive | `64px` |
| `--dt-dim-40` | primitive | `160px` |
| `--dt-font-family-mono` | primitive | `"Geist Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace` |
| `--dt-font-size-xl` | primitive | `23px` |
| `--dt-measure-narrow` | primitive | `45ch` |

## Source

```jsx
import React from "react";

const LABELS = { connecting: "Connecting", listening: "Listening", thinking: "Thinking", searching: "Searching", speaking: "Speaking" };

const SIZES = {
  sm: "var(--dt-thinking-size-inline, var(--dt-size-icon-md))",
  md: "var(--dt-size-icon-xl)",
  lg: "var(--dt-dim-16)",
  xl: "var(--dt-thinking-size-overlay, var(--dt-dim-40))",
};

const TONES = {
  brand: ["var(--dt-thinking-color-start, var(--dt-color-primary-400))", "var(--dt-thinking-color-end, var(--dt-color-primary-700))"],
  primary: ["var(--dt-thinking-primary-start, var(--dt-color-primary-400))", "var(--dt-thinking-primary-end, var(--dt-color-primary-700))"],
  secondary: ["var(--dt-thinking-secondary-start, var(--dt-color-secondary-400))", "var(--dt-thinking-secondary-end, var(--dt-color-secondary-700))"],
  duotone: ["var(--dt-thinking-primary-start, var(--dt-color-primary-400))", "var(--dt-thinking-secondary-end, var(--dt-color-secondary-700))"],
  neutral: ["var(--dt-thinking-neutral-start, var(--dt-color-neutral-400))", "var(--dt-thinking-neutral-end, var(--dt-color-neutral-800))"],
};

const WELLS = { orb: "var(--dt-radius-pill)", tile: "var(--dt-radius-container)" };

const PARTICLES = 7;
const BARS = 5;
const TAU = Math.PI * 2;
const ENVELOPE = [0.55, 0.8, 1, 0.8, 0.55];

/* Smooth pseudo-noise in -1..1: two detuned sines per index, so each particle
   drifts on its own path and the whole never visibly repeats. */
function noise(i, t) {
  return 0.5 * Math.sin(t * (1.3 + i * 0.37) + i * 2.1) + 0.5 * Math.sin(t * (0.7 + i * 0.23) + i * 5.3);
}

/* What listening and speaking follow when no live level is passed: a restless
   hum for listening, a syllable rhythm for speaking. */
function synthLevel(state, t) {
  if (state === "listening") return 0.35 + 0.25 * noise(9, t * 2.2);
  if (state === "speaking") return Math.min(1, 0.12 + Math.abs(Math.sin(t * 6.3)) * (0.55 + 0.45 * Math.sin(t * 1.7)));
  return 0;
}

function ring(cx, cy, a, d, r) {
  return { x: cx + Math.cos(a) * d, y: cy + Math.sin(a) * d, r: r };
}

/* The fluid shapes: blob, orb and tile. A centre body and its satellites,
   blurred and thresholded so they merge like liquid. Coordinates are in a
   100 by 100 box. */
function blobParts(state, t, lvl, k) {
  const P = [];
  const spread = 0.6 + 0.6 * k;
  if (state === "connecting") {
    const breathe = 0.5 + 0.5 * Math.sin(t * 1.5);
    P.push({ x: 50, y: 50, r: 8 + 6 * breathe });
    for (let i = 0; i < 6; i++) P.push(ring(50, 50, t * 0.6 + (i * TAU) / 6, 8 + 20 * (1 - breathe) * spread, 6.5));
  } else if (state === "listening") {
    P.push({ x: 50, y: 50, r: 15 + 8 * lvl });
    for (let i = 0; i < 5; i++) {
      const d = 11 + 13 * lvl * (0.5 + 0.5 * noise(i + 3, t * 2)) * spread;
      P.push(ring(50, 50, (i * TAU) / 5 + t * 0.25 + noise(i, t) * 0.5, d, 8 + 2 * noise(i + 7, t)));
    }
  } else if (state === "thinking") {
    P.push({ x: 50, y: 50, r: 15 });
    for (let i = 0; i < 5; i++) {
      const dir = i % 2 ? -1 : 1;
      P.push(ring(50, 50, dir * t * (0.8 + i * 0.17) + i * 1.3, 10 + (7 + 6 * noise(i, t)) * spread, 9 + 3 * noise(i + 4, t * 1.3)));
    }
  } else if (state === "searching") {
    P.push({ x: 50, y: 50, r: 13 + 1.5 * Math.sin(t * 2) });
    const a = t * 1.6;
    const d = 24 + 5 * k;
    [7.5, 6, 4.8, 3.8, 2.9].forEach((r, j) => P.push(ring(50, 50, a - j * 0.34, d, r)));
  } else {
    P.push({ x: 50, y: 50, r: 13 + 9 * lvl });
    for (let i = 0; i < 6; i++) {
      const pulse = Math.max(0, Math.sin(t * 4 - i * 0.9)) * lvl;
      P.push(ring(50, 50, (i * TAU) / 6 + t * 0.3, 10 + 18 * pulse * spread, 6.5 + 4 * pulse));
    }
  }
  /* Sized so the bodies overlap enough to read as one fluid after the
     threshold eats their soft edges. */
  return P.map((p) => ({ x: p.x, y: p.y, r: p.r * 1.35 }));
}

/* Three dots, the chat convention, still thresholded so they can touch and
   merge. */
function dotParts(state, t, lvl, k) {
  const xs = [26, 50, 74];
  const P = [];
  if (state === "connecting") {
    xs.forEach((x, i) => P.push({ x: x, y: 50, r: 5 + 4 * (0.5 + 0.5 * Math.sin(t * 2.2 - i * 0.9)) }));
  } else if (state === "listening") {
    xs.forEach((x, i) => P.push({ x: x, y: 50, r: 6 + 8 * lvl * (0.6 + 0.4 * noise(i, t * 2)) }));
  } else if (state === "thinking") {
    xs.forEach((x, i) => P.push({ x: x, y: 50 - (8 + 8 * k) * Math.max(0, Math.sin(t * 3 - i * 0.7)), r: 8 }));
  } else if (state === "searching") {
    xs.forEach((x) => P.push({ x: x, y: 50, r: 6 }));
    P.push({ x: 50 + 30 * Math.sin(t * 1.8), y: 50, r: 9 + 2 * k });
  } else {
    xs.forEach((x, i) => P.push({ x: x, y: 50, r: 5 + 10 * lvl * Math.max(0.25, Math.abs(Math.sin(t * 5 - i * 1.1))) }));
  }
  return P.map((p) => ({ x: p.x, y: p.y, r: p.r * 1.15 }));
}

/* Five bars, the voice convention. Heights are fractions of the box. */
function barHeights(state, t, lvl, k) {
  const H = [];
  for (let i = 0; i < BARS; i++) {
    let h;
    if (state === "connecting") h = 0.2 + 0.18 * (0.5 + 0.5 * Math.sin(t * 2 - i * 0.8));
    else if (state === "listening") h = 0.16 + 0.78 * lvl * ENVELOPE[i] * (0.7 + 0.3 * noise(i, t * 3));
    else if (state === "thinking") h = 0.25 + 0.4 * (0.5 + 0.5 * Math.sin(t * 2.4 - i * 0.9)) * (0.6 + 0.6 * k);
    else if (state === "searching") {
      const p = 2 + 2 * Math.sin(t * 1.6);
      h = 0.18 + 0.7 * Math.exp(-((i - p) * (i - p)) / 0.8);
    } else h = 0.14 + 0.82 * lvl * ENVELOPE[i] * Math.abs(Math.sin(t * 6 + i * 1.7));
    H.push(Math.max(0.1, Math.min(1, h)));
  }
  return H;
}

/* The duration token, in milliseconds, read from the element itself so a
   theme or context that retunes it is honoured. */
function readDuration(node) {
  const raw = node ? getComputedStyle(node).getPropertyValue("--dt-thinking-duration").trim() : "";
  const n = parseFloat(raw);
  if (!n) return 600;
  return /ms$/.test(raw) ? n : n * 1000;
}

function useReducedMotion() {
  const query = "(prefers-reduced-motion: reduce)";
  const [reduced, setReduced] = React.useState(() => typeof window !== "undefined" && !!window.matchMedia && window.matchMedia(query).matches);
  React.useEffect(() => {
    if (!window.matchMedia) return undefined;
    const mq = window.matchMedia(query);
    const sync = () => setReduced(mq.matches);
    mq.addEventListener("change", sync);
    return () => mq.removeEventListener("change", sync);
  }, []);
  return reduced;
}

function Fluid({ state, shape, colors, size, speed, intensity, level }) {
  const uid = "dt-thinking-" + React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const box = React.useRef(null);
  const circles = React.useRef([]);
  const bars = React.useRef([]);
  const gradient = React.useRef(null);
  const live = React.useRef({});
  const clock = React.useRef({ t: 0, level: 0 });
  const reduced = useReducedMotion();
  live.current = { state, shape, speed, intensity, level };

  const wide = shape === "dots" || shape === "bars";
  const well = WELLS[shape];
  const dim = SIZES[size] || size || SIZES.sm;

  React.useEffect(() => {
    let frame = 0;
    let last = performance.now();
    const c = clock.current;
    const beat = readDuration(box.current) / 600;

    function draw(now) {
      const o = live.current;
      const dt = Math.min(0.1, (now - last) / 1000);
      last = now;
      if (!reduced) c.t += (dt * Math.max(0.1, Number(o.speed) || 1)) / beat;
      const t = reduced ? 1.3 : c.t;
      const k = Math.max(0, Math.min(1, o.intensity == null ? 0.6 : Number(o.intensity)));
      const target = o.level == null ? synthLevel(o.state, t) : Math.max(0, Math.min(1, Number(o.level)));
      c.level += (target - c.level) * (reduced ? 1 : 0.25);
      const smooth = c.level;

      if (o.shape === "bars") {
        const H = barHeights(o.state, t, smooth, k);
        bars.current.forEach((rect, i) => {
          if (!rect) return;
          const h = H[i] * 56;
          rect.setAttribute("y", String(50 - h / 2));
          rect.setAttribute("height", String(h));
        });
      } else {
        const P = o.shape === "dots" ? dotParts(o.state, t, smooth, k) : blobParts(o.state, t, smooth, k);
        circles.current.forEach((node, i) => {
          if (!node) return;
          const p = P[i];
          node.setAttribute("cx", p ? p.x.toFixed(2) : "50");
          node.setAttribute("cy", p ? p.y.toFixed(2) : "50");
          node.setAttribute("r", p ? Math.max(0, p.r).toFixed(2) : "0");
        });
      }
      if (gradient.current) gradient.current.setAttribute("gradientTransform", "rotate(" + ((t * 24) % 360).toFixed(1) + " 50 50)");
      if (!reduced) frame = requestAnimationFrame(draw);
    }

    frame = requestAnimationFrame(draw);
    return () => cancelAnimationFrame(frame);
    /* The clock lives in a ref, so a state or shape change carries on from
       where the motion was. A live level is read each frame; only a frozen,
       reduced-motion render needs redrawing when it changes. */
  }, [reduced, state, shape, reduced ? level : null]);

  const viewBox = wide ? "0 18.75 100 62.5" : well ? "8 8 84 84" : "12 12 76 76";

  return (
    <span
      ref={box}
      aria-hidden="true"
      style={{
        display: "inline-block",
        flex: "none",
        position: "relative",
        height: dim,
        width: wide ? `calc(${dim} * 1.6)` : dim,
        borderRadius: well,
        overflow: well ? "hidden" : undefined,
        background: well ? "var(--dt-thinking-surface, var(--dt-surface-brand-muted))" : undefined,
        verticalAlign: "middle",
      }}
    >
      <svg viewBox={viewBox} width="100%" height="100%" style={{ display: "block", overflow: "visible" }}>
        <defs>
          <linearGradient id={uid + "-g"} ref={gradient} gradientUnits="userSpaceOnUse" x1="28" y1="28" x2="72" y2="72">
            <stop offset="0" style={{ stopColor: colors[0] }} />
            <stop offset="1" style={{ stopColor: colors[1] }} />
          </linearGradient>
          <filter id={uid + "-f"} x="-30%" y="-30%" width="160%" height="160%" colorInterpolationFilters="sRGB">
            <feGaussianBlur in="SourceGraphic" stdDeviation="3.5" result="blur" />
            <feColorMatrix in="blur" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -8" />
          </filter>
        </defs>
        {shape === "bars" ? (
          <g fill={`url(#${uid}-g)`}>
            {Array.from({ length: BARS }, (_, i) => (
              <rect key={i} ref={(n) => (bars.current[i] = n)} x={13 + i * 16} y="44" width="10" height="12" rx="5" />
            ))}
          </g>
        ) : (
          <g filter={`url(#${uid}-f)`} fill={`url(#${uid}-g)`}>
            {Array.from({ length: PARTICLES }, (_, i) => (
              <circle key={i} ref={(n) => (circles.current[i] = n)} cx="50" cy="50" r="0" />
            ))}
          </g>
        )}
      </svg>
    </span>
  );
}

/* The textural shapes: matrix, ascii, particles and sequence. Each builds
   its figure from many small marks on a canvas, so it reads like a dot
   display or ascii art rather than a liquid. */
const FIELD_SHAPES = ["matrix", "ascii", "particles", "sequence"];
const GLYPHS = " .·:-=+*#%@";

function clamp01(v) {
  return v < 0 ? 0 : v > 1 ? 1 : v;
}

function smoothstep(e0, e1, x) {
  const u = clamp01((x - e0) / (e1 - e0));
  return u * u * (3 - 2 * u);
}

function frac(v) {
  return v - Math.floor(v);
}

/* A lit sphere of radius R seen from the front: bright where it faces a light
   up and to the left, dim at the far rim, zero outside. This is what gives
   the ascii and matrix figures their depth. */
function sphere(x, y, R) {
  if (R <= 0) return 0;
  const q = (x * x + y * y) / (R * R);
  if (q >= 1) return 0;
  const z = Math.sqrt(1 - q);
  const lit = (-0.45 * x - 0.55 * y) / R + 0.7 * z;
  return (0.42 + 0.58 * clamp01(lit)) * Math.min(1, (1 - Math.sqrt(q)) * 10);
}

/* One scalar field per state, 0 to 1 over a -1..1 square and masked to the
   unit disc. The matrix samples it as dot sizes, ascii as glyph density. */
function fieldAt(state, x, y, t, lvl, k) {
  const r = Math.sqrt(x * x + y * y);
  if (r > 1) return 0;
  const a = Math.atan2(y, x);
  if (state === "connecting") {
    let v = 0;
    for (let j = 0; j < 2; j++) {
      const R = 1 - frac(t * 0.35 + j * 0.5);
      const fade = Math.min(1, (1 - R) * 5) * Math.min(1, R * 2.5);
      const d = (r - R) / (0.09 + 0.06 * k);
      v = Math.max(v, Math.exp(-d * d) * fade);
    }
    return Math.max(v, sphere(x, y, 0.22 + 0.06 * Math.sin(t * 3)));
  }
  if (state === "listening") {
    const R = 0.42 + 0.42 * lvl + (0.04 + 0.07 * k) * Math.sin(3 * a + t * 2) + 0.04 * Math.sin(5 * a - t * 1.3);
    return sphere(x, y, R);
  }
  if (state === "thinking") {
    const arms = 0.5 + 0.5 * Math.sin(3 * a - 7 * r * (0.6 + 0.6 * k) + t * 2.2);
    return Math.max(arms * smoothstep(1, 0.7, r) * smoothstep(0.1, 0.32, r), sphere(x, y, 0.16));
  }
  if (state === "searching") {
    let d = (t * 1.6 - a) % TAU;
    if (d < 0) d += TAU;
    const sweep = Math.exp(-d * (1.3 - 0.6 * k)) * smoothstep(0.98, 0.85, r) * smoothstep(0.12, 0.26, r);
    const e1 = (r - 0.55) / 0.04;
    const e2 = (r - 0.92) / 0.04;
    const rings = 0.3 * Math.max(Math.exp(-e1 * e1), Math.exp(-e2 * e2));
    return Math.max(sweep, rings, sphere(x, y, 0.15));
  }
  const ripple = (0.5 + 0.5 * Math.sin(11 * r - t * 6)) * (0.4 + 0.6 * lvl) * smoothstep(1, 0.75, r);
  return Math.max(ripple * smoothstep(0.1, 0.3, r), sphere(x, y, 0.24 + 0.26 * lvl));
}

/* Any CSS colour, token output included, as [r, g, b]: painted onto a single
   pixel and read back, so oklch, color-mix and hex all resolve the same way. */
let swatch = null;
const rgbCache = {};
function toRgb(css) {
  if (rgbCache[css]) return rgbCache[css];
  if (!swatch) {
    const cv = document.createElement("canvas");
    cv.width = cv.height = 1;
    swatch = cv.getContext("2d", { willReadFrequently: true });
  }
  swatch.clearRect(0, 0, 1, 1);
  swatch.fillStyle = "#000";
  swatch.fillStyle = css;
  swatch.fillRect(0, 0, 1, 1);
  const d = swatch.getImageData(0, 0, 1, 1).data;
  return (rgbCache[css] = [d[0], d[1], d[2]]);
}

function mix(a, b, g, alpha) {
  const u = clamp01(g);
  return "rgba(" + Math.round(a[0] + (b[0] - a[0]) * u) + "," + Math.round(a[1] + (b[1] - a[1]) * u) + "," + Math.round(a[2] + (b[2] - a[2]) * u) + "," + clamp01(alpha).toFixed(3) + ")";
}

/* matrix: a dot display. Every cell keeps a faint dot so the grid shows, and
   the field swells the dots into the figure. */
function drawMatrix(ctx, W, H, st, t, lvl, k, p) {
  let n = Math.max(5, Math.min(19, Math.round(Math.min(W, H) / 6)));
  if (n % 2 === 0) n += 1;
  const cell = Math.min(W, H) / n;
  const ox = (W - cell * n) / 2;
  const oy = (H - cell * n) / 2;
  const ga = t * 0.4;
  const ca = Math.cos(ga);
  const sa = Math.sin(ga);
  for (let i = 0; i < n; i++) {
    for (let j = 0; j < n; j++) {
      const x = ((i + 0.5) / n) * 2 - 1;
      const y = ((j + 0.5) / n) * 2 - 1;
      const v = fieldAt(st, x, y, t, lvl, k);
      ctx.fillStyle = mix(p.a, p.b, 0.5 + 0.45 * (x * ca + y * sa), 0.2 + 0.8 * v);
      ctx.beginPath();
      ctx.arc(ox + (i + 0.5) * cell, oy + (j + 0.5) * cell, cell * 0.42 * (0.18 + 0.82 * v), 0, TAU);
      ctx.fill();
    }
  }
}

/* ascii: the same field as glyphs of rising density, set in the mono token. */
function drawAscii(ctx, W, H, st, t, lvl, k, p) {
  const rows = Math.max(6, Math.min(20, Math.round(H / 8)));
  const ch = H / rows;
  const cw = ch * 0.62;
  const cols = Math.max(1, Math.floor(W / cw));
  const ox = (W - cols * cw) / 2;
  const half = Math.min(W, H) / 2;
  const ga = t * 0.4;
  const ca = Math.cos(ga);
  const sa = Math.sin(ga);
  ctx.font = "600 " + (ch * 1.08).toFixed(1) + "px " + p.mono;
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  for (let rI = 0; rI < rows; rI++) {
    for (let c = 0; c < cols; c++) {
      const px = ox + (c + 0.5) * cw;
      const py = (rI + 0.5) * ch;
      const x = (px - W / 2) / half;
      const y = (py - H / 2) / half;
      const v = fieldAt(st, x, y, t, lvl, k);
      const idx = Math.round(v * (GLYPHS.length - 1));
      if (!idx) continue;
      ctx.fillStyle = mix(p.a, p.b, 0.5 + 0.45 * (x * ca + y * sa), 0.45 + 0.55 * v);
      ctx.fillText(GLYPHS[idx], px, py);
    }
  }
}

/* particles: a point cloud on a sphere, turning in perspective. Nearer points
   are larger, stronger and take the end colour. */
const pointCache = {};
function fibonacci(n) {
  if (pointCache[n]) return pointCache[n];
  const pts = [];
  const golden = Math.PI * (3 - Math.sqrt(5));
  for (let i = 0; i < n; i++) {
    const y = 1 - ((i + 0.5) / n) * 2;
    const rr = Math.sqrt(1 - y * y);
    pts.push({ x: Math.cos(i * golden) * rr, y: y, z: Math.sin(i * golden) * rr, lat: Math.asin(y) });
  }
  return (pointCache[n] = pts);
}

function drawParticles(ctx, W, H, st, t, lvl, k, p) {
  const small = Math.min(W, H) < 48;
  const n = small ? 48 : 150;
  const pts = fibonacci(n);
  const R = (Math.min(W, H) / 2) * 0.86;
  const yaw = t * 0.45;
  const pitch = st === "searching" ? 0.95 : 0.35;
  const cy = Math.cos(yaw);
  const sy = Math.sin(yaw);
  const cp = Math.cos(pitch);
  const sp = Math.sin(pitch);
  const breathe = 0.5 + 0.5 * Math.sin(t * 1.5);
  const out = [];
  for (let i = 0; i < n; i++) {
    const q = pts[i];
    let x = q.x;
    let y = q.y;
    let z = q.z;
    let rr = 0.8;
    let hot = 0;
    if (st === "connecting") {
      rr = 0.55 + 0.3 * breathe + 0.35 * (1 - breathe) * noise(i, t * 0.6) * (0.5 + k);
    } else if (st === "listening") {
      rr = 0.5 + 0.4 * lvl * (0.85 + 0.15 * noise(i, t * 2));
    } else if (st === "thinking") {
      const tw = (0.8 + 1.8 * k) * Math.sin(t * 1.1) * y;
      const x0 = x;
      x = x0 * Math.cos(tw) - z * Math.sin(tw);
      z = x0 * Math.sin(tw) + z * Math.cos(tw);
    } else if (st === "searching") {
      /* The sphere flattens into a disc of dust, and a bright arc sweeps it. */
      const ph = (i / n) * TAU * 7;
      const band = 0.55 + 0.45 * frac(Math.sin(i * 78.233) * 43758.5453);
      x = Math.cos(ph) * band;
      z = Math.sin(ph) * band;
      y = 0.04 * Math.sin(i * 12.9898);
      let d = (t * 2.2 - (ph % TAU)) % TAU;
      if (d < 0) d += TAU;
      hot = Math.exp(-d * (1.6 - k));
    } else {
      rr = 0.62 + 0.12 * lvl + 0.16 * lvl * Math.sin(q.lat * 4 - t * 6);
    }
    x *= rr;
    y *= rr;
    z *= rr;
    const x1 = x * cy + z * sy;
    const z1 = -x * sy + z * cy;
    const y2 = y * cp - z1 * sp;
    const z2 = y * sp + z1 * cp;
    const s = 1 / (1 - 0.22 * z2);
    out.push({ sx: W / 2 + x1 * R * s, sy: H / 2 + y2 * R * s, z: z2, hot: hot });
  }
  out.sort((m, o) => m.z - o.z);
  const base = Math.max(0.7, Math.min(W, H) / 75);
  for (let i = 0; i < out.length; i++) {
    const o = out[i];
    const d = clamp01((o.z + 1) / 2);
    ctx.fillStyle = mix(p.a, p.b, Math.max(d, o.hot), 0.18 + 0.7 * d + 0.3 * o.hot);
    ctx.beginPath();
    ctx.arc(o.sx, o.sy, base * (0.55 + 0.9 * d) * (1 + 0.9 * o.hot), 0, TAU);
    ctx.fill();
  }
}

/* sequence: a ring of dots lit in order, the way a loader on a device panel
   steps round. */
function trail(head, i, K, len) {
  const d = (((head - i) % K) + K) % K;
  return d < len ? Math.pow(1 - d / len, 1.6) : 0;
}

function drawSequence(ctx, W, H, st, t, lvl, k, p) {
  const K = Math.min(W, H) < 48 ? 8 : 16;
  const half = Math.min(W, H) / 2;
  const ringR = half * 0.66;
  const dot = half * (K === 8 ? 0.19 : 0.12);
  for (let i = 0; i < K; i++) {
    let b = 0;
    let off = 0;
    if (st === "connecting") b = trail(frac(t * 0.45) * K, i, K, K * 0.45);
    else if (st === "listening") {
      b = 0.3 + 0.7 * lvl * (0.6 + 0.4 * noise(i, t * 2));
      off = 0.26 * lvl * (0.5 + 0.5 * noise(i + 5, t * 2.5)) * (0.5 + k);
    } else if (st === "thinking") {
      const head = frac(t * 0.32) * K;
      b = Math.max(trail(head, i, K, K * 0.3), trail(head + K / 2, i, K, K * 0.3));
    } else if (st === "searching") b = trail(frac(t * 0.85) * K, i, K, K * (0.35 + 0.3 * k));
    else {
      b = 0.35 + 0.65 * lvl;
      off = 0.3 * lvl * Math.abs(Math.sin(t * 6 - i * 0.9)) * (0.5 + k);
    }
    const ang = (i / K) * TAU - Math.PI / 2;
    const rr = ringR * (1 + off);
    ctx.fillStyle = mix(p.a, p.b, b, 0.16 + 0.84 * b);
    ctx.beginPath();
    ctx.arc(W / 2 + Math.cos(ang) * rr, H / 2 + Math.sin(ang) * rr, dot * (0.4 + 0.6 * b), 0, TAU);
    ctx.fill();
  }
}

const PAINTERS = { matrix: drawMatrix, ascii: drawAscii, particles: drawParticles, sequence: drawSequence };

function Field({ state, shape, colors, size, speed, intensity, level }) {
  const box = React.useRef(null);
  const canvas = React.useRef(null);
  const probeA = React.useRef(null);
  const probeB = React.useRef(null);
  const live = React.useRef({});
  const clock = React.useRef({ t: 0, level: 0 });
  const reduced = useReducedMotion();
  live.current = { state, shape, speed, intensity, level };
  const dim = SIZES[size] || size || SIZES.sm;

  React.useEffect(() => {
    const el = box.current;
    const cv = canvas.current;
    const ctx = cv && cv.getContext ? cv.getContext("2d") : null;
    if (!ctx) return undefined;
    const c = clock.current;
    const beat = readDuration(el) / 600;
    const view = { w: 0, h: 0, dpr: 0 };
    /* Colours and the mono family are read back from the page every half
       second or so, so Configure, a theme switch or a dark band shows up
       without a re-render. */
    const paint = { a: [0, 0, 0], b: [0, 0, 0], mono: "monospace", age: Infinity };
    let frame = 0;
    let timer = 0;
    let last = performance.now();

    function draw(now) {
      const o = live.current;
      const dt = Math.min(0.1, (now - last) / 1000);
      last = now;
      if (!reduced) c.t += (dt * Math.max(0.1, Number(o.speed) || 1)) / beat;
      const t = reduced ? 1.3 : c.t;
      const k = clamp01(o.intensity == null ? 0.6 : Number(o.intensity));
      const target = o.level == null ? synthLevel(o.state, t) : clamp01(Number(o.level));
      c.level += (target - c.level) * (reduced ? 1 : 0.25);

      const w = el.clientWidth;
      const h = el.clientHeight;
      const dpr = Math.min(2, window.devicePixelRatio || 1);
      if (w !== view.w || h !== view.h || dpr !== view.dpr) {
        view.w = w;
        view.h = h;
        view.dpr = dpr;
        cv.width = Math.max(1, Math.round(w * dpr));
        cv.height = Math.max(1, Math.round(h * dpr));
      }
      if (paint.age++ > 30) {
        paint.a = toRgb(getComputedStyle(probeA.current).color);
        paint.b = toRgb(getComputedStyle(probeB.current).color);
        paint.mono = getComputedStyle(el).getPropertyValue("--dt-font-family-mono").trim() || "ui-monospace, monospace";
        paint.age = 0;
      }
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);
      if (w && h) (PAINTERS[o.shape] || drawMatrix)(ctx, w, h, o.state, t, c.level, k, paint);
      if (!reduced) frame = requestAnimationFrame(draw);
    }

    frame = requestAnimationFrame(draw);
    /* Held still, the figure is repainted now and then so a resize or a
       colour change still lands. */
    if (reduced) timer = setInterval(() => { paint.age = Infinity; draw(performance.now()); }, 500);
    return () => {
      cancelAnimationFrame(frame);
      clearInterval(timer);
    };
  }, [reduced, state, shape, reduced ? level : null]);

  return (
    <span
      ref={box}
      aria-hidden="true"
      style={{ display: "inline-block", flex: "none", position: "relative", width: dim, height: dim, verticalAlign: "middle" }}
    >
      <span ref={probeA} hidden style={{ color: colors[0] }} />
      <span ref={probeB} hidden style={{ color: colors[1] }} />
      <canvas ref={canvas} style={{ display: "block", width: "100%", height: "100%" }} />
    </span>
  );
}

/* The light screen stays light on a dark page too. Custom properties resolve
   where they are declared, so the gradient stops, and the text, surface and
   border roles a caption reads, are pointed at light values right here. */
const LIGHT_BLUR = "var(--dt-backdrop-glass, saturate(1.4) blur(16px))";
const LIGHT_SCREEN = {
  "--dt-thinking-primary-start": "var(--dt-thinking-light-primary-start)",
  "--dt-thinking-primary-end": "var(--dt-thinking-light-primary-end)",
  "--dt-thinking-secondary-start": "var(--dt-thinking-light-secondary-start)",
  "--dt-thinking-secondary-end": "var(--dt-thinking-light-secondary-end)",
  "--dt-thinking-neutral-start": "var(--dt-thinking-light-neutral-start)",
  "--dt-thinking-neutral-end": "var(--dt-thinking-light-neutral-end)",
  "--dt-thinking-color-start": "var(--dt-thinking-light-color-start)",
  "--dt-thinking-color-end": "var(--dt-thinking-light-color-end)",
  "--dt-thinking-surface": "var(--dt-thinking-light-surface)",
  "--dt-text-primary": "var(--dt-thinking-screen-light-text)",
  "--dt-text-secondary": "var(--dt-thinking-screen-light-text-secondary)",
  "--dt-text-tertiary": "var(--dt-thinking-screen-light-text-secondary)",
  /* The light scale's own surface and border values, so a control in the
     caption reads as it would on a light page. */
  "--dt-surface-base": "var(--dt-color-white)",
  "--dt-surface-raised": "var(--dt-color-white)",
  "--dt-surface-overlay": "var(--dt-color-white)",
  "--dt-surface-subtle": "var(--dt-color-neutral-050)",
  "--dt-surface-sunken": "var(--dt-color-neutral-100)",
  "--dt-border-subtle": "var(--dt-color-neutral-100)",
  "--dt-border-default": "var(--dt-color-neutral-200)",
  "--dt-border-strong": "var(--dt-color-neutral-400)",
  color: "var(--dt-thinking-screen-light-text)",
};

export function Thinking({
  state = "thinking",
  mode = "inline",
  shape = "blob",
  tone = "brand",
  colors,
  size,
  speed = 1,
  intensity = 0.6,
  level,
  label,
  showLabel = true,
  screen = "dark",
  onDismiss,
  children,
  style,
  ...rest
}) {
  const text = label || LABELS[state] || LABELS.thinking;
  const pair = Array.isArray(colors) && colors.length === 2 ? colors : TONES[tone] || TONES.brand;
  const overlay = mode === "overlay";
  const light = overlay && screen === "light";

  React.useEffect(() => {
    if (!overlay || !onDismiss) return undefined;
    const onKey = (e) => { if (e.key === "Escape") onDismiss(); };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [overlay, onDismiss]);

  const Figure = FIELD_SHAPES.indexOf(shape) >= 0 ? Field : Fluid;
  const fluid = <Figure state={state} shape={shape} colors={pair} size={size || (overlay ? "xl" : "sm")} speed={speed} intensity={intensity} level={level} />;

  if (overlay) {
    return (
      <div
        role="status"
        aria-live="polite"
        style={{
          position: "fixed", inset: 0, zIndex: "var(--dt-z-overlay)",
          display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
          gap: "var(--dt-space-stack-lg)", padding: "var(--dt-space-inset-xl)", boxSizing: "border-box",
          background: light ? "var(--dt-thinking-screen-light)" : "var(--dt-thinking-scrim, var(--dt-surface-scrim))",
          backdropFilter: light ? LIGHT_BLUR : "blur(var(--dt-dialog-scrim-blur, 2px))",
          WebkitBackdropFilter: light ? LIGHT_BLUR : "blur(var(--dt-dialog-scrim-blur, 2px))",
          ...(light ? LIGHT_SCREEN : null),
          ...style,
        }}
        {...rest}
      >
        {fluid}
        <span style={{ fontFamily: "var(--dt-text-heading-sm-family)", fontSize: "var(--dt-text-heading-sm-size)", lineHeight: "var(--dt-text-heading-sm-line)", fontWeight: "var(--dt-text-heading-sm-weight)", color: light ? "var(--dt-thinking-screen-light-text)" : "var(--dt-text-on-scrim, var(--dt-color-neutral-050))" }}>{text}</span>
        {children && (
          <div style={{ maxWidth: "var(--dt-measure-narrow)", textAlign: "center", fontFamily: "var(--dt-text-body-md-family)", fontSize: "var(--dt-text-body-md-size)", lineHeight: "var(--dt-text-body-md-line)", color: light ? "var(--dt-thinking-screen-light-text-secondary)" : "var(--dt-text-on-scrim-secondary, var(--dt-color-neutral-200))" }}>{children}</div>
        )}
        {onDismiss && (
          <button
            type="button"
            aria-label="Close"
            onClick={onDismiss}
            style={{
              position: "absolute", top: "var(--dt-space-inset-lg)", right: "var(--dt-space-inset-lg)",
              width: "var(--dt-size-control-md)", height: "var(--dt-size-control-md)", border: 0,
              borderRadius: "var(--dt-radius-pill)", background: "transparent", cursor: "pointer",
              color: light ? "var(--dt-thinking-screen-light-text)" : "var(--dt-text-on-scrim, var(--dt-color-neutral-050))", fontSize: "var(--dt-font-size-xl)", lineHeight: 1,
            }}
          >×</button>
        )}
      </div>
    );
  }

  return (
    <span
      role="status"
      aria-live="polite"
      aria-label={showLabel ? undefined : text}
      style={{ display: "inline-flex", alignItems: "center", gap: "var(--dt-space-inline-xs)", verticalAlign: "middle", ...style }}
      {...rest}
    >
      {fluid}
      {showLabel && (
        <span style={{ fontFamily: "var(--dt-text-body-sm-family)", fontSize: "var(--dt-text-body-sm-size)", lineHeight: "var(--dt-text-body-sm-line)", color: "var(--dt-thinking-label-color, var(--dt-text-secondary))" }}>{text}</span>
      )}
      {children}
    </span>
  );
}
```
