{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "contour",
  "title": "Contour",
  "description": "A paragraph set to a contour, the way a score draws a swell: each line laid to its own width, so the words taper, open or swell and close.",
  "dependencies": [
    "@chenglou/pretext"
  ],
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/contour.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 attributes on <html> that can change the face. Not class: smooth scrolling toggles one on every scroll. */\nconst FACE = [\"data-pair\", \"data-scheme\", \"data-mode\", \"data-key\"]\n\ntype Shape = \"diminuendo\" | \"crescendo\" | \"hairpin\"\ntype Variant = \"edge\" | \"tale\" | \"cola\"\n\n/** How much of the measure a line may take, `t` of the way through the text. */\nconst share: Record<Shape, (t: number, least: number) => number> = {\n  diminuendo: (t, least) => 1 - (1 - least) * t,\n  crescendo: (t, least) => least + (1 - least) * t,\n  hairpin: (t, least) => least + (1 - least) * (1 - Math.abs(2 * t - 1)),\n}\n\n/** A line as laid: its words, how far through the text it starts, and what it is set with. */\ntype Line = { text: string; t: number; spacing?: number; tracking?: number; size?: number; c?: number; a?: number; k?: number; colon?: number; turn?: boolean }\n\ntype ContourProps = Omit<React.ComponentProps<\"p\">, \"children\"> & {\n  /** The paragraph. Plain text: pretext measures it. */\n  children: string\n  /** diminuendo narrows to the end, crescendo opens towards it, hairpin swells and closes. For tale it is the size, not the width. */\n  shape?: Shape\n  /** The narrowest a line may be (for tale, the smallest size), as a share of the measure. */\n  least?: number\n  /** edge: each line spread to its width, so the edge is the hairpin's straight line. tale: the Mouse's Tale, the type\n   *  shrinking as the paragraph winds down the page. cola: a line to each phrase, as texts were set to be read aloud. */\n  variant?: Variant\n  /** Let the colour follow the shape: ink where it's loud, pencil where it's quiet. */\n  fade?: boolean\n}\n\nconst TALE = { measure: 0.5, turn: 7, floor: 10 } // half the measure a line, a swing every seven lines, never under 10px\n\n/**\n * A paragraph set to a contour, the way a score draws a swell. Pretext lays each line to its own\n * width and each is spread to reach it, so the words taper like a diminuendo or open like a\n * crescendo along a straight edge; with `fade` the colour falls away with them. `tale` sets the\n * contour in the size of the type instead, winding like Carroll's mouse's tail, and `cola` breaks\n * at the phrases. Until the fonts have come and the layout is done, it's a plain paragraph, and a\n * reader of the page always gets the plain text.\n */\nfunction Contour({ children: text, shape = \"diminuendo\", least = 0.3, variant = \"edge\", fade = false, className, ref: forwardedRef, ...props }: ContourProps) {\n  const ref = React.useRef<HTMLParagraphElement>(null)\n  const composedRef = useComposedRefs(ref, forwardedRef)\n  const [lines, setLines] = React.useState<Line[] | null>(null)\n\n  React.useEffect(() => {\n    const el = ref.current\n    if (!el) return\n    let cancelled = false\n    let font = \"\"\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 plain paragraph stays\n      }\n      let prepared: ReturnType<typeof lib.prepareWithSegments> | undefined\n      let phrases: ReturnType<typeof lib.prepareWithSegments>[] = []\n      const start = { segmentIndex: 0, graphemeIndex: 0 }\n\n      async function lay() {\n        if (cancelled || !el!.isConnected) return\n        const style = getComputedStyle(el!)\n        const next = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`\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)\n          // A phrase ends at a comma, a colon, a full stop, a question or a dash, with its mark.\n          phrases = (text.match(/[^,;:.!?—–]+(?:[,;:.!?—–]+|$)/g) ?? [text]).map((p) => p.trim()).filter(Boolean).map((p) => lib.prepareWithSegments(p, font))\n        }\n        width = el!.clientWidth\n        const size = parseFloat(style.fontSize)\n        const total = Math.max(1, prepared.segments.length)\n        const out: Line[] = []\n\n        if (variant === \"cola\") {\n          // A line to each phrase; one too long for the measure turns over, hung an indent in, like verse.\n          const hang = 1.5 * size\n          phrases.forEach((p, colon) => {\n            let cursor = start\n            for (let guard = 0; guard < 100; guard++) {\n              const turn = cursor !== start\n              const line = lib.layoutNextLine(p, cursor, Math.max(width - (turn ? hang : 0), 48))\n              if (!line) break\n              out.push({ text: line.text.trim(), t: colon / phrases.length, colon, turn })\n              cursor = line.end\n            }\n          })\n        } else if (variant === \"tale\") {\n          // Every line holds the same measure in its own size, so the tail narrows as the type does.\n          const ratio = parseFloat(style.lineHeight) / size || 1.45\n          const measure = Math.max(width * TALE.measure, 96)\n          let cursor = start, y = 0\n          for (let guard = 0; guard < 500; guard++) {\n            const t = cursor.segmentIndex / total\n            const line = lib.layoutNextLine(prepared, cursor, measure)\n            if (!line) break\n            const s = Math.max(TALE.floor, size * share[shape](t, least))\n            const room = (width - (line.width * s) / size) / 2\n            out.push({ text: line.text.trimEnd(), t, size: s, c: room, a: room * 0.85, k: (2 * Math.PI * y) / (TALE.turn * size * ratio) })\n            y += s * ratio\n            cursor = line.end\n          }\n        } else {\n          let cursor = start\n          for (let guard = 0; guard < 500; guard++) {\n            const t = cursor.segmentIndex / total\n            const want = Math.max(width * share[shape](t, least), 48)\n            let line = lib.layoutNextLine(prepared, cursor, want)\n            if (!line) break\n            // One more word, if the gaps can close a little to take it: the edge comes out truer.\n            const more = lib.layoutNextLine(prepared, cursor, want * 1.06)\n            if (more && more.end.segmentIndex > line.end.segmentIndex) {\n              const g = more.text.trimEnd().split(\" \").length - 1\n              if (g && (more.width - want) / g <= size * 0.06) line = more\n            }\n            const words = line.text.trimEnd()\n            const gaps = words.split(\" \").length - 1\n            // Spread it to its width, mostly in the gaps and a little in the letters; the last line stays as it is.\n            const slack = lib.layoutNextLine(prepared, line.end, 1e5) ? want - line.width : 0\n            const spacing = gaps ? Math.max(-size * 0.06, Math.min(slack / gaps, size * 0.3)) : 0\n            const rest = slack - spacing * gaps\n            out.push({ text: words, t, spacing, tracking: rest > 0 ? Math.min(rest / words.length, size * 0.03) : 0 })\n            cursor = line.end\n          }\n        }\n        if (!cancelled) setLines(out)\n      }\n\n      await lay()\n      if (cancelled) return\n      const resized = new ResizeObserver(() => {\n        if (el.clientWidth !== width) lay()\n      })\n      resized.observe(el)\n      off.push(() => resized.disconnect())\n      // A change of pair or scheme on <html> can change the face: lay out again if it did.\n      const restyled = new MutationObserver(() => lay())\n      restyled.observe(document.documentElement, { attributeFilter: FACE })\n      off.push(() => restyled.disconnect())\n\n      // The tale swings with the hand across it, and comes to rest when the hand leaves.\n      if (variant === \"tale\" && !matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n        const swing = (e: PointerEvent) => {\n          if (e.pointerType === \"touch\") return\n          const box = el.getBoundingClientRect()\n          el.style.setProperty(\"--phase\", `${((e.clientX - box.left) / box.width) * 2 * Math.PI}rad`)\n        }\n        const rest = () => el.style.removeProperty(\"--phase\")\n        el.addEventListener(\"pointermove\", swing)\n        el.addEventListener(\"pointerleave\", rest)\n        off.push(() => {\n          el.removeEventListener(\"pointermove\", swing)\n          el.removeEventListener(\"pointerleave\", rest)\n          rest()\n        })\n      }\n    })()\n\n    return () => {\n      cancelled = true\n      off.forEach((f) => f())\n    }\n  }, [text, shape, least, variant])\n\n  const line = (l: Line, i: number) => (\n    <span\n      key={i}\n      data-turn={l.turn || undefined}\n      style={{ \"--t\": l.t, \"--i\": i, wordSpacing: l.spacing || undefined, letterSpacing: l.tracking || undefined, fontSize: l.size, ...(l.k === undefined ? null : { \"--c\": `${l.c}px`, \"--a\": `${l.a}px`, \"--k\": `${l.k}rad` }) } as React.CSSProperties}\n    >\n      {l.text}\n    </span>\n  )\n\n  return (\n    <p ref={composedRef} data-slot=\"contour\" data-shape={shape} data-variant={variant === \"edge\" ? undefined : variant} data-fade={fade || undefined} className={cn(\"ot-contour\", className)} {...props}>\n      {lines ? (\n        <>\n          <span className=\"ot-sr\">{text}</span>\n          <span aria-hidden=\"true\" className=\"ot-contour-lines\">\n            {variant === \"cola\"\n              ? Array.from(new Set(lines.map((l) => l.colon))).map((colon) => (\n                  <span key={colon} className=\"ot-contour-colon\">\n                    {lines.map((l, i) => (l.colon === colon ? line(l, i) : null))}\n                  </span>\n                ))\n              : lines.map(line)}\n          </span>\n        </>\n      ) : (\n        text\n      )}\n    </p>\n  )\n}\n\nexport { Contour, type ContourProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/contour.css",
      "content": "@layer components {\n  /* ── ot-contour ── a paragraph set to a contour: each line laid to its own width by pretext.\n     The lines never re-wrap in the browser; their widths are already decided. With data-fade,\n     the colour follows the shape: ink where it's loud, pencil where it's quiet. Each line but the last is spread\n     to its width (word-spacing, then a little tracking, set by the hook), so the edge is a straight line. */\n  .ot-contour { margin: 0; }\n  .ot-contour-lines { display: block; }\n  .ot-contour-lines > span { display: block; white-space: pre; }\n  .ot-contour[data-shape=\"hairpin\"] { text-align: center; }\n  .ot-contour[data-fade] :is(.ot-contour-lines, .ot-contour-colon) > span {\n    --loud: calc(1 - var(--t));\n    color: color-mix(in oklab, var(--ot-ink) calc(var(--loud) * 100%), var(--ot-pencil));\n  }\n  .ot-contour[data-fade][data-shape=\"crescendo\"] .ot-contour-lines > span { --loud: var(--t); }\n  .ot-contour[data-fade][data-shape=\"hairpin\"] .ot-contour-lines > span { --loud: calc(1 - max(2 * var(--t) - 1, 1 - 2 * var(--t))); }\n  /* tale: the Mouse's Tale. Each line keeps its own size and swings about the middle on a slow wave; the hand\n     across it moves the wave along (--phase), and the lines further down the tail follow it later. */\n  .ot-contour[data-variant=\"tale\"] { text-align: start; }\n  .ot-contour[data-variant=\"tale\"] .ot-contour-lines > span {\n    width: max-content;\n    translate: calc(var(--c) + var(--a) * sin(var(--phase, 0rad) + var(--k))) 0;\n    transition: translate calc(var(--ot-andante) + var(--i) * var(--ot-arpeggio)) var(--ot-exhale);\n  }\n  :is([dir=\"rtl\"], [dir=\"rtl\"] *) .ot-contour[data-variant=\"tale\"] .ot-contour-lines > span { translate: calc(-1 * (var(--c) + var(--a) * sin(var(--phase, 0rad) + var(--k)))) 0; }\n  /* cola: a line to each phrase, a turnover hung in. Pointing at a phrase keeps it in ink and lets the others rest in pencil. */\n  .ot-contour[data-variant=\"cola\"] { text-align: start; }\n  .ot-contour-colon { display: block; transition: color var(--ot-moderato) var(--ot-exhale); }\n  .ot-contour-colon > span { display: block; white-space: pre; }\n  .ot-contour-colon > [data-turn] { padding-inline-start: 1.5em; }\n  .ot-contour[data-variant=\"cola\"] .ot-contour-lines:hover > .ot-contour-colon:not(:hover) { color: var(--ot-pencil); }\n  .ot-contour[data-variant=\"cola\"] .ot-contour-lines:hover > .ot-contour-colon:hover { color: var(--ot-ink); }\n  .ot-contour[data-variant=\"cola\"] .ot-contour-lines:hover > .ot-contour-colon > span { color: inherit; }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/contour.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/contour.css\"": {}
  },
  "meta": {
    "movement": "II",
    "underneath": "hook",
    "contract": "ot-contour"
  },
  "type": "registry:ui"
}
