{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "waterfall",
  "title": "Waterfall",
  "description": "A type specimen waterfall: one sentence at every dynamic, loudest first, each line filled to the measure with as many whole words as the size holds.",
  "dependencies": [
    "@chenglou/pretext"
  ],
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/waterfall.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { useComposedRefs } from \"@/lib/0nlytype/refs\"\nimport { cn } from \"@/lib/utils\"\n\nconst DYNAMICS = [\"pp\", \"p\", \"mp\", \"mf\", \"f\", \"ff\", \"fff\", \"ffff\"] as const\ntype Dynamic = (typeof DYNAMICS)[number]\n\ntype WaterfallProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The words. Plain text: pretext measures how many of them each size holds. */\n  children: string\n  /** The loudest dynamic to show. */\n  from?: Dynamic\n  /** The quietest dynamic to show. */\n  to?: Dynamic\n  /** Set the specimen in the expression italic. */\n  italic?: boolean\n  /** specimen: the same words at every size. decay: the sentence read once, each line a dynamic quieter. solo: pointing at a line mutes the rest. */\n  variant?: \"specimen\" | \"decay\" | \"solo\"\n}\n\ntype Row = { text: string; spacing: number; scale: number; px: number; rest?: boolean }\n\n/**\n * A type specimen waterfall: the same words at every dynamic, loudest first. Each line takes as\n * many whole words as the measure holds at its size (pretext counts them), then its tracking opens\n * just enough to reach the edge, so the block is flush on both sides with no wrapping and no\n * ellipsis. Pointing at a line shows its size. decay reads the sentence once instead, each line\n * taking up where the one above stopped, a dynamic quieter, the quietest saying the rest; solo mutes\n * every line but the one you point at, as a mixing desk's solo button does. Before the fonts and the layout arrive, each line\n * is the plain text, clipped; a reader always gets the words once.\n */\nfunction Waterfall({ children: text, from = \"ffff\", to = \"pp\", italic = false, variant = \"specimen\", className, ref: forwardedRef, ...props }: WaterfallProps) {\n  const ref = React.useRef<HTMLDivElement>(null)\n  const composedRef = useComposedRefs(ref, forwardedRef)\n  const [rows, setRows] = React.useState<Row[] | null>(null)\n  const [a, b] = [DYNAMICS.indexOf(from), DYNAMICS.indexOf(to)]\n  const shown = DYNAMICS.slice(Math.min(a, b), Math.max(a, b) + 1).reverse()\n\n  React.useEffect(() => {\n    const el = ref.current\n    if (!el) return\n    let cancelled = false\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 clipped plain lines stay\n      }\n      const cells = [...el.querySelectorAll<HTMLElement>(\".ot-waterfall-text\")]\n      const decay = variant === \"decay\"\n\n      async function lay() {\n        if (cancelled || !el!.isConnected) return\n        const styles = cells.map((c) => getComputedStyle(c))\n        const fonts = styles.map((s) => `${s.fontStyle} ${s.fontWeight} ${s.fontSize} ${s.fontFamily}`)\n        await Promise.all(fonts.map((f) => document.fonts.load(f, text)))\n        if (cancelled || !el!.isConnected) return\n        let above = Infinity // the size above: a quieter line is never louder, even where a fluid size crosses another's\n        let rest = text // decay: what the lines above haven't said yet\n        const width = cells[0].clientWidth // one column for every line; a line decay has no words for is hidden\n        const out = cells.map((_, i): Row => {\n          const size = parseFloat(styles[i].fontSize)\n          const track = parseFloat(styles[i].letterSpacing) || 0\n          const said = decay ? rest : text\n          if (!said) return { text: \"\", spacing: 0, scale: 1, px: 0 }\n          const first = said.split(/\\s+/)[0]\n          const prepared = lib.prepareWithSegments(said, fonts[i], { letterSpacing: track })\n          const start = { segmentIndex: 0, graphemeIndex: 0 }\n          // A first word wider than the line would be broken mid-word: set the line smaller instead.\n          const word = lib.layoutNextLine(lib.prepareWithSegments(first, fonts[i], { letterSpacing: track }), start, 1e5)!.width\n          const scale = Math.min(1, (width / word) * 0.98, (above * 0.88) / size)\n          if (decay && i === cells.length - 1) {\n            // The quietest line says the rest, wrapping as a paragraph does.\n            rest = \"\"\n            return { text: said, spacing: track, scale, px: Math.round(size * scale), rest: true }\n          }\n          const line = lib.layoutNextLine(prepared, start, width / scale)!\n          const words = line.text.trimEnd()\n          const whole = !lib.layoutNextLine(prepared, line.end, width / scale) // all the text fits: leave it be\n          if (decay) rest = whole ? \"\" : said.startsWith(line.text) ? said.slice(line.text.length).trimStart() : said.slice(words.length).trimStart()\n          const slack = width - line.width * scale\n          // Tracking takes up the slack, by no more than a twentieth of an em: beyond that, it stays ragged.\n          const extra = whole || slack <= 0 ? 0 : Math.min(slack / (words.length * scale), size * 0.05)\n          above = size * scale\n          return { text: words, spacing: track + extra, scale, px: Math.round(above) }\n        })\n        if (!cancelled) setRows(out)\n      }\n\n      await lay()\n      if (cancelled) return\n      let width = el.clientWidth\n      const resized = new ResizeObserver(() => {\n        if (el.clientWidth !== width) { width = el.clientWidth; lay() }\n      })\n      resized.observe(el)\n      // A change of pair or scheme on <html> can change the face: lay out again.\n      const restyled = new MutationObserver(() => lay())\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      off.forEach((f) => f())\n    }\n  }, [text, from, to, italic, variant])\n\n  return (\n    <div ref={composedRef} data-slot=\"waterfall\" data-variant={variant === \"specimen\" ? undefined : variant} data-italic={italic || undefined} className={cn(\"ot-waterfall\", className)} {...props}>\n      <span className=\"ot-sr\">{text}</span>\n      {shown.map((d, i) => {\n        const row = rows?.[i]\n        return (\n          <div key={d} aria-hidden=\"true\" hidden={row && !row.text ? true : undefined} data-rest={row?.rest || undefined} className=\"ot-waterfall-row\" style={{ \"--s\": `var(--ot-${d})`, \"--lh\": `var(--ot-${d}-lh)`, \"--tr\": `var(--ot-${d}-tr)` } as React.CSSProperties}>\n            <span className=\"ot-waterfall-mark\">{d}</span>\n            <span className=\"ot-waterfall-text\">\n              {row ? <span style={{ letterSpacing: `${row.spacing}px`, fontSize: row.scale < 1 ? `${row.scale}em` : undefined }}>{row.text}</span> : text}\n            </span>\n            <span className=\"ot-waterfall-px\">{row ? `${row.px} px` : \"\"}</span>\n          </div>\n        )\n      })}\n    </div>\n  )\n}\n\nexport { Waterfall, type WaterfallProps, type Dynamic }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/waterfall.css",
      "content": "@layer components {\n  /* ── ot-waterfall ── one sentence at every dynamic, loudest first. A row is a margin (the\n     dynamic's mark, in pencil), the line, and a second margin that says its size when you point.\n     The line never wraps: pretext has already chosen its words, and the clip is only a safety.\n     Narrow, the size takes the mark's place instead of a margin of its own. */\n  .ot-waterfall { --ot-waterfall-mark: 3.25em; --ot-waterfall-end: 4.5em; container-type: inline-size; max-width: 100%; margin: 0; font-size: var(--ot-pp); line-height: var(--ot-pp-lh); }\n  .ot-waterfall-row {\n    display: grid; grid-template-columns: var(--ot-waterfall-mark) minmax(0, 1fr) var(--ot-waterfall-end);\n    align-items: baseline; column-gap: var(--ot-space-2);\n  }\n  .ot-waterfall-row + .ot-waterfall-row { margin-top: var(--ot-space-3); }\n  .ot-waterfall-text {\n    display: block; min-width: 0; white-space: nowrap; overflow-x: clip; overflow-y: visible;\n    font-size: var(--s); line-height: var(--lh); letter-spacing: var(--tr); color: var(--ot-ink);\n  }\n  .ot-waterfall[data-italic] .ot-waterfall-text { font-family: var(--ot-expression); font-style: italic; font-weight: 400; font-size: calc(var(--s) * var(--ot-expression-scale)); letter-spacing: 0; }\n  .ot-waterfall-mark, .ot-waterfall-px { font-family: var(--ot-expression); font-style: italic; font-size: calc(var(--ot-pp) * var(--ot-expression-scale)); line-height: 1; letter-spacing: 0; color: var(--ot-pencil); white-space: nowrap; }\n  .ot-waterfall-px { font-family: var(--ot-voice); font-style: normal; font-size: var(--ot-pp); text-align: end; font-variant-numeric: tabular-nums; opacity: 0; transition: opacity var(--ot-allegro); }\n  .ot-waterfall-row:hover .ot-waterfall-px { opacity: 1; }\n  /* decay: the sentence is read once and falls away as it goes, a sound after its attack. Each line takes up where the one\n     above stopped, a dynamic quieter; the quietest line says the rest and wraps. */\n  .ot-waterfall-row[hidden] { display: none; }\n  .ot-waterfall-row[data-rest] .ot-waterfall-text { white-space: normal; overflow: visible; }\n  /* solo: the mixing desk's solo button. Pointing at a line keeps it in ink and sends every other line back to a rule. */\n  .ot-waterfall[data-variant=\"solo\"] :is(.ot-waterfall-text, .ot-waterfall-mark) { transition: color var(--ot-andante) var(--ot-exhale); }\n  .ot-waterfall[data-variant=\"solo\"]:has(.ot-waterfall-row:hover) .ot-waterfall-row:not(:hover) .ot-waterfall-text { color: var(--ot-rule-strong); }\n  .ot-waterfall[data-variant=\"solo\"] .ot-waterfall-row:hover .ot-waterfall-mark { color: var(--ot-ink); }\n\n  @container (max-width: 34rem) {\n    .ot-waterfall { --ot-waterfall-mark: 3em; --ot-waterfall-end: 0px; }\n    .ot-waterfall-row { column-gap: var(--ot-space-1); }\n    .ot-waterfall-mark, .ot-waterfall-px { grid-area: 1 / 1; text-align: start; }\n    .ot-waterfall-row:hover .ot-waterfall-mark { opacity: 0; }\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/waterfall.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/waterfall.css\"": {}
  },
  "meta": {
    "movement": "II",
    "underneath": "hook",
    "contract": "ot-waterfall"
  },
  "type": "registry:ui"
}
