{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reverb",
  "title": "Reverb",
  "description": "A phrase that echoes into silence: each line quieter, more open and a dynamic smaller, drifting right like a canon, answering from either wall, or losing its consonants first; it ends on a rest.",
  "dependencies": [
    "@chenglou/pretext"
  ],
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/reverb.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { useComposedRefs } from \"@/lib/0nlytype/refs\"\nimport { cn } from \"@/lib/utils\"\n\n/** The <html> switches that change the face; smooth scrolling toggles a class there on every scroll, which must not re-lay the text. */\nconst FACE = [\"data-pair\", \"data-scheme\", \"data-mode\", \"data-key\"]\n\n/** How much further the echoes drift when pointed at. */\nconst BREATH = 1.2\n\n/** The dynamics, loudest first: each echo steps one down, and stays at pp. */\nconst DYNAMICS = [\"ffff\", \"fff\", \"ff\", \"f\", \"mf\", \"mp\", \"p\", \"pp\"] as const\ntype Dynamic = (typeof DYNAMICS)[number]\n\ntype ReverbProps = Omit<React.ComponentProps<\"p\">, \"children\"> & {\n  /** The phrase, as plain text. It's read once; the echoes are for the eye. */\n  children: string\n  /** How many times it comes back. */\n  echoes?: number\n  /** The dynamic the phrase is set in. */\n  from?: Dynamic\n  /** canon: each echo drifts on by a word. antiphon: the echoes answer from either wall in turn. vowels: the consonants go first. */\n  variant?: \"canon\" | \"antiphon\" | \"vowels\"\n}\n\nconst VOWEL = /^[aeiouyàáâãäåæèéêëìíîïòóôõöøùúûüýÿœ]/i\n\n/**\n * For the vowels: the letters of the phrase, and the echo each one is lost in. In a live room the\n * reverberation masks the short consonants first and the long vowels ring on, so the consonants\n * (and the punctuation) go first, in a fixed order hashed from their place, then the vowels. By the\n * last echo about a tenth is left. Spaces are never lost, so every echo keeps the phrase's layout.\n */\nfunction fading(text: string, n: number) {\n  const seg = typeof Intl !== \"undefined\" && \"Segmenter\" in Intl ? [...new Intl.Segmenter(undefined, { granularity: \"grapheme\" }).segment(text)].map((s) => s.segment) : Array.from(text)\n  const letters = seg.map((g, i) => ({ g, i })).filter(({ g }) => /\\S/.test(g))\n  const hash = (i: number) => (((i + 1) * 2654435761) >>> 0) / 4294967296\n  const order = [...letters].sort((a, b) => Number(VOWEL.test(a.g)) + hash(a.i) - (Number(VOWEL.test(b.g)) + hash(b.i)))\n  const lost = new Array<number>(seg.length).fill(Infinity)\n  order.forEach(({ i }, j) => {\n    // letter j of the order is lost in the first echo k where round(L k / (n + 0.5)) passes it\n    for (let k = 1; k <= n; k++) if (j < Math.round((letters.length * k) / (n + 0.5))) { lost[i] = k; break }\n  })\n  return seg.map((g, i) => ({ g, lost: lost[i] }))\n}\n\n/**\n * A phrase that echoes into silence. Each line repeats it quieter: the colour goes from ink through\n * graphite to pencil and then fades, the letters open, the size steps down a dynamic. Each echo is\n * shifted right by the width of the last one's first word, which pretext measures, so they drift\n * like a canon. It ends on a rest. Before pretext has measured, the echoes simply stack.\n * The antiphon sends the echoes back from either wall in turn, as the two choirs of San Marco\n * answered each other across the church; the vowels keep every echo under the phrase, letter for\n * letter, and lose the consonants first.\n */\nfunction Reverb({ children: text, echoes = 5, from = \"mf\", variant = \"canon\", className, ref: forwardedRef, ...props }: ReverbProps) {\n  const ref = React.useRef<HTMLParagraphElement>(null)\n  const composedRef = useComposedRefs(ref, forwardedRef)\n  const n = Math.max(1, Math.round(echoes))\n  const first = DYNAMICS.indexOf(from)\n  // 0 is the phrase itself; 1…n are the echoes.\n  const line = (k: number) => {\n    // The vowels keep the phrase's own size and spacing, so every echo lies letter for letter under it.\n    const d = DYNAMICS[variant === \"vowels\" ? first : Math.min(first + k, DYNAMICS.length - 1)]\n    return {\n      \"--size\": `var(--ot-${d})`,\n      \"--lh\": `var(--ot-${d}-lh)`,\n      \"--tr\": `var(--ot-${d}-tr)`,\n      \"--open\": variant === \"vowels\" ? 0 : k * 0.02, // the letters open a little more each time, in em\n      \"--k\": k,\n      \"--o\": k <= 2 ? 1 : 1 - (0.7 * (k - 2)) / Math.max(1, n - 2), // ink, graphite, pencil, then it fades\n    } as React.CSSProperties\n  }\n\n  const letters = React.useMemo(() => (variant === \"vowels\" ? fading(text, n) : null), [variant, text, n])\n\n  React.useEffect(() => {\n    const el = ref.current\n    // Only the canon drifts; the antiphon keeps to its walls, the vowels to the phrase's columns.\n    if (!el || variant !== \"canon\") return\n    let cancelled = false\n    let frame = 0\n    let width = 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 echoes stay stacked\n      }\n      const word = text.trim().split(/\\s+/)[0]\n\n      async function lay() {\n        if (cancelled || !el!.isConnected) return\n        const lines = [...el!.querySelectorAll<HTMLElement>(\".ot-reverb-line\")]\n        const styles = lines.map((l) => getComputedStyle(l))\n        const fonts = styles.map((s) => `${s.fontStyle} ${s.fontWeight} ${s.fontSize} ${s.fontFamily}`)\n        await document.fonts.load(fonts[0], text)\n        if (cancelled) return\n        // Each echo starts where the last one's first word ends, in its own type.\n        const drift: number[] = [0]\n        const room: number[] = [] // what each line leaves free of the container\n        const W = el!.clientWidth\n        width = W\n        lines.forEach((_, k) => {\n          const letterSpacing = parseFloat(styles[k].letterSpacing) || 0 // \"normal\" is 0\n          const measure = (s: string) => lib.measureNaturalWidth(lib.prepareWithSegments(s, fonts[k], { letterSpacing }))\n          room.push(W - Math.min(measure(text), W))\n          drift.push(drift[k] + measure(word))\n        })\n        // If the canon would run out of the page, close it up so it fits, with room to breathe out.\n        const fit = Math.max(0, Math.min(1, ...room.map((r, k) => (k ? r / (BREATH * drift[k]) : 1))))\n        lines.forEach((l, k) => k && l.style.setProperty(\"--x\", `${drift[k] * fit}px`))\n        el!.querySelector<HTMLElement>(\".ot-reverb-rest\")?.style.setProperty(\"--x\", `${drift[lines.length] * fit}px`)\n        // Only now may the shift ease, so that arriving isn't a move.\n        if (!frame) frame = requestAnimationFrame(() => (el!.dataset.ready = \"\"))\n      }\n\n      await lay()\n      if (cancelled) return\n      // A change of pair or scheme on <html> can change the face: measure again.\n      const restyled = new MutationObserver(() => lay())\n      restyled.observe(document.documentElement, { attributeFilter: FACE })\n      const resized = new ResizeObserver(() => el.clientWidth !== width && lay())\n      resized.observe(el)\n      off.push(() => {\n        restyled.disconnect()\n        resized.disconnect()\n      })\n    })()\n\n    return () => {\n      cancelled = true\n      cancelAnimationFrame(frame)\n      off.forEach((f) => f())\n      delete el.dataset.ready\n    }\n  }, [text, n, from, variant])\n\n  return (\n    <p ref={composedRef} data-slot=\"reverb\" data-variant={variant} className={cn(\"ot-reverb\", className)} {...props}>\n      <span className=\"ot-reverb-line\" style={line(0)}>\n        {text}\n      </span>\n      <span aria-hidden=\"true\" className=\"ot-reverb-echoes\">\n        {Array.from({ length: n }, (_, i) => i + 1).map((k) => (\n          <span key={k} className=\"ot-reverb-line ot-reverb-echo\" data-tone={k === 1 ? \"graphite\" : \"pencil\"} data-side={variant === \"antiphon\" ? (k % 2 ? \"end\" : \"start\") : undefined} style={line(k)}>\n            {letters ? letters.map(({ g, lost }, i) => (lost <= k ? <span key={i} className=\"ot-reverb-lost\">{g}</span> : g)) : text}\n          </span>\n        ))}\n        <i className=\"ot-reverb-rest\" data-side={variant === \"antiphon\" ? ((n + 1) % 2 ? \"end\" : \"start\") : undefined} style={line(n + 1)} />\n      </span>\n    </p>\n  )\n}\n\nexport { Reverb, type ReverbProps, type Dynamic }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/reverb.css",
      "content": "@layer components {\n  /* ── ot-reverb ── a phrase that echoes into silence: each line quieter, more open, a dynamic\n     smaller, and shifted right by the last echo's first word (pretext measures it). It ends on a\n     rest, a hairline in pencil. Pointing at it lets the echoes breathe out a little further. */\n  .ot-reverb { margin: 0; overflow-x: clip; } /* a phrase too long for its drift is cut, never a sideways scroll */\n  .ot-reverb-line {\n    display: block;\n    font-size: var(--size); line-height: var(--lh); letter-spacing: calc(var(--tr) + var(--open) * 1em);\n    color: var(--ot-ink); opacity: var(--o);\n  }\n  .ot-reverb-echoes { display: block; user-select: none; }\n  .ot-reverb-echo { white-space: nowrap; } /* an echo is one line: it drifts, it doesn't wrap */\n  .ot-reverb-echo[data-tone=\"graphite\"] { color: var(--ot-graphite); }\n  .ot-reverb-echo[data-tone=\"pencil\"] { color: var(--ot-pencil); }\n  .ot-reverb-rest {\n    display: block; inline-size: var(--ot-space-6); block-size: var(--ot-hairline);\n    margin-block-start: var(--ot-space-3); background: var(--ot-pencil); opacity: 0.3; /* pp, in pencil: silence */\n  }\n  .ot-reverb-echo, .ot-reverb-rest { translate: var(--x, 0px) 0; }\n  .ot-reverb[data-ready] :is(.ot-reverb-echo, .ot-reverb-rest) {\n    transition: translate var(--ot-andante) var(--ot-exhale);\n    transition-delay: calc(var(--k) * var(--ot-arpeggio));\n  }\n  .ot-reverb:is(:hover, [data-force~=\"hover\"]) :is(.ot-reverb-echo, .ot-reverb-rest) { translate: calc(var(--x, 0px) * 1.2) 0; }\n\n  /* antiphon: the two choirs of San Marco, answering across the church. The echoes come back from\n     either wall in turn, the first from the far one, each a dynamic quieter; nothing drifts, the\n     measure is the room. Pointing opens the letters a little further, one arpeggio after the last. */\n  .ot-reverb-echo[data-side=\"end\"] { text-align: end; }\n  .ot-reverb-rest[data-side=\"end\"] { margin-inline-start: auto; }\n  .ot-reverb[data-variant=\"antiphon\"] .ot-reverb-echo { transition: letter-spacing var(--ot-andante) var(--ot-exhale) calc(var(--k) * var(--ot-arpeggio)); }\n  .ot-reverb[data-variant=\"antiphon\"]:is(:hover, [data-force~=\"hover\"]) .ot-reverb-echo { letter-spacing: calc(var(--tr) + var(--open) * 2em); }\n\n  /* vowels: every echo at the phrase's own size, letter for letter under it; a lost letter keeps its\n     place and goes silent, consonants first, so the vowels ring on in columns. Pointing sketches the\n     lost letters back in pencil, one echo after another. */\n  .ot-reverb[data-variant=\"vowels\"] .ot-reverb-echo { white-space: normal; } /* it breaks where the phrase breaks, so the columns hold on every line */\n  .ot-reverb-lost { opacity: 0; transition: opacity var(--ot-moderato) var(--ot-exhale) calc(var(--k) * var(--ot-arpeggio)); }\n  .ot-reverb:is(:hover, [data-force~=\"hover\"]) .ot-reverb-lost { opacity: 0.32; color: var(--ot-pencil); }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/reverb.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/reverb.css\"": {}
  },
  "meta": {
    "movement": "II",
    "underneath": "hook",
    "contract": "ot-reverb"
  },
  "type": "registry:ui"
}
