{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"story-thread","type":"registry:ui","title":"StoryThread","description":"A narrative thread for scroll stories: the page pans sideways through your sections while a dotted line draws itself from node to node, a glowing head rides its tip, nodes light up as it reaches them, and segments can fork and meet again.","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["navigation"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/story-thread/StoryNode.tsx","content":"\"use client\";\n\nimport type { HTMLAttributes } from \"react\";\nimport { cn } from \"../../utils\";\nimport \"./story-thread.css\";\n\nexport interface StoryNodeProps extends Omit<HTMLAttributes<HTMLSpanElement>, \"id\" | \"className\"> {\n\t/** Id the thread runs through; referenced by StoryThread's `segments`. */\n\tid: string;\n\t/** `\"dot\"` is a filled dot; `\"ring\"` a hollow ring, for a fork or a destination. */\n\tvariant?: \"dot\" | \"ring\";\n\t/** Additional CSS classes. Position the node with them (or with `style`). */\n\tclassName?: string;\n}\n\n/** An anchor the thread runs through. Purely visual: hidden from assistive tech. */\nexport function StoryNode({ id, variant = \"dot\", className, ...rest }: StoryNodeProps) {\n\treturn (\n\t\t<span\n\t\t\t{...rest}\n\t\t\tclassName={cn(\"st-node\", className)}\n\t\t\tdata-story-node={id}\n\t\t\tdata-variant={variant}\n\t\t\taria-hidden=\"true\"\n\t\t/>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/story-thread/StoryNode.tsx"},{"path":"react/src/components/story-thread/StoryThread.tsx","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { sound as soundFx } from \"../../sound/sound\";\nimport { useReducedMotion } from \"../../internals/motion/media-query\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { createStoryThread, STORY_THREAD_DEFAULTS } from \"./story-thread-core\";\nimport type { StoryThreadCore } from \"./story-thread-core\";\nimport type { StoryThreadDash, StoryThreadLayout, StoryThreadSegment } from \"./types\";\nimport \"./story-thread.css\";\n\nexport interface StoryThreadProps {\n\t/**\n\t * `\"horizontal\"` pins a full-height viewport and pans the content sideways\n\t * as the page scrolls; `\"vertical\"` keeps a plain stack. `\"auto\"` is\n\t * horizontal from 900 × 520 px up, vertical below.\n\t */\n\tlayout?: StoryThreadLayout;\n\t/**\n\t * The thread's segments, each through a list of StoryNode ids. Leave it\n\t * out for one segment through every node in document order. Several\n\t * segments that leave the same node and meet again draw a fork whose\n\t * branches advance together.\n\t */\n\tsegments?: StoryThreadSegment[];\n\t/** Where the tip of the thread sits in the viewport, 0..1 along the reading axis. */\n\tlead?: number;\n\t/** Curve tension between nodes: 0 draws straight lines, 1 long flat tangents. */\n\ttension?: number;\n\t/** Stroke pattern of the thread. */\n\tdash?: StoryThreadDash;\n\t/** Draw a glowing dot at the tip of every segment while it is drawing. */\n\thead?: boolean;\n\t/**\n\t * Plays a soft `tick` each time the thread reaches a node going forward.\n\t * Off by default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n\t/** Called with a node's id each time the thread reaches it going forward. */\n\tonReach?: (id: string) => void;\n\t/** Accessible name of the region. */\n\tariaLabel?: string;\n\t/** Additional CSS classes on the root. */\n\tclassName?: string;\n\t/** The story: sections holding StoryNode anchors. */\n\tchildren?: ReactNode;\n}\n\n/** Stable default: the core re-measures only when `segments` changes identity. */\nconst NO_SEGMENTS: StoryThreadSegment[] = [];\n\n/**\n * A thread that draws itself through the StoryNode anchors as the page scrolls.\n * All geometry and scroll handling live in the shared core, which writes\n * straight to the DOM; React only creates it and forwards props.\n */\nexport function StoryThread({\n\tlayout = STORY_THREAD_DEFAULTS.layout,\n\tsegments = NO_SEGMENTS,\n\tlead = STORY_THREAD_DEFAULTS.lead,\n\ttension = STORY_THREAD_DEFAULTS.tension,\n\tdash = STORY_THREAD_DEFAULTS.dash,\n\thead = STORY_THREAD_DEFAULTS.head,\n\tsound = false,\n\tonReach,\n\tariaLabel = \"Story\",\n\tclassName,\n\tchildren,\n}: StoryThreadProps) {\n\tconst [root, rootRef] = useElementRef<HTMLDivElement>();\n\tconst [viewport, viewportRef] = useElementRef<HTMLDivElement>();\n\tconst [track, trackRef] = useElementRef<HTMLDivElement>();\n\tconst [svg, svgRef] = useElementRef<SVGSVGElement>();\n\tconst [core, setCore] = useState<StoryThreadCore | null>(null);\n\n\tconst reduced = useReducedMotion();\n\tconst live = useLiveRef({ layout, segments, lead, tension, dash, head, reduced, sound, onReach });\n\n\tuseEffect(() => {\n\t\tif (!root || !viewport || !track || !svg) return;\n\t\tconst { layout, segments, lead, tension, dash, head, reduced } = live.current;\n\t\tconst instance = createStoryThread(root, viewport, track, svg, {\n\t\t\tlayout,\n\t\t\tsegments,\n\t\t\tlead,\n\t\t\ttension,\n\t\t\tdash,\n\t\t\thead,\n\t\t\treducedMotion: reduced,\n\t\t\tonReach(id) {\n\t\t\t\tif (live.current.sound) soundFx.play(\"tick\", { volume: 0.5 });\n\t\t\t\tlive.current.onReach?.(id);\n\t\t\t},\n\t\t});\n\t\tsetCore(instance);\n\t\treturn () => {\n\t\t\tinstance.destroy();\n\t\t\tsetCore(null);\n\t\t};\n\t}, [root, viewport, track, svg, live]);\n\n\tuseEffect(() => {\n\t\tcore?.update({ layout, segments, lead, tension, dash, head, reducedMotion: reduced });\n\t}, [core, layout, segments, lead, tension, dash, head, reduced]);\n\n\treturn (\n\t\t// Focusable so the arrow keys can pan a story with no control in it, the\n\t\t// same contract as a scrollable region.\n\t\t<div\n\t\t\tref={rootRef}\n\t\t\tclassName={cn(\"st-root\", className)}\n\t\t\trole=\"region\"\n\t\t\taria-label={ariaLabel}\n\t\t\ttabIndex={0}\n\t\t>\n\t\t\t<div ref={viewportRef} className=\"st-viewport\">\n\t\t\t\t<div ref={trackRef} className=\"st-track\">\n\t\t\t\t\t<svg ref={svgRef} className=\"st-svg\" aria-hidden=\"true\" focusable=\"false\" />\n\t\t\t\t\t{children}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/story-thread/StoryThread.tsx"},{"path":"react/src/components/story-thread/index.ts","content":"\"use client\";\n\nexport { StoryThread } from \"./StoryThread\";\nexport type { StoryThreadProps } from \"./StoryThread\";\nexport { StoryNode } from \"./StoryNode\";\nexport type { StoryNodeProps } from \"./StoryNode\";\nexport type { StoryThreadSegment, StoryThreadLayout, StoryThreadDash } from \"./types\";\n","type":"registry:ui","target":"@ui/fancy/components/story-thread/index.ts"},{"path":"react/src/components/story-thread/story-thread-core.ts","content":"\"use client\";\n\n/**\n * StoryThread engine — framework-free, byte-identical in the Svelte, React and\n * Vue trees (see shared-cores.json).\n *\n * The component renders the markup; this module drives everything that moves:\n *\n * - the layout: horizontal (a sticky viewport whose track pans as the page\n *   scrolls vertically) or vertical (a plain stack),\n * - the thread: one smooth curve per segment through the centres of the\n *   `[data-story-node]` anchors, drawn into the component's <svg> as a faint\n *   ghost plus a bright copy uncovered by a mask whose stroke-dashoffset\n *   follows the reading position,\n * - the head: a glowing dot at the tip of every segment that is drawing,\n * - the nodes: `data-reached` on each anchor the tip has passed,\n * - the horizontal inputs: sideways wheel, mouse drag, arrow keys, and\n *   panning to whatever receives focus.\n *\n * Expected markup:\n *   root        .st-root            the scroll spacer (height set here)\n *     viewport  .st-viewport        sticky window onto the track\n *       track   .st-track           panned with translate3d\n *         svg   .st-svg             filled here, aria-hidden\n *         …content holding [data-story-node=\"<id>\"] anchors\n *\n * Per-frame output goes straight to the DOM (attributes, `d`, transforms),\n * never through framework state. No browser global is touched at import time.\n */\n\nexport type StoryThreadAxis = \"x\" | \"y\";\nexport type StoryThreadLayout = \"auto\" | \"horizontal\" | \"vertical\";\nexport type StoryThreadDash = \"dotted\" | \"dashed\" | \"solid\";\n\nexport interface StoryThreadSegment {\n\t/** Node ids the segment runs through, in order. */\n\tnodes: string[];\n\t/** Stroke and head colour of the drawn part. Any CSS colour. */\n\tcolor?: string;\n}\n\nexport interface StoryThreadPoint {\n\tx: number;\n\ty: number;\n}\n\n/** [start, end] of a segment along the scroll axis. */\nexport type StoryThreadSpan = [number, number];\n\nexport interface StoryThreadOptions {\n\tlayout: StoryThreadLayout;\n\t/** Segments to draw; empty means one segment through every node in DOM order. */\n\tsegments: StoryThreadSegment[];\n\t/** Where the tip sits in the viewport, 0..1 along the scroll axis. */\n\tlead: number;\n\t/** Curve tension, 0 (straight lines) .. 1. */\n\ttension: number;\n\tdash: StoryThreadDash;\n\t/** Draw the glowing head at each segment's tip. */\n\thead: boolean;\n\t/** No smooth scrolling for the arrow keys. */\n\treducedMotion: boolean;\n\t/** Called once each time the tip moves past a node going forward. */\n\tonReach?: (id: string) => void;\n}\n\nexport interface StoryThreadCore {\n\tupdate(options: Partial<StoryThreadOptions>): void;\n\t/** Re-read the nodes and the geometry (after content changed). */\n\trefresh(): void;\n\tdestroy(): void;\n}\n\nexport const STORY_THREAD_DEFAULTS = {\n\tlayout: \"auto\",\n\tlead: 0.7,\n\ttension: 0.55,\n\tdash: \"dotted\",\n\thead: true,\n} as const;\n\n/** Viewport size at which `layout=\"auto\"` turns horizontal. */\nexport const STORY_THREAD_HORIZONTAL_QUERY = \"(min-width: 900px) and (min-height: 520px)\";\n\nconst SVG_NS = \"http://www.w3.org/2000/svg\";\n/** Elements a mouse drag never starts from. */\nconst NO_DRAG = \"a, button, input, textarea, select, label, [contenteditable], [data-no-drag]\";\n/** Elements that keep the arrow keys for themselves (text fields and composite widgets). */\nconst OWNS_ARROWS = [\n\t\"input\",\n\t\"textarea\",\n\t\"select\",\n\t\"[contenteditable]\",\n\t\"button\",\n\t\"a[href]\",\n\t\"[tabindex]\",\n\t...[\n\t\t\"tab\",\n\t\t\"menuitem\",\n\t\t\"menuitemcheckbox\",\n\t\t\"menuitemradio\",\n\t\t\"radio\",\n\t\t\"option\",\n\t\t\"slider\",\n\t\t\"spinbutton\",\n\t\t\"scrollbar\",\n\t\t\"gridcell\",\n\t\t\"treeitem\",\n\t\t\"listbox\",\n\t\t\"combobox\",\n\t\t\"textbox\",\n\t].map((role) => `[role=\"${role}\"]`),\n].join(\", \");\n/** Spacing, px of path length, between the samples that map axis → length. */\nconst SAMPLE_STEP = 6;\n\nfunction clamp(n: number, lo: number, hi: number): number {\n\treturn Math.max(lo, Math.min(hi, n));\n}\n\n/**\n * Cubic curve through `pts` with tangents flat along `axis`: each control\n * point sits `tension` of the way to the neighbour along the scroll axis.\n * Empty string under two points.\n */\nexport function curvePath(pts: StoryThreadPoint[], axis: StoryThreadAxis, tension = 0.55): string {\n\tconst first = pts[0];\n\tif (!first || pts.length < 2) return \"\";\n\tconst r = (n: number) => Math.round(n * 100) / 100;\n\tlet d = `M ${r(first.x)} ${r(first.y)}`;\n\tlet q = first;\n\tfor (const p of pts.slice(1)) {\n\t\tif (axis === \"x\") {\n\t\t\tconst dx = (p.x - q.x) * tension;\n\t\t\td += ` C ${r(q.x + dx)} ${r(q.y)} ${r(p.x - dx)} ${r(p.y)} ${r(p.x)} ${r(p.y)}`;\n\t\t} else {\n\t\t\tconst dy = (p.y - q.y) * tension;\n\t\t\td += ` C ${r(q.x)} ${r(q.y + dy)} ${r(p.x)} ${r(p.y - dy)} ${r(p.x)} ${r(p.y)}`;\n\t\t}\n\t\tq = p;\n\t}\n\treturn d;\n}\n\n/**\n * The segments to draw: the given ones, or one through every node in\n * document order. Ids that are not on the page are dropped; a segment left\n * with fewer than two points is dropped too.\n */\nexport function resolveSegments(\n\tsegments: StoryThreadSegment[],\n\torder: string[],\n\tpoints: Record<string, StoryThreadPoint>\n): { ids: string[]; points: StoryThreadPoint[]; color?: string }[] {\n\tconst source = segments.length > 0 ? segments : [{ nodes: order }];\n\tconst out: { ids: string[]; points: StoryThreadPoint[]; color?: string }[] = [];\n\tfor (const seg of source) {\n\t\tconst ids: string[] = [];\n\t\tconst pts: StoryThreadPoint[] = [];\n\t\tfor (const id of seg.nodes) {\n\t\t\tconst pt = points[id];\n\t\t\tif (!pt) continue;\n\t\t\tids.push(id);\n\t\t\tpts.push(pt);\n\t\t}\n\t\tif (ids.length < 2) continue;\n\t\tout.push({ ids, points: pts, color: seg.color });\n\t}\n\treturn out;\n}\n\n/** A segment's extent along the scroll axis, from its first to its last point. */\nexport function segmentSpan(points: StoryThreadPoint[], axis: StoryThreadAxis): StoryThreadSpan {\n\tconst first = points[0];\n\tconst last = points[points.length - 1];\n\treturn first && last ? [first[axis], last[axis]] : [0, 0];\n}\n\n/** Drawn fraction of a segment, 0..1, for the tip at `tip` along the axis. */\nexport function spanProgress(tip: number, span: StoryThreadSpan): number {\n\treturn clamp((tip - span[0]) / Math.max(1, span[1] - span[0]), 0, 1);\n}\n\n/** Where the tip is, along the axis, in track coordinates. */\nexport function tipPosition(pos: number, viewport: number, lead: number): number {\n\treturn pos + viewport * clamp(lead, 0, 1);\n}\n\n/**\n * Tip of the thread in the horizontal layout. It starts `lead` of the way\n * across the viewport and reaches the end of the track exactly when the pan\n * does, so the last node is always drawn by the end of the story. A track\n * that does not overflow is drawn whole.\n */\nexport function panTip(\n\tpan: number,\n\tmaxPan: number,\n\tviewportWidth: number,\n\ttrackWidth: number,\n\tlead: number\n): number {\n\tif (maxPan <= 0) return trackWidth;\n\tconst start = viewportWidth * clamp(lead, 0, 1);\n\treturn start + (clamp(pan, 0, maxPan) / maxPan) * (trackWidth - start);\n}\n\n/** Height of the scroll spacer that lets vertical scrolling pan the whole track. */\nexport function spacerHeight(\n\ttrackWidth: number,\n\tviewportWidth: number,\n\tviewportHeight: number\n): number {\n\treturn Math.max(0, trackWidth - viewportWidth) + viewportHeight;\n}\n\n/** Pan offset, px, for a reader who has scrolled `scrolled` px into the spacer. */\nexport function clampPan(scrolled: number, trackWidth: number, viewportWidth: number): number {\n\treturn clamp(scrolled, 0, Math.max(0, trackWidth - viewportWidth));\n}\n\n/** Scroll distance of one arrow-key press. */\nexport function keyStep(viewportWidth: number, dir: 1 | -1): number {\n\treturn dir * viewportWidth * 0.85;\n}\n\n/** `stroke-dasharray` of the visible strokes; `null` draws a solid line. */\nexport function dashPattern(dash: StoryThreadDash): string | null {\n\tif (dash === \"dashed\") return \"10 12\";\n\tif (dash === \"dotted\") return \"0.5 11\";\n\treturn null;\n}\n\n/** Ids of the nodes the tip has passed (a node exactly at the tip counts). */\nexport function reachedIds(\n\tpoints: Record<string, StoryThreadPoint>,\n\ttip: number,\n\taxis: StoryThreadAxis\n): Set<string> {\n\tconst out = new Set<string>();\n\tfor (const [id, pt] of Object.entries(points)) if (pt[axis] <= tip) out.add(id);\n\treturn out;\n}\n\n/**\n * Path length at which the curve reaches `at` along the axis, interpolated\n * between samples `[length, axisCoordinate]` taken along the path. The curve\n * is assumed to move forward along the axis, which it does when its nodes do.\n */\nexport function lengthAtAxis(samples: [number, number][], at: number): number {\n\tconst first = samples[0];\n\tconst last = samples[samples.length - 1];\n\tif (!first || !last) return 0;\n\tif (at <= first[1]) return first[0];\n\tif (at >= last[1]) return last[0];\n\tlet lo = 0;\n\tlet hi = samples.length - 1;\n\twhile (hi - lo > 1) {\n\t\tconst mid = (lo + hi) >> 1;\n\t\tif ((samples[mid]?.[1] ?? Infinity) <= at) lo = mid;\n\t\telse hi = mid;\n\t}\n\tconst [l0, a0] = samples[lo] ?? first;\n\tconst [l1, a1] = samples[hi] ?? last;\n\treturn a1 === a0 ? l0 : l0 + ((at - a0) / (a1 - a0)) * (l1 - l0);\n}\n\nlet instances = 0;\n\ninterface DrawnSegment {\n\tids: string[];\n\tspan: StoryThreadSpan;\n\tlength: number;\n\tsamples: [number, number][];\n\t/** The mask path: its dashoffset uncovers the bright stroke. */\n\tmask: SVGPathElement;\n\thead: SVGGElement;\n\theadX: number;\n\theadY: number;\n\tshown: boolean;\n\tdrawn: number;\n}\n\n/** Nearest ancestor that scrolls vertically, or null for the window. */\nfunction scrollParent(el: HTMLElement): HTMLElement | null {\n\tlet node = el.parentElement;\n\twhile (node && node !== document.body && node !== document.documentElement) {\n\t\tconst oy = getComputedStyle(node).overflowY;\n\t\tif (oy === \"auto\" || oy === \"scroll\" || oy === \"overlay\") return node;\n\t\tnode = node.parentElement;\n\t}\n\treturn null;\n}\n\nexport function createStoryThread(\n\troot: HTMLElement,\n\tviewport: HTMLElement,\n\ttrack: HTMLElement,\n\tsvg: SVGSVGElement,\n\tinitial: Partial<StoryThreadOptions> = {}\n): StoryThreadCore {\n\tconst opts: StoryThreadOptions = {\n\t\tlayout: STORY_THREAD_DEFAULTS.layout,\n\t\tsegments: [],\n\t\tlead: STORY_THREAD_DEFAULTS.lead,\n\t\ttension: STORY_THREAD_DEFAULTS.tension,\n\t\tdash: STORY_THREAD_DEFAULTS.dash,\n\t\thead: STORY_THREAD_DEFAULTS.head,\n\t\treducedMotion: false,\n\t\t...initial,\n\t};\n\tconst uid = `st${++instances}`;\n\tconst win = root.ownerDocument.defaultView ?? window;\n\tconst scroller = scrollParent(root);\n\tconst query = win.matchMedia ? win.matchMedia(STORY_THREAD_HORIZONTAL_QUERY) : null;\n\n\tlet axis: StoryThreadAxis = \"y\";\n\tlet nodes: Record<string, HTMLElement> = {};\n\tlet points: Record<string, StoryThreadPoint> = {};\n\tlet drawn: DrawnSegment[] = [];\n\t/** Points of the nodes the resolved segments run through: the only ones that can be reached. */\n\tlet threaded: Record<string, StoryThreadPoint> = {};\n\tlet reached = new Set<string>();\n\tlet primed = false;\n\tlet frame = 0;\n\tlet measureFrame = 0;\n\tlet pan = -1;\n\tlet maxPan = 0;\n\tlet viewW = 0;\n\tlet viewH = 0;\n\tlet trackLength = 0;\n\tlet destroyed = false;\n\n\tconst horizontal = () =>\n\t\topts.layout === \"horizontal\" || (opts.layout === \"auto\" && (query?.matches ?? false));\n\n\t/** Visible box of the scroller, in client coordinates. */\n\tfunction scrollerBox(): { top: number; height: number; width: number } {\n\t\tif (!scroller) return { top: 0, height: win.innerHeight, width: win.innerWidth };\n\t\tconst r = scroller.getBoundingClientRect();\n\t\treturn {\n\t\t\ttop: r.top + scroller.clientTop,\n\t\t\theight: scroller.clientHeight,\n\t\t\twidth: scroller.clientWidth,\n\t\t};\n\t}\n\n\tfunction scrollBy(dy: number, smooth = false): void {\n\t\tconst behavior: ScrollBehavior = smooth && !opts.reducedMotion ? \"smooth\" : \"auto\";\n\t\tif (scroller) scroller.scrollBy({ top: dy, behavior });\n\t\telse win.scrollBy({ top: dy, behavior });\n\t}\n\n\tfunction el<K extends keyof SVGElementTagNameMap>(name: K, attrs: Record<string, string>) {\n\t\tconst node = root.ownerDocument.createElementNS(SVG_NS, name) as SVGElementTagNameMap[K];\n\t\tfor (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);\n\t\treturn node;\n\t}\n\n\tfunction measure(): void {\n\t\tmeasureFrame = 0;\n\t\tif (destroyed) return;\n\t\tconst isH = horizontal();\n\t\taxis = isH ? \"x\" : \"y\";\n\t\troot.dataset.layout = isH ? \"horizontal\" : \"vertical\";\n\t\tconst box = scrollerBox();\n\t\troot.style.setProperty(\"--st-vh\", `${box.height}px`);\n\n\t\t// Read the anchors with the track at rest so their offsets are layout ones.\n\t\ttrack.style.transform = \"\";\n\t\tconst trackRect = track.getBoundingClientRect();\n\t\tconst trackW = track.offsetWidth;\n\t\tconst trackH = track.offsetHeight;\n\t\tviewW = isH ? viewport.clientWidth || box.width : box.width;\n\t\tviewH = box.height;\n\t\tmaxPan = isH ? Math.max(0, trackW - viewW) : 0;\n\t\ttrackLength = trackW;\n\t\troot.style.height = isH ? `${spacerHeight(trackW, viewW, viewH)}px` : \"\";\n\n\t\tnodes = {};\n\t\tpoints = {};\n\t\tconst order: string[] = [];\n\t\tfor (const node of Array.from(track.querySelectorAll<HTMLElement>(\"[data-story-node]\"))) {\n\t\t\tconst id = node.dataset.storyNode;\n\t\t\tif (!id || nodes[id]) continue;\n\t\t\tconst r = node.getBoundingClientRect();\n\t\t\tnodes[id] = node;\n\t\t\tpoints[id] = {\n\t\t\t\tx: r.left - trackRect.left + r.width / 2,\n\t\t\t\ty: r.top - trackRect.top + r.height / 2,\n\t\t\t};\n\t\t\torder.push(id);\n\t\t}\n\n\t\tsvg.setAttribute(\"viewBox\", `0 0 ${Math.max(1, trackW)} ${Math.max(1, trackH)}`);\n\t\tsvg.setAttribute(\"width\", String(Math.max(1, trackW)));\n\t\tsvg.setAttribute(\"height\", String(Math.max(1, trackH)));\n\t\twhile (svg.firstChild) svg.removeChild(svg.firstChild);\n\t\tconst defs = el(\"defs\", {});\n\t\tsvg.appendChild(defs);\n\t\tconst ghosts = el(\"g\", { class: \"st-ghosts\" });\n\t\tconst brights = el(\"g\", { class: \"st-brights\" });\n\t\tconst heads = el(\"g\", { class: \"st-heads\" });\n\t\tsvg.append(ghosts, brights, heads);\n\t\tconst dash = dashPattern(opts.dash);\n\n\t\tdrawn = resolveSegments(opts.segments, order, points).map((seg, i) => {\n\t\t\tconst d = curvePath(seg.points, axis, opts.tension);\n\t\t\tconst colour: Record<string, string> = seg.color ? { style: `--st-seg: ${seg.color}` } : {};\n\t\t\tconst ghost = el(\"path\", {\n\t\t\t\tclass: \"st-ghost\",\n\t\t\t\td,\n\t\t\t\t...(dash ? { \"stroke-dasharray\": dash } : {}),\n\t\t\t});\n\t\t\tconst maskId = `${uid}-m${i}`;\n\t\t\tconst mask = el(\"mask\", { id: maskId, maskUnits: \"userSpaceOnUse\" });\n\t\t\tconst maskPath = el(\"path\", { class: \"st-mask\", d });\n\t\t\tmask.appendChild(maskPath);\n\t\t\tdefs.appendChild(mask);\n\t\t\tconst bright = el(\"path\", {\n\t\t\t\tclass: \"st-bright\",\n\t\t\t\td,\n\t\t\t\tmask: `url(#${maskId})`,\n\t\t\t\t...(dash ? { \"stroke-dasharray\": dash } : {}),\n\t\t\t\t...colour,\n\t\t\t});\n\t\t\tconst glowId = `${uid}-g${i}`;\n\t\t\tconst glow = el(\"radialGradient\", { id: glowId, class: \"st-glow\", ...colour });\n\t\t\tglow.append(\n\t\t\t\tel(\"stop\", { class: \"st-glow-core\", offset: \"0\" }),\n\t\t\t\tel(\"stop\", { class: \"st-glow-edge\", offset: \"1\" })\n\t\t\t);\n\t\t\tdefs.appendChild(glow);\n\t\t\tconst head = el(\"g\", { class: \"st-head\", ...colour });\n\t\t\thead.append(\n\t\t\t\tel(\"circle\", { class: \"st-head-glow\", r: \"18\", fill: `url(#${glowId})` }),\n\t\t\t\tel(\"circle\", { class: \"st-head-dot\", r: \"3.5\" })\n\t\t\t);\n\t\t\tghosts.appendChild(ghost);\n\t\t\tbrights.appendChild(bright);\n\t\t\theads.appendChild(head);\n\n\t\t\tconst length = typeof bright.getTotalLength === \"function\" ? bright.getTotalLength() : 0;\n\t\t\tconst samples: [number, number][] = [];\n\t\t\tif (length > 0 && typeof bright.getPointAtLength === \"function\") {\n\t\t\t\tconst n = Math.max(2, Math.ceil(length / SAMPLE_STEP));\n\t\t\t\tlet prev = -Infinity;\n\t\t\t\tfor (let k = 0; k <= n; k++) {\n\t\t\t\t\tconst l = (length * k) / n;\n\t\t\t\t\t// Keep the axis coordinate monotonic so the lookup is a search.\n\t\t\t\t\tprev = Math.max(prev, bright.getPointAtLength(l)[axis]);\n\t\t\t\t\tsamples.push([l, prev]);\n\t\t\t\t}\n\t\t\t}\n\t\t\tmaskPath.setAttribute(\"stroke-dasharray\", `${length} ${length + 1}`);\n\t\t\tmaskPath.setAttribute(\"stroke-dashoffset\", String(length));\n\t\t\treturn {\n\t\t\t\tids: seg.ids,\n\t\t\t\tspan: segmentSpan(seg.points, axis),\n\t\t\t\tlength,\n\t\t\t\tsamples,\n\t\t\t\tmask: maskPath,\n\t\t\t\thead,\n\t\t\t\theadX: NaN,\n\t\t\t\theadY: NaN,\n\t\t\t\tshown: false,\n\t\t\t\tdrawn: -1,\n\t\t\t};\n\t\t});\n\t\tthreaded = {};\n\t\tfor (const seg of drawn) {\n\t\t\tfor (const id of seg.ids) {\n\t\t\t\tconst pt = points[id];\n\t\t\t\tif (pt) threaded[id] = pt;\n\t\t\t}\n\t\t}\n\t\tpan = -1;\n\t\t// New geometry is a new baseline: whatever the tip covers now is marked\n\t\t// without being reported, so a re-measure never reads as the reader moving.\n\t\tprimed = false;\n\t\tpaint();\n\t}\n\n\tfunction paint(): void {\n\t\tframe = 0;\n\t\tif (destroyed) return;\n\t\tconst box = scrollerBox();\n\t\tlet tip: number;\n\t\tif (axis === \"x\") {\n\t\t\tconst scrolled = box.top - root.getBoundingClientRect().top;\n\t\t\tconst next = clampPan(scrolled, maxPan + viewW, viewW);\n\t\t\tif (next !== pan) {\n\t\t\t\tpan = next;\n\t\t\t\ttrack.style.transform = `translate3d(${-pan}px, 0, 0)`;\n\t\t\t}\n\t\t\ttip = panTip(pan, maxPan, viewW, trackLength, opts.lead);\n\t\t} else {\n\t\t\tif (track.style.transform) track.style.transform = \"\";\n\t\t\tconst pos = box.top - track.getBoundingClientRect().top;\n\t\t\ttip = tipPosition(pos, viewH, opts.lead);\n\t\t}\n\n\t\tfor (const seg of drawn) {\n\t\t\tconst p = spanProgress(tip, seg.span);\n\t\t\tconst len = p >= 1 ? seg.length : p <= 0 ? 0 : lengthAtAxis(seg.samples, tip);\n\t\t\tif (Math.abs(len - seg.drawn) > 0.25) {\n\t\t\t\tseg.drawn = len;\n\t\t\t\tseg.mask.setAttribute(\"stroke-dashoffset\", String(seg.length - len));\n\t\t\t}\n\t\t\tconst show = opts.head && p > 0 && p < 1 && seg.length > 0;\n\t\t\tif (show !== seg.shown) {\n\t\t\t\tseg.shown = show;\n\t\t\t\tseg.head.toggleAttribute(\"data-visible\", show);\n\t\t\t}\n\t\t\tif (show) {\n\t\t\t\tconst pt = seg.mask.getPointAtLength(len);\n\t\t\t\tif (pt.x !== seg.headX || pt.y !== seg.headY) {\n\t\t\t\t\tseg.headX = pt.x;\n\t\t\t\t\tseg.headY = pt.y;\n\t\t\t\t\tseg.head.setAttribute(\"transform\", `translate(${pt.x} ${pt.y})`);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst next = reachedIds(threaded, tip, axis);\n\t\tfor (const [id, node] of Object.entries(nodes)) {\n\t\t\tconst was = reached.has(id);\n\t\t\tconst now = next.has(id);\n\t\t\tif (was === now && primed) continue;\n\t\t\tnode.toggleAttribute(\"data-reached\", now);\n\t\t\tif (now && !was && primed) opts.onReach?.(id);\n\t\t}\n\t\treached = next;\n\t\tprimed = true;\n\t}\n\n\tfunction schedulePaint(): void {\n\t\tif (!frame && !destroyed) frame = win.requestAnimationFrame(paint);\n\t}\n\n\tfunction scheduleMeasure(): void {\n\t\tif (measureFrame || destroyed) return;\n\t\tif (frame) {\n\t\t\twin.cancelAnimationFrame(frame);\n\t\t\tframe = 0;\n\t\t}\n\t\tmeasureFrame = win.requestAnimationFrame(measure);\n\t}\n\n\t// Inputs that only make sense while the track runs sideways.\n\tfunction onWheel(e: WheelEvent): void {\n\t\tif (axis !== \"x\" || e.ctrlKey) return;\n\t\tif (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;\n\t\tconst atStart = pan <= 0 && e.deltaX < 0;\n\t\tconst atEnd = pan >= maxPan && e.deltaX > 0;\n\t\tif (atStart || atEnd) return;\n\t\te.preventDefault();\n\t\tscrollBy(e.deltaX);\n\t}\n\n\tlet drag: { x: number; id: number; moved: boolean } | null = null;\n\tfunction onPointerDown(e: PointerEvent): void {\n\t\tif (axis !== \"x\" || e.pointerType !== \"mouse\" || e.button !== 0) return;\n\t\tif ((e.target as Element | null)?.closest?.(NO_DRAG)) return;\n\t\tdrag = { x: e.clientX, id: e.pointerId, moved: false };\n\t}\n\tfunction onPointerMove(e: PointerEvent): void {\n\t\tif (!drag || e.pointerId !== drag.id) return;\n\t\tif (e.buttons === 0) return endDrag();\n\t\tconst dx = e.clientX - drag.x;\n\t\tif (!drag.moved && Math.abs(dx) < 3) return;\n\t\tif (!drag.moved) {\n\t\t\tdrag.moved = true;\n\t\t\troot.dataset.dragging = \"\";\n\t\t\troot.setPointerCapture?.(e.pointerId);\n\t\t}\n\t\tdrag.x = e.clientX;\n\t\tscrollBy(-dx);\n\t}\n\tfunction endDrag(): void {\n\t\tif (drag && root.hasPointerCapture?.(drag.id)) root.releasePointerCapture(drag.id);\n\t\tdrag = null;\n\t\tdelete root.dataset.dragging;\n\t}\n\n\tfunction onKeyDown(e: KeyboardEvent): void {\n\t\tif (axis !== \"x\" || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;\n\t\tif (e.key !== \"ArrowRight\" && e.key !== \"ArrowLeft\") return;\n\t\tif (e.defaultPrevented) return;\n\t\tconst owner = (e.target as Element | null)?.closest?.(OWNS_ARROWS);\n\t\tif (owner && owner !== root && owner !== viewport && owner !== track) return;\n\t\te.preventDefault();\n\t\tscrollBy(keyStep(viewW, e.key === \"ArrowRight\" ? 1 : -1), true);\n\t}\n\n\t// The track is panned by a transform, so the browser cannot scroll a\n\t// focused element into view on its own: move the reading position to it.\n\tfunction onFocusIn(e: FocusEvent): void {\n\t\tif (axis !== \"x\") return;\n\t\tconst t = e.target as HTMLElement | null;\n\t\tif (!t || t === root || t === viewport) return;\n\t\tconst r = t.getBoundingClientRect();\n\t\tconst v = viewport.getBoundingClientRect();\n\t\tif (r.left >= v.left && r.right <= v.right) return;\n\t\tconst at = r.left - track.getBoundingClientRect().left;\n\t\tconst target = clampPan(at - viewW * 0.1, maxPan + viewW, viewW);\n\t\tviewport.scrollLeft = 0;\n\t\tscrollBy(target - pan);\n\t}\n\n\tconst scrollTarget: EventTarget = scroller ?? win;\n\tscrollTarget.addEventListener(\"scroll\", schedulePaint, { passive: true });\n\twin.addEventListener(\"resize\", scheduleMeasure);\n\troot.addEventListener(\"wheel\", onWheel, { passive: false });\n\troot.addEventListener(\"pointerdown\", onPointerDown);\n\troot.addEventListener(\"pointermove\", onPointerMove);\n\troot.addEventListener(\"pointerup\", endDrag);\n\troot.addEventListener(\"pointercancel\", endDrag);\n\troot.addEventListener(\"keydown\", onKeyDown);\n\troot.addEventListener(\"focusin\", onFocusIn);\n\tquery?.addEventListener?.(\"change\", scheduleMeasure);\n\n\tconst resize = typeof ResizeObserver === \"function\" ? new ResizeObserver(scheduleMeasure) : null;\n\tresize?.observe(track);\n\tif (scroller) resize?.observe(scroller);\n\t// Content changes re-measure; the <svg> this module redraws does not. A\n\t// node's class or style moving it counts too, but only on the nodes\n\t// themselves: the track's own transform and the rest of the content's\n\t// styling change far too often to re-measure on.\n\tconst moves = (r: MutationRecord) =>\n\t\tr.type !== \"attributes\" ||\n\t\tr.attributeName === \"data-story-node\" ||\n\t\t(r.target as Element).hasAttribute?.(\"data-story-node\");\n\tconst mutations =\n\t\ttypeof MutationObserver === \"function\"\n\t\t\t? new MutationObserver((records) => {\n\t\t\t\t\tif (records.some((r) => !svg.contains(r.target) && moves(r))) scheduleMeasure();\n\t\t\t\t})\n\t\t\t: null;\n\tmutations?.observe(track, {\n\t\tchildList: true,\n\t\tsubtree: true,\n\t\tattributeFilter: [\"data-story-node\", \"class\", \"style\"],\n\t});\n\tvoid root.ownerDocument.fonts?.ready?.then(scheduleMeasure);\n\n\tmeasure();\n\n\treturn {\n\t\tupdate(next) {\n\t\t\tconst geometry =\n\t\t\t\t(next.layout !== undefined && next.layout !== opts.layout) ||\n\t\t\t\t(next.tension !== undefined && next.tension !== opts.tension) ||\n\t\t\t\t(next.dash !== undefined && next.dash !== opts.dash) ||\n\t\t\t\t(next.segments !== undefined && next.segments !== opts.segments);\n\t\t\tObject.assign(opts, next);\n\t\t\tif (geometry) scheduleMeasure();\n\t\t\telse schedulePaint();\n\t\t},\n\t\trefresh: scheduleMeasure,\n\t\tdestroy() {\n\t\t\tdestroyed = true;\n\t\t\tif (frame) win.cancelAnimationFrame(frame);\n\t\t\tif (measureFrame) win.cancelAnimationFrame(measureFrame);\n\t\t\tscrollTarget.removeEventListener(\"scroll\", schedulePaint);\n\t\t\twin.removeEventListener(\"resize\", scheduleMeasure);\n\t\t\troot.removeEventListener(\"wheel\", onWheel);\n\t\t\troot.removeEventListener(\"pointerdown\", onPointerDown);\n\t\t\troot.removeEventListener(\"pointermove\", onPointerMove);\n\t\t\troot.removeEventListener(\"pointerup\", endDrag);\n\t\t\troot.removeEventListener(\"pointercancel\", endDrag);\n\t\t\troot.removeEventListener(\"keydown\", onKeyDown);\n\t\t\troot.removeEventListener(\"focusin\", onFocusIn);\n\t\t\tquery?.removeEventListener?.(\"change\", scheduleMeasure);\n\t\t\tresize?.disconnect();\n\t\t\tmutations?.disconnect();\n\t\t\twhile (svg.firstChild) svg.removeChild(svg.firstChild);\n\t\t\ttrack.style.transform = \"\";\n\t\t\troot.style.height = \"\";\n\t\t\troot.style.removeProperty(\"--st-vh\");\n\t\t\tdelete root.dataset.layout;\n\t\t\tdelete root.dataset.dragging;\n\t\t\tfor (const node of Object.values(nodes)) node.removeAttribute(\"data-reached\");\n\t\t},\n\t};\n}\n","type":"registry:ui","target":"@ui/fancy/components/story-thread/story-thread-core.ts"},{"path":"react/src/components/story-thread/story-thread.css","content":".st-root {\n\t--story-thread-color: currentColor;\n\t--story-thread-faint: color-mix(in oklab, currentColor 22%, transparent);\n\t--story-thread-head: var(--story-thread-color);\n\t--story-thread-width: 2px;\n\tposition: relative;\n\toutline-offset: -2px;\n}\n.st-viewport {\n\tposition: relative;\n}\n.st-track {\n\tposition: relative;\n\tisolation: isolate;\n}\n.st-root[data-layout=\"horizontal\"] .st-viewport {\n\tposition: sticky;\n\ttop: 0;\n\theight: var(--st-vh, 100dvh);\n\toverflow: hidden;\n}\n.st-root[data-layout=\"horizontal\"] .st-track {\n\tdisplay: flex;\n\tflex-wrap: nowrap;\n\twidth: max-content;\n\theight: 100%;\n\twill-change: transform;\n}\n.st-root[data-dragging] {\n\tcursor: grabbing;\n\tuser-select: none;\n}\n.st-svg {\n\tposition: absolute;\n\ttop: 0;\n\tleft: 0;\n\tz-index: -1;\n\toverflow: visible;\n\tpointer-events: none;\n}\n.st-svg .st-ghost,\n.st-svg .st-bright {\n\tfill: none;\n\tstroke-width: var(--story-thread-width);\n\tstroke-linecap: round;\n}\n.st-svg .st-ghost {\n\tstroke: var(--story-thread-faint);\n}\n.st-svg .st-bright {\n\tstroke: var(--st-seg, var(--story-thread-color));\n}\n.st-svg .st-mask {\n\tfill: none;\n\tstroke: #fff;\n\tstroke-width: 12;\n}\n.st-svg .st-head {\n\tcolor: var(--st-seg, var(--story-thread-head));\n\topacity: 0;\n\ttransition: opacity 0.3s ease;\n}\n.st-svg .st-head[data-visible] {\n\topacity: 1;\n}\n.st-svg .st-glow-core {\n\tstop-color: var(--st-seg, var(--story-thread-head));\n\tstop-opacity: 0.6;\n}\n.st-svg .st-glow-edge {\n\tstop-color: var(--st-seg, var(--story-thread-head));\n\tstop-opacity: 0;\n}\n.st-svg .st-head-glow {\n\ttransform-box: fill-box;\n\ttransform-origin: center;\n\tanimation: st-breathe 1.8s ease-in-out infinite;\n}\n.st-svg .st-head-dot {\n\tfill: currentColor;\n}\n@keyframes st-breathe {\n\t0%,\n\t100% {\n\t\tscale: 0.75;\n\t\topacity: 1;\n\t}\n\t50% {\n\t\tscale: 1;\n\t\topacity: 0.7;\n\t}\n}\n@media (prefers-reduced-motion: reduce) {\n\t.st-svg .st-head-glow {\n\t\tanimation: none;\n\t}\n}\n\n.st-node {\n\tdisplay: inline-block;\n\tflex: none;\n\twidth: 10px;\n\theight: 10px;\n\tborder-radius: 9999px;\n\tbackground: var(--story-thread-faint, color-mix(in oklab, currentColor 22%, transparent));\n\ttransition:\n\t\tbackground-color 0.35s ease,\n\t\tborder-color 0.35s ease,\n\t\tbox-shadow 0.35s ease,\n\t\tscale 0.35s cubic-bezier(0.2, 0.7, 0.2, 1);\n}\n.st-node[data-variant=\"ring\"] {\n\twidth: 14px;\n\theight: 14px;\n\tbackground: var(--background, Canvas);\n\tborder: 2px solid var(--story-thread-faint, color-mix(in oklab, currentColor 22%, transparent));\n}\n.st-node[data-reached] {\n\tbackground: var(--story-thread-color, currentColor);\n\tscale: 1.15;\n\tbox-shadow:\n\t\t0 0 0 4px color-mix(in oklab, var(--story-thread-color, currentColor) 16%, transparent),\n\t\t0 0 16px color-mix(in oklab, var(--story-thread-color, currentColor) 45%, transparent);\n}\n.st-node[data-variant=\"ring\"][data-reached] {\n\tbackground: var(--background, Canvas);\n\tborder-color: var(--story-thread-color, currentColor);\n}\n@media (prefers-reduced-motion: reduce) {\n\t.st-node {\n\t\ttransition: none;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/story-thread/story-thread.css"},{"path":"react/src/components/story-thread/types.ts","content":"\"use client\";\n\nexport type {\n\tStoryThreadSegment,\n\tStoryThreadLayout,\n\tStoryThreadDash,\n} from \"./story-thread-core\";\n","type":"registry:ui","target":"@ui/fancy/components/story-thread/types.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/story-thread?framework=react"}}