{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "wake",
  "title": "Wake",
  "description": "A paragraph that parts around your pointer like water around a hand: the lines it crosses split in two and the words move on.",
  "dependencies": [
    "@chenglou/pretext"
  ],
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/wake.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { useComposedRefs } from \"@/lib/0nlytype/refs\"\nimport { cn } from \"@/lib/utils\"\n\ntype WakeProps = Omit<React.ComponentProps<\"p\">, \"children\"> & {\n  /** The paragraph. Plain text: pretext measures it. */\n  children: string\n  /** How far the text gives way around the pointer, in em. */\n  radius?: number\n  /** Draw a hairline where the text has parted: a ring, the river's middle, or rules either side of the held line. */\n  mark?: boolean\n  /**\n   * circle: a hole round the hand. river: a gutter down the whole paragraph at the hand. caesura: the paragraph\n   * opens at the line the hand is on. weight: nothing parts; the letters under the hand press heavier and narrower,\n   * each keeping its place, so no line moves.\n   */\n  variant?: \"circle\" | \"river\" | \"caesura\" | \"weight\"\n}\n\n/**\n * A paragraph that parts around your hand like water. Where the pointer is, each line that crosses\n * the circle is set as two runs, one either side, sharing a single cursor through the text, so the\n * words are pushed on rather than hidden. The circle swells as you arrive and closes as you leave;\n * the loop runs only while something is still moving. Touch, keyboard and reduced motion get the\n * plain paragraph, and the plain paragraph is always the one a reader hears.\n */\nfunction Wake({ children: text, radius = 3.2, mark = false, variant = \"circle\", className, ref: forwardedRef, ...props }: WakeProps) {\n  const ref = React.useRef<HTMLParagraphElement>(null)\n  const composedRef = useComposedRefs(ref, forwardedRef)\n  const lines = React.useRef<HTMLSpanElement>(null)\n  const ring = React.useRef<HTMLSpanElement>(null)\n\n  React.useEffect(() => {\n    const el = ref.current, layer = lines.current, hole = ring.current\n    if (!el || !layer || variant === \"weight\") return\n    const still = matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const hand = matchMedia(\"(hover: hover) and (pointer: fine)\")\n    let cancelled = false, frame = 0\n    const off: (() => void)[] = []\n\n    ;(async () => {\n      let lib: typeof import(\"@chenglou/pretext\")\n      try {\n        lib = await import(\"@chenglou/pretext\")\n      } catch {\n        return // the plain paragraph stays\n      }\n      if (cancelled) return\n      let prepared: ReturnType<typeof lib.prepareWithSegments> | undefined\n      let font = \"\", size = 16, lh = 24, W = 0, R = 0\n      let cx = 0, cy = 0, tx = 0, ty = 0, r = 0, tr = 0\n      let inside = false, live = false, rtl = false\n      const pool: HTMLSpanElement[] = []\n\n      async function measure() {\n        if (cancelled || !el!.isConnected) return\n        const style = getComputedStyle(el!)\n        const next = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`\n        size = parseFloat(style.fontSize)\n        rtl = style.direction === \"rtl\"\n        lh = parseFloat(style.lineHeight) || size * 1.5\n        if (next !== font || !prepared) {\n          font = next\n          await document.fonts.load(font, text)\n          if (cancelled || !el!.isConnected) return\n          prepared = lib.prepareWithSegments(text, font, { letterSpacing: parseFloat(style.letterSpacing) || 0 })\n        }\n        W = el!.clientWidth\n        // The river's gutter is the circle's radius wide; the caesura's breath is at most the room under the paragraph.\n        R = variant === \"circle\" ? radius * size : variant === \"river\" ? (radius * size) / 2 : Math.min(radius * size, 2 * lh)\n      }\n\n      function put(i: number, s: string, x: number, y: number) {\n        let span = pool[i]\n        if (!span) span = pool[i] = layer!.appendChild(document.createElement(\"span\"))\n        span.hidden = false\n        if (span.textContent !== s) span.textContent = s\n        const at = Math.round(x * 2) / 2\n        span.style.translate = rtl ? `calc(${at}px - 100%) ${y}px` : `${at}px ${y}px` // right to left, a run hangs from its end\n      }\n\n      // One frame of the wake: rows top to bottom, a row the circle crosses becomes two runs.\n      function paint() {\n        const on = r > 0.5 && !!prepared\n        if (on !== live) {\n          live = on\n          el!.toggleAttribute(\"data-live\", on)\n          layer!.hidden = !on\n          if (hole) hole.hidden = !on\n        }\n        if (!on || !prepared) return\n        const least = Math.min(W * 0.4, size * 4) // a run shorter than this beside the circle stays empty\n        let cursor = { segmentIndex: 0, graphemeIndex: 0 }, k = 0, y = 0, j = 0, done = false, end = 0\n        // ponytail: the loop ends with the text; the two lines of room under the paragraph hold what the circle pushes on\n        for (; !done && y < 20000; y += lh, j++) {\n          const dy = cy < y ? y - cy : cy > y + lh ? cy - y - lh : 0\n          const half = variant === \"caesura\" ? 0 : variant === \"river\" ? r : dy < r ? Math.sqrt(r * r - dy * dy) : 0\n          // caesura: rows above the hand stay, rows below drop by the breath; the row under it sits between, held apart.\n          const drop = variant === \"caesura\" ? r * Math.min(1, Math.max(0, (y + lh - cy) / lh)) : 0\n          const runs = half ? [[0, cx - half], [cx + half, W]] : [[0, W]]\n          for (const [a, b] of rtl ? runs.reverse() : runs) {\n            const x0 = Math.max(0, a), x1 = Math.min(W, b)\n            if (half && x1 - x0 < least) continue\n            const line = lib.layoutNextLine(prepared, cursor, x1 - x0)\n            if (!line) { done = true; break }\n            cursor = line.end\n            const words = line.text.trimEnd()\n            put(k++, words, rtl ? x1 : x0, y + drop)\n            end = y + lh\n          }\n          if (hole && variant === \"caesura\" && j === Math.floor(cy / lh)) {\n            // The held line between two rules, each in the middle of its half of the breath.\n            const s = Math.min(1, Math.max(0, (y + lh - cy) / lh))\n            hole.style.width = `${W}px`\n            hole.style.height = `${lh + r / 2}px`\n            hole.style.translate = `0 ${y + (s * r) / 2}px`\n            hole.style.opacity = String(Math.min(1, r / R))\n          }\n        }\n        for (let i = k; i < pool.length; i++) pool[i].hidden = true\n        if (hole && variant === \"river\") {\n          hole.style.height = `${end}px`\n          hole.style.translate = `${cx}px 0`\n          hole.style.opacity = String(Math.min(1, r / R))\n        } else if (hole && variant === \"circle\") {\n          const d = r * 1.56 // the ring sits inside the hole, so the words keep their distance\n          hole.style.width = hole.style.height = `${d}px`\n          hole.style.translate = `${cx - d / 2}px ${cy - d / 2}px`\n        }\n      }\n\n      function tick() {\n        cx += (tx - cx) * 0.2\n        cy += (ty - cy) * 0.2\n        r += (tr - r) * 0.14\n        const settled = Math.abs(tx - cx) < 0.3 && Math.abs(ty - cy) < 0.3 && Math.abs(tr - r) < 0.5\n        if (settled) { cx = tx; cy = ty; r = tr }\n        paint()\n        frame = settled ? 0 : requestAnimationFrame(tick)\n      }\n      const go = () => { if (!frame) frame = requestAnimationFrame(tick) }\n\n      const move = (e: PointerEvent) => {\n        if (e.pointerType === \"touch\" || !hand.matches || still.matches || !prepared) return\n        const box = el.getBoundingClientRect()\n        tx = (e.clientX - box.left) / (box.width / el.offsetWidth || 1)\n        ty = (e.clientY - box.top) / (box.height / el.offsetHeight || 1)\n        if (!inside) { inside = true; cx = tx; cy = ty } // it opens where the hand arrived\n        tr = R\n        go()\n      }\n      const leave = () => { inside = false; tr = 0; go() }\n      el.addEventListener(\"pointermove\", move)\n      el.addEventListener(\"pointerleave\", leave)\n      off.push(() => { el.removeEventListener(\"pointermove\", move); el.removeEventListener(\"pointerleave\", leave) })\n\n      await measure()\n      if (cancelled) return\n      const again = async () => { await measure(); if (!cancelled && live) paint() }\n      const resized = new ResizeObserver(() => { if (el.clientWidth !== W) again() })\n      resized.observe(el)\n      // A change of pair or scheme on <html> can change the face: measure again.\n      const restyled = new MutationObserver(again)\n      restyled.observe(document.documentElement, { attributeFilter: [\"data-pair\", \"data-scheme\", \"data-mode\", \"data-key\"] })\n      off.push(() => { resized.disconnect(); restyled.disconnect() })\n    })()\n\n    return () => {\n      cancelled = true\n      cancelAnimationFrame(frame)\n      off.forEach((f) => f())\n    }\n  }, [text, radius, variant])\n\n  useWeight(ref, lines, ring, text, radius, variant === \"weight\")\n\n  return (\n    <p ref={composedRef} data-slot=\"wake\" data-variant={variant === \"circle\" ? undefined : variant} className={cn(\"ot-wake\", className)} {...props}>\n      <span className=\"ot-wake-plain\">{text}</span>\n      <span ref={lines} aria-hidden=\"true\" hidden className=\"ot-wake-lines\" />\n      {mark ? <span ref={ring} aria-hidden=\"true\" hidden className=\"ot-wake-ring\" /> : null}\n    </p>\n  )\n}\n\n/**\n * weight: the letterpress's impression, and \"It has to be design.\"'s heavy condensed roman. Each letter near the\n * hand is pressed heavier (the face's weight axis) and, where the face has a width axis, narrower by as much as the\n * weight widened it, so it keeps its set width. The letters are a layer placed exactly over the plain text (each\n * one measured from it, kerning and all), centred on its own place, so no line ever reflows.\n */\nfunction useWeight(\n  ref: React.RefObject<HTMLParagraphElement | null>,\n  lines: React.RefObject<HTMLSpanElement | null>,\n  ring: React.RefObject<HTMLSpanElement | null>,\n  text: string,\n  radius: number,\n  on: boolean,\n) {\n  React.useEffect(() => {\n    const el = ref.current, layer = lines.current, hole = ring.current\n    const plain = el?.firstElementChild?.firstChild\n    if (!on || !el || !layer || !(plain instanceof Text)) return\n    const still = matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const hand = matchMedia(\"(hover: hover) and (pointer: fine)\")\n    let letters: { span: HTMLSpanElement; x: number; y: number; t: number }[] = []\n    let dirty = true, live = false, inside = false, frame = 0\n    let R = 0, cx = 0, cy = 0, tx = 0, ty = 0, r = 0, tr = 0\n\n    function measure() {\n      dirty = false\n      layer!.hidden = false // measured shown, in the same frame: nothing paints between\n      const style = getComputedStyle(el!)\n      R = radius * parseFloat(style.fontSize)\n      const w0 = parseFloat(style.fontWeight) || 400, s0 = parseFloat(style.fontStretch) || 100\n      el!.style.setProperty(\"--ot-wake-w0\", String(w0))\n      el!.style.setProperty(\"--ot-wake-s0\", String(s0))\n      // How far the width axis must close for the heaviest letters to keep their set width (none without one).\n      const probe = layer!.appendChild(document.createElement(\"span\"))\n      probe.style.cssText = \"visibility:hidden;white-space:pre;translate:none\"\n      probe.textContent = [...new Set(text.replace(/\\s/g, \"\"))].join(\"\")\n      const wide = (w: number, st: number) => ((probe.style.fontWeight = String(w)), (probe.style.fontStretch = `${st}%`), probe.getBoundingClientRect().width)\n      const rest = wide(w0, s0)\n      let lo = 50, hi = s0\n      if (wide(800, s0) > rest + 0.5) for (let i = 0; i < 7; i++) { const mid = (lo + hi) / 2; if (wide(800, mid) > rest) hi = mid; else lo = mid }\n      el!.style.setProperty(\"--ot-wake-s1\", String(Math.round(hi)))\n      probe.remove()\n\n      const box = el!.getBoundingClientRect()\n      // Ranges are in viewport pixels; the letter layer is in local CSS pixels. A fitted specimen\n      // may be transformed, so undo that scale before placing its letters over the original text.\n      const sx = box.width / el!.offsetWidth || 1, sy = box.height / el!.offsetHeight || 1\n      const range = document.createRange()\n      const next: typeof letters = []\n      layer!.replaceChildren()\n      for (const { segment, index } of new Intl.Segmenter(undefined, { granularity: \"grapheme\" }).segment(text)) {\n        if (!segment.trim()) continue\n        range.setStart(plain as Text, index)\n        range.setEnd(plain as Text, index + segment.length)\n        const b = range.getClientRects()[0]\n        if (!b) continue\n        const span = layer!.appendChild(document.createElement(\"span\"))\n        span.textContent = segment\n        const x = (b.left - box.left + b.width / 2) / sx, y = (b.top - box.top) / sy\n        // Its line box is its content area, so the letter sits on the baseline it had in the text.\n        span.style.cssText = `translate:calc(${x}px - 50%) ${y}px;line-height:${b.height / sy}px`\n        next.push({ span, x, y: y + b.height / sy / 2, t: 0 })\n      }\n      letters = next\n      layer!.hidden = !live\n    }\n\n    function paint() {\n      const show = r > 0.5\n      if (show !== live) {\n        live = show\n        el!.toggleAttribute(\"data-live\", show)\n        layer!.hidden = !show\n        if (hole) hole.hidden = !show\n      }\n      for (const l of letters) {\n        const d = Math.hypot(l.x - cx, l.y - cy)\n        const t = d < r ? Math.round(((1 + Math.cos((Math.PI * d) / r)) / 2) * 50) / 50 : 0\n        if (t !== l.t) l.span.style.setProperty(\"--t\", String((l.t = t)))\n      }\n      if (hole) {\n        hole.style.width = hole.style.height = `${2 * r}px`\n        hole.style.translate = `${cx - r}px ${cy - r}px`\n      }\n    }\n\n    function tick() {\n      cx += (tx - cx) * 0.2\n      cy += (ty - cy) * 0.2\n      r += (tr - r) * 0.14\n      const settled = Math.abs(tx - cx) < 0.3 && Math.abs(ty - cy) < 0.3 && Math.abs(tr - r) < 0.5\n      if (settled) { cx = tx; cy = ty; r = tr }\n      paint()\n      frame = settled ? 0 : requestAnimationFrame(tick) // a still hand, a still page\n    }\n    const go = () => { if (!frame) frame = requestAnimationFrame(tick) }\n\n    const move = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\" || !hand.matches || still.matches) return\n      if (dirty && !live) measure()\n      const box = el.getBoundingClientRect()\n      tx = (e.clientX - box.left) / (box.width / el.offsetWidth || 1)\n      ty = (e.clientY - box.top) / (box.height / el.offsetHeight || 1)\n      if (!inside) { inside = true; cx = tx; cy = ty }\n      tr = R\n      go()\n    }\n    const leave = () => { inside = false; tr = 0; go() }\n    // A new width or face moves the letters: measure again, now if they are showing, else when the hand returns.\n    const stale = () => { dirty = true; if (live) { measure(); paint() } }\n    let alive = true\n    document.fonts.ready.then(() => { if (alive) dirty = true })\n    const resized = new ResizeObserver(stale)\n    resized.observe(el)\n    const restyled = new MutationObserver(stale)\n    restyled.observe(document.documentElement, { attributeFilter: [\"data-pair\", \"data-scheme\", \"data-mode\", \"data-key\"] })\n    el.addEventListener(\"pointermove\", move)\n    el.addEventListener(\"pointerleave\", leave)\n    return () => {\n      alive = false\n      cancelAnimationFrame(frame)\n      resized.disconnect()\n      restyled.disconnect()\n      el.removeEventListener(\"pointermove\", move)\n      el.removeEventListener(\"pointerleave\", leave)\n      layer.replaceChildren()\n      el.removeAttribute(\"data-live\")\n    }\n  }, [ref, lines, ring, text, radius, on])\n}\n\nexport { Wake, type WakeProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/wake.css",
      "content": "@layer components {\n  /* ── ot-wake ── a paragraph that parts around the pointer. The plain text stays in the flow the\n     whole time (it goes transparent while the wake is drawn), so nothing below it ever moves; the\n     wake is a layer of runs over it. Two lines of room under the paragraph hold what the circle\n     pushes on. */\n  .ot-wake { position: relative; margin: 0; padding-bottom: calc(var(--ot-wake-room, 2) * 1lh); }\n  .ot-wake[data-live] .ot-wake-plain { color: transparent; }\n  .ot-wake-lines { position: absolute; inset: 0 0 auto; pointer-events: none; }\n  .ot-wake-lines > span { position: absolute; inset: 0 auto auto 0; white-space: pre; }\n  .ot-wake-ring { position: absolute; inset: 0 auto auto 0; border: var(--ot-hairline) solid var(--ot-rule-strong); border-radius: 50%; pointer-events: none; }\n  /* river: after the typesetter's river of white, made on purpose. The gutter runs the paragraph's whole height at the hand;\n     every row is set as two runs either side of it, so the paragraph reads as two columns. The mark is a hairline down its middle. */\n  .ot-wake[data-variant=\"river\"] .ot-wake-ring { width: 0; border-width: 0 0 0 var(--ot-hairline); border-radius: 0; }\n  /* caesura: the paragraph opens at the line you point at, a breath above and below it, and closes as you go. The mark is a rule\n     in each breath, the held line between them, as the \"Less is more.\" poster sets its lines between hairlines. */\n  .ot-wake[data-variant=\"caesura\"] .ot-wake-ring { border-width: var(--ot-hairline) 0; border-radius: 0; }\n  /* weight: the letterpress's impression. Nothing parts: each letter near the hand presses heavier and, on a face\n     with a width axis, narrower by as much, so it keeps its set width, as \"It has to be design.\" sets its roman heavy\n     and condensed. The letters are placed from the plain text itself and centred on their places; --t (0 to 1) is\n     how hard each is pressed. The mark is a hairline ring round the hand's reach. */\n  .ot-wake[data-variant=\"weight\"] { padding-bottom: 0; }\n  .ot-wake[data-variant=\"weight\"] .ot-wake-lines > span {\n    font-synthesis: none;\n    font-weight: calc(var(--ot-wake-w0, 400) + var(--t, 0) * (800 - var(--ot-wake-w0, 400)));\n    font-stretch: calc((var(--ot-wake-s0, 100) + var(--t, 0) * (var(--ot-wake-s1, 100) - var(--ot-wake-s0, 100))) * 1%);\n  }\n  @media (forced-colors: active) { .ot-wake-ring { border-color: GrayText; } }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/wake.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/wake.css\"": {}
  },
  "meta": {
    "movement": "II",
    "underneath": "hook",
    "contract": "ot-wake"
  },
  "type": "registry:ui"
}
