{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ink-sequence",
  "title": "InkSequence, InkBeat & InkLand",
  "description": "One state machine (rest, playing, scrubbing, still) for multi-beat line art: beats chain by CSS animation-delay sums, an optional scroll driver uses animation-timeline: view(), and Stop always lands on the finished drawing.",
  "registryDependencies": [
    "https://frame12.dev/r/frame12-styles.json",
    "https://frame12.dev/r/use-tempo.json",
    "https://frame12.dev/r/ink-stroke.json"
  ],
  "files": [
    {
      "path": "ui/ink-sequence.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\nimport { useResolvedMotion } from '@/hooks/use-tempo';\nimport { InkDrivenContext, clampPoses, isInkCycleEvent, useInkCycle } from './ink';\n\n// Same union as `MotionMode` in use-on-twos.ts. Declared locally so this file only needs use-tempo.\ntype MotionMode = 'stepped' | 'smooth' | 'cut';\n\n/**\n * `rest`      finished drawing (the default, the SSR state and the no-JS state).\n * `playing`   time-driven: every beat draws in order, chained by CSS `animation-delay` sums.\n * `scrubbing` scroll-driven: poses follow the scroll position (`animation-timeline: view()`).\n * `still`     resolved motion is `cut` (still tempo, OS reduced motion): finished, never animates.\n */\nexport type InkSequenceState = 'rest' | 'playing' | 'scrubbing' | 'still';\n\nexport interface InkSequenceProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Pose count of each beat, in order (integers 1–24). Beat `i` starts when beat `i - 1` ends. */\n  beats: readonly number[];\n  /**\n   * Plays every beat in time order when this changes to `true` after mount; changing it to\n   * `false` stops and returns to `rest` (finished), never to a half-drawn pose. A value that is\n   * `true` at mount never plays (rule 5; the `ink-autoplay` lint rule enforces it).\n   */\n  play?: boolean;\n  /**\n   * Lets the scroll position drive the poses where `animation-timeline: view()` is supported and\n   * resolved motion is not `cut`. Attaches after mount, once the drawing has been out of view (so\n   * hydration never swaps a finished drawing on screen for a scroll pose); the first `play` detaches it.\n   */\n  scroll?: boolean;\n  /** The sequence's own motion, resolved against the viewer's tempo and OS reduced motion. */\n  motion?: MotionMode;\n  /** Called when the last beat finishes drawing (`animationend`). */\n  onEnd?: () => void;\n  /** Called whenever the state machine changes state. */\n  onStateChange?: (state: InkSequenceState) => void;\n}\n\nexport interface InkBeatProps extends React.SVGProps<SVGGElement> {\n  /** Index into the parent sequence's `beats`. */\n  beat: number;\n}\n\nexport interface InkLandProps extends React.SVGProps<SVGGElement> {\n  /** `cut`: the group appears in one pose. `wipe`: it is uncovered in reading order over its poses. */\n  reveal?: 'cut' | 'wipe';\n  /** Pose offset inside the beat at which the group lands (or starts its wipe). */\n  at?: number;\n  /** Poses a `wipe` takes (integer 1–24). Defaults to the beat's poses. */\n  poses?: number;\n}\n\ninterface SequenceInfo {\n  starts: readonly number[];\n  beats: readonly number[];\n}\n\nconst SequenceContext = React.createContext<SequenceInfo | null>(null);\nSequenceContext.displayName = 'InkSequenceContext';\n\nconst SCROLL_TIMELINE_QUERY = 'animation-timeline: view()';\n\nfunction supportsScrollTimeline(): boolean {\n  return typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports(SCROLL_TIMELINE_QUERY);\n}\n\n/**\n * One state machine for a multi-beat ink drawing. It renders a wrapper `<div>` (put one or more\n * `<svg>` drawings inside) and drives every `InkStroke`, `InkBeat` and `InkLand` it contains\n * through CSS only: no `requestAnimationFrame`, no timers.\n */\nexport const InkSequence = React.forwardRef<HTMLDivElement, InkSequenceProps>(function InkSequence(\n  {\n    beats,\n    play = false,\n    scroll = false,\n    motion = 'stepped',\n    onEnd,\n    onStateChange,\n    className = '',\n    style,\n    children,\n    onAnimationEnd,\n    ...rest\n  },\n  ref,\n) {\n  const resolved = useResolvedMotion(motion);\n  const cycle = useInkCycle(play);\n  const [ended, setEnded] = React.useState<number>(0);\n  const endedRef = React.useRef<number>(0);\n  const [canScroll, setCanScroll] = React.useState<boolean>(false);\n\n  // The scroll driver attaches only while the drawing is out of view, so hydration never swaps a\n  // finished drawing the visitor is looking at (a reload restoring the scroll position, a tall\n  // viewport) for a scroll pose. One IntersectionObserver callback, no scroll listener.\n  const rootRef = React.useRef<HTMLDivElement | null>(null);\n  React.useEffect(() => {\n    if (!scroll || !supportsScrollTimeline()) return;\n    const el = rootRef.current;\n    if (!el || typeof IntersectionObserver === 'undefined') {\n      setCanScroll(true);\n      return;\n    }\n    const io = new IntersectionObserver((entries) => {\n      if (entries.some((e) => !e.isIntersecting)) {\n        setCanScroll(true);\n        io.disconnect();\n      }\n    });\n    io.observe(el);\n    return () => io.disconnect();\n  }, [scroll]);\n  const setRefs = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      rootRef.current = node;\n      if (typeof ref === 'function') ref(node);\n      else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;\n    },\n    [ref],\n  );\n\n  // Tempo or reduced motion turning `cut` mid-play finishes the play; it never resumes later.\n  if (resolved === 'cut' && play && cycle > 0 && ended !== cycle) setEnded(cycle);\n  const playing = play && cycle > 0 && ended !== cycle && resolved !== 'cut';\n  // Any play detaches the scroll timeline for good: Stop and the end both land on `rest`.\n  const detached = cycle > 0;\n  const state: InkSequenceState =\n    resolved === 'cut'\n      ? 'still'\n      : playing\n        ? 'playing'\n        : scroll && canScroll && !detached\n          ? 'scrubbing'\n          : 'rest';\n\n  // Keyed on the pose values, not the array's identity: an inline `beats={[12, 12]}` must not\n  // rebuild the context (and re-render every InkBeat) on each parent render.\n  const beatsKey = beats.join(',');\n  const info = React.useMemo<SequenceInfo>(() => {\n    const clean = beatsKey === '' ? [] : beatsKey.split(',').map((v) => clampPoses(Number(v)));\n    const starts: number[] = [];\n    let sum = 0;\n    for (const p of clean) {\n      starts.push(sum);\n      sum += p;\n    }\n    return { starts, beats: clean };\n  }, [beatsKey]);\n  const total = info.beats.reduce((a, b) => a + b, 0) || 1;\n\n  const lastState = React.useRef<InkSequenceState>(state);\n  React.useEffect(() => {\n    if (lastState.current !== state) {\n      lastState.current = state;\n      onStateChange?.(state);\n    }\n  }, [state, onStateChange]);\n\n  return (\n    <div\n      {...rest}\n      ref={setRefs}\n      className={`f12-ink-seq f12-m-${resolved}${className ? ` ${className}` : ''}`}\n      data-ink={playing ? (cycle % 2 ? 'a' : 'b') : undefined}\n      data-ink-state={state}\n      data-ink-scroll={state === 'scrubbing' ? '' : undefined}\n      style={{ ...({ '--f12-ink-total': total } as React.CSSProperties), ...style }}\n      onAnimationEnd={(e) => {\n        onAnimationEnd?.(e);\n        // The root's own clock animation lasts exactly `total` poses: its end is the last beat's end.\n        // It counts only for the play it belongs to: a late end of a superseded play never ends\n        // the newer one, and a play stopped as it finished still reports its completion.\n        if (\n          e.target === e.currentTarget &&\n          isInkCycleEvent(e.animationName, 'f12InkClock', cycle) &&\n          ended !== cycle &&\n          endedRef.current !== cycle\n        ) {\n          endedRef.current = cycle;\n          setEnded(cycle);\n          onEnd?.();\n        }\n      }}\n    >\n      <SequenceContext.Provider value={info}>\n        <InkDrivenContext.Provider value={true}>{children}</InkDrivenContext.Provider>\n      </SequenceContext.Provider>\n    </div>\n  );\n});\n\nfunction beatVars(info: SequenceInfo | null, beat: number): Record<string, number> {\n  const i = Math.max(0, Math.round(beat));\n  return {\n    '--f12-ink-beat': info?.starts[i] ?? 0,\n    '--f12-ink-poses': info?.beats[i] ?? 6,\n  };\n}\n\n/** A group of strokes that draw during one beat of the parent `InkSequence`. */\nexport const InkBeat = React.forwardRef<SVGGElement, InkBeatProps>(function InkBeat(\n  { beat, className = '', style, ...rest },\n  ref,\n) {\n  const info = React.useContext(SequenceContext);\n  return (\n    <g\n      {...rest}\n      ref={ref}\n      className={`f12-ink-beat${className ? ` ${className}` : ''}`}\n      data-beat={beat}\n      style={{ ...(beatVars(info, beat) as React.CSSProperties), ...style }}\n    />\n  );\n});\n\n/** Non-stroke art inside a beat (screentone, labels): lands in one pose or wipes in reading order. */\nexport const InkLand = React.forwardRef<SVGGElement, InkLandProps>(function InkLand(\n  { reveal = 'cut', at, poses, className = '', style, ...rest },\n  ref,\n) {\n  const vars: Record<string, number> = {};\n  if (at !== undefined) vars['--f12-ink-at'] = Math.max(0, Math.round(at));\n  if (poses !== undefined) vars['--f12-ink-poses'] = clampPoses(poses);\n  return (\n    <g\n      {...rest}\n      ref={ref}\n      className={`${reveal === 'wipe' ? 'f12-ink-wipe' : 'f12-ink-land'}${className ? ` ${className}` : ''}`}\n      style={{ ...(vars as React.CSSProperties), ...style }}\n    />\n  );\n});\n",
      "type": "registry:ui",
      "target": "components/frame12/ink-sequence.tsx"
    }
  ],
  "type": "registry:ui"
}