{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "blueprint",
  "type": "registry:ui",
  "title": "Blueprint",
  "description": "A finished layout that builds itself. It starts as a wireframe measured from your real markup, then fills in block by block: text streams in, photos come into focus, buttons pop up. Scrub back and it comes apart again.",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/blueprint.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  animate,\n  isMotionValue,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n  type AnimationPlaybackControls,\n  type MotionValue,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─────────────────────────────────────────────────────────\n * BLUEPRINT STORYBOARD\n *\n *  wireframe  every [data-blueprint] part is measured from the\n *             real markup and drawn as a skeleton: text as one\n *             bar per rendered line, images as boxes with a thin\n *             ✕, buttons as pills, avatars as circles, surfaces\n *             in gray, outlined if they have a border. The page is\n *             laid out underneath from the first frame, so nothing\n *             moves as it builds\n *  build      one timeline from 0 to 1. Each block takes a slice\n *             of it that overlaps its neighbors, in reading order\n *    text     words stream in over their bars, each from a slight\n *             blur to sharp, and the bar retreats ahead of them\n *             like a print head. Headlines carry a caret\n *    image    the ✕ fades, then the photo develops from blurred\n *             and gray to sharp full color, settling from a few\n *             percent larger inside its frame\n *    button   pops in on a soft spring with the smallest settle\n *    box      fills from gray to its real color while its border\n *             draws in around it, then its shadow comes up\n *  annotate   a small spec label sits beside each block until\n *             it's built\n *  scrub      every frame is a pure function of progress, so\n *             scrubbing back takes the page apart exactly\n *  auto       rest on the wireframe, build, hold the page, come\n *             apart, again. Hovering pauses it, and so does being\n *             off screen or in a background tab\n *  scroll     progress follows the section scrolling into view\n *  reduced    no streaming, blur or pops: each block fades from\n *             skeleton to content in turn. Auto mode shows the\n *             finished page with a Replay button\n * ───────────────────────────────────────────────────────── */\n\nconst BLUEPRINT = {\n  build: 5, // s from wireframe to finished page\n  hold: 2.5, // s the finished page stays up\n  unbuild: 1, // s to come apart\n  rest: 0.8, // s on the wireframe before the next build\n  lead: 0.5, // the next block starts once the one before is this far along\n  weight: { word: 0.05, text: [0.6, 1.8], image: 1.2, avatar: 0.6, button: 0.6, box: 0.7 },\n  span: 6, // characters behind the print head a word takes to come into focus\n  blur: 0.12, // em a word starts blurred by\n  bar: 0.6, // em, height of a text bar\n  split: 1.2, // em; a wider gap between words starts a new bar (table cells, nav links)\n  develop: { blur: 14, saturate: 0.1, zoom: 0.05 }, // px max blur, starting saturation, extra scale\n  pop: { from: 0.82, damping: 0.72, decay: 6 }, // decay is ζω: the spring settles by the end of its slice\n  scroll: { stiffness: 400, damping: 60, restDelta: 0.0005 },\n  replay: 0.6, // a reduced-motion replay runs this much of the build duration\n} as const;\n\n// Skeleton tones, all from the layer's text color, so `skeletonClassName=\"text-white\"` restyles every part.\nconst TONE = {\n  bar: \"color-mix(in oklab, currentColor 22%, transparent)\",\n  fill: \"color-mix(in oklab, currentColor 14%, transparent)\",\n  line: \"color-mix(in oklab, currentColor 38%, transparent)\",\n  chip: \"color-mix(in oklab, currentColor 10%, transparent)\",\n};\nconst SURFACE = 9; // % of the skeleton color a box's surface starts at\n\nexport type BlueprintKind = \"text\" | \"image\" | \"button\" | \"box\";\nexport type BlueprintState = \"wireframe\" | \"building\" | \"built\";\nexport type BlueprintTiming = { build: number; hold: number; unbuild: number; rest: number };\n\n/* ── Math ───────────────────────────────────────────────── */\n\nconst clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);\nconst smooth = (v: number) => {\n  const t = clamp01(v);\n  return t * t * (3 - 2 * t);\n};\nconst easeOut = (v: number) => 1 - (1 - clamp01(v)) ** 3;\nconst easeInOut = (v: number) => {\n  const t = clamp01(v);\n  return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;\n};\n// A damped spring from 0 to 1 over t = 0…1, solved in closed form so it can be scrubbed.\nconst spring = (t: number) => {\n  if (t <= 0) return 0;\n  if (t >= 1) return 1;\n  const { damping, decay } = BLUEPRINT.pop;\n  const wd = (decay / damping) * Math.sqrt(1 - damping * damping);\n  return 1 - Math.exp(-decay * t) * (Math.cos(wd * t) + (decay / wd) * Math.sin(wd * t));\n};\nconst fix = (v: number, digits = 3) => String(Number(v.toFixed(digits)));\n// Within a hair of 1, a scale is dropped, so text and edges don't re-rasterize when the block hands back.\nconst scaleOf = (v: number) => (Math.abs(v - 1) < 0.002 ? \"none\" : fix(v, 4));\n\nfunction timingOf(duration: BlueprintProps[\"duration\"]): BlueprintTiming {\n  const own = typeof duration === \"number\" ? { build: duration } : (duration ?? {});\n  return {\n    build: own.build ?? BLUEPRINT.build,\n    hold: own.hold ?? BLUEPRINT.hold,\n    unbuild: own.unbuild ?? BLUEPRINT.unbuild,\n    rest: own.rest ?? BLUEPRINT.rest,\n  };\n}\n\n// One auto cycle: rest on the wireframe, build, hold, come apart.\nfunction progressAt(t: number, { rest, build, hold, unbuild }: BlueprintTiming) {\n  if (t < rest) return 0;\n  if (t < rest + build) return (t - rest) / build;\n  if (t < rest + build + hold) return 1;\n  return 1 - easeInOut((t - rest - build - hold) / unbuild);\n}\n\n/* ── Measuring ──────────────────────────────────────────── */\n\ntype Rect = { x: number; y: number; w: number; h: number };\n\ntype Count = { prefix: string; value: number; decimals: number; group: boolean; suffix: string };\n\ntype Word = Rect & {\n  text: string;\n  /** Characters before this word, counting one per gap. The print head moves in characters. */\n  c0: number;\n  size: number;\n  css: string;\n  /** Opacity the real text is drawn at, from its own and its ancestors' opacity. */\n  alpha: number;\n  count?: Count;\n  /** The edge a counting number holds as its width changes. */\n  anchor: Anchor;\n};\n\ntype Line = Rect & { c0: number; c1: number; words: number[]; bar: Rect };\n\ntype Base = {\n  el: HTMLElement;\n  id: number;\n  /** Position in reading order, before `data-blueprint-order` reshuffles the build. */\n  dom: number;\n  rect: Rect;\n  radius: string;\n  label: string;\n  start: number;\n  end: number;\n};\n\ntype Block =\n  | (Base & { kind: \"text\"; words: Word[]; lines: Line[]; chars: number; caret: string | null })\n  | (Base & { kind: \"image\"; round: boolean; blur: number })\n  | (Base & { kind: \"button\"; lines: Line[] })\n  | (Base & {\n      kind: \"box\";\n      background: string;\n      border: string;\n      borderWidth: number;\n      shadow: string;\n      surface: string;\n    });\n\ntype Anchor = \"left\" | \"center\" | \"right\";\n\nconst KINDS: BlueprintKind[] = [\"text\", \"image\", \"button\", \"box\"];\nconst WORD = /\\S+/g;\nconst NUMBER = /^(\\D*?)(\\d[\\d,]*(?:\\.\\d+)?)(.*)$/;\nconst COLOR = /(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\\([^()]*\\)|#[0-9a-f]{3,8}\\b/gi;\nconst PX = /(\\d*\\.?\\d+(?:e[+-]?\\d+)?)px/g;\nconst RATIOS = [\n  [1, 1], [4, 3], [3, 4], [3, 2], [2, 3], [16, 9], [9, 16], [4, 5], [5, 4], [21, 9], [2, 1],\n];\n// Text properties a word's stand-in copies, so it lands on the real glyphs.\nconst TEXT_STYLE = [\n  \"font-family\", \"font-size\", \"font-weight\", \"font-style\", \"font-stretch\", \"font-variant\",\n  \"font-feature-settings\", \"font-variation-settings\", \"font-kerning\", \"font-optical-sizing\",\n  \"letter-spacing\", \"text-transform\", \"text-decoration\", \"text-shadow\", \"color\",\n  \"-webkit-font-smoothing\", \"-moz-osx-font-smoothing\", \"text-rendering\",\n];\n\nconst ids = new WeakMap<Element, number>();\nlet nextId = 0;\nconst idOf = (el: Element) => {\n  let id = ids.get(el);\n  if (id === undefined) ids.set(el, (id = nextId++));\n  return id;\n};\n\nconst isClear = (color: string) =>\n  color === \"transparent\" || /^rgba\\(.*,\\s*0\\)$/.test(color) || /\\/\\s*0\\)$/.test(color);\n\nconst anchorOf = (align: string): Anchor =>\n  align === \"center\" ? \"center\" : /right|end/.test(align) ? \"right\" : \"left\";\n\nfunction kindOf(el: HTMLElement): BlueprintKind {\n  const own = el.getAttribute(\"data-blueprint\") as BlueprintKind;\n  if (KINDS.includes(own)) return own;\n  const tag = el.tagName.toUpperCase();\n  if (/^(IMG|PICTURE|VIDEO|CANVAS|SVG|IFRAME)$/.test(tag)) return \"image\";\n  if (tag === \"BUTTON\" || el.getAttribute(\"role\") === \"button\") return \"button\";\n  if (tag === \"INPUT\") {\n    return /^(button|submit|reset)$/.test((el as HTMLInputElement).type) ? \"button\" : \"box\";\n  }\n  const style = getComputedStyle(el);\n  const text = Boolean(el.textContent?.trim());\n  if (tag === \"A\" && style.display !== \"inline\") {\n    if (!isClear(style.backgroundColor) || parseFloat(style.borderTopWidth) > 0) return \"button\";\n  }\n  if (!text && style.backgroundImage !== \"none\") return \"image\";\n  const blocky = Array.from(el.children).some(\n    (child) => !getComputedStyle(child).display.startsWith(\"inline\"),\n  );\n  return text && !blocky ? \"text\" : \"box\";\n}\n\nfunction radiusOf(el: HTMLElement, rect: DOMRect) {\n  const read = (s: CSSStyleDeclaration) =>\n    [s.borderTopLeftRadius, s.borderTopRightRadius, s.borderBottomRightRadius, s.borderBottomLeftRadius].join(\" \");\n  const own = getComputedStyle(el);\n  if (parseFloat(own.borderTopLeftRadius) || parseFloat(own.borderBottomRightRadius)) return read(own);\n  // An image cropped by a rounded wrapper takes the wrapper's corners.\n  let parent = el.parentElement;\n  for (let i = 0; parent && i < 3; i++, parent = parent.parentElement) {\n    const r = parent.getBoundingClientRect();\n    if (Math.abs(r.width - rect.width) > 1 || Math.abs(r.height - rect.height) > 1) break;\n    const s = getComputedStyle(parent);\n    if (s.overflow !== \"visible\" && parseFloat(s.borderTopLeftRadius)) return read(s);\n  }\n  return read(own);\n}\n\nfunction ratioOf(w: number, h: number) {\n  const r = w / h;\n  const hit = RATIOS.find(([a, b]) => Math.abs(a / b - r) / r < 0.03);\n  return hit ? `${hit[0]}:${hit[1]}` : `${Math.round(w)}×${Math.round(h)}`;\n}\n\nfunction labelOf(el: HTMLElement, kind: BlueprintKind, rect: Rect, round: boolean) {\n  const own = el.getAttribute(\"data-blueprint-label\");\n  if (own !== null) return own;\n  if (kind === \"image\") return round ? \"Avatar\" : `Image ${ratioOf(rect.w, rect.h)}`;\n  if (kind === \"button\") return \"Button\";\n  if (kind === \"box\") return `Box ${Math.round(rect.w)}×${Math.round(rect.h)}`;\n  const style = getComputedStyle(el);\n  const tag = el.tagName.toUpperCase();\n  const name = /^H[1-6]$/.test(tag) ? tag : tag === \"P\" ? \"Body\" : \"Text\";\n  const size = Math.round(parseFloat(style.fontSize));\n  const leading = style.lineHeight === \"normal\" ? \"\" : `/${Math.round(parseFloat(style.lineHeight))}`;\n  return `${name} · ${size}${leading}`;\n}\n\nfunction parseCount(text: string): Count | undefined {\n  const match = NUMBER.exec(text);\n  if (!match) return undefined;\n  const [, prefix, digits, suffix] = match;\n  return {\n    prefix,\n    value: parseFloat(digits.replace(/,/g, \"\")),\n    decimals: digits.split(\".\")[1]?.length ?? 0,\n    group: digits.includes(\",\"),\n    suffix,\n  };\n}\n\nfunction countText({ prefix, value, decimals, group, suffix }: Count, t: number) {\n  const n = (value * t).toLocaleString(\"en-US\", {\n    minimumFractionDigits: decimals,\n    maximumFractionDigits: decimals,\n    useGrouping: group,\n  });\n  return prefix + n + suffix;\n}\n\n/** Splits a text element into words with their rendered boxes, then groups them into bars. */\nfunction readText(el: HTMLElement, stage: HTMLElement, toRect: (r: DOMRect) => Rect, count: boolean) {\n  type Source = {\n    css: string;\n    size: number;\n    skip: boolean;\n    clip: HTMLElement | null;\n    anchor: Anchor;\n    alpha: number;\n  };\n  const sources = new Map<Element, Source>();\n  const sourceOf = (parent: HTMLElement): Source => {\n    let source = sources.get(parent);\n    if (source) return source;\n    const style = getComputedStyle(parent);\n    const r = parent.getBoundingClientRect();\n    // Gradient text paints the background of the element that clips it, so the stand-ins borrow it.\n    let clip: HTMLElement | null = null;\n    for (let node: HTMLElement | null = parent; node; node = node.parentElement) {\n      const s = getComputedStyle(node);\n      if (s.backgroundClip === \"text\" || s.getPropertyValue(\"-webkit-background-clip\") === \"text\") {\n        clip = node;\n        break;\n      }\n      if (node === el) break;\n    }\n    let alpha = 1;\n    for (let node: HTMLElement | null = parent; node && node !== stage; node = node.parentElement) {\n      alpha *= parseFloat(getComputedStyle(node).opacity);\n    }\n    source = {\n      css: TEXT_STYLE.map((p) => {\n        const v = style.getPropertyValue(p);\n        return v ? `${p}:${v};` : \"\";\n      }).join(\"\"),\n      size: parseFloat(style.fontSize) || 16,\n      // Screen-reader-only and hidden text keeps its box but doesn't paint.\n      skip: style.visibility !== \"visible\" || r.width <= 1 || r.height <= 1,\n      clip,\n      anchor: anchorOf(style.textAlign),\n      alpha,\n    };\n    sources.set(parent, source);\n    return source;\n  };\n\n  const words: Word[] = [];\n  const range = document.createRange();\n  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);\n  let chars = 0;\n  for (let node = walker.nextNode(); node; node = walker.nextNode()) {\n    const text = node as Text;\n    const parent = text.parentElement;\n    if (!parent) continue;\n    const source = sourceOf(parent);\n    if (source.skip) continue;\n    for (const match of text.data.matchAll(WORD)) {\n      range.setStart(text, match.index);\n      range.setEnd(text, match.index + match[0].length);\n      const r = range.getClientRects()[0];\n      if (!r || r.width === 0) continue;\n      const rect = toRect(r);\n      let css = source.css;\n      if (source.clip) {\n        const style = getComputedStyle(source.clip);\n        const box = toRect(source.clip.getBoundingClientRect());\n        css +=\n          `background-image:${style.backgroundImage};background-repeat:no-repeat;` +\n          `background-size:${box.w}px ${box.h}px;` +\n          `background-position:${box.x - rect.x}px ${box.y - rect.y}px;` +\n          \"-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;\";\n      }\n      words.push({\n        ...rect,\n        text: match[0],\n        c0: chars,\n        size: source.size,\n        css,\n        alpha: source.alpha,\n        count: count ? parseCount(match[0]) : undefined,\n        anchor: source.anchor,\n      });\n      chars += match[0].length + 1;\n    }\n  }\n  return { words, lines: linesOf(words), chars: Math.max(chars - 1, 0) };\n}\n\nfunction linesOf(words: Word[]): Line[] {\n  const lines: Line[] = [];\n  let line: Line | undefined;\n  words.forEach((w, i) => {\n    const prev = line && words[line.words[line.words.length - 1]];\n    const right = prev ? prev.x + prev.w : 0;\n    const joins =\n      prev &&\n      Math.abs(w.y + w.h / 2 - (prev.y + prev.h / 2)) < Math.min(w.h, prev.h) / 2 &&\n      w.x >= right - 1 &&\n      w.x - right < BLUEPRINT.split * Math.max(w.size, prev.size);\n    if (!line || !joins) {\n      line = { x: w.x, y: w.y, w: w.w, h: w.h, c0: w.c0, c1: w.c0 + w.text.length, words: [i], bar: w };\n      lines.push(line);\n      return;\n    }\n    const r = Math.max(line.x + line.w, w.x + w.w);\n    const b = Math.max(line.y + line.h, w.y + w.h);\n    line.y = Math.min(line.y, w.y);\n    line.w = r - line.x;\n    line.h = b - line.y;\n    line.c1 = w.c0 + w.text.length;\n    line.words.push(i);\n  });\n  for (const l of lines) {\n    const h = Math.min(l.h, words[l.words[0]].size * BLUEPRINT.bar);\n    l.bar = { x: l.x, y: l.y + (l.h - h) / 2, w: l.w, h };\n  }\n  return lines;\n}\n\n/** Where the print head sits along a line, in px, when it has passed `head` characters. */\nfunction xAt(words: Word[], line: Line, head: number) {\n  let right = line.x;\n  let end = line.c0;\n  for (const i of line.words) {\n    const w = words[i];\n    if (head < w.c0) return right + (w.x - right) * clamp01((head - end) / Math.max(w.c0 - end, 1e-6));\n    const stop = w.c0 + w.text.length;\n    if (head <= stop) return w.x + (w.w * (head - w.c0)) / w.text.length;\n    right = w.x + w.w;\n    end = stop;\n  }\n  return right;\n}\n\nfunction weightOf(block: Block) {\n  const { weight } = BLUEPRINT;\n  if (block.kind === \"text\") {\n    const [min, max] = weight.text;\n    return Math.min(Math.max(0.45 + weight.word * block.words.length, min), max);\n  }\n  if (block.kind === \"image\") return block.round ? weight.avatar : weight.image;\n  return weight[block.kind];\n}\n\nfunction readLayout(stage: HTMLElement, layer: HTMLElement): Block[] {\n  const origin = stage.getBoundingClientRect();\n  const zoom = origin.width / (stage.offsetWidth || origin.width) || 1; // a scaled ancestor\n  const dx = origin.left + stage.clientLeft * zoom - stage.scrollLeft * zoom;\n  const dy = origin.top + stage.clientTop * zoom - stage.scrollTop * zoom;\n  const toRect = (r: DOMRect): Rect => ({\n    x: (r.left - dx) / zoom,\n    y: (r.top - dy) / zoom,\n    w: r.width / zoom,\n    h: r.height / zoom,\n  });\n  const ink = getComputedStyle(layer).color;\n\n  const kinds = new Map<HTMLElement, BlueprintKind>();\n  const kind = (el: HTMLElement) => {\n    let k = kinds.get(el);\n    if (!k) kinds.set(el, (k = kindOf(el)));\n    return k;\n  };\n  const marked = Array.from(stage.querySelectorAll<HTMLElement>(\"[data-blueprint]\")).filter((el) => {\n    if (layer.contains(el) || !el.getClientRects().length) return false;\n    // Only boxes hold other blocks.\n    for (let p = el.parentElement; p && p !== stage; p = p.parentElement) {\n      if (p.hasAttribute(\"data-blueprint\") && kind(p) !== \"box\") return false;\n    }\n    return true;\n  });\n  const dom = new Map(marked.map((el, i) => [el, i]));\n  const order = (el: HTMLElement) => Number(el.getAttribute(\"data-blueprint-order\")) || 0;\n  marked.sort((a, b) => order(a) - order(b)); // stable, so reading order holds within an order\n\n  const blocks = marked.map((el): Block => {\n    const k = kind(el);\n    const box = el.getBoundingClientRect();\n    const rect = toRect(box);\n    const radius = radiusOf(el, box);\n    const round = Math.abs(rect.w - rect.h) < 2 && parseFloat(radius) >= Math.min(rect.w, rect.h) / 2 - 0.5;\n    const base = {\n      el,\n      id: idOf(el),\n      dom: dom.get(el) ?? 0,\n      rect,\n      radius,\n      label: labelOf(el, k, rect, round),\n      start: 0,\n      end: 1,\n    };\n    if (k === \"image\") {\n      // Blur scales with the photo, so a small one doesn't turn to mush.\n      const blur = Math.min(BLUEPRINT.develop.blur, Math.min(rect.w, rect.h) * 0.035);\n      return { ...base, kind: k, round, blur };\n    }\n    if (k === \"button\") return { ...base, kind: k, lines: readText(el, stage, toRect, false).lines };\n    if (k === \"box\") {\n      const s = getComputedStyle(el);\n      return {\n        ...base,\n        kind: k,\n        background: s.backgroundColor,\n        border: s.borderTopColor,\n        borderWidth: parseFloat(s.borderTopWidth) || 0,\n        shadow: s.boxShadow,\n        surface: `color-mix(in oklab, ${ink} ${SURFACE}%, transparent)`,\n      };\n    }\n    const s = getComputedStyle(el);\n    const text = readText(el, stage, toRect, el.closest(\"[data-blueprint-count]\") !== null);\n    const size = parseFloat(s.fontSize);\n    const tag = el.tagName.toUpperCase();\n    return {\n      ...base,\n      kind: k,\n      ...text,\n      caret: tag === \"H1\" || tag === \"H2\" || size >= 28 ? s.color : null,\n    };\n  });\n\n  // Overlapping slices: each block starts once the one before is `lead` of the way through.\n  let t = 0;\n  const spans = blocks.map((block) => {\n    const w = weightOf(block);\n    const span = [t, t + w];\n    t += w * BLUEPRINT.lead;\n    return span;\n  });\n  const total = Math.max(1e-6, ...spans.map(([, end]) => end));\n  blocks.forEach((block, i) => {\n    block.start = spans[i][0] / total;\n    block.end = spans[i][1] / total;\n  });\n  return blocks;\n}\n\n/* ── Painting ───────────────────────────────────────────── */\n\ntype Nodes = {\n  group: HTMLElement;\n  bars: HTMLElement[];\n  words: HTMLElement | null;\n  wordNodes: HTMLElement[];\n  caret: HTMLElement | null;\n  part: HTMLElement | null;\n  cross: SVGElement | null;\n  draw: SVGRectElement | null;\n  label: HTMLElement | null;\n  /** The label sits over the block's own skeleton, so it leaves as soon as the block starts. */\n  inside: boolean;\n};\n\ntype Style = Partial<Record<keyof CSSStyleDeclaration, string>>;\nconst SVG = \"http://www.w3.org/2000/svg\";\nconst px = (v: number) => `${v}px`;\nconst place = ({ x, y, w, h }: Rect): Style => ({ left: px(x), top: px(y), width: px(w), height: px(h) });\n\nfunction make(parent: Element, style: Style = {}) {\n  const el = document.createElement(\"div\");\n  el.style.position = \"absolute\";\n  Object.assign(el.style, style);\n  parent.appendChild(el);\n  return el;\n}\n\n/** Writes a style only when it changes. */\nfunction put(el: HTMLElement | SVGElement | null, prop: string, value: string) {\n  if (el && el.style.getPropertyValue(prop) !== value) el.style.setProperty(prop, value);\n}\n\n// Inline styles Blueprint has written on the real page, with the values they replaced.\nconst originals = new WeakMap<HTMLElement, Map<string, string>>();\n\nfunction paint(el: HTMLElement, prop: string, value: string) {\n  let saved = originals.get(el);\n  if (!saved) originals.set(el, (saved = new Map()));\n  if (!saved.has(prop)) saved.set(prop, el.style.getPropertyValue(prop));\n  put(el, prop, value);\n}\n\nfunction unpaint(el: HTMLElement) {\n  const saved = originals.get(el);\n  if (!saved) return;\n  for (const [prop, value] of saved) {\n    if (value) el.style.setProperty(prop, value);\n    else el.style.removeProperty(prop);\n  }\n  originals.delete(el);\n}\n\nconst mix = (real: string, t: number, from: string) =>\n  t <= 0 ? from : `color-mix(in oklab, ${real} ${fix(t * 100, 2)}%, ${from})`;\nconst fadeColors = (value: string, t: number) =>\n  value.replace(COLOR, (c) => `color-mix(in srgb, ${c} ${fix(t * 100, 2)}%, transparent)`);\n\nfunction build(layer: HTMLElement, blocks: Block[], annotate: boolean, width: number): Nodes[] {\n  layer.replaceChildren();\n  const nodes = blocks.map((block) => {\n    const group = make(layer, { inset: \"0\", display: \"none\" });\n    const n: Nodes = {\n      group,\n      bars: [],\n      words: null,\n      wordNodes: [],\n      caret: null,\n      part: null,\n      cross: null,\n      draw: null,\n      label: null,\n      inside: false,\n    };\n    const { rect, radius } = block;\n    const bar = (parent: Element, r: Rect, offset = { x: 0, y: 0 }) =>\n      make(parent, {\n        ...place({ ...r, x: r.x - offset.x, y: r.y - offset.y }),\n        borderRadius: px(Math.min(r.h / 2, 6)),\n        background: TONE.bar,\n      });\n\n    if (block.kind === \"text\") {\n      n.bars = block.lines.map((line) => bar(group, line.bar));\n      n.words = make(group, { inset: \"0\", display: \"none\" });\n      n.wordNodes = block.words.map((w) => {\n        const span = document.createElement(\"span\");\n        // Counting numbers change width, so they hold the edge the text is aligned to.\n        const edge =\n          w.count && w.anchor === \"right\"\n            ? `right:${width - w.x - w.w}px;`\n            : w.count && w.anchor === \"center\"\n              ? `left:${w.x + w.w / 2}px;translate:-50% 0;`\n              : `left:${w.x}px;`;\n        span.style.cssText =\n          `position:absolute;${edge}top:${w.y}px;height:${w.h}px;line-height:${w.h}px;` +\n          `white-space:pre;text-indent:0;word-spacing:normal;opacity:0;${w.css}`;\n        span.textContent = w.text;\n        n.words!.appendChild(span);\n        return span;\n      });\n      if (block.caret) {\n        n.caret = make(group, {\n          left: \"0\",\n          top: \"0\",\n          width: px(Math.max(2, Math.round((block.words[0]?.size ?? 16) * 0.05))),\n          borderRadius: \"1px\",\n          background: block.caret,\n          opacity: \"0\",\n        });\n      }\n    } else if (block.kind === \"image\") {\n      n.part = make(group, {\n        ...place(rect),\n        borderRadius: radius,\n        overflow: \"hidden\",\n        background: TONE.fill,\n        boxShadow: `inset 0 0 0 1px ${TONE.line}`,\n      });\n      if (!block.round && Math.min(rect.w, rect.h) >= 40) {\n        const svg = document.createElementNS(SVG, \"svg\");\n        svg.setAttribute(\"viewBox\", `0 0 ${rect.w} ${rect.h}`);\n        Object.assign(svg.style, { position: \"absolute\", inset: \"0\", width: \"100%\", height: \"100%\" });\n        const path = document.createElementNS(SVG, \"path\");\n        path.setAttribute(\"d\", `M0 0L${rect.w} ${rect.h}M${rect.w} 0L0 ${rect.h}`);\n        path.setAttribute(\"stroke\", TONE.line);\n        path.setAttribute(\"stroke-width\", \"1\");\n        path.setAttribute(\"fill\", \"none\");\n        svg.appendChild(path);\n        n.part.appendChild(svg);\n        n.cross = svg;\n      }\n    } else if (block.kind === \"button\") {\n      n.part = make(group, {\n        ...place(rect),\n        borderRadius: radius,\n        background: TONE.fill,\n        boxShadow: `inset 0 0 0 1px ${TONE.line}`,\n      });\n      for (const line of block.lines) bar(n.part, line.bar, rect);\n    } else if (block.borderWidth > 0) {\n      // A bordered box gets a wireframe outline, then its real border draws in over it.\n      // Borderless boxes (a page, a chart bar) are just their gray surface.\n      n.part = make(group, {\n        ...place(rect),\n        borderRadius: radius,\n        boxShadow: `inset 0 0 0 1px ${TONE.line}`,\n      });\n      const bw = block.borderWidth;\n      const svg = document.createElementNS(SVG, \"svg\");\n      Object.assign(svg.style, { position: \"absolute\", overflow: \"visible\", ...place(rect) });\n      const outline = document.createElementNS(SVG, \"rect\");\n      const r = Math.min(parseFloat(radius) || 0, rect.w / 2, rect.h / 2);\n      outline.setAttribute(\"x\", String(bw / 2));\n      outline.setAttribute(\"y\", String(bw / 2));\n      outline.setAttribute(\"width\", String(Math.max(rect.w - bw, 0)));\n      outline.setAttribute(\"height\", String(Math.max(rect.h - bw, 0)));\n      outline.setAttribute(\"rx\", String(Math.max(r - bw / 2, 0)));\n      outline.setAttribute(\"pathLength\", \"1\");\n      outline.setAttribute(\"fill\", \"none\");\n      outline.setAttribute(\"stroke\", block.border);\n      outline.setAttribute(\"stroke-width\", String(bw));\n      outline.style.strokeDasharray = \"1 2\";\n      outline.style.strokeDashoffset = \"1\";\n      svg.appendChild(outline);\n      group.appendChild(svg);\n      n.draw = outline;\n    }\n    return n;\n  });\n\n  if (annotate) {\n    // Labels go just above their block, or tuck into its corner when that's taken by another\n    // block or label. One that fits neither is left out.\n    const placed: Rect[] = [];\n    const hits = (a: Rect, b: Rect) =>\n      a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;\n    const byReading = blocks.map((_, i) => i).sort((a, b) => blocks[a].dom - blocks[b].dom);\n    for (const i of byReading) {\n      const block = blocks[i];\n      if (!block.label) continue;\n      const label = make(layer, {\n        left: \"0\",\n        top: \"0\",\n        padding: \"1px 4px\",\n        borderRadius: \"3px\",\n        background: TONE.chip,\n        font: \"500 10px/13px ui-monospace, SFMono-Regular, Menlo, monospace\",\n        letterSpacing: \"0.02em\",\n        whiteSpace: \"nowrap\",\n        opacity: \"0\",\n      });\n      label.textContent = block.label;\n      const size = { w: label.offsetWidth, h: label.offsetHeight };\n      const { x, y } = block.rect;\n      const others = blocks\n        .filter((o) => o !== block && !o.el.contains(block.el) && !block.el.contains(o.el))\n        .map((o) => o.rect);\n      const spots = [\n        { x, y: y - size.h - 3, inside: false },\n        { x: x + 4, y: y + 4, inside: true },\n      ];\n      const spot = spots.find((s) => {\n        const r = { ...s, ...size };\n        return s.y >= 0 && !placed.some((p) => hits(p, r)) && !others.some((o) => hits(o, r));\n      });\n      if (!spot) {\n        label.remove();\n        continue;\n      }\n      placed.push({ x: spot.x, y: spot.y, ...size });\n      label.style.left = px(spot.x);\n      label.style.top = px(spot.y);\n      nodes[i].label = label;\n      nodes[i].inside = spot.inside;\n    }\n  }\n  return nodes;\n}\n\nfunction show(n: Nodes) {\n  put(n.group, \"display\", \"\");\n  put(n.group, \"opacity\", \"\");\n}\n\nfunction hide(n: Nodes) {\n  put(n.group, \"display\", \"none\");\n}\n\nfunction paintText(block: Extract<Block, { kind: \"text\" }>, n: Nodes, q: number) {\n  if (q >= 1) return hide(n);\n  show(n);\n  paint(block.el, \"opacity\", \"0\");\n  const head = q * (block.chars + BLUEPRINT.span);\n  put(n.words, \"display\", q > 0 ? \"\" : \"none\");\n  if (q > 0) {\n    block.words.forEach((w, i) => {\n      const node = n.wordNodes[i];\n      const a = easeOut((head - w.c0) / BLUEPRINT.span);\n      put(node, \"opacity\", fix(a * w.alpha));\n      put(node, \"filter\", a > 0 && a < 1 ? `blur(${fix((1 - a) * BLUEPRINT.blur)}em)` : \"none\");\n      if (w.count) {\n        // Counts from when the print head reaches the number to the end of the block.\n        const t = easeOut((head - w.c0) / Math.max(block.chars + BLUEPRINT.span - w.c0, 1));\n        const text = countText(w.count, t);\n        if (node.textContent !== text) node.textContent = text;\n      }\n    });\n  }\n  block.lines.forEach((line, i) => {\n    const cut = Math.min(Math.max(xAt(block.words, line, head) - line.x, 0), line.w);\n    const r = Math.min(line.bar.h / 2, 6);\n    put(n.bars[i], \"clip-path\", cut > 0 ? `inset(0 0 0 ${fix(cut, 1)}px round ${fix(r, 1)}px)` : \"none\");\n  });\n  if (n.caret && block.lines.length) {\n    const at = Math.min(head, block.chars);\n    const line = block.lines.find((l) => at <= l.c1) ?? block.lines[block.lines.length - 1];\n    put(n.caret, \"transform\", `translate(${fix(xAt(block.words, line, at), 1)}px, ${fix(line.y, 1)}px)`);\n    put(n.caret, \"height\", px(line.h));\n    put(n.caret, \"opacity\", fix(Math.min(smooth(q / 0.04), 1 - smooth((q - 0.86) / 0.14))));\n  }\n}\n\nfunction paintImage(block: Extract<Block, { kind: \"image\" }>, n: Nodes, q: number) {\n  if (q >= 1) return hide(n);\n  show(n);\n  const { saturate, zoom } = BLUEPRINT.develop;\n  const d = easeOut((q - 0.15) / 0.85);\n  const s = 1 + zoom * (1 - d);\n  put(n.cross, \"opacity\", fix(1 - smooth(q / 0.3)));\n  put(n.part, \"opacity\", fix(1 - smooth((q - 0.2) / 0.45)));\n  paint(block.el, \"opacity\", fix(smooth((q - 0.15) / 0.3)));\n  const blur = block.blur * (1 - d);\n  const color = saturate + (1 - saturate) * d;\n  paint(block.el, \"filter\", d > 0.999 ? \"none\" : `blur(${fix(blur, 2)}px) saturate(${fix(color)})`);\n  // Scaled up inside a clip that's scaled down to match, so the photo settles inside a still frame.\n  paint(block.el, \"scale\", scaleOf(s));\n  const corners = block.radius.replace(PX, (_, v) => `${fix(parseFloat(v) / s, 2)}px`);\n  paint(block.el, \"clip-path\", `inset(${fix((1 - 1 / s) * 50, 3)}% round ${corners})`);\n}\n\nfunction paintButton(block: Extract<Block, { kind: \"button\" }>, n: Nodes, q: number) {\n  if (q >= 1) return hide(n);\n  show(n);\n  const { from } = BLUEPRINT.pop;\n  paint(block.el, \"opacity\", fix(smooth(q / 0.25)));\n  paint(block.el, \"scale\", scaleOf(from + (1 - from) * spring(q)));\n  put(n.part, \"opacity\", fix(1 - smooth(q / 0.3)));\n  put(n.part, \"scale\", fix(1 - 0.08 * smooth(q / 0.3), 4));\n}\n\nfunction paintBox(block: Extract<Block, { kind: \"box\" }>, n: Nodes, q: number) {\n  if (q >= 1) return hide(n);\n  show(n);\n  paint(block.el, \"background-color\", mix(block.background, smooth(q / 0.7), block.surface));\n  if (block.borderWidth > 0) {\n    const drawn = q >= 0.7;\n    put(n.draw, \"stroke-dashoffset\", fix(1 - easeInOut(q / 0.7)));\n    put(n.draw, \"opacity\", drawn ? \"0\" : \"1\");\n    paint(block.el, \"border-color\", drawn ? block.border : \"transparent\");\n  }\n  if (block.shadow !== \"none\") {\n    paint(block.el, \"box-shadow\", fadeColors(block.shadow, smooth((q - 0.4) / 0.6)));\n  }\n  put(n.part, \"opacity\", fix(1 - smooth(q / 0.5)));\n}\n\n// Reduced motion: a short crossfade from skeleton to content, nothing else.\nfunction paintReduced(block: Block, n: Nodes, q: number) {\n  const f = smooth((q - 0.3) / 0.4);\n  if (f >= 1) return hide(n);\n  show(n);\n  put(n.group, \"opacity\", fix(1 - f));\n  put(n.words, \"display\", \"none\");\n  put(n.caret, \"opacity\", \"0\");\n  for (const bar of n.bars) put(bar, \"clip-path\", \"none\");\n  put(n.cross, \"opacity\", \"1\");\n  put(n.part, \"opacity\", \"1\");\n  put(n.part, \"scale\", \"1\");\n  put(n.draw, \"opacity\", \"0\");\n  if (block.kind !== \"box\") return paint(block.el, \"opacity\", fix(f));\n  paint(block.el, \"background-color\", mix(block.background, f, block.surface));\n  if (block.borderWidth > 0) paint(block.el, \"border-color\", mix(block.border, f, \"transparent\"));\n  if (block.shadow !== \"none\") paint(block.el, \"box-shadow\", fadeColors(block.shadow, f));\n}\n\n/* ── Engine ─────────────────────────────────────────────── */\n\ntype EngineOptions = {\n  annotate: boolean;\n  reduced: boolean;\n  onBlockChange?: (element: HTMLElement, state: BlueprintState) => void;\n  onSchedule?: (starts: number[]) => void;\n};\n\n/** Measures the page, keeps the skeleton layer in step with it, and paints any progress on demand. */\nfunction createEngine() {\n  let stage: HTMLElement | null = null;\n  let layer: HTMLElement | null = null;\n  let blocks: Block[] = [];\n  let nodes: Nodes[] = [];\n  let drawn: number[] = [];\n  let progress = 0;\n  let signature = \"\";\n  let options: EngineOptions = { annotate: false, reduced: false };\n  let frame = 0;\n  let resize: ResizeObserver | null = null;\n\n  function draw() {\n    blocks.forEach((block, i) => {\n      const q = clamp01((progress - block.start) / (block.end - block.start));\n      if (drawn[i] === q) return;\n      drawn[i] = q;\n      const n = nodes[i];\n      const state: BlueprintState = q <= 0 ? \"wireframe\" : q >= 1 ? \"built\" : \"building\";\n      const prev = block.el.getAttribute(\"data-blueprint-state\");\n      if (prev !== state) {\n        block.el.setAttribute(\"data-blueprint-state\", state);\n        if (prev !== null || state !== \"wireframe\") options.onBlockChange?.(block.el, state);\n      }\n      // Reduced motion steps it halfway through the block's fade, so effects built on it don't move.\n      const step = options.reduced ? (smooth((q - 0.3) / 0.4) < 0.5 ? 0 : 1) : q;\n      if (q < 1) paint(block.el, \"--blueprint-progress\", fix(step, 4));\n      if (options.reduced) paintReduced(block, n, q);\n      else if (block.kind === \"text\") paintText(block, n, q);\n      else if (block.kind === \"image\") paintImage(block, n, q);\n      else if (block.kind === \"button\") paintButton(block, n, q);\n      else paintBox(block, n, q);\n      if (q >= 1) unpaint(block.el);\n      if (n.label) {\n        const busy = options.reduced ? 0 : smooth(q / 0.06);\n        const opacity = n.inside\n          ? 0.75 * (1 - smooth(q / 0.1))\n          : q >= 1\n            ? 0\n            : (0.6 + 0.4 * busy) * (1 - smooth((q - 0.7) / 0.3));\n        put(n.label, \"opacity\", fix(opacity));\n      }\n    });\n  }\n\n  function measure() {\n    if (!stage || !layer) return;\n    for (const block of blocks) unpaint(block.el);\n    // Read the page as it really is. Nothing paints before draw() hides the blocks again.\n    stage.setAttribute(\"data-bp-ready\", \"\");\n    const next = readLayout(stage, layer);\n    const key = JSON.stringify([options.annotate, next], (k, v) =>\n      k === \"el\" ? undefined : typeof v === \"number\" ? Math.round(v * 2) / 2 : v,\n    );\n    if (key !== signature) {\n      signature = key;\n      for (const block of blocks) {\n        if (!next.some((n) => n.el === block.el)) block.el.removeAttribute(\"data-blueprint-state\");\n      }\n      blocks = next;\n      nodes = build(layer, next, options.annotate, layer.clientWidth);\n      resize?.disconnect();\n      resize?.observe(stage);\n      for (const block of next) resize?.observe(block.el);\n      options.onSchedule?.(next.map((block) => block.start));\n    }\n    drawn = [];\n    draw();\n  }\n\n  const schedule = () => {\n    if (!frame) frame = requestAnimationFrame(() => ((frame = 0), measure()));\n  };\n\n  return {\n    connect(nextStage: HTMLElement, nextLayer: HTMLElement) {\n      stage = nextStage;\n      layer = nextLayer;\n      resize = new ResizeObserver(schedule);\n      // Blueprint's own layer and controls change all the time; only the page counts.\n      const ui = (node: Node) =>\n        nextLayer.contains(node) ||\n        (node instanceof Element ? node : node.parentElement)?.closest(\"[data-bp-ui]\");\n      const mutations = new MutationObserver((records) => {\n        if (records.some((record) => !ui(record.target))) schedule();\n      });\n      mutations.observe(nextStage, {\n        subtree: true,\n        childList: true,\n        characterData: true,\n        attributes: true,\n        attributeFilter: [\n          \"data-blueprint\",\n          \"data-blueprint-order\",\n          \"data-blueprint-label\",\n          \"data-blueprint-count\",\n          \"class\",\n          \"src\",\n          \"hidden\",\n        ],\n      });\n      const scheme = window.matchMedia(\"(prefers-color-scheme: dark)\");\n      scheme.addEventListener(\"change\", schedule);\n      document.fonts?.addEventListener(\"loadingdone\", schedule);\n      document.fonts?.ready.then(schedule);\n      measure();\n      return () => {\n        cancelAnimationFrame(frame);\n        frame = 0;\n        resize?.disconnect();\n        mutations.disconnect();\n        scheme.removeEventListener(\"change\", schedule);\n        document.fonts?.removeEventListener(\"loadingdone\", schedule);\n        for (const block of blocks) {\n          unpaint(block.el);\n          block.el.removeAttribute(\"data-blueprint-state\");\n        }\n        nextLayer.replaceChildren();\n        nextStage.removeAttribute(\"data-bp-ready\");\n        blocks = [];\n        nodes = [];\n        signature = \"\";\n        stage = layer = null;\n      };\n    },\n    configure(next: EngineOptions) {\n      const rebuild = next.annotate !== options.annotate;\n      const repaint = next.reduced !== options.reduced;\n      options = next;\n      if (rebuild) {\n        signature = \"\";\n        measure();\n      } else if (repaint) {\n        for (const block of blocks) unpaint(block.el);\n        drawn = [];\n        draw();\n      }\n    },\n    seek(next: number) {\n      progress = clamp01(next);\n      draw();\n    },\n  };\n}\n\n/* ── Blueprint ──────────────────────────────────────────── */\n\nconst subscribeNever = () => () => {};\n/** False while rendering on the server and hydrating, true after. */\nconst useHydrated = () =>\n  React.useSyncExternalStore(\n    subscribeNever,\n    () => true,\n    () => false,\n  );\n\nexport interface BlueprintProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** \"auto\" loops on its own, \"scroll\" follows the section into view, \"manual\" follows `progress`. */\n  play?: \"auto\" | \"scroll\" | \"manual\";\n  /** Progress in manual mode, from 0 (wireframe) to 1 (finished page). A MotionValue skips renders. */\n  progress?: number | MotionValue<number>;\n  /** Called as Blueprint moves the progress itself: the auto loop, scrolling, or the scrubber. */\n  onProgressChange?: (progress: number) => void;\n  /** Seconds the build takes in auto mode, or the length of each part of the loop. */\n  duration?: number | Partial<BlueprintTiming>;\n  /** Called each time the auto loop starts over on the wireframe, with how many loops have run. */\n  onLoop?: (loop: number) => void;\n  /** Called when a marked element starts building, finishes, or goes back to its wireframe. */\n  onBlockChange?: (element: HTMLElement, state: BlueprintState) => void;\n  /** Label each block with its spec while it builds: type size, image ratio, and so on. */\n  annotate?: boolean;\n  /** Show a scrubber under the stage to build and unbuild by hand. */\n  scrubber?: boolean;\n  /** Classes for the skeleton layer. Skeletons are drawn in its text color, so `text-*` recolors them. */\n  skeletonClassName?: string;\n}\n\n/**\n * A finished layout that builds itself. Mark parts of your markup with\n * `data-blueprint` and each one starts as a skeleton measured from it, then\n * fills in, one block at a time, as progress runs from 0 to 1.\n */\nexport function Blueprint({\n  play = \"auto\",\n  progress,\n  onProgressChange,\n  duration,\n  onLoop,\n  onBlockChange,\n  annotate = false,\n  scrubber = false,\n  skeletonClassName,\n  className,\n  children,\n  onPointerEnter,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  ...props\n}: BlueprintProps) {\n  const reduced = useReducedMotion() ?? false;\n  const hydrated = useHydrated();\n  const stageRef = React.useRef<HTMLDivElement>(null);\n  const layerRef = React.useRef<HTMLDivElement>(null);\n  const [engine] = React.useState(createEngine);\n  const [ticks, setTicks] = React.useState<number[]>([]);\n\n  const internal = useMotionValue(\n    play === \"manual\" && typeof progress === \"number\" ? clamp01(progress) : 0,\n  );\n  const external = play === \"manual\" && isMotionValue(progress) ? progress : null;\n  const value = external ?? internal;\n\n  const timing = timingOf(duration);\n  const { build, hold, unbuild, rest } = timing;\n  const latest = React.useRef({ onLoop, onProgressChange, timing });\n  React.useLayoutEffect(() => {\n    latest.current = { onLoop, onProgressChange, timing };\n  });\n\n  React.useLayoutEffect(() => {\n    engine.configure({ annotate, reduced, onBlockChange, onSchedule: setTicks });\n  }, [engine, annotate, reduced, onBlockChange]);\n\n  React.useLayoutEffect(() => {\n    const stage = stageRef.current;\n    const layer = layerRef.current;\n    if (!stage || !layer) return;\n    return engine.connect(stage, layer);\n  }, [engine]);\n\n  React.useLayoutEffect(() => {\n    engine.seek(value.get());\n    return value.on(\"change\", (p) => engine.seek(p));\n  }, [engine, value]);\n\n  React.useLayoutEffect(() => {\n    if (play === \"manual\" && typeof progress === \"number\") internal.set(clamp01(progress));\n  }, [play, progress, internal]);\n\n  useMotionValueEvent(internal, \"change\", (p) => {\n    if (play !== \"manual\") latest.current.onProgressChange?.(p);\n  });\n\n  /* Auto: one clock through rest, build, hold and unbuild. It only runs while nothing holds it. */\n  const auto = play === \"auto\";\n  const clock = React.useRef({ t: 0, loop: 0 });\n  const [hovered, setHovered] = React.useState(false);\n  const [focused, setFocused] = React.useState(false);\n  const [seen, setSeen] = React.useState(true);\n  const [dragging, setDragging] = React.useState(false);\n  const [replaying, setReplaying] = React.useState(false);\n  const running = auto && !reduced && !hovered && !focused && seen && !dragging;\n\n  React.useEffect(() => {\n    if (!auto || reduced) return;\n    latest.current.onLoop?.(clock.current.loop);\n  }, [auto, reduced]);\n\n  React.useEffect(() => {\n    if (!running) return;\n    const length = rest + build + hold + unbuild;\n    const timing = { rest, build, hold, unbuild };\n    let raf = 0;\n    let prev = performance.now();\n    const tick = (now: number) => {\n      const c = clock.current;\n      c.t += Math.min(now - prev, 100) / 1000;\n      prev = now;\n      while (c.t >= length) {\n        c.t -= length;\n        c.loop += 1;\n        latest.current.onLoop?.(c.loop);\n      }\n      internal.set(progressAt(c.t, timing));\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [running, rest, build, hold, unbuild, internal]);\n\n  React.useEffect(() => {\n    const stage = stageRef.current;\n    if (!auto || !stage) return;\n    let inView = true;\n    const update = () => setSeen(inView && document.visibilityState === \"visible\");\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        inView = entry.isIntersecting;\n        update();\n      },\n      { threshold: 0.1 },\n    );\n    observer.observe(stage);\n    document.addEventListener(\"visibilitychange\", update);\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", update);\n    };\n  }, [auto]);\n\n  /* Reduced motion: auto mode rests on the finished page, with a replay. */\n  const replay = React.useRef<AnimationPlaybackControls | null>(null);\n  React.useEffect(() => {\n    if (auto && reduced) internal.set(1);\n  }, [auto, reduced, internal]);\n  React.useEffect(() => () => replay.current?.stop(), []);\n\n  const startReplay = () => {\n    replay.current?.stop();\n    setReplaying(true);\n    internal.jump(0);\n    replay.current = animate(internal, 1, {\n      duration: latest.current.timing.build * BLUEPRINT.replay,\n      ease: \"linear\",\n      onComplete: () => setReplaying(false),\n    });\n  };\n\n  /* Scrubbing: auto mode holds while dragging, then carries on building from there. */\n  const scrub = (p: number) => {\n    if (play === \"manual\") {\n      if (external) external.set(p);\n      onProgressChange?.(p);\n      return;\n    }\n    replay.current?.stop();\n    setReplaying(false);\n    setDragging(true);\n    internal.set(p);\n  };\n  const release = () => {\n    if (auto) clock.current.t = latest.current.timing.rest + internal.get() * latest.current.timing.build;\n    setDragging(false);\n  };\n\n  // The reduced-motion setting is known on the client before hydration, so the button waits for it.\n  const showReplay = hydrated && auto && reduced && !replaying;\n\n  const stage = (\n    <div\n      {...props}\n      ref={stageRef}\n      className={cn(\"relative isolate [&:not([data-bp-ready])_[data-blueprint]]:opacity-0\", className)}\n      onPointerEnter={(event) => {\n        onPointerEnter?.(event);\n        if (event.pointerType === \"mouse\") setHovered(true);\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event);\n        setHovered(false);\n      }}\n      onFocus={(event) => {\n        onFocus?.(event);\n        if (event.target.matches(\":focus-visible\")) setFocused(true);\n      }}\n      onBlur={(event) => {\n        onBlur?.(event);\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false);\n      }}\n    >\n      {children}\n      <div\n        ref={layerRef}\n        aria-hidden\n        className={cn(\"pointer-events-none absolute inset-0 z-50 text-zinc-500 dark:text-zinc-400\", skeletonClassName)}\n      />\n      {showReplay && !scrubber && (\n        <button\n          type=\"button\"\n          data-bp-ui=\"\"\n          onClick={startReplay}\n          className=\"absolute right-3 bottom-3 z-50 inline-flex items-center gap-1.5 rounded-full border border-black/10 bg-white/90 px-3 py-1.5 text-xs font-medium text-zinc-900 shadow-sm backdrop-blur outline-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 dark:border-white/10 dark:bg-zinc-900/90 dark:text-zinc-100 dark:outline-white\"\n        >\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" className=\"size-3.5\" aria-hidden>\n            <path d=\"M2.5 8a5.5 5.5 0 1 0 1.6-3.9M2.5 2.5v3h3\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n          </svg>\n          Replay\n        </button>\n      )}\n      {play === \"scroll\" && <ScrollDriver target={stageRef} value={internal} smooth={!reduced} />}\n    </div>\n  );\n\n  if (!scrubber) return stage;\n  return (\n    <div className=\"flex flex-col\">\n      {stage}\n      <Scrubber\n        value={value}\n        ticks={ticks}\n        readOnly={play === \"scroll\"}\n        onScrub={scrub}\n        onRelease={release}\n        onReplay={showReplay ? startReplay : undefined}\n      />\n    </div>\n  );\n}\n\n/* ── Drivers ────────────────────────────────────────────── */\n\nfunction ScrollDriver({\n  target,\n  value,\n  smooth,\n}: {\n  target: React.RefObject<HTMLDivElement | null>;\n  value: MotionValue<number>;\n  smooth: boolean;\n}) {\n  // From the section's top entering the viewport to its middle reaching the middle.\n  const { scrollYProgress } = useScroll({ target, offset: [\"start end\", \"center center\"] });\n  const eased = useSpring(scrollYProgress, BLUEPRINT.scroll);\n  const source = smooth ? eased : scrollYProgress;\n  useMotionValueEvent(source, \"change\", (p) => value.set(p));\n  React.useEffect(() => value.set(source.get()), [source, value]);\n  return null;\n}\n\n// How far each key moves the scrubber. Home and End jump to the ends.\nconst SCRUB_KEYS: Record<string, number | undefined> = {\n  ArrowRight: 0.02,\n  ArrowUp: 0.02,\n  ArrowLeft: -0.02,\n  ArrowDown: -0.02,\n  PageUp: 0.1,\n  PageDown: -0.1,\n};\n\nfunction Scrubber({\n  value,\n  ticks,\n  readOnly,\n  onScrub,\n  onRelease,\n  onReplay,\n}: {\n  value: MotionValue<number>;\n  ticks: number[];\n  readOnly: boolean;\n  onScrub: (progress: number) => void;\n  onRelease: () => void;\n  onReplay?: () => void;\n}) {\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const fillRef = React.useRef<HTMLDivElement>(null);\n  const thumbRef = React.useRef<HTMLDivElement>(null);\n  const readoutRef = React.useRef<HTMLSpanElement>(null);\n  const dragging = React.useRef(false);\n\n  const show = React.useCallback((p: number) => {\n    const percent = Math.round(p * 100);\n    trackRef.current?.setAttribute(\"aria-valuenow\", String(percent));\n    if (fillRef.current) fillRef.current.style.transform = `scaleX(${p})`;\n    if (thumbRef.current) thumbRef.current.style.left = `${p * 100}%`;\n    if (readoutRef.current) readoutRef.current.textContent = `${percent}%`;\n  }, []);\n  React.useLayoutEffect(() => show(value.get()), [show, value]);\n  useMotionValueEvent(value, \"change\", show);\n\n  const at = (clientX: number) => {\n    const r = trackRef.current?.getBoundingClientRect();\n    return r && r.width ? clamp01((clientX - r.left) / r.width) : 0;\n  };\n  const end = () => {\n    if (!dragging.current) return;\n    dragging.current = false;\n    onRelease();\n  };\n\n  return (\n    <div data-bp-ui=\"\" className=\"mt-3 flex items-center gap-3 text-zinc-900 dark:text-zinc-100\">\n      <div\n        ref={trackRef}\n        role=\"slider\"\n        tabIndex={readOnly ? -1 : 0}\n        aria-label=\"Build progress\"\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={Math.round(value.get() * 100)}\n        aria-readonly={readOnly || undefined}\n        className={cn(\n          \"group relative h-6 flex-1 touch-none rounded-sm outline-current outline-offset-4 focus-visible:outline-2\",\n          !readOnly && \"cursor-ew-resize\",\n        )}\n        onPointerDown={(event) => {\n          if (readOnly || event.button !== 0) return;\n          event.currentTarget.setPointerCapture(event.pointerId);\n          dragging.current = true;\n          onScrub(at(event.clientX));\n        }}\n        onPointerMove={(event) => {\n          if (dragging.current) onScrub(at(event.clientX));\n        }}\n        onPointerUp={end}\n        onPointerCancel={end}\n        onKeyDown={(event) => {\n          if (readOnly) return;\n          const delta = SCRUB_KEYS[event.key];\n          const next =\n            event.key === \"Home\"\n              ? 0\n              : event.key === \"End\"\n                ? 1\n                : delta === undefined\n                  ? null\n                  : clamp01(value.get() + delta);\n          if (next === null) return;\n          event.preventDefault();\n          dragging.current = true;\n          onScrub(next);\n        }}\n        onKeyUp={end}\n        onBlur={end}\n      >\n        <div className=\"absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 overflow-hidden rounded-full bg-current/12\">\n          <div ref={fillRef} className=\"h-full w-full origin-left scale-x-0 rounded-full bg-current/70\" />\n        </div>\n        {ticks.map((t, i) => (\n          <span\n            key={i}\n            className=\"absolute top-1/2 mt-1.5 h-1 w-px bg-current/25\"\n            style={{ left: `${t * 100}%` }}\n          />\n        ))}\n        <div\n          ref={thumbRef}\n          className=\"absolute top-1/2 left-0 size-3 -translate-1/2 rounded-full bg-current shadow-sm ring-4 ring-current/10 transition-[scale] duration-150 group-active:scale-125\"\n        />\n      </div>\n      <span ref={readoutRef} className=\"w-9 text-right font-mono text-xs tabular-nums opacity-55\">\n        0%\n      </span>\n      {onReplay && (\n        <button\n          type=\"button\"\n          onClick={onReplay}\n          className=\"-my-1 rounded-full border border-current/15 px-2.5 py-1 text-xs font-medium outline-current outline-offset-2 focus-visible:outline-2\"\n        >\n          Replay\n        </button>\n      )}\n    </div>\n  );\n}\n"
    }
  ]
}