{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "segue",
  "title": "Segue",
  "description": "One scene at a time. When you change it, one hairline crosses the frame carrying the next scene's number and name, and the next scene is already behind it.",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/segue.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { useComposedRefs } from \"@/lib/0nlytype/refs\"\nimport { cn } from \"@/lib/utils\"\n\nconst pad = (n: number) => String(n).padStart(2, \"0\")\nconst still = () => matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n\ntype SegueSceneProps = React.ComponentProps<\"div\"> & {\n  /** The scene's name. The hairline carries it, with the scene's number, as it crosses. */\n  label?: string\n}\n\n/** One scene. Direct children of Segue only. */\nfunction SegueScene({ label, className, ...props }: SegueSceneProps) {\n  return <div data-slot=\"segue-scene\" role={label ? \"group\" : undefined} aria-label={label} className={cn(\"ot-segue-body\", className)} {...props} />\n}\n\ntype SegueProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\"> & {\n  /** Which scene is showing, from 0. Change it (from a button, a key, a form's answer) and the hairline crosses. */\n  value?: number\n  defaultValue?: number\n  onValueChange?: (value: number) => void\n  /** wipe: an upright hairline crosses along the line. horizon: a level hairline rises from the foot, as the sun over a horizon. */\n  variant?: \"wipe\" | \"horizon\"\n  /** Tie the crossings to the scroll: the scenes change as the frame travels up the view, and change back as you scroll back. */\n  scrub?: boolean\n}\n\n/**\n * One scene at a time. When the scene changes, one hairline crosses the frame, carrying the next scene's number\n * and name; behind it the next scene is already there, drifting the last few pixels into place, while the last is\n * pushed on ahead of it. Going back, it crosses the other way. Nothing crosses unless the person acts: a change of\n * `value`, or, with `scrub`, the scroll itself.\n */\nfunction Segue({ value, defaultValue = 0, onValueChange, variant = \"wipe\", scrub = false, className, children, ref: forwardedRef, ...props }: SegueProps) {\n  const scenes = React.Children.toArray(children).filter(React.isValidElement) as React.ReactElement<SegueSceneProps>[]\n  const n = scenes.length\n  const [own, setOwn] = React.useState(defaultValue)\n  const current = Math.max(0, Math.min(n - 1, value ?? own))\n  const ref = React.useRef<HTMLDivElement>(null)\n  const composedRef = useComposedRefs(ref, forwardedRef)\n  const shown = React.useRef(current)\n  const labels = scenes.map((s) => s.props.label ?? \"\")\n\n  // Scrub scenes remain readable in document order. Only the scene showing accepts interaction.\n  React.useLayoutEffect(() => {\n    const el = ref.current\n    if (!el || !scrub) return\n    const saved = new Map<HTMLElement, string | null>()\n    const sync = () => {\n      const currentScene = el.querySelector<HTMLElement>(\":scope > [data-current]\")\n      const active = el.ownerDocument.activeElement\n      if (active && el.contains(active) && !currentScene?.contains(active)) currentScene?.focus({ preventScroll: true })\n      for (const [control, tab] of saved) {\n        if (!el.contains(control) || currentScene?.contains(control)) {\n          if (tab === null) control.removeAttribute(\"tabindex\")\n          else control.setAttribute(\"tabindex\", tab)\n          saved.delete(control)\n        }\n      }\n      el.querySelectorAll<HTMLElement>(\":scope > .ot-segue-scene:not([data-current])\").forEach((scene) => {\n        scene.querySelectorAll<HTMLElement>('a[href], area[href], button, input, select, textarea, iframe, object, embed, audio[controls], video[controls], summary, [contenteditable]:not([contenteditable=\"false\"]), [tabindex]').forEach((control) => {\n          const tab = control.getAttribute(\"tabindex\")\n          if (!saved.has(control) || tab !== \"-1\") saved.set(control, tab)\n          if (tab !== \"-1\") control.setAttribute(\"tabindex\", \"-1\")\n        })\n      })\n    }\n    sync()\n    const watch = new MutationObserver(sync)\n    watch.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: [\"tabindex\", \"href\", \"contenteditable\", \"controls\"] })\n    return () => {\n      watch.disconnect()\n      for (const [control, tab] of saved) {\n        if (tab === null) control.removeAttribute(\"tabindex\")\n        else control.setAttribute(\"tabindex\", tab)\n      }\n    }\n  })\n\n  // The scrub chooses from a scroll handler, so it reads the latest value and callback through a ref.\n  const choose = React.useRef<(i: number) => void>(() => {})\n  React.useLayoutEffect(() => {\n    choose.current = (i: number) => {\n      if (i === current) return\n      if (value === undefined) setOwn(i)\n      onValueChange?.(i)\n    }\n  })\n\n  // The crossing, for one scene: `from` ahead of the line, `to` behind it, `p` how far across (0 to 1).\n  const cross = React.useCallback((el: HTMLDivElement, from: number, to: number, back: boolean) => {\n    const parts = el.querySelectorAll<HTMLElement>(\":scope > .ot-segue-scene\")\n    parts.forEach((s, i) => (i === from ? (s.dataset.part = \"from\") : i === to ? (s.dataset.part = \"to\") : delete s.dataset.part))\n    const rtl = getComputedStyle(el).direction === \"rtl\"\n    el.dataset.edge = variant === \"horizon\" ? (back ? \"top\" : \"bottom\") : rtl !== back ? \"right\" : \"left\"\n    const caption = el.querySelector<HTMLElement>(\".ot-segue-caption\")!\n    caption.firstElementChild!.textContent = pad(to + 1)\n    caption.lastElementChild!.textContent = parts[to]?.dataset.label ?? \"\"\n  }, [variant])\n  const rest = (el: HTMLDivElement) => {\n    delete el.dataset.phase\n    delete el.dataset.edge\n    el.style.removeProperty(\"--ot-segue-p\")\n    el.querySelectorAll<HTMLElement>(\":scope > .ot-segue-scene\").forEach((s) => delete s.dataset.part)\n  }\n\n  // Pressed: set before the new scene paints, so it never shows whole before the line has crossed.\n  React.useLayoutEffect(() => {\n    const el = ref.current, from = shown.current\n    shown.current = current\n    if (!el || scrub || from === current || still()) return\n    cross(el, from, current, current < from)\n    el.dataset.phase = \"set\"\n    el.style.setProperty(\"--ot-segue-p\", \"0\")\n    void el.offsetWidth // drawn at the edge once, so it can travel\n    el.dataset.phase = \"cross\"\n    el.style.setProperty(\"--ot-segue-p\", \"1\")\n    const done = (e: TransitionEvent) => e.target === el && e.propertyName === \"--ot-segue-p\" && rest(el)\n    el.addEventListener(\"transitionend\", done)\n    el.addEventListener(\"transitioncancel\", done)\n    return () => {\n      el.removeEventListener(\"transitionend\", done)\n      el.removeEventListener(\"transitioncancel\", done)\n      rest(el) // a second press arrives at once and crosses again from there\n    }\n  }, [current, scrub, cross])\n\n  // Scrubbed: how far the frame's middle has risen, from four fifths of the way down the view to one fifth (or as far\n  // as the page can scroll), is how far through the scenes you are. Read from the real layout in a frame asked for by\n  // the scroll event, which runs after a smooth scroller's own frame, so the line and the page move together.\n  React.useEffect(() => {\n    const el = ref.current\n    if (!el || !scrub || n < 2) return\n    let frame = 0\n    const read = () => {\n      const r = el.getBoundingClientRect(), h = innerHeight, mid = r.top + scrollY + r.height / 2\n      const from = Math.max(0, mid - 0.8 * h), to = Math.min(document.documentElement.scrollHeight - h, mid - 0.2 * h)\n      const p = (to <= from ? 1 : Math.min(1, Math.max(0, (scrollY - from) / (to - from)))) * (n - 1)\n      const k = Math.min(n - 2, Math.floor(p)), f = p - k\n      choose.current(Math.round(p))\n      if (still() || f <= 0 || f >= 1) return rest(el) // under reduced motion the scene changes halfway and nothing travels\n      if (el.dataset.phase !== \"scrub\" || el.querySelector(\":scope > [data-part='from']\") !== el.children[k]) cross(el, k, k + 1, false)\n      el.dataset.phase = \"scrub\"\n      el.style.setProperty(\"--ot-segue-p\", String(f))\n    }\n    const onScroll = () => {\n      cancelAnimationFrame(frame)\n      frame = requestAnimationFrame(read)\n    }\n    read()\n    addEventListener(\"scroll\", onScroll, { passive: true })\n    addEventListener(\"resize\", onScroll)\n    return () => {\n      cancelAnimationFrame(frame)\n      removeEventListener(\"scroll\", onScroll)\n      removeEventListener(\"resize\", onScroll)\n      rest(el)\n    }\n  }, [scrub, n, cross])\n\n  return (\n    <div ref={composedRef} data-slot=\"segue\" data-variant={variant === \"wipe\" ? undefined : variant} data-scrub={scrub || undefined} className={cn(\"ot-segue\", className)} {...props}>\n      {scenes.map((scene, i) => {\n        const on = i === current\n        // A scrub is read in order, so every scene stays with readers; pressed, only the one showing does.\n        const hide = !on && !scrub\n        return (\n          <div\n            key={scene.key ?? i}\n            className=\"ot-segue-scene\"\n            data-current={on || undefined}\n            data-label={labels[i] || undefined}\n            inert={hide}\n            aria-hidden={hide || undefined}\n            tabIndex={-1}\n            onFocusCapture={(event) => {\n              if (!on && scrub) event.currentTarget.parentElement?.querySelector<HTMLElement>(\":scope > [data-current]\")?.focus({ preventScroll: true })\n            }}\n            onClickCapture={(event) => {\n              if (!on && scrub) {\n                event.preventDefault()\n                event.stopPropagation()\n              }\n            }}\n          >\n            {scene}\n          </div>\n        )\n      })}\n      <span className=\"ot-segue-line\" aria-hidden=\"true\">\n        <span className=\"ot-segue-caption\">\n          <span />\n          <span />\n        </span>\n      </span>\n      {!scrub && (\n        <span className=\"ot-sr\" aria-live=\"polite\">\n          {`${current + 1} of ${n}${labels[current] ? `: ${labels[current]}` : \"\"}`}\n        </span>\n      )}\n    </div>\n  )\n}\n\nexport { Segue, SegueScene, type SegueProps, type SegueSceneProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/segue.css",
      "content": "@layer components {\n  /* ── ot-segue ── one scene at a time. A change sends one hairline across the frame, carrying the next scene's number\n     and name; behind it the next scene drifts the last few pixels into place, ahead of it the last is pushed on. Every\n     scene stands in one cell, so the frame is as tall as its tallest scene and nothing below it moves. */\n  @property --ot-segue-p { syntax: \"<number>\"; inherits: true; initial-value: 0; }\n  .ot-segue { --ot-segue-p: 0; --ot-segue-drift: var(--ot-space-5); position: relative; display: grid; overflow: clip; }\n  .ot-segue > .ot-segue-scene { grid-area: 1 / 1; min-width: 0; }\n  .ot-segue > .ot-segue-scene:not([data-current], [data-part]) { visibility: hidden; }\n  .ot-segue[data-scrub] > .ot-segue-scene:not([data-current], [data-part]) { visibility: visible; opacity: 0; } /* still read, in order */\n  .ot-segue > .ot-segue-scene:not([data-current]) { pointer-events: none; }\n  .ot-segue[data-phase=\"cross\"] { transition: --ot-segue-p var(--ot-andante) var(--ot-breath); }\n\n  /* The line: a frame-sized layer whose one edge is the hairline, translated across. Drawn only while crossing. */\n  .ot-segue-line { position: absolute; inset: 0; z-index: 1; pointer-events: none; border: 0 solid var(--ot-ink); visibility: hidden; }\n  .ot-segue:is([data-phase=\"cross\"], [data-phase=\"scrub\"]) > .ot-segue-line { visibility: visible; }\n  .ot-segue-caption {\n    position: absolute; display: flex; gap: var(--ot-space-2); white-space: nowrap;\n    font: 400 var(--ot-pp) / var(--ot-pp-lh) var(--ot-voice); letter-spacing: 0.04em; font-variant-numeric: tabular-nums; color: var(--ot-graphite);\n    paint-order: stroke fill; -webkit-text-stroke: 0.3em var(--ot-segue-ground, var(--ot-paper)); /* a halo of the paper, so it reads over the scene */\n  }\n  .ot-segue-caption > :first-child { color: var(--ot-ink); font-weight: 600; }\n\n  /* Where the line comes in from. The scene behind it (to) shows between that edge and the line; the one ahead (from)\n     shows past it. p runs 0 to 1. */\n  .ot-segue[data-edge=\"left\"] > .ot-segue-line { border-left-width: var(--ot-hairline); translate: calc(var(--ot-segue-p) * 100%) 0; }\n  .ot-segue[data-edge=\"left\"] > [data-part=\"to\"] { clip-path: inset(0 calc(100% - var(--ot-segue-p) * 100%) 0 0); }\n  .ot-segue[data-edge=\"left\"] > [data-part=\"from\"] { clip-path: inset(0 0 0 calc(var(--ot-segue-p) * 100%)); }\n  .ot-segue[data-edge=\"left\"] > [data-part=\"to\"] > * { translate: calc((var(--ot-segue-p) - 1) * var(--ot-segue-drift)) 0; }\n  .ot-segue[data-edge=\"left\"] > [data-part=\"from\"] > * { translate: calc(var(--ot-segue-p) * var(--ot-segue-drift)) 0; }\n  .ot-segue[data-edge=\"left\"] .ot-segue-caption { top: var(--ot-space-2); right: 100%; padding-inline: var(--ot-space-2); }\n\n  .ot-segue[data-edge=\"right\"] > .ot-segue-line { border-right-width: var(--ot-hairline); translate: calc(var(--ot-segue-p) * -100%) 0; }\n  .ot-segue[data-edge=\"right\"] > [data-part=\"to\"] { clip-path: inset(0 0 0 calc(100% - var(--ot-segue-p) * 100%)); }\n  .ot-segue[data-edge=\"right\"] > [data-part=\"from\"] { clip-path: inset(0 calc(var(--ot-segue-p) * 100%) 0 0); }\n  .ot-segue[data-edge=\"right\"] > [data-part=\"to\"] > * { translate: calc((1 - var(--ot-segue-p)) * var(--ot-segue-drift)) 0; }\n  .ot-segue[data-edge=\"right\"] > [data-part=\"from\"] > * { translate: calc(var(--ot-segue-p) * -1 * var(--ot-segue-drift)) 0; }\n  .ot-segue[data-edge=\"right\"] .ot-segue-caption { top: var(--ot-space-2); left: 100%; padding-inline: var(--ot-space-2); }\n\n  /* horizon: after Eclipse, a level line with the light on one side of it. It rises from the foot; back, it sets. */\n  .ot-segue[data-edge=\"bottom\"] > .ot-segue-line { border-top-width: var(--ot-hairline); translate: 0 calc((1 - var(--ot-segue-p)) * 100%); }\n  .ot-segue[data-edge=\"bottom\"] > [data-part=\"to\"] { clip-path: inset(calc(100% - var(--ot-segue-p) * 100%) 0 0 0); }\n  .ot-segue[data-edge=\"bottom\"] > [data-part=\"from\"] { clip-path: inset(0 0 calc(var(--ot-segue-p) * 100%) 0); }\n  .ot-segue[data-edge=\"bottom\"] > [data-part=\"to\"] > * { translate: 0 calc((1 - var(--ot-segue-p)) * var(--ot-segue-drift)); }\n  .ot-segue[data-edge=\"bottom\"] > [data-part=\"from\"] > * { translate: 0 calc(var(--ot-segue-p) * -1 * var(--ot-segue-drift)); }\n  .ot-segue[data-edge=\"bottom\"] .ot-segue-caption { top: var(--ot-space-2); inset-inline-start: 0; }\n\n  .ot-segue[data-edge=\"top\"] > .ot-segue-line { border-bottom-width: var(--ot-hairline); translate: 0 calc((var(--ot-segue-p) - 1) * 100%); }\n  .ot-segue[data-edge=\"top\"] > [data-part=\"to\"] { clip-path: inset(0 0 calc(100% - var(--ot-segue-p) * 100%) 0); }\n  .ot-segue[data-edge=\"top\"] > [data-part=\"from\"] { clip-path: inset(calc(var(--ot-segue-p) * 100%) 0 0 0); }\n  .ot-segue[data-edge=\"top\"] > [data-part=\"to\"] > * { translate: 0 calc((var(--ot-segue-p) - 1) * var(--ot-segue-drift)); }\n  .ot-segue[data-edge=\"top\"] > [data-part=\"from\"] > * { translate: 0 calc(var(--ot-segue-p) * var(--ot-segue-drift)); }\n  .ot-segue[data-edge=\"top\"] .ot-segue-caption { bottom: var(--ot-space-2); inset-inline-start: 0; }\n\n  @media (forced-colors: active) {\n    .ot-segue-line { border-color: CanvasText; }\n    .ot-segue-caption { -webkit-text-stroke: 0; }\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/segue.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/segue.css\"": {}
  },
  "meta": {
    "movement": "IX",
    "underneath": "hook",
    "contract": "ot-segue"
  },
  "type": "registry:ui"
}
