{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hero-text-animation",
  "type": "registry:ui",
  "title": "HeroTextAnimation",
  "description": "Accessible hero heading text animation foundation with Motion-powered staggered word and masked curtain reveals, bounded typewriter, scramble decrypt, rotating keyword, one-shot gradient highlight, short blur-to-focus, kinetic emphasis pop, decorative SVG stroke draw, and subtle scroll-responsive text effects, plus opt-in replay, keyword timing, and static reduced-motion emphasis.",
  "dependencies": [
    "motion@^12.43.0"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://components.dethink.co.uk/r/dethink-base.json"
  ],
  "files": [
    {
      "path": "packages/components/src/components/hero-text-animation/hero-text-animation.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/hero-text-animation/hero-text-animation.tsx",
      "content": "import {\n  createElement,\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type CSSProperties,\n  type HTMLAttributes,\n  type ReactNode,\n} from \"react\";\nimport {\n  AnimatePresence,\n  MotionConfig,\n  motion as motionElement,\n  useMotionValue,\n  type Transition,\n  type Variants,\n} from \"motion/react\";\nimport { cn } from \"../../utils/cn\";\n\nexport type HeroTextAnimationKind =\n  | \"stagger-words\"\n  | \"masked-curtain\"\n  | \"typewriter\"\n  | \"scramble-decrypt\"\n  | \"rotating-keyword\"\n  | \"gradient-highlight\"\n  | \"blur-focus\"\n  | \"kinetic-emphasis-pop\"\n  | \"svg-stroke-draw\"\n  | \"scroll-responsive\";\nexport type HeroTextAnimationElement = \"h1\" | \"h2\" | \"p\" | \"span\";\nexport type HeroTextAnimationSplitBy = \"word\" | \"line\";\nexport type HeroTextAnimationTrigger = \"mount\" | \"in-view\" | \"manual\";\nexport type HeroTextAnimationReducedMotionStrategy = \"static\" | \"opacity-only\";\nexport type HeroTextAnimationProviderReducedMotion =\n  \"user\" | \"always\" | \"never\";\n\n/**\n * @deprecated `svg-stroke-draw` now traces the heading's own letterforms\n * (outline → fill) instead of a decorative accent path, so `svgPathData` is no\n * longer used. The type is retained for backwards compatibility only.\n */\nexport interface HeroTextAnimationSvgPath {\n  d: string;\n  strokeWidth?: number;\n}\n\nexport interface HeroTextAnimationProps extends Omit<\n  HTMLAttributes<HTMLElement>,\n  \"aria-label\" | \"children\" | \"onAnimationStart\"\n> {\n  active?: boolean;\n  animation?: HeroTextAnimationKind;\n  ariaLabel?: string;\n  as?: HeroTextAnimationElement;\n  delay?: number;\n  duration?: number;\n  emphasisWordIndices?: readonly number[];\n  emphasisWords?: readonly string[];\n  once?: boolean;\n  reducedMotionStrategy?: HeroTextAnimationReducedMotionStrategy;\n  repeat?: boolean;\n  repeatDelay?: number;\n  autoRotateKeywords?: boolean;\n  defaultRotatingKeywordIndex?: number;\n  rotatingKeywordIndex?: number;\n  rotatingKeywordInterval?: number;\n  rotatingKeywordOptions?: readonly string[];\n  rotatingKeywordPrefix?: string;\n  rotatingKeywordSuffix?: string;\n  showCaret?: boolean;\n  splitBy?: HeroTextAnimationSplitBy;\n  stagger?: number;\n  svgAccessibleTitle?: string;\n  /**\n   * @deprecated `svg-stroke-draw` traces the heading's own letterforms now, so\n   * this prop is ignored. It is kept only so existing callers keep type-checking.\n   */\n  svgPathData?:\n    string | readonly string[] | readonly HeroTextAnimationSvgPath[];\n  /**\n   * Optional view box override for the `svg-stroke-draw` letter trace. When\n   * omitted (recommended) the component frames the text automatically from its\n   * measured glyph metrics.\n   */\n  svgViewBox?: string;\n  text: string;\n  trigger?: HeroTextAnimationTrigger;\n  \"data-testid\"?: string;\n  onAnimationComplete?: () => void;\n  onAnimationStart?: () => void;\n  onRotatingKeywordIndexChange?: (index: number) => void;\n}\n\nexport interface HeroTextAnimationProviderProps {\n  children?: ReactNode;\n  reducedMotion?: HeroTextAnimationProviderReducedMotion;\n}\n\nexport type HeroTextAnimationSegment =\n  | {\n      kind: \"text\";\n      text: string;\n    }\n  | {\n      kind: \"space\";\n      text: string;\n    };\n\nconst standardEase = [0.22, 1, 0.36, 1] as [number, number, number, number];\nconst softEase = [0.16, 1, 0.3, 1] as [number, number, number, number];\n\nexport const heroTextAnimationMotionTokens = {\n  easing: {\n    standard: standardEase,\n    soft: softEase,\n  },\n  duration: {\n    fast: 0.28,\n    base: 0.48,\n    slow: 0.72,\n    cinematic: 1.1,\n    maskedCurtain: 0.64,\n    typewriter: 1.1,\n    scramble: 1.2,\n    rotatingKeyword: 0.34,\n    gradientHighlight: 1.6,\n    blurFocus: 0.9,\n    kineticEmphasisPop: 0.5,\n    svgStrokeDraw: 1.4,\n    scrollResponsive: 0,\n  },\n  stagger: {\n    word: 0.045,\n    line: 0.11,\n    character: 0.018,\n  },\n  distance: {\n    wordY: \"0.6em\",\n    lineY: \"0.6em\",\n    curtainY: \"0.85em\",\n    blurFocusY: \"0.2em\",\n    scrollY: -32,\n  },\n} as const;\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nconst heroTextAnimationRootClasses =\n  \"min-w-0 max-w-full [text-wrap:balance] text-foreground\";\nconst heroTextAnimationAccessibleTextClasses = \"sr-only\";\nconst heroTextAnimationVisualClasses = \"inline min-w-0 max-w-full\";\nconst heroTextAnimationMotionClasses = \"inline min-w-0 max-w-full\";\nconst heroTextAnimationWordClasses =\n  \"inline-block whitespace-pre align-baseline will-change-transform data-[reduced-motion=true]:will-change-auto\";\nconst heroTextAnimationLineClasses =\n  \"block min-w-0 overflow-visible will-change-transform data-[reduced-motion=true]:will-change-auto\";\nconst heroTextAnimationCurtainMaskClasses =\n  \"block min-w-0 overflow-hidden py-[0.2em] -my-[0.2em] [line-height:inherit]\";\nconst heroTextAnimationCurtainLineClasses =\n  \"block min-w-0 will-change-transform data-[reduced-motion=true]:will-change-auto\";\nconst heroTextAnimationTypewriterMotionClasses =\n  \"inline-grid min-w-0 max-w-full align-baseline\";\nconst heroTextAnimationTypewriterSizerClasses =\n  \"invisible col-start-1 row-start-1 whitespace-pre-wrap\";\nconst heroTextAnimationTypewriterTextClasses =\n  \"col-start-1 row-start-1 whitespace-pre-wrap\";\nconst heroTextAnimationTypewriterCaretClasses =\n  \"ml-[0.08em] inline-block h-[0.9em] w-[0.08em] translate-y-[0.08em] bg-current align-baseline opacity-70 animate-pulse motion-reduce:animate-none\";\n// Every state of the scramble — jumbled, settling, fully resolved, and the SSR\n// fallback — is rendered from the *same* per-character slots, so a character's\n// box is byte-for-byte identical whether it is showing a cipher glyph or its\n// final letter. Resolving a character only swaps the glyph inside its slot; the\n// slot itself never changes size. That invariance is what removes every source\n// of jitter and the settle-time shift, without depending on font kerning at all.\nconst heroTextAnimationScrambleMotionClasses = \"inline min-w-0 max-w-full\";\nconst heroTextAnimationScrambleTextClasses = \"inline\";\n// Each word is an inline-block so a run of character slots never breaks mid-word\n// — line breaks only happen at the real spaces between words.\nconst heroTextAnimationScrambleWordClasses =\n  \"inline-block whitespace-pre align-baseline\";\n// A resolved slot simply shows its final character (clean text, natural width).\nconst heroTextAnimationScrambleResolvedGlyphClasses =\n  \"inline-block whitespace-pre align-baseline\";\n// An unresolved slot reserves its final character's width with an invisible copy\n// and floats the cycling cipher glyph, centered, on top — so glyph churn never\n// moves anything. Because the reserved width equals the resolved width, the slot\n// does not resize when it locks in.\nconst heroTextAnimationScrambleGlyphClasses =\n  \"relative inline-block whitespace-pre align-baseline\";\nconst heroTextAnimationScrambleGlyphOverlayClasses =\n  \"absolute left-0 top-0 w-full text-center\";\nconst heroTextAnimationRotatingMotionClasses =\n  \"inline-flex min-w-0 max-w-full flex-wrap items-baseline gap-x-[0.18em]\";\nconst heroTextAnimationRotatingTextClasses = \"whitespace-pre-wrap\";\nconst heroTextAnimationRotatingSlotClasses =\n  \"relative inline-grid overflow-hidden align-baseline [line-height:inherit] [contain:layout]\";\nconst heroTextAnimationRotatingSizerClasses =\n  \"invisible col-start-1 row-start-1 whitespace-pre\";\nconst heroTextAnimationRotatingKeywordClasses =\n  \"col-start-1 row-start-1 whitespace-pre will-change-transform data-[reduced-motion=true]:will-change-auto\";\n// A light-sweep \"paint-in\" reveal. The heading is painted into the text via\n// `background-clip: text`, and the gradient runs revealed → glint → hidden:\n// `base` (foreground) on the left, a bright primary `sheen`/`accent` band at the\n// boundary, then `transparent` on the right. Animating `background-position`\n// slides that boundary across so each glyph flashes primary as it appears and\n// settles to foreground — a directional reveal, not a sheen over visible text,\n// so it reads clearly in light and dark themes. At the default position (0%) the\n// window sits entirely in `base`, so the static/reduced fallback is solid text.\nconst heroTextAnimationGradientHighlightClasses =\n  \"inline whitespace-pre-wrap bg-[linear-gradient(100deg,var(--hero-text-animation-highlight-base)_0%,var(--hero-text-animation-highlight-base)_36%,var(--hero-text-animation-highlight-accent)_45%,var(--hero-text-animation-highlight-sheen)_50%,var(--hero-text-animation-highlight-accent)_55%,transparent_64%,transparent_100%)] bg-[length:300%_100%] bg-no-repeat bg-clip-text text-transparent will-change-[background-position] forced-colors:bg-none forced-colors:text-[CanvasText] data-[reduced-motion=true]:will-change-auto\";\nconst heroTextAnimationBlurFocusClasses =\n  \"inline-block min-w-0 max-w-full whitespace-pre-wrap align-baseline will-change-[filter,opacity,transform] data-[reduced-motion=true]:will-change-auto\";\nconst heroTextAnimationBlurFocusWordClasses =\n  \"inline-block whitespace-pre align-baseline will-change-[filter,opacity,transform] data-[reduced-motion=true]:will-change-auto\";\nconst heroTextAnimationBlurFocusLineClasses =\n  \"block min-w-0 overflow-visible will-change-[filter,opacity,transform] data-[reduced-motion=true]:will-change-auto\";\nconst heroTextAnimationKineticEmphasisMotionClasses =\n  \"inline min-w-0 max-w-full\";\nconst heroTextAnimationKineticEmphasisWordClasses =\n  \"inline-block whitespace-pre align-baseline [transform-origin:center_70%] data-[emphasized=true]:text-primary data-[emphasized=true]:font-semibold data-[emphasized=true]:underline data-[emphasized=true]:decoration-current data-[emphasized=true]:decoration-[0.08em] data-[emphasized=true]:underline-offset-[0.14em] data-[emphasized=true]:[text-decoration-skip-ink:auto] data-[emphasized=true]:forced-colors:text-[CanvasText] data-[emphasized=true]:forced-colors:decoration-[CanvasText] data-[emphasized=true]:will-change-transform data-[reduced-motion=true]:will-change-auto\";\nconst heroTextAnimationScrollResponsiveClasses =\n  \"inline-block min-w-0 max-w-full whitespace-pre-wrap align-baseline will-change-transform [transform-origin:center_top] data-[reduced-motion=true]:will-change-auto\";\nconst heroTextAnimationSvgStrokeDrawClasses =\n  \"block w-full min-w-0 max-w-full text-foreground\";\nconst heroTextAnimationSvgStrokeDrawSvgClasses =\n  \"block h-auto w-full overflow-visible\";\nconst heroTextAnimationSvgStrokeDrawLineClasses = \"[paint-order:stroke]\";\n\nconst typewriterMinimumIntervalMs = 16;\n// Upper bound on the per-character cadence so a long duration on a short string\n// still reads as typing rather than stalling between glyphs.\nconst typewriterMaximumIntervalMs = 96;\nconst typewriterMinimumDurationMs = 300;\n// Raised so a deliberately paced hero (a slower, more \"typewriter\" feel) is\n// honored end to end instead of being clamped back to a rushed reveal.\nconst typewriterMaximumDurationMs = 2600;\n// A fast cadence with many frames so the reveal reads as rapid cipher churn\n// resolving left to right, rather than a handful of sluggish discrete steps.\nconst scrambleMinimumIntervalMs = 30;\nconst scrambleMinimumDurationMs = 500;\nconst scrambleMaximumDurationMs = 2400;\nconst scrambleMaximumUpdateCount = 56;\n// Uppercase letters, digits, and a few code-flavored symbols so the pre-reveal\n// text reads as encrypted copy decoding into the headline (not abstract noise).\nconst scrambleGlyphs = Array.from(\n  \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&/<>*\",\n) as readonly string[];\nconst rotatingKeywordDefaultIntervalMs = 2000;\nconst rotatingKeywordMinimumIntervalMs = 400;\nconst rotatingKeywordMaximumAutoRotateMs = 5000;\n// Blur is expressed in `em` so the radius scales with the heading size — a\n// stronger, more cinematic defocus on large hero text while staying readable on\n// smaller headings. Initial and final must share a unit for Motion to\n// interpolate the blur() filter smoothly.\nconst blurFocusInitialBlur = \"0.2em\";\nconst blurFocusFinalBlur = \"0em\";\nconst blurFocusInitialScale = 1.06;\n// A gently back-loaded ease so the defocus lingers and racks into sharp focus\n// near the end, instead of the front-loaded soft ease that snapped crisp within\n// the first fraction of the duration.\nconst blurFocusFocusEase = [0.42, 0, 0.2, 1] as [\n  number,\n  number,\n  number,\n  number,\n];\n// The gradient sheen must glide across at a near-even pace so the sweep reads as\n// a deliberate glint. The front-loaded \"soft\" ease dumped most of the travel in\n// the first fraction of the duration, so the highlight flashed past instantly;\n// this gentle ease-in-out keeps it moving through the whole sweep.\nconst gradientHighlightSweepEase = [0.4, 0, 0.2, 1] as [\n  number,\n  number,\n  number,\n  number,\n];\nconst kineticEmphasisMaximumWordCount = 2;\n// Peak scale of the emphasis pop. The word starts small, overshoots this peak,\n// then settles at rest — a deliberate punch rather than a symmetric zoom.\nconst kineticEmphasisScale = 1.08;\nconst kineticEmphasisHiddenScale = 0.84;\nconst kineticEmphasisRise = \"0.6em\";\n// Start inside the viewport so the subtle effect remains visible, and finish\n// over a fixed scroll distance independent of heading size.\nconst scrollResponsiveAnchor = \"start 70%\";\nconst scrollResponsiveRangePx = 220;\nconst scrollResponsiveMinimumOpacity = 0.92;\n// The letter-trace renders the heading as an SVG `<text>` in a fixed user-unit\n// coordinate space (font-size 100) so the geometry is independent of the\n// responsive display size — the SVG then scales to fill its container width.\n// Each line is revealed by a left-to-right clip wipe: the stroked outline is\n// exposed first, then a fill wipe follows a beat behind, so the word reads as a\n// pen drawing the outline and inking it in (PRD: outline → fill \"wordmark that\n// draws itself\"). A clip wipe is used rather than stroke-dashoffset because\n// `<text>` gives no reliable stroke length and browsers apply dashes per glyph\n// inconsistently, which makes a true dash trace non-portable.\nconst svgStrokeDrawFontSize = 100;\nconst svgStrokeDrawAscent = 82;\nconst svgStrokeDrawDescent = 26;\nconst svgStrokeDrawLineGap = 122;\nconst svgStrokeDrawStrokeWidth = 2.4;\nconst svgStrokeDrawViewBoxPadding = 8;\n// Fallback glyph advance (user units) used to frame the box before the text is\n// measured, and on the server where no layout is available.\nconst svgStrokeDrawFallbackCharWidth = 60;\n// Fraction of the per-line duration by which the fill wipe trails the outline\n// wipe, so the fill inks in just behind the drawing edge.\nconst svgStrokeDrawFillLagRatio = 0.22;\nconst svgStrokeDrawLineStagger = 0.18;\nconst defaultRepeatDelay = 1.8;\nconst gradientHighlightStyle = {\n  // Settled text color once revealed.\n  \"--hero-text-animation-highlight-base\": \"var(--dt-color-foreground)\",\n  // The glint edge — a vivid primary so each glyph flashes brand color as it is\n  // painted in.\n  \"--hero-text-animation-highlight-accent\":\n    \"color-mix(in oklab, var(--dt-color-primary) 80%, var(--dt-color-foreground) 20%)\",\n  // The luminous core of the sweep. Leaning primary toward background keeps it\n  // reading as a bright light band that travels across the reveal boundary in\n  // any theme.\n  \"--hero-text-animation-highlight-sheen\":\n    \"color-mix(in oklab, var(--dt-color-primary) 55%, var(--dt-color-background) 45%)\",\n} as CSSProperties;\nconst svgStrokeDrawStyle = {\n  \"--hero-text-animation-svg-stroke\":\n    \"color-mix(in oklab, var(--dt-color-foreground) 82%, var(--dt-color-primary) 18%)\",\n} as CSSProperties;\n\nconst HeroTextAnimationReducedMotionContext =\n  createContext<HeroTextAnimationProviderReducedMotion>(\"user\");\n\nconst reducedMotionQuery = \"(prefers-reduced-motion: reduce)\";\nfunction subscribeToReducedMotion(onChange: () => void) {\n  const preference = window.matchMedia?.(reducedMotionQuery);\n  preference?.addEventListener(\"change\", onChange);\n  return () => preference?.removeEventListener(\"change\", onChange);\n}\nfunction getReducedMotionSnapshot() {\n  return window.matchMedia?.(reducedMotionQuery).matches ?? false;\n}\n// The server already renders static text; keep hydration identical, then\n// synchronise with the browser and listen for subsequent preference changes.\nfunction getServerReducedMotionSnapshot() {\n  return false;\n}\n\nexport function HeroTextAnimationProvider({\n  children,\n  reducedMotion = \"user\",\n}: HeroTextAnimationProviderProps) {\n  return (\n    <HeroTextAnimationReducedMotionContext.Provider value={reducedMotion}>\n      <MotionConfig reducedMotion={reducedMotion}>{children}</MotionConfig>\n    </HeroTextAnimationReducedMotionContext.Provider>\n  );\n}\n\nexport function splitHeroText(\n  text: string,\n  splitBy: HeroTextAnimationSplitBy = \"word\",\n): HeroTextAnimationSegment[] {\n  if (splitBy === \"line\") {\n    return text.split(/\\r?\\n/).map((line) => ({ kind: \"text\", text: line }));\n  }\n\n  return (text.match(/\\S+|\\s+/g) ?? []).map((part) => ({\n    kind: /^\\s+$/.test(part) ? \"space\" : \"text\",\n    text: part,\n  }));\n}\n\nexport function heroTextAnimationClassNames({\n  className,\n}: Pick<HeroTextAnimationProps, \"className\"> = {}) {\n  return cn(heroTextAnimationRootClasses, className);\n}\n\nfunction normalizeEmphasisWord(word: string) {\n  return word\n    .trim()\n    .replace(/^[^\\p{L}\\p{N}]+|[^\\p{L}\\p{N}]+$/gu, \"\")\n    .toLocaleLowerCase();\n}\n\nfunction resolveKineticEmphasisWordIndices({\n  emphasisWordIndices,\n  emphasisWords,\n  segments,\n}: {\n  emphasisWordIndices?: readonly number[];\n  emphasisWords?: readonly string[];\n  segments: HeroTextAnimationSegment[];\n}) {\n  const wordSegments = segments.filter((segment) => segment.kind === \"text\");\n  const resolvedIndices: number[] = [];\n\n  for (const configuredIndex of emphasisWordIndices ?? []) {\n    if (!Number.isFinite(configuredIndex)) {\n      continue;\n    }\n\n    const normalizedIndex = Math.trunc(configuredIndex);\n\n    if (\n      normalizedIndex >= 0 &&\n      normalizedIndex < wordSegments.length &&\n      !resolvedIndices.includes(normalizedIndex)\n    ) {\n      resolvedIndices.push(normalizedIndex);\n    }\n\n    if (resolvedIndices.length >= kineticEmphasisMaximumWordCount) {\n      return resolvedIndices;\n    }\n  }\n\n  const normalizedWords = (emphasisWords ?? [])\n    .map((word) => normalizeEmphasisWord(word))\n    .filter((word) => word.length > 0);\n\n  for (const configuredWord of normalizedWords) {\n    const matchedIndex = wordSegments.findIndex(\n      (segment, wordIndex) =>\n        !resolvedIndices.includes(wordIndex) &&\n        normalizeEmphasisWord(segment.text) === configuredWord,\n    );\n\n    if (matchedIndex >= 0) {\n      resolvedIndices.push(matchedIndex);\n    }\n\n    if (resolvedIndices.length >= kineticEmphasisMaximumWordCount) {\n      return resolvedIndices;\n    }\n  }\n\n  return resolvedIndices;\n}\n\nfunction getSegmentWordIndices(segments: readonly HeroTextAnimationSegment[]) {\n  let nextWordIndex = -1;\n\n  return segments.map((segment) => {\n    if (segment.kind === \"space\") {\n      return -1;\n    }\n\n    nextWordIndex += 1;\n\n    return nextWordIndex;\n  });\n}\n\ninterface SvgStrokeDrawGeometry {\n  viewBox: string;\n  centerX: number;\n  baselines: number[];\n  lineWidths: number[];\n  measured: boolean;\n}\n\nfunction splitSvgStrokeDrawLines(text: string): string[] {\n  const lines = text\n    .split(/\\r?\\n/)\n    .map((line) => line.trim())\n    .filter((line) => line.length > 0);\n\n  return lines.length > 0 ? lines : [text.trim()];\n}\n\n// Deterministic geometry used for SSR, reduced motion, and as the pre-measure\n// frame on the client. Because it depends only on the text (not on layout), the\n// server and first client render agree, so hydration never mismatches; the\n// animated variant then refines the view box and dash lengths from the real\n// measured glyph metrics.\nfunction estimateSvgStrokeDrawGeometry(\n  lines: string[],\n  viewBoxOverride?: string,\n): SvgStrokeDrawGeometry {\n  const lineCount = Math.max(1, lines.length);\n  const baselines = Array.from(\n    { length: lineCount },\n    (_, index) => svgStrokeDrawAscent + index * svgStrokeDrawLineGap,\n  );\n  const contentWidth = Math.max(\n    svgStrokeDrawFontSize * 2,\n    ...lines.map(\n      (line) => Array.from(line).length * svgStrokeDrawFallbackCharWidth,\n    ),\n  );\n  const contentHeight =\n    svgStrokeDrawAscent +\n    svgStrokeDrawDescent +\n    (lineCount - 1) * svgStrokeDrawLineGap;\n  const pad = svgStrokeDrawViewBoxPadding;\n  const viewBox =\n    viewBoxOverride ??\n    `${-pad} ${-pad} ${contentWidth + pad * 2} ${contentHeight + pad * 2}`;\n  const lineWidths = lines.map((line) =>\n    Math.max(1, Array.from(line).length * svgStrokeDrawFallbackCharWidth),\n  );\n\n  return {\n    viewBox,\n    centerX: contentWidth / 2,\n    baselines,\n    lineWidths,\n    measured: false,\n  };\n}\n\nfunction isValidSvgViewBox(viewBox: string) {\n  const values = viewBox\n    .trim()\n    .split(/[\\s,]+/)\n    .map((value) => Number(value));\n\n  return (\n    values.length === 4 &&\n    values.every((value) => Number.isFinite(value)) &&\n    values[2] > 0 &&\n    values[3] > 0\n  );\n}\n\nfunction useHasHydrated() {\n  const [hasHydrated, setHasHydrated] = useState(false);\n\n  useIsomorphicLayoutEffect(() => {\n    setHasHydrated(true);\n  }, []);\n\n  return hasHydrated;\n}\n\n/**\n * Tracks whether a one-shot animation is at rest so we can drop the\n * `will-change` hint once motion completes. Motion's own performance guidance\n * flags leaving `will-change` on every animated node as an anti-pattern because\n * each promoted layer holds GPU memory and can subtly change text rasterization\n * at rest. `willChangeClass` returns the resting override to merge in.\n */\nfunction useMotionRest() {\n  const [atRest, setAtRest] = useState(false);\n  const markActive = useCallback(() => setAtRest(false), []);\n  const markRest = useCallback(() => setAtRest(true), []);\n  const willChangeClass = atRest ? \"will-change-auto\" : undefined;\n\n  return { markActive, markRest, willChangeClass };\n}\n\nfunction getContainerVariants({\n  delay,\n  stagger,\n}: {\n  delay: number;\n  stagger: number;\n}): Variants {\n  return {\n    hidden: {},\n    visible: {\n      transition: {\n        delayChildren: delay,\n        staggerChildren: stagger,\n      },\n    },\n  };\n}\n\nfunction getStaggeredSegmentVariants({\n  duration,\n  reducedMotion,\n  splitBy,\n}: {\n  duration: number;\n  reducedMotion: boolean;\n  splitBy: HeroTextAnimationSplitBy;\n}): Variants {\n  const distance =\n    splitBy === \"line\"\n      ? heroTextAnimationMotionTokens.distance.lineY\n      : heroTextAnimationMotionTokens.distance.wordY;\n  const hidden = reducedMotion\n    ? { opacity: 0 }\n    : {\n        opacity: 0,\n        y: distance,\n      };\n\n  return {\n    hidden,\n    visible: {\n      opacity: 1,\n      transition: {\n        duration,\n        ease: heroTextAnimationMotionTokens.easing.standard,\n      },\n      y: reducedMotion ? undefined : \"0em\",\n    },\n  };\n}\n\nfunction getMaskedCurtainSegmentVariants({\n  duration,\n  reducedMotion,\n}: {\n  duration: number;\n  reducedMotion: boolean;\n}): Variants {\n  const hidden = reducedMotion\n    ? { opacity: 0 }\n    : {\n        opacity: 0,\n        y: heroTextAnimationMotionTokens.distance.curtainY,\n      };\n\n  return {\n    hidden,\n    visible: {\n      opacity: 1,\n      transition: {\n        duration,\n        ease: heroTextAnimationMotionTokens.easing.soft,\n      },\n      y: reducedMotion ? undefined : \"0em\",\n    },\n  };\n}\n\nfunction renderStaticSegments({\n  segments,\n  splitBy,\n}: {\n  segments: HeroTextAnimationSegment[];\n  splitBy: HeroTextAnimationSplitBy;\n}) {\n  if (splitBy === \"line\") {\n    return segments.map((segment, index) => (\n      <span\n        key={`${index}-${segment.text}`}\n        data-slot=\"hero-text-animation-segment\"\n        data-segment=\"line\"\n        className={cn(heroTextAnimationLineClasses, \"will-change-auto\")}\n      >\n        {segment.text}\n      </span>\n    ));\n  }\n\n  return segments.map((segment, index) => {\n    if (segment.kind === \"space\") {\n      return segment.text;\n    }\n\n    return (\n      <span\n        key={`${index}-${segment.text}`}\n        data-slot=\"hero-text-animation-segment\"\n        data-segment=\"word\"\n        className={cn(heroTextAnimationWordClasses, \"will-change-auto\")}\n      >\n        {segment.text}\n      </span>\n    );\n  });\n}\n\nfunction renderStaticKineticEmphasis({\n  emphasisIndices,\n  segments,\n}: {\n  emphasisIndices: readonly number[];\n  segments: HeroTextAnimationSegment[];\n}) {\n  const segmentWordIndices = getSegmentWordIndices(segments);\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-emphasis-count={emphasisIndices.length}\n      data-kinetic-emphasis=\"pop\"\n      data-reduced-motion=\"true\"\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by=\"word\"\n      className={heroTextAnimationKineticEmphasisMotionClasses}\n    >\n      {segments.map((segment, index) => {\n        if (segment.kind === \"space\") {\n          return segment.text;\n        }\n\n        const wordIndex = segmentWordIndices[index] ?? -1;\n        const emphasisOrder = emphasisIndices.indexOf(wordIndex);\n        const emphasized = emphasisOrder >= 0;\n\n        return (\n          <span\n            key={`${index}-${segment.text}`}\n            data-emphasized={emphasized ? \"true\" : undefined}\n            data-emphasis-order={emphasized ? emphasisOrder : undefined}\n            data-emphasis-word-index={emphasized ? wordIndex : undefined}\n            data-reduced-motion=\"true\"\n            data-slot=\"hero-text-animation-segment\"\n            data-segment=\"word\"\n            className={heroTextAnimationKineticEmphasisWordClasses}\n          >\n            {segment.text}\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\nfunction getKineticEmphasisWordVariants({\n  duration,\n  emphasized,\n}: {\n  duration: number;\n  emphasized: boolean;\n}): Variants {\n  // Every word rises and fades in on a stagger so the line assembles as a\n  // kinetic sweep instead of the whole block blinking on. Emphasized words pop\n  // in from small, overshoot the peak scale, then settle — a one-shot punch that\n  // draws the eye, with the lasting emphasis still carried by the static weight,\n  // color, and underline (PRD 9.8).\n  if (!emphasized) {\n    return {\n      hidden: { opacity: 0, y: kineticEmphasisRise },\n      visible: {\n        opacity: 1,\n        transition: {\n          duration,\n          ease: heroTextAnimationMotionTokens.easing.standard,\n        },\n        y: \"0em\",\n      },\n    };\n  }\n\n  return {\n    hidden: {\n      opacity: 0,\n      scale: kineticEmphasisHiddenScale,\n      y: kineticEmphasisRise,\n    },\n    visible: {\n      opacity: 1,\n      scale: [kineticEmphasisHiddenScale, kineticEmphasisScale, 1],\n      transition: {\n        duration,\n        ease: heroTextAnimationMotionTokens.easing.standard,\n        scale: {\n          duration,\n          ease: heroTextAnimationMotionTokens.easing.standard,\n          times: [0, 0.62, 1],\n        },\n      },\n      y: \"0em\",\n    },\n  };\n}\n\nfunction KineticEmphasisSegments({\n  active,\n  delay,\n  duration,\n  emphasisIndices,\n  once,\n  reducedMotion,\n  segments,\n  stagger,\n  trigger,\n  onAnimationComplete,\n  onAnimationStart,\n}: {\n  active: boolean;\n  delay: number;\n  duration: number;\n  emphasisIndices: readonly number[];\n  once: boolean;\n  reducedMotion: boolean;\n  segments: HeroTextAnimationSegment[];\n  stagger: number;\n  trigger: HeroTextAnimationTrigger;\n  onAnimationComplete?: () => void;\n  onAnimationStart?: () => void;\n}) {\n  const { markActive, markRest, willChangeClass } = useMotionRest();\n  // Non-reduced: the container only orchestrates the per-word stagger so the\n  // line assembles as a sweep. Reduced motion falls back to a single opacity\n  // fade of the whole phrase with no transforms.\n  const containerVariants: Variants = reducedMotion\n    ? {\n        hidden: { opacity: 0 },\n        visible: {\n          opacity: 1,\n          transition: { delay, duration: 0.2, ease: \"linear\" },\n        },\n      }\n    : getContainerVariants({ delay, stagger });\n  const triggerProps =\n    trigger === \"in-view\"\n      ? {\n          whileInView: \"visible\",\n          viewport: { amount: 0.6, once },\n        }\n      : {\n          animate: trigger === \"manual\" && !active ? \"hidden\" : \"visible\",\n        };\n  const segmentWordIndices = getSegmentWordIndices(segments);\n\n  return (\n    <motionElement.span\n      aria-hidden=\"true\"\n      data-emphasis-count={emphasisIndices.length}\n      data-kinetic-emphasis=\"pop\"\n      data-reduced-motion={reducedMotion ? \"true\" : undefined}\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by=\"word\"\n      className={heroTextAnimationKineticEmphasisMotionClasses}\n      variants={containerVariants}\n      initial=\"hidden\"\n      onAnimationComplete={() => {\n        markRest();\n        onAnimationComplete?.();\n      }}\n      onAnimationStart={() => {\n        markActive();\n        onAnimationStart?.();\n      }}\n      {...triggerProps}\n    >\n      {segments.map((segment, index) => {\n        if (segment.kind === \"space\") {\n          return segment.text;\n        }\n\n        const wordIndex = segmentWordIndices[index] ?? -1;\n        const emphasisOrder = emphasisIndices.indexOf(wordIndex);\n        const emphasized = emphasisOrder >= 0;\n        const sharedProps = {\n          \"data-emphasized\": emphasized ? \"true\" : undefined,\n          \"data-emphasis-order\": emphasized ? emphasisOrder : undefined,\n          \"data-emphasis-scale\":\n            emphasized && !reducedMotion ? kineticEmphasisScale : undefined,\n          \"data-emphasis-word-index\": emphasized ? wordIndex : undefined,\n          \"data-reduced-motion\": reducedMotion ? \"true\" : undefined,\n          \"data-segment\": \"word\",\n          \"data-slot\": \"hero-text-animation-segment\",\n          className: cn(\n            heroTextAnimationKineticEmphasisWordClasses,\n            willChangeClass,\n          ),\n        };\n\n        if (reducedMotion) {\n          return (\n            <span key={`${index}-${segment.text}`} {...sharedProps}>\n              {segment.text}\n            </span>\n          );\n        }\n\n        return (\n          <motionElement.span\n            key={`${index}-${segment.text}`}\n            {...sharedProps}\n            variants={getKineticEmphasisWordVariants({ duration, emphasized })}\n          >\n            {segment.text}\n          </motionElement.span>\n        );\n      })}\n    </motionElement.span>\n  );\n}\n\nfunction StaggeredSegments({\n  active,\n  delay,\n  duration,\n  once,\n  reducedMotion,\n  segments,\n  splitBy,\n  stagger,\n  trigger,\n  onAnimationComplete,\n  onAnimationStart,\n}: {\n  active: boolean;\n  delay: number;\n  duration: number;\n  once: boolean;\n  reducedMotion: boolean;\n  segments: HeroTextAnimationSegment[];\n  splitBy: HeroTextAnimationSplitBy;\n  stagger: number;\n  trigger: HeroTextAnimationTrigger;\n  onAnimationComplete?: () => void;\n  onAnimationStart?: () => void;\n}) {\n  const containerVariants = getContainerVariants({ delay, stagger });\n  const segmentVariants = getStaggeredSegmentVariants({\n    duration,\n    reducedMotion,\n    splitBy,\n  });\n  const { markActive, markRest, willChangeClass } = useMotionRest();\n  const triggerProps =\n    trigger === \"in-view\"\n      ? {\n          whileInView: \"visible\",\n          viewport: { amount: 0.6, once },\n        }\n      : {\n          animate: trigger === \"manual\" && !active ? \"hidden\" : \"visible\",\n        };\n\n  return (\n    <motionElement.span\n      aria-hidden=\"true\"\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by={splitBy}\n      className={heroTextAnimationMotionClasses}\n      variants={containerVariants}\n      initial=\"hidden\"\n      onAnimationComplete={() => {\n        markRest();\n        onAnimationComplete?.();\n      }}\n      onAnimationStart={() => {\n        markActive();\n        onAnimationStart?.();\n      }}\n      {...triggerProps}\n    >\n      {splitBy === \"line\"\n        ? segments.map((segment, index) => (\n            <motionElement.span\n              key={`${index}-${segment.text}`}\n              data-slot=\"hero-text-animation-segment\"\n              data-reduced-motion={reducedMotion ? \"true\" : undefined}\n              data-segment=\"line\"\n              className={cn(heroTextAnimationLineClasses, willChangeClass)}\n              variants={segmentVariants}\n              transition={getSegmentTransition(duration)}\n            >\n              {segment.text}\n            </motionElement.span>\n          ))\n        : segments.map((segment, index) => {\n            if (segment.kind === \"space\") {\n              return segment.text;\n            }\n\n            return (\n              <motionElement.span\n                key={`${index}-${segment.text}`}\n                data-slot=\"hero-text-animation-segment\"\n                data-reduced-motion={reducedMotion ? \"true\" : undefined}\n                data-segment=\"word\"\n                className={cn(heroTextAnimationWordClasses, willChangeClass)}\n                variants={segmentVariants}\n                transition={getSegmentTransition(duration)}\n              >\n                {segment.text}\n              </motionElement.span>\n            );\n          })}\n    </motionElement.span>\n  );\n}\n\nfunction MaskedCurtainSegments({\n  active,\n  delay,\n  duration,\n  once,\n  reducedMotion,\n  segments,\n  stagger,\n  trigger,\n  onAnimationComplete,\n  onAnimationStart,\n}: {\n  active: boolean;\n  delay: number;\n  duration: number;\n  once: boolean;\n  reducedMotion: boolean;\n  segments: HeroTextAnimationSegment[];\n  stagger: number;\n  trigger: HeroTextAnimationTrigger;\n  onAnimationComplete?: () => void;\n  onAnimationStart?: () => void;\n}) {\n  const containerVariants = getContainerVariants({ delay, stagger });\n  const segmentVariants = getMaskedCurtainSegmentVariants({\n    duration,\n    reducedMotion,\n  });\n  const { markActive, markRest, willChangeClass } = useMotionRest();\n  const triggerProps =\n    trigger === \"in-view\"\n      ? {\n          whileInView: \"visible\",\n          viewport: { amount: 0.6, once },\n        }\n      : {\n          animate: trigger === \"manual\" && !active ? \"hidden\" : \"visible\",\n        };\n\n  return (\n    <motionElement.span\n      aria-hidden=\"true\"\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by=\"line\"\n      className={heroTextAnimationMotionClasses}\n      variants={containerVariants}\n      initial=\"hidden\"\n      onAnimationComplete={() => {\n        markRest();\n        onAnimationComplete?.();\n      }}\n      onAnimationStart={() => {\n        markActive();\n        onAnimationStart?.();\n      }}\n      {...triggerProps}\n    >\n      {segments.map((segment, index) => (\n        <span\n          key={`${index}-${segment.text}`}\n          data-slot=\"hero-text-animation-mask\"\n          className={heroTextAnimationCurtainMaskClasses}\n        >\n          <motionElement.span\n            data-slot=\"hero-text-animation-segment\"\n            data-reduced-motion={reducedMotion ? \"true\" : undefined}\n            data-segment=\"line\"\n            className={cn(heroTextAnimationCurtainLineClasses, willChangeClass)}\n            variants={segmentVariants}\n            transition={getSegmentTransition(duration)}\n          >\n            {segment.text}\n          </motionElement.span>\n        </span>\n      ))}\n    </motionElement.span>\n  );\n}\n\nfunction splitTypewriterCharacters(text: string) {\n  return Array.from(text);\n}\n\nfunction getTypewriterTiming({\n  characterCount,\n  duration,\n}: {\n  characterCount: number;\n  duration: number;\n}) {\n  const boundedDurationMs = Math.min(\n    typewriterMaximumDurationMs,\n    Math.max(typewriterMinimumDurationMs, Math.round(duration * 1000)),\n  );\n  const count = Math.max(characterCount, 1);\n  // Cap how many ticks we schedule so very long copy still reveals in a few\n  // characters per frame rather than thousands of timers.\n  const maxFrames = Math.max(\n    1,\n    Math.floor(boundedDurationMs / typewriterMinimumIntervalMs),\n  );\n  const charactersPerTick = Math.max(1, Math.ceil(count / maxFrames));\n  const tickCount = Math.max(1, Math.ceil(count / charactersPerTick));\n  // Derive the cadence from the duration so the reveal actually fills the time\n  // asked for (bounded by a min/max so it never feels janky or stalled),\n  // instead of snapping back to a fixed fast interval.\n  const intervalMs = Math.min(\n    typewriterMaximumIntervalMs,\n    Math.max(\n      typewriterMinimumIntervalMs,\n      Math.round(boundedDurationMs / tickCount),\n    ),\n  );\n\n  return {\n    charactersPerTick,\n    intervalMs,\n  };\n}\n\nfunction useTypewriterInView({\n  once,\n  trigger,\n}: {\n  once: boolean;\n  trigger: HeroTextAnimationTrigger;\n}) {\n  const ref = useRef<HTMLSpanElement>(null);\n  const [isInView, setIsInView] = useState(trigger !== \"in-view\");\n\n  const [previousViewOptions, setPreviousViewOptions] = useState({\n    once,\n    trigger,\n  });\n  if (\n    previousViewOptions.once !== once ||\n    previousViewOptions.trigger !== trigger\n  ) {\n    setPreviousViewOptions({ once, trigger });\n    setIsInView(trigger !== \"in-view\");\n  }\n\n  useEffect(() => {\n    if (trigger !== \"in-view\") return;\n    const element = ref.current;\n\n    if (\n      !element ||\n      typeof window === \"undefined\" ||\n      !(\"IntersectionObserver\" in window)\n    ) {\n      setIsInView(true);\n      return;\n    }\n\n    const observer = new window.IntersectionObserver(\n      ([entry]) => {\n        if (!entry) {\n          return;\n        }\n\n        if (entry.isIntersecting) {\n          setIsInView(true);\n\n          if (once) {\n            observer.disconnect();\n          }\n\n          return;\n        }\n\n        if (!once) {\n          setIsInView(false);\n        }\n      },\n      { threshold: 0.6 },\n    );\n\n    observer.observe(element);\n\n    return () => observer.disconnect();\n  }, [once, trigger]);\n\n  return [ref, isInView] as const;\n}\n\nfunction TypewriterSegments({\n  active,\n  delay,\n  duration,\n  once,\n  reducedMotion,\n  showCaret,\n  text,\n  trigger,\n  onAnimationComplete,\n  onAnimationStart,\n}: {\n  active: boolean;\n  delay: number;\n  duration: number;\n  once: boolean;\n  reducedMotion: boolean;\n  showCaret: boolean;\n  text: string;\n  trigger: HeroTextAnimationTrigger;\n  onAnimationComplete?: () => void;\n  onAnimationStart?: () => void;\n}) {\n  const [inViewRef, isInView] = useTypewriterInView({ once, trigger });\n  const characters = useMemo(() => splitTypewriterCharacters(text), [text]);\n  const characterCount = characters.length;\n  const shouldStart =\n    trigger === \"in-view\" ? isInView : trigger === \"manual\" ? active : true;\n  const [visibleLength, setVisibleLength] = useState(() =>\n    reducedMotion ? characterCount : 0,\n  );\n\n  const [previousRun, setPreviousRun] = useState({\n    text,\n    delay,\n    duration,\n    reducedMotion,\n    shouldStart,\n    onAnimationStart,\n    onAnimationComplete,\n  });\n  if (\n    previousRun.text !== text ||\n    previousRun.delay !== delay ||\n    previousRun.duration !== duration ||\n    previousRun.reducedMotion !== reducedMotion ||\n    previousRun.shouldStart !== shouldStart ||\n    previousRun.onAnimationStart !== onAnimationStart ||\n    previousRun.onAnimationComplete !== onAnimationComplete\n  ) {\n    setPreviousRun({\n      text,\n      delay,\n      duration,\n      reducedMotion,\n      shouldStart,\n      onAnimationStart,\n      onAnimationComplete,\n    });\n    setVisibleLength(reducedMotion ? characterCount : 0);\n  }\n\n  useEffect(() => {\n    if (reducedMotion || !shouldStart || characterCount === 0) return;\n\n    const { charactersPerTick, intervalMs } = getTypewriterTiming({\n      characterCount,\n      duration,\n    });\n    let cancelled = false;\n    let nextVisibleLength = 0;\n    let intervalId: number | undefined;\n\n    const delayId = window.setTimeout(\n      () => {\n        if (cancelled) {\n          return;\n        }\n\n        onAnimationStart?.();\n\n        intervalId = window.setInterval(() => {\n          if (cancelled) {\n            return;\n          }\n\n          nextVisibleLength = Math.min(\n            characterCount,\n            nextVisibleLength + charactersPerTick,\n          );\n          setVisibleLength(nextVisibleLength);\n\n          if (nextVisibleLength >= characterCount) {\n            if (intervalId !== undefined) {\n              window.clearInterval(intervalId);\n            }\n            onAnimationComplete?.();\n          }\n        }, intervalMs);\n      },\n      Math.max(0, Math.round(delay * 1000)),\n    );\n\n    return () => {\n      cancelled = true;\n      window.clearTimeout(delayId);\n\n      if (intervalId !== undefined) {\n        window.clearInterval(intervalId);\n      }\n    };\n  }, [\n    characterCount,\n    delay,\n    duration,\n    onAnimationComplete,\n    onAnimationStart,\n    reducedMotion,\n    shouldStart,\n    text,\n  ]);\n\n  const normalizedVisibleLength = reducedMotion\n    ? characterCount\n    : Math.min(visibleLength, characterCount);\n  const visibleText = characters.slice(0, normalizedVisibleLength).join(\"\");\n  const isComplete = normalizedVisibleLength >= characterCount;\n  const shouldShowCaret =\n    showCaret &&\n    shouldStart &&\n    !reducedMotion &&\n    !isComplete &&\n    characterCount > 0;\n\n  return (\n    <span\n      ref={inViewRef}\n      aria-hidden=\"true\"\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by=\"character\"\n      data-typewriter-complete={isComplete ? \"true\" : \"false\"}\n      className={heroTextAnimationTypewriterMotionClasses}\n    >\n      <span\n        data-slot=\"hero-text-animation-typewriter-sizer\"\n        className={heroTextAnimationTypewriterSizerClasses}\n      >\n        {text}\n      </span>\n      <span\n        data-slot=\"hero-text-animation-typewriter-text\"\n        data-reduced-motion={reducedMotion ? \"true\" : undefined}\n        className={heroTextAnimationTypewriterTextClasses}\n      >\n        {visibleText}\n        {shouldShowCaret ? (\n          <span\n            aria-hidden=\"true\"\n            data-slot=\"hero-text-animation-typewriter-caret\"\n            className={heroTextAnimationTypewriterCaretClasses}\n          />\n        ) : null}\n      </span>\n    </span>\n  );\n}\n\n// Static (pre-hydration / reduced-motion-static) render for the typewriter.\n// It reuses the exact grid + invisible sizer box that `TypewriterSegments`\n// renders so the reserved space is pixel-identical before and after hydration.\n// Falling back to plain word spans here (a different inline layout) is what made\n// the heading shift the moment typing began.\nfunction renderStaticTypewriter(text: string) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by=\"character\"\n      data-typewriter-complete=\"true\"\n      className={heroTextAnimationTypewriterMotionClasses}\n    >\n      <span\n        data-slot=\"hero-text-animation-typewriter-sizer\"\n        className={heroTextAnimationTypewriterSizerClasses}\n      >\n        {text}\n      </span>\n      <span\n        data-slot=\"hero-text-animation-typewriter-text\"\n        data-reduced-motion=\"true\"\n        className={heroTextAnimationTypewriterTextClasses}\n      >\n        {text}\n      </span>\n    </span>\n  );\n}\n\nfunction getScrambleTiming({ duration }: { duration: number }) {\n  const boundedDurationMs = Math.min(\n    scrambleMaximumDurationMs,\n    Math.max(scrambleMinimumDurationMs, Math.round(duration * 1000)),\n  );\n  const updateCount = Math.max(\n    1,\n    Math.min(\n      scrambleMaximumUpdateCount,\n      Math.floor(boundedDurationMs / scrambleMinimumIntervalMs),\n    ),\n  );\n\n  return {\n    intervalMs: Math.max(\n      scrambleMinimumIntervalMs,\n      Math.floor(boundedDurationMs / updateCount),\n    ),\n    updateCount,\n  };\n}\n\n// Deterministic (no Math.random, so SSR and every mount agree) but chaotic\n// enough that consecutive frames don't march predictably through the alphabet.\nfunction getScrambleGlyph({\n  characterIndex,\n  frame,\n}: {\n  characterIndex: number;\n  frame: number;\n}) {\n  const hash = ((characterIndex + 1) * 2654435761 + (frame + 1) * 40503) >>> 0;\n\n  return scrambleGlyphs[hash % scrambleGlyphs.length];\n}\n\ninterface ScrambleSlot {\n  finalCharacter: string;\n  glyph: string;\n  isSpace: boolean;\n  resolved: boolean;\n}\n\n// Resolves the whole string left to right: character i locks to its final glyph\n// once the frame passes its share of the timeline, while everything after it\n// keeps cycling. Spaces pass through untouched so word breaks stay put.\nfunction getScrambleFrameSlots({\n  frame,\n  text,\n  updateCount,\n}: {\n  frame: number;\n  text: string;\n  updateCount: number;\n}): ScrambleSlot[] {\n  const characters = Array.from(text);\n  const revealableCount = characters.filter(\n    (character) => !/\\s/.test(character),\n  ).length;\n  let revealableIndex = 0;\n\n  return characters.map((finalCharacter, characterIndex) => {\n    if (/\\s/.test(finalCharacter)) {\n      return {\n        finalCharacter,\n        glyph: finalCharacter,\n        isSpace: true,\n        resolved: true,\n      };\n    }\n\n    revealableIndex += 1;\n\n    const resolveFrame = Math.max(\n      1,\n      Math.ceil((revealableIndex / Math.max(1, revealableCount)) * updateCount),\n    );\n    const resolved = frame >= resolveFrame;\n\n    return {\n      finalCharacter,\n      glyph: resolved\n        ? finalCharacter\n        : getScrambleGlyph({ characterIndex, frame }),\n      isSpace: false,\n      resolved,\n    };\n  });\n}\n\ntype ScrambleGroup =\n  | { kind: \"space\"; text: string }\n  | { kind: \"word\"; slots: { slot: ScrambleSlot; index: number }[] };\n\n// Groups the flat slot list into words (kept whole via an inline-block wrapper)\n// and the spaces between them (real break opportunities), so the overlay wraps\n// exactly like the plain sizer text underneath it.\nfunction groupScrambleSlots(slots: ScrambleSlot[]): ScrambleGroup[] {\n  const groups: ScrambleGroup[] = [];\n\n  slots.forEach((slot, index) => {\n    if (slot.isSpace) {\n      const previous = groups[groups.length - 1];\n\n      if (previous && previous.kind === \"space\") {\n        previous.text += slot.finalCharacter;\n      } else {\n        groups.push({ kind: \"space\", text: slot.finalCharacter });\n      }\n\n      return;\n    }\n\n    const previous = groups[groups.length - 1];\n\n    if (previous && previous.kind === \"word\") {\n      previous.slots.push({ slot, index });\n    } else {\n      groups.push({ kind: \"word\", slots: [{ slot, index }] });\n    }\n  });\n\n  return groups;\n}\n\n// All-resolved slots, used for the SSR / reduced-motion / pre-hydration render\n// so it shares the exact per-character slot layout the animation settles into —\n// no plain-text run anywhere, so the client takeover never shifts the text.\nfunction getResolvedScrambleGroups(text: string): ScrambleGroup[] {\n  return groupScrambleSlots(\n    Array.from(text).map((finalCharacter) => ({\n      finalCharacter,\n      glyph: finalCharacter,\n      isSpace: /\\s/.test(finalCharacter),\n      resolved: true,\n    })),\n  );\n}\n\n// Shared renderer for both the live animation and the static fallback. A\n// resolved slot is just its final character (clean text); an unresolved slot\n// reserves that same width with an invisible copy and floats the cipher glyph on\n// top. Only unresolved slots carry the fragment marker, so the static render is\n// free of scramble internals.\nfunction renderScrambleGroups(groups: ScrambleGroup[]) {\n  return groups.map((group, groupIndex) =>\n    group.kind === \"space\" ? (\n      <span key={`space-${groupIndex}`}>{group.text}</span>\n    ) : (\n      <span\n        key={`word-${groupIndex}`}\n        data-slot=\"hero-text-animation-scramble-word\"\n        className={heroTextAnimationScrambleWordClasses}\n      >\n        {group.slots.map(({ slot, index }) =>\n          slot.resolved ? (\n            <span\n              key={`${index}-${slot.finalCharacter}`}\n              data-scramble-resolved=\"true\"\n              className={heroTextAnimationScrambleResolvedGlyphClasses}\n            >\n              {slot.finalCharacter}\n            </span>\n          ) : (\n            <span\n              key={`${index}-${slot.finalCharacter}`}\n              data-slot=\"hero-text-animation-scramble-fragment\"\n              data-scramble-resolved=\"false\"\n              className={heroTextAnimationScrambleGlyphClasses}\n            >\n              <span aria-hidden=\"true\" className=\"invisible\">\n                {slot.finalCharacter}\n              </span>\n              <span\n                aria-hidden=\"true\"\n                className={heroTextAnimationScrambleGlyphOverlayClasses}\n              >\n                {slot.glyph}\n              </span>\n            </span>\n          ),\n        )}\n      </span>\n    ),\n  );\n}\n\nfunction resolveRotatingKeywordOptions({\n  rotatingKeywordOptions,\n  text,\n}: {\n  rotatingKeywordOptions?: readonly string[];\n  text: string;\n}) {\n  const options =\n    rotatingKeywordOptions?.filter((keyword) => keyword.length > 0) ?? [];\n\n  return options.length > 0 ? options : [text];\n}\n\nfunction normalizeRotatingKeywordIndex(index: number, keywordCount: number) {\n  if (keywordCount <= 0) {\n    return 0;\n  }\n\n  const integerIndex = Number.isFinite(index) ? Math.trunc(index) : 0;\n\n  return ((integerIndex % keywordCount) + keywordCount) % keywordCount;\n}\n\nfunction getLongestRotatingKeyword(keywords: readonly string[]) {\n  return keywords.reduce((longestKeyword, keyword) =>\n    Array.from(keyword).length > Array.from(longestKeyword).length\n      ? keyword\n      : longestKeyword,\n  );\n}\n\nfunction getRotatingKeywordIntervalMs(intervalSeconds: number | undefined) {\n  return Math.max(\n    rotatingKeywordMinimumIntervalMs,\n    Math.round(\n      (intervalSeconds ?? rotatingKeywordDefaultIntervalMs / 1000) * 1000,\n    ),\n  );\n}\n\nfunction getRotatingKeywordVariants({\n  duration,\n  reducedMotion,\n}: {\n  duration: number;\n  reducedMotion: boolean;\n}): Variants {\n  if (reducedMotion) {\n    return {\n      enter: { opacity: 1 },\n      center: { opacity: 1 },\n      exit: { opacity: 1 },\n    };\n  }\n\n  return {\n    enter: {\n      opacity: 0,\n      y: \"0.4em\",\n    },\n    center: {\n      opacity: 1,\n      transition: {\n        duration,\n        ease: heroTextAnimationMotionTokens.easing.soft,\n      },\n      y: \"0em\",\n    },\n    exit: {\n      opacity: 0,\n      transition: {\n        duration: Math.min(\n          duration,\n          heroTextAnimationMotionTokens.duration.fast,\n        ),\n        ease: heroTextAnimationMotionTokens.easing.standard,\n      },\n      y: \"-0.35em\",\n    },\n  };\n}\n\nfunction renderStaticRotatingKeyword({\n  keyword,\n  keywordIndex,\n  longestKeyword,\n  prefix,\n  suffix,\n}: {\n  keyword: string;\n  keywordIndex: number;\n  longestKeyword: string;\n  prefix: string;\n  suffix: string;\n}) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-auto-rotate-keywords=\"false\"\n      data-rotating-keyword-index={keywordIndex}\n      data-rotating-keyword-max-duration-ms={rotatingKeywordMaximumAutoRotateMs}\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by=\"keyword\"\n      className={heroTextAnimationRotatingMotionClasses}\n    >\n      {prefix.length > 0 ? (\n        <span\n          data-slot=\"hero-text-animation-rotating-prefix\"\n          className={heroTextAnimationRotatingTextClasses}\n        >\n          {prefix}\n        </span>\n      ) : null}\n      <span\n        data-slot=\"hero-text-animation-rotating-slot\"\n        className={heroTextAnimationRotatingSlotClasses}\n      >\n        <span\n          data-slot=\"hero-text-animation-rotating-sizer\"\n          className={heroTextAnimationRotatingSizerClasses}\n        >\n          {longestKeyword}\n        </span>\n        <span\n          data-reduced-motion=\"true\"\n          data-slot=\"hero-text-animation-rotating-keyword\"\n          className={heroTextAnimationRotatingKeywordClasses}\n        >\n          {keyword}\n        </span>\n      </span>\n      {suffix.length > 0 ? (\n        <span\n          data-slot=\"hero-text-animation-rotating-suffix\"\n          className={heroTextAnimationRotatingTextClasses}\n        >\n          {suffix}\n        </span>\n      ) : null}\n    </span>\n  );\n}\n\nfunction RotatingKeywordSegments({\n  active,\n  autoRotateKeywords,\n  duration,\n  keyword,\n  keywordCount,\n  keywordIndex,\n  keywords,\n  longestKeyword,\n  once,\n  prefix,\n  reducedMotion,\n  rotatingKeywordInterval,\n  suffix,\n  trigger,\n  onAnimationComplete,\n  onAnimationStart,\n  onKeywordIndexChange,\n}: {\n  active: boolean;\n  autoRotateKeywords: boolean;\n  duration: number;\n  keyword: string;\n  keywordCount: number;\n  keywordIndex: number;\n  keywords: readonly string[];\n  longestKeyword: string;\n  once: boolean;\n  prefix: string;\n  reducedMotion: boolean;\n  rotatingKeywordInterval?: number;\n  suffix: string;\n  trigger: HeroTextAnimationTrigger;\n  onAnimationComplete?: () => void;\n  onAnimationStart?: () => void;\n  onKeywordIndexChange: (index: number) => void;\n}) {\n  const [inViewRef, isInView] = useTypewriterInView({ once, trigger });\n  const keywordIndexRef = useRef(keywordIndex);\n  const intervalMs = getRotatingKeywordIntervalMs(rotatingKeywordInterval);\n  const variants = getRotatingKeywordVariants({ duration, reducedMotion });\n  const shouldStart =\n    trigger === \"in-view\" ? isInView : trigger === \"manual\" ? active : true;\n\n  useEffect(() => {\n    keywordIndexRef.current = keywordIndex;\n  }, [keywordIndex]);\n\n  useEffect(() => {\n    if (\n      reducedMotion ||\n      !autoRotateKeywords ||\n      !shouldStart ||\n      keywordCount <= 1\n    ) {\n      return;\n    }\n\n    onAnimationStart?.();\n\n    const intervalId = window.setInterval(() => {\n      onKeywordIndexChange(keywordIndexRef.current + 1);\n    }, intervalMs);\n    const stopTimeoutId = window.setTimeout(() => {\n      window.clearInterval(intervalId);\n      onAnimationComplete?.();\n    }, rotatingKeywordMaximumAutoRotateMs - 1);\n\n    return () => {\n      window.clearInterval(intervalId);\n      window.clearTimeout(stopTimeoutId);\n    };\n  }, [\n    autoRotateKeywords,\n    intervalMs,\n    keywordCount,\n    onAnimationComplete,\n    onAnimationStart,\n    onKeywordIndexChange,\n    reducedMotion,\n    shouldStart,\n  ]);\n\n  return (\n    <span\n      ref={inViewRef}\n      aria-hidden=\"true\"\n      data-auto-rotate-keywords={autoRotateKeywords ? \"true\" : \"false\"}\n      data-rotating-keyword-count={keywordCount}\n      data-rotating-keyword-index={keywordIndex}\n      data-rotating-keyword-interval-ms={intervalMs}\n      data-rotating-keyword-max-duration-ms={rotatingKeywordMaximumAutoRotateMs}\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by=\"keyword\"\n      className={heroTextAnimationRotatingMotionClasses}\n    >\n      {prefix.length > 0 ? (\n        <span\n          data-slot=\"hero-text-animation-rotating-prefix\"\n          className={heroTextAnimationRotatingTextClasses}\n        >\n          {prefix}\n        </span>\n      ) : null}\n      <span\n        data-slot=\"hero-text-animation-rotating-slot\"\n        data-rotating-keyword-longest={longestKeyword}\n        className={heroTextAnimationRotatingSlotClasses}\n      >\n        <span\n          data-slot=\"hero-text-animation-rotating-sizer\"\n          className={heroTextAnimationRotatingSizerClasses}\n        >\n          {longestKeyword}\n        </span>\n        {reducedMotion ? (\n          <span\n            data-reduced-motion=\"true\"\n            data-slot=\"hero-text-animation-rotating-keyword\"\n            className={heroTextAnimationRotatingKeywordClasses}\n          >\n            {keyword}\n          </span>\n        ) : (\n          <AnimatePresence initial={false}>\n            <motionElement.span\n              key={`${keywordIndex}-${keywords[keywordIndex] ?? keyword}`}\n              data-slot=\"hero-text-animation-rotating-keyword\"\n              className={heroTextAnimationRotatingKeywordClasses}\n              variants={variants}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n            >\n              {keyword}\n            </motionElement.span>\n          </AnimatePresence>\n        )}\n      </span>\n      {suffix.length > 0 ? (\n        <span\n          data-slot=\"hero-text-animation-rotating-suffix\"\n          className={heroTextAnimationRotatingTextClasses}\n        >\n          {suffix}\n        </span>\n      ) : null}\n    </span>\n  );\n}\n\n// Static (SSR / pre-hydration / reduced-motion-static) render. It reuses the\n// exact sizer + overlay box the animated component settles into, so the plain\n// final text is shown with no glyph fragments and the layout never shifts when\n// the client takes over.\nfunction renderStaticScramble(text: string) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-slot=\"hero-text-animation-motion\"\n      data-scramble-complete=\"true\"\n      data-split-by=\"character\"\n      className={heroTextAnimationScrambleMotionClasses}\n    >\n      <span\n        data-slot=\"hero-text-animation-scramble-text\"\n        data-reduced-motion=\"true\"\n        className={heroTextAnimationScrambleTextClasses}\n      >\n        {renderScrambleGroups(getResolvedScrambleGroups(text))}\n      </span>\n    </span>\n  );\n}\n\nfunction ScrambleDecryptSegments({\n  active,\n  delay,\n  duration,\n  once,\n  reducedMotion,\n  text,\n  trigger,\n  onAnimationComplete,\n  onAnimationStart,\n}: {\n  active: boolean;\n  delay: number;\n  duration: number;\n  once: boolean;\n  reducedMotion: boolean;\n  text: string;\n  trigger: HeroTextAnimationTrigger;\n  onAnimationComplete?: () => void;\n  onAnimationStart?: () => void;\n}) {\n  const [inViewRef, isInView] = useTypewriterInView({ once, trigger });\n  const { intervalMs, updateCount } = useMemo(\n    () => getScrambleTiming({ duration }),\n    [duration],\n  );\n  const shouldStart =\n    trigger === \"in-view\" ? isInView : trigger === \"manual\" ? active : true;\n  const [frame, setFrame] = useState(() => (reducedMotion ? updateCount : 0));\n\n  useEffect(() => {\n    if (reducedMotion) {\n      return;\n    }\n\n    if (!shouldStart || text.length === 0) {\n      return;\n    }\n\n    let cancelled = false;\n    let nextFrame = 0;\n    let intervalId: number | undefined;\n\n    const delayId = window.setTimeout(\n      () => {\n        if (cancelled) {\n          return;\n        }\n\n        setFrame(0);\n        onAnimationStart?.();\n\n        intervalId = window.setInterval(() => {\n          if (cancelled) {\n            return;\n          }\n\n          nextFrame = Math.min(updateCount, nextFrame + 1);\n          setFrame(nextFrame);\n\n          if (nextFrame >= updateCount) {\n            if (intervalId !== undefined) {\n              window.clearInterval(intervalId);\n            }\n            onAnimationComplete?.();\n          }\n        }, intervalMs);\n      },\n      Math.max(0, Math.round(delay * 1000)),\n    );\n\n    return () => {\n      cancelled = true;\n      window.clearTimeout(delayId);\n\n      if (intervalId !== undefined) {\n        window.clearInterval(intervalId);\n      }\n    };\n  }, [\n    delay,\n    intervalMs,\n    onAnimationComplete,\n    onAnimationStart,\n    reducedMotion,\n    shouldStart,\n    text,\n    updateCount,\n  ]);\n\n  const normalizedFrame = reducedMotion\n    ? updateCount\n    : !shouldStart\n      ? 0\n      : Math.min(frame, updateCount);\n  const isComplete = normalizedFrame >= updateCount;\n  const slots = getScrambleFrameSlots({\n    frame: normalizedFrame,\n    text,\n    updateCount,\n  });\n  const groups = groupScrambleSlots(slots);\n\n  return (\n    <span\n      ref={inViewRef}\n      aria-hidden=\"true\"\n      data-slot=\"hero-text-animation-motion\"\n      data-scramble-complete={isComplete ? \"true\" : \"false\"}\n      data-scramble-interval-ms={intervalMs}\n      data-scramble-max-updates={updateCount}\n      data-split-by=\"character\"\n      className={heroTextAnimationScrambleMotionClasses}\n    >\n      <span\n        data-slot=\"hero-text-animation-scramble-text\"\n        data-reduced-motion={reducedMotion ? \"true\" : undefined}\n        className={heroTextAnimationScrambleTextClasses}\n      >\n        {renderScrambleGroups(groups)}\n      </span>\n    </span>\n  );\n}\n\nfunction GradientHighlightSegments({\n  active,\n  delay,\n  duration,\n  once,\n  reducedMotion,\n  text,\n  trigger,\n  onAnimationComplete,\n  onAnimationStart,\n}: {\n  active: boolean;\n  delay: number;\n  duration: number;\n  once: boolean;\n  reducedMotion: boolean;\n  text: string;\n  trigger: HeroTextAnimationTrigger;\n  onAnimationComplete?: () => void;\n  onAnimationStart?: () => void;\n}) {\n  const [inViewRef, isInView] = useTypewriterInView({ once, trigger });\n  const { markActive, markRest, willChangeClass } = useMotionRest();\n  const shouldStart =\n    trigger === \"in-view\" ? isInView : trigger === \"manual\" ? active : true;\n  // Position 0% shows the fully revealed (base) text; 100% parks the window in\n  // the transparent zone so the heading starts invisible and paints in as the\n  // sweep drives the position back to 0%. Reduced motion rests revealed; an\n  // in-view trigger waits hidden until it scrolls into view.\n  const initialBackgroundPositionX = reducedMotion ? \"0%\" : \"100%\";\n  const targetBackgroundPositionX = reducedMotion\n    ? \"0%\"\n    : !shouldStart\n      ? \"100%\"\n      : \"0%\";\n  const transition = reducedMotion\n    ? { duration: 0 }\n    : {\n        delay,\n        duration,\n        ease: gradientHighlightSweepEase,\n      };\n\n  return (\n    <motionElement.span\n      ref={inViewRef}\n      aria-hidden=\"true\"\n      data-highlight=\"gradient-highlight\"\n      data-reduced-motion={reducedMotion ? \"true\" : undefined}\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by=\"phrase\"\n      className={cn(\n        heroTextAnimationMotionClasses,\n        heroTextAnimationGradientHighlightClasses,\n        willChangeClass,\n      )}\n      style={gradientHighlightStyle}\n      initial={{ backgroundPositionX: initialBackgroundPositionX }}\n      animate={{ backgroundPositionX: targetBackgroundPositionX }}\n      transition={transition}\n      onAnimationComplete={\n        reducedMotion || !shouldStart\n          ? undefined\n          : () => {\n              markRest();\n              onAnimationComplete?.();\n            }\n      }\n      onAnimationStart={\n        reducedMotion || !shouldStart\n          ? undefined\n          : () => {\n              markActive();\n              onAnimationStart?.();\n            }\n      }\n    >\n      {text}\n    </motionElement.span>\n  );\n}\n\nfunction renderStaticGradientHighlight(text: string) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-highlight=\"gradient-highlight\"\n      data-reduced-motion=\"true\"\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by=\"phrase\"\n      className={cn(\n        heroTextAnimationMotionClasses,\n        heroTextAnimationGradientHighlightClasses,\n      )}\n      style={gradientHighlightStyle}\n    >\n      {text}\n    </span>\n  );\n}\n\nfunction renderStaticSvgStrokeDraw({\n  accessibleTitle,\n  lines,\n  viewBox,\n  viewBoxValid,\n}: {\n  accessibleTitle?: string;\n  lines: string[];\n  viewBox: string;\n  viewBoxValid: boolean;\n}) {\n  const geometry = estimateSvgStrokeDrawGeometry(\n    lines,\n    viewBoxValid ? viewBox : undefined,\n  );\n  const shouldExposeSvg =\n    accessibleTitle !== undefined && accessibleTitle.length > 0;\n\n  return (\n    <span\n      aria-hidden={shouldExposeSvg ? undefined : \"true\"}\n      data-reduced-motion=\"true\"\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by=\"line\"\n      data-svg-line-count={lines.length}\n      data-svg-stroke-draw=\"true\"\n      data-svg-stroke-draw-mode=\"letter-trace\"\n      data-svg-view-box-valid={viewBoxValid ? \"true\" : \"false\"}\n      className={cn(\n        heroTextAnimationMotionClasses,\n        heroTextAnimationSvgStrokeDrawClasses,\n      )}\n      style={svgStrokeDrawStyle}\n    >\n      <svg\n        aria-hidden={shouldExposeSvg ? undefined : \"true\"}\n        aria-label={shouldExposeSvg ? accessibleTitle : undefined}\n        data-slot=\"hero-text-animation-svg\"\n        fill=\"none\"\n        focusable=\"false\"\n        preserveAspectRatio=\"xMidYMid meet\"\n        role={shouldExposeSvg ? \"img\" : undefined}\n        viewBox={geometry.viewBox}\n        className={heroTextAnimationSvgStrokeDrawSvgClasses}\n      >\n        {shouldExposeSvg && accessibleTitle ? (\n          <title>{accessibleTitle}</title>\n        ) : null}\n        {lines.map((line, index) => (\n          <text\n            key={`${index}-${line}`}\n            aria-hidden=\"true\"\n            data-reduced-motion=\"true\"\n            data-slot=\"hero-text-animation-svg-line\"\n            x={geometry.centerX}\n            y={geometry.baselines[index]}\n            textAnchor=\"middle\"\n            fontSize={svgStrokeDrawFontSize}\n            fill=\"currentColor\"\n            fillOpacity={1}\n            stroke=\"var(--hero-text-animation-svg-stroke)\"\n            strokeWidth={svgStrokeDrawStrokeWidth}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            className={heroTextAnimationSvgStrokeDrawLineClasses}\n          >\n            {line}\n          </text>\n        ))}\n      </svg>\n    </span>\n  );\n}\n\nfunction SvgStrokeDrawSegments({\n  accessibleTitle,\n  active,\n  delay,\n  duration,\n  lines,\n  once,\n  trigger,\n  viewBox,\n  viewBoxValid,\n  onAnimationComplete,\n  onAnimationStart,\n}: {\n  accessibleTitle?: string;\n  active: boolean;\n  delay: number;\n  duration: number;\n  lines: string[];\n  once: boolean;\n  trigger: HeroTextAnimationTrigger;\n  viewBox: string;\n  viewBoxValid: boolean;\n  onAnimationComplete?: () => void;\n  onAnimationStart?: () => void;\n}) {\n  const [inViewRef, isInView] = useTypewriterInView({ once, trigger });\n  const { markActive, markRest } = useMotionRest();\n  const clipId = useId().replace(/:/g, \"\");\n  const shouldStart =\n    trigger === \"in-view\" ? isInView : trigger === \"manual\" ? active : true;\n  const shouldExposeSvg =\n    accessibleTitle !== undefined && accessibleTitle.length > 0;\n  const viewBoxOverride = viewBoxValid ? viewBox : undefined;\n  const [geometry, setGeometry] = useState(() =>\n    estimateSvgStrokeDrawGeometry(lines, viewBoxOverride),\n  );\n  const groupRef = useRef<SVGGElement>(null);\n  const lineRefs = useRef<Array<SVGTextElement | null>>([]);\n  const linesKey = lines.join(\"\\n\");\n\n  // Measure the real glyph metrics after layout so each line's wipe spans its\n  // exact width and the view box frames the text tightly. Re-measures once web\n  // fonts settle, since the glyph advances change when the real face swaps in.\n  useIsomorphicLayoutEffect(() => {\n    const measure = () => {\n      const group = groupRef.current;\n\n      if (!group || typeof group.getBBox !== \"function\") {\n        return;\n      }\n\n      let box: DOMRect;\n\n      try {\n        box = group.getBBox();\n      } catch {\n        return;\n      }\n\n      if (!box || box.width === 0 || box.height === 0) {\n        return;\n      }\n\n      const lineWidths = lines.map((_, index) => {\n        const element = lineRefs.current[index];\n        const length =\n          element && typeof element.getComputedTextLength === \"function\"\n            ? element.getComputedTextLength()\n            : 0;\n\n        return Math.max(1, length);\n      });\n      const pad = svgStrokeDrawViewBoxPadding;\n\n      setGeometry((previous) => ({\n        ...previous,\n        viewBox:\n          viewBoxOverride ??\n          `${box.x - pad} ${box.y - pad} ${box.width + pad * 2} ${\n            box.height + pad * 2\n          }`,\n        lineWidths,\n        measured: true,\n      }));\n    };\n\n    measure();\n\n    if (\n      typeof document !== \"undefined\" &&\n      document.fonts &&\n      \"ready\" in document.fonts\n    ) {\n      let cancelled = false;\n\n      document.fonts.ready\n        .then(() => {\n          if (!cancelled) {\n            measure();\n          }\n        })\n        .catch(() => {});\n\n      return () => {\n        cancelled = true;\n      };\n    }\n\n    return undefined;\n  }, [linesKey, viewBoxOverride]);\n\n  const lastLineIndex = lines.length - 1;\n  const wipePad = svgStrokeDrawStrokeWidth * 2;\n  const fillLag = duration * svgStrokeDrawFillLagRatio;\n  const wipeTransition = (wipeDelay: number) => ({\n    delay: wipeDelay,\n    duration,\n    ease: \"linear\" as const,\n  });\n\n  return (\n    <span\n      ref={inViewRef}\n      aria-hidden={shouldExposeSvg ? undefined : \"true\"}\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by=\"line\"\n      data-svg-line-count={lines.length}\n      data-svg-measured={geometry.measured ? \"true\" : \"false\"}\n      data-svg-stroke-draw=\"true\"\n      data-svg-stroke-draw-mode=\"letter-trace\"\n      data-svg-view-box-valid={viewBoxValid ? \"true\" : \"false\"}\n      className={cn(\n        heroTextAnimationMotionClasses,\n        heroTextAnimationSvgStrokeDrawClasses,\n      )}\n      style={svgStrokeDrawStyle}\n    >\n      <svg\n        aria-hidden={shouldExposeSvg ? undefined : \"true\"}\n        aria-label={shouldExposeSvg ? accessibleTitle : undefined}\n        data-slot=\"hero-text-animation-svg\"\n        fill=\"none\"\n        focusable=\"false\"\n        preserveAspectRatio=\"xMidYMid meet\"\n        role={shouldExposeSvg ? \"img\" : undefined}\n        viewBox={geometry.viewBox}\n        className={heroTextAnimationSvgStrokeDrawSvgClasses}\n      >\n        {shouldExposeSvg && accessibleTitle ? (\n          <title>{accessibleTitle}</title>\n        ) : null}\n        <defs>\n          {lines.map((line, index) => {\n            const width = geometry.lineWidths[index] ?? 1;\n            const left = geometry.centerX - width / 2 - wipePad;\n            const fullWidth = width + wipePad * 2;\n            const top =\n              geometry.baselines[index] - svgStrokeDrawAscent - wipePad;\n            const height =\n              svgStrokeDrawAscent + svgStrokeDrawDescent + wipePad * 2;\n            const lineDelay = delay + index * svgStrokeDrawLineStagger;\n            // The wipe only runs once the text has been measured, so the reveal\n            // always spans the real line width rather than the placeholder.\n            const isDrawing = shouldStart && geometry.measured;\n\n            return [\n              <clipPath key={`o-${index}`} id={`${clipId}-o-${index}`}>\n                <motionElement.rect\n                  x={left}\n                  y={top}\n                  height={height}\n                  initial={{ width: 0 }}\n                  animate={{ width: isDrawing ? fullWidth : 0 }}\n                  transition={wipeTransition(lineDelay)}\n                  onAnimationStart={\n                    isDrawing && index === 0\n                      ? () => {\n                          markActive();\n                          onAnimationStart?.();\n                        }\n                      : undefined\n                  }\n                />\n              </clipPath>,\n              <clipPath key={`f-${index}`} id={`${clipId}-f-${index}`}>\n                <motionElement.rect\n                  x={left}\n                  y={top}\n                  height={height}\n                  initial={{ width: 0 }}\n                  animate={{ width: isDrawing ? fullWidth : 0 }}\n                  transition={wipeTransition(lineDelay + fillLag)}\n                  onAnimationComplete={\n                    isDrawing && index === lastLineIndex\n                      ? () => {\n                          markRest();\n                          onAnimationComplete?.();\n                        }\n                      : undefined\n                  }\n                />\n              </clipPath>,\n            ];\n          })}\n        </defs>\n        <g ref={groupRef} data-slot=\"hero-text-animation-svg-group\">\n          {lines.map((line, index) => {\n            const baseTextProps = {\n              x: geometry.centerX,\n              y: geometry.baselines[index],\n              textAnchor: \"middle\" as const,\n              fontSize: svgStrokeDrawFontSize,\n            };\n\n            return (\n              <g key={`${index}-${line}`} data-svg-line-index={index}>\n                <text\n                  {...baseTextProps}\n                  ref={(element) => {\n                    lineRefs.current[index] = element;\n                  }}\n                  aria-hidden=\"true\"\n                  data-slot=\"hero-text-animation-svg-line\"\n                  clipPath={`url(#${clipId}-o-${index})`}\n                  fill=\"none\"\n                  stroke=\"var(--hero-text-animation-svg-stroke)\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth={svgStrokeDrawStrokeWidth}\n                >\n                  {line}\n                </text>\n                <text\n                  {...baseTextProps}\n                  aria-hidden=\"true\"\n                  data-slot=\"hero-text-animation-svg-fill\"\n                  clipPath={`url(#${clipId}-f-${index})`}\n                  fill=\"currentColor\"\n                  stroke=\"none\"\n                  className={heroTextAnimationSvgStrokeDrawLineClasses}\n                >\n                  {line}\n                </text>\n              </g>\n            );\n          })}\n        </g>\n      </svg>\n    </span>\n  );\n}\n\nfunction BlurFocusSegments({\n  active,\n  delay,\n  duration,\n  once,\n  reducedMotion,\n  text,\n  trigger,\n  onAnimationComplete,\n  onAnimationStart,\n}: {\n  active: boolean;\n  delay: number;\n  duration: number;\n  once: boolean;\n  reducedMotion: boolean;\n  text: string;\n  trigger: HeroTextAnimationTrigger;\n  onAnimationComplete?: () => void;\n  onAnimationStart?: () => void;\n}) {\n  const [inViewRef, isInView] = useTypewriterInView({ once, trigger });\n  const { markActive, markRest, willChangeClass } = useMotionRest();\n  const shouldStart =\n    trigger === \"in-view\" ? isInView : trigger === \"manual\" ? active : true;\n  const hiddenState = reducedMotion\n    ? { opacity: 0 }\n    : {\n        filter: `blur(${blurFocusInitialBlur})`,\n        opacity: 0,\n        scale: blurFocusInitialScale,\n        y: heroTextAnimationMotionTokens.distance.blurFocusY,\n      };\n  const visibleState = reducedMotion\n    ? { opacity: 1 }\n    : {\n        filter: `blur(${blurFocusFinalBlur})`,\n        opacity: 1,\n        scale: 1,\n        y: \"0em\",\n      };\n  const transition: Transition = reducedMotion\n    ? { delay, duration: 0.18, ease: \"linear\" }\n    : {\n        delay,\n        duration,\n        ease: blurFocusFocusEase,\n        // The defocus (blur/scale/y) racks in with the back-loaded ease so it\n        // stays perceptible through the reveal; opacity resolves sooner so the\n        // heading reads as a soft-focus word settling into clarity rather than\n        // fading in from nothing.\n        filter: { delay, duration, ease: blurFocusFocusEase },\n        scale: { delay, duration, ease: blurFocusFocusEase },\n        y: { delay, duration, ease: blurFocusFocusEase },\n        opacity: {\n          delay,\n          duration: duration * 0.6,\n          ease: heroTextAnimationMotionTokens.easing.soft,\n        },\n      };\n\n  return (\n    <motionElement.span\n      ref={inViewRef}\n      aria-hidden=\"true\"\n      data-blur-final={blurFocusFinalBlur}\n      data-blur-initial={\n        reducedMotion ? blurFocusFinalBlur : blurFocusInitialBlur\n      }\n      data-focus-reveal=\"blur-focus\"\n      data-reduced-motion={reducedMotion ? \"true\" : undefined}\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by=\"phrase\"\n      className={cn(\n        heroTextAnimationMotionClasses,\n        heroTextAnimationBlurFocusClasses,\n        willChangeClass,\n      )}\n      initial={hiddenState}\n      animate={shouldStart ? visibleState : hiddenState}\n      transition={transition}\n      onAnimationComplete={\n        shouldStart && !reducedMotion\n          ? () => {\n              markRest();\n              onAnimationComplete?.();\n            }\n          : undefined\n      }\n      onAnimationStart={\n        shouldStart && !reducedMotion\n          ? () => {\n              markActive();\n              onAnimationStart?.();\n            }\n          : undefined\n      }\n    >\n      {text}\n    </motionElement.span>\n  );\n}\n\nfunction renderStaticBlurFocus(text: string) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-blur-final={blurFocusFinalBlur}\n      data-blur-initial={blurFocusFinalBlur}\n      data-focus-reveal=\"blur-focus\"\n      data-reduced-motion=\"true\"\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by=\"phrase\"\n      className={cn(\n        heroTextAnimationMotionClasses,\n        heroTextAnimationBlurFocusClasses,\n      )}\n    >\n      {text}\n    </span>\n  );\n}\n\nfunction getBlurFocusSegmentVariants({\n  duration,\n  reducedMotion,\n  splitBy,\n}: {\n  duration: number;\n  reducedMotion: boolean;\n  splitBy: HeroTextAnimationSplitBy;\n}): Variants {\n  if (reducedMotion) {\n    return {\n      hidden: { opacity: 0 },\n      visible: {\n        opacity: 1,\n        transition: { duration: 0.18, ease: \"linear\" },\n      },\n    };\n  }\n\n  const distance =\n    splitBy === \"line\"\n      ? heroTextAnimationMotionTokens.distance.lineY\n      : heroTextAnimationMotionTokens.distance.blurFocusY;\n\n  return {\n    hidden: {\n      filter: `blur(${blurFocusInitialBlur})`,\n      opacity: 0,\n      scale: blurFocusInitialScale,\n      y: distance,\n    },\n    visible: {\n      filter: `blur(${blurFocusFinalBlur})`,\n      opacity: 1,\n      scale: 1,\n      // Mirror the single-phrase reveal per segment: the defocus racks in with\n      // the back-loaded ease while opacity resolves sooner, so each word settles\n      // from soft focus into clarity as the stagger sweeps across the line.\n      transition: {\n        duration,\n        ease: blurFocusFocusEase,\n        opacity: {\n          duration: duration * 0.6,\n          ease: heroTextAnimationMotionTokens.easing.soft,\n        },\n      },\n      y: \"0em\",\n    },\n  };\n}\n\nfunction renderStaticBlurFocusSegments({\n  segments,\n  splitBy,\n}: {\n  segments: HeroTextAnimationSegment[];\n  splitBy: HeroTextAnimationSplitBy;\n}) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-blur-final={blurFocusFinalBlur}\n      data-blur-initial={blurFocusFinalBlur}\n      data-focus-reveal=\"blur-focus\"\n      data-reduced-motion=\"true\"\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by={splitBy}\n      className={heroTextAnimationMotionClasses}\n    >\n      {renderStaticSegments({ segments, splitBy })}\n    </span>\n  );\n}\n\nfunction BlurFocusStaggeredSegments({\n  active,\n  delay,\n  duration,\n  once,\n  reducedMotion,\n  segments,\n  splitBy,\n  stagger,\n  trigger,\n  onAnimationComplete,\n  onAnimationStart,\n}: {\n  active: boolean;\n  delay: number;\n  duration: number;\n  once: boolean;\n  reducedMotion: boolean;\n  segments: HeroTextAnimationSegment[];\n  splitBy: HeroTextAnimationSplitBy;\n  stagger: number;\n  trigger: HeroTextAnimationTrigger;\n  onAnimationComplete?: () => void;\n  onAnimationStart?: () => void;\n}) {\n  const containerVariants = getContainerVariants({ delay, stagger });\n  const segmentVariants = getBlurFocusSegmentVariants({\n    duration,\n    reducedMotion,\n    splitBy,\n  });\n  const { markActive, markRest, willChangeClass } = useMotionRest();\n  const triggerProps =\n    trigger === \"in-view\"\n      ? {\n          whileInView: \"visible\",\n          viewport: { amount: 0.6, once },\n        }\n      : {\n          animate: trigger === \"manual\" && !active ? \"hidden\" : \"visible\",\n        };\n\n  return (\n    <motionElement.span\n      aria-hidden=\"true\"\n      data-blur-final={blurFocusFinalBlur}\n      data-blur-initial={\n        reducedMotion ? blurFocusFinalBlur : blurFocusInitialBlur\n      }\n      data-focus-reveal=\"blur-focus\"\n      data-reduced-motion={reducedMotion ? \"true\" : undefined}\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by={splitBy}\n      className={heroTextAnimationMotionClasses}\n      variants={containerVariants}\n      initial=\"hidden\"\n      onAnimationComplete={() => {\n        markRest();\n        onAnimationComplete?.();\n      }}\n      onAnimationStart={() => {\n        markActive();\n        onAnimationStart?.();\n      }}\n      {...triggerProps}\n    >\n      {splitBy === \"line\"\n        ? segments.map((segment, index) => (\n            <motionElement.span\n              key={`${index}-${segment.text}`}\n              data-slot=\"hero-text-animation-segment\"\n              data-reduced-motion={reducedMotion ? \"true\" : undefined}\n              data-segment=\"line\"\n              className={cn(\n                heroTextAnimationBlurFocusLineClasses,\n                willChangeClass,\n              )}\n              variants={segmentVariants}\n            >\n              {segment.text}\n            </motionElement.span>\n          ))\n        : segments.map((segment, index) => {\n            if (segment.kind === \"space\") {\n              return segment.text;\n            }\n\n            return (\n              <motionElement.span\n                key={`${index}-${segment.text}`}\n                data-slot=\"hero-text-animation-segment\"\n                data-reduced-motion={reducedMotion ? \"true\" : undefined}\n                data-segment=\"word\"\n                className={cn(\n                  heroTextAnimationBlurFocusWordClasses,\n                  willChangeClass,\n                )}\n                variants={segmentVariants}\n              >\n                {segment.text}\n              </motionElement.span>\n            );\n          })}\n    </motionElement.span>\n  );\n}\n\nfunction renderStaticScrollResponsive(text: string) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-reduced-motion=\"true\"\n      data-scroll-anchor={scrollResponsiveAnchor}\n      data-scroll-opacity-min={scrollResponsiveMinimumOpacity}\n      data-scroll-progress=\"0.000\"\n      data-scroll-range-px={scrollResponsiveRangePx}\n      data-scroll-responsive=\"subtle\"\n      data-scroll-y-max=\"0\"\n      data-scroll-y-min={heroTextAnimationMotionTokens.distance.scrollY}\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by=\"phrase\"\n      className={cn(\n        heroTextAnimationMotionClasses,\n        heroTextAnimationScrollResponsiveClasses,\n      )}\n    >\n      {text}\n    </span>\n  );\n}\n\nfunction ScrollResponsiveSegments({\n  reducedMotion,\n  text,\n}: {\n  reducedMotion: boolean;\n  text: string;\n}) {\n  const motionRef = useRef<HTMLSpanElement>(null);\n  const y = useMotionValue(0);\n  const opacity = useMotionValue(1);\n  const scale = useMotionValue(1);\n\n  useEffect(() => {\n    const element = motionRef.current;\n\n    y.set(0);\n    opacity.set(1);\n    scale.set(1);\n    element?.setAttribute(\"data-scroll-progress\", \"0.000\");\n    element?.setAttribute(\"data-scroll-y\", \"0.000\");\n    element?.setAttribute(\"data-scroll-opacity\", \"1.000\");\n\n    if (reducedMotion || typeof window === \"undefined\") {\n      return;\n    }\n\n    let frameId: number | undefined;\n\n    const update = () => {\n      frameId = undefined;\n\n      if (!element) {\n        return;\n      }\n\n      // Measure the untransformed heading, not the moving visual layer: using\n      // its transform in the input creates feedback on subsequent scrolls.\n      // Begin while the heading is still visible. A hero above this viewport\n      // threshold starts from its document position, staying unchanged at scroll 0.\n      const heading = element.closest('[data-slot=\"hero-text-animation\"]');\n      if (!heading) return;\n      const rect = heading.getBoundingClientRect();\n      const anchor = Math.min(\n        window.innerHeight * 0.7,\n        rect.top + window.scrollY,\n      );\n      const range = Math.max(\n        scrollResponsiveRangePx,\n        window.innerHeight * 0.55,\n      );\n      const progress = Math.min(1, Math.max(0, (anchor - rect.top) / range));\n      const nextY = heroTextAnimationMotionTokens.distance.scrollY * progress;\n      const nextOpacity = 1 - (1 - scrollResponsiveMinimumOpacity) * progress;\n\n      y.set(nextY);\n      opacity.set(nextOpacity);\n      scale.set(1 - 0.1 * progress);\n      element.setAttribute(\"data-scroll-range-px\", range.toFixed(0));\n      element.setAttribute(\n        \"data-scroll-scale\",\n        (1 - 0.1 * progress).toFixed(3),\n      );\n      element.setAttribute(\"data-scroll-progress\", progress.toFixed(3));\n      element.setAttribute(\"data-scroll-y\", nextY.toFixed(3));\n      element.setAttribute(\"data-scroll-opacity\", nextOpacity.toFixed(3));\n    };\n\n    const scheduleUpdate = () => {\n      if (frameId !== undefined) {\n        return;\n      }\n\n      frameId =\n        window.requestAnimationFrame?.(update) ?? window.setTimeout(update, 16);\n    };\n\n    update();\n    window.addEventListener(\"scroll\", scheduleUpdate, { passive: true });\n    window.addEventListener(\"resize\", scheduleUpdate, { passive: true });\n\n    return () => {\n      window.removeEventListener(\"scroll\", scheduleUpdate);\n      window.removeEventListener(\"resize\", scheduleUpdate);\n\n      if (frameId !== undefined) {\n        if (window.cancelAnimationFrame) {\n          window.cancelAnimationFrame(frameId);\n        } else {\n          window.clearTimeout(frameId);\n        }\n      }\n    };\n  }, [opacity, reducedMotion, scale, y]);\n\n  return (\n    <motionElement.span\n      ref={motionRef}\n      aria-hidden=\"true\"\n      data-reduced-motion={reducedMotion ? \"true\" : undefined}\n      data-scroll-anchor={scrollResponsiveAnchor}\n      data-scroll-opacity-min={scrollResponsiveMinimumOpacity}\n      data-scroll-progress=\"0.000\"\n      data-scroll-range-px={scrollResponsiveRangePx}\n      data-scroll-responsive=\"subtle\"\n      data-scroll-y-max=\"0\"\n      data-scroll-y-min={heroTextAnimationMotionTokens.distance.scrollY}\n      data-slot=\"hero-text-animation-motion\"\n      data-split-by=\"phrase\"\n      className={cn(\n        heroTextAnimationMotionClasses,\n        heroTextAnimationScrollResponsiveClasses,\n      )}\n      style={reducedMotion ? undefined : { opacity, scale, y }}\n    >\n      {text}\n    </motionElement.span>\n  );\n}\n\nfunction getSegmentTransition(duration: number): Transition {\n  return {\n    duration,\n    ease: heroTextAnimationMotionTokens.easing.standard,\n  };\n}\n\nexport const HeroTextAnimation = forwardRef<\n  HTMLElement,\n  HeroTextAnimationProps\n>(\n  (\n    {\n      active = true,\n      animation = \"stagger-words\",\n      ariaLabel,\n      as = \"h1\",\n      className,\n      delay = 0.05,\n      duration,\n      emphasisWordIndices,\n      emphasisWords,\n      once = true,\n      reducedMotionStrategy = \"opacity-only\",\n      repeat = false,\n      repeatDelay = defaultRepeatDelay,\n      autoRotateKeywords = false,\n      defaultRotatingKeywordIndex = 0,\n      rotatingKeywordIndex,\n      rotatingKeywordInterval,\n      rotatingKeywordOptions,\n      rotatingKeywordPrefix = \"\",\n      rotatingKeywordSuffix = \"\",\n      showCaret = true,\n      splitBy,\n      stagger,\n      svgAccessibleTitle,\n      // Deprecated and ignored; destructured only to keep it out of `...props`\n      // so it never lands on the DOM node.\n      svgPathData: _svgPathData,\n      svgViewBox,\n      text,\n      trigger = \"mount\",\n      \"data-testid\": dataTestId = \"hero-text-animation\",\n      onAnimationComplete,\n      onAnimationStart,\n      onRotatingKeywordIndexChange,\n      ...props\n    },\n    ref,\n  ) => {\n    const hasHydrated = useHasHydrated();\n    const reducedMotionOverride = useContext(\n      HeroTextAnimationReducedMotionContext,\n    );\n    const prefersReducedMotion = useSyncExternalStore(\n      subscribeToReducedMotion,\n      getReducedMotionSnapshot,\n      getServerReducedMotionSnapshot,\n    );\n    const reducedMotion =\n      reducedMotionOverride === \"always\"\n        ? true\n        : reducedMotionOverride === \"never\"\n          ? false\n          : prefersReducedMotion === true;\n    const resolvedDuration =\n      duration ??\n      (animation === \"masked-curtain\"\n        ? heroTextAnimationMotionTokens.duration.maskedCurtain\n        : animation === \"rotating-keyword\"\n          ? heroTextAnimationMotionTokens.duration.rotatingKeyword\n          : animation === \"scramble-decrypt\"\n            ? heroTextAnimationMotionTokens.duration.scramble\n            : animation === \"gradient-highlight\"\n              ? heroTextAnimationMotionTokens.duration.gradientHighlight\n              : animation === \"blur-focus\"\n                ? heroTextAnimationMotionTokens.duration.blurFocus\n                : animation === \"kinetic-emphasis-pop\"\n                  ? heroTextAnimationMotionTokens.duration.kineticEmphasisPop\n                  : animation === \"svg-stroke-draw\"\n                    ? heroTextAnimationMotionTokens.duration.svgStrokeDraw\n                    : animation === \"scroll-responsive\"\n                      ? heroTextAnimationMotionTokens.duration.scrollResponsive\n                      : animation === \"typewriter\"\n                        ? heroTextAnimationMotionTokens.duration.typewriter\n                        : heroTextAnimationMotionTokens.duration.base);\n    const renderStatic =\n      !hasHydrated ||\n      (reducedMotion &&\n        (reducedMotionStrategy === \"static\" ||\n          animation === \"svg-stroke-draw\"));\n    const canRepeat =\n      hasHydrated &&\n      repeat &&\n      !reducedMotion &&\n      animation !== \"rotating-keyword\" &&\n      animation !== \"scroll-responsive\" &&\n      (trigger !== \"manual\" || active) &&\n      !renderStatic;\n    const repeatDelayMs = Math.max(0, Math.round(repeatDelay * 1000));\n    const repeatTimeoutRef = useRef<number | undefined>(undefined);\n    const [repeatIteration, setRepeatIteration] = useState(0);\n    const resolvedSplitBy =\n      animation === \"masked-curtain\"\n        ? \"line\"\n        : animation === \"kinetic-emphasis-pop\"\n          ? \"word\"\n          : (splitBy ?? \"word\");\n    // Blur-focus reveals the whole phrase at once by default. Passing an\n    // explicit splitBy opts into a staggered word-by-word (or line-by-line)\n    // focus-in; leaving it undefined keeps the original single-phrase behavior.\n    const blurFocusSegmented =\n      animation === \"blur-focus\" && (splitBy === \"word\" || splitBy === \"line\");\n    const resolvedStagger =\n      stagger ??\n      (resolvedSplitBy === \"line\"\n        ? heroTextAnimationMotionTokens.stagger.line\n        : heroTextAnimationMotionTokens.stagger.word);\n    const segments = useMemo(\n      () => splitHeroText(text, resolvedSplitBy),\n      [resolvedSplitBy, text],\n    );\n    const kineticEmphasisIndices = useMemo(\n      () =>\n        resolveKineticEmphasisWordIndices({\n          emphasisWordIndices,\n          emphasisWords,\n          segments,\n        }),\n      [emphasisWordIndices, emphasisWords, segments],\n    );\n    const rotatingKeywords = useMemo(\n      () =>\n        resolveRotatingKeywordOptions({\n          rotatingKeywordOptions,\n          text,\n        }),\n      [rotatingKeywordOptions, text],\n    );\n    const svgStrokeDrawLines = useMemo(\n      () => splitSvgStrokeDrawLines(text),\n      [text],\n    );\n    const svgViewBoxValid =\n      svgViewBox !== undefined && isValidSvgViewBox(svgViewBox);\n    const resolvedSvgViewBox = svgViewBoxValid ? svgViewBox.trim() : \"\";\n    const exposesSvgAlternative =\n      animation === \"svg-stroke-draw\" &&\n      svgAccessibleTitle !== undefined &&\n      svgAccessibleTitle.length > 0;\n    const keywordCount = rotatingKeywords.length;\n    const isRotatingKeywordControlled = rotatingKeywordIndex !== undefined;\n    const [\n      uncontrolledRotatingKeywordIndex,\n      setUncontrolledRotatingKeywordIndex,\n    ] = useState(() =>\n      normalizeRotatingKeywordIndex(defaultRotatingKeywordIndex, keywordCount),\n    );\n    const selectedRotatingKeywordIndex = normalizeRotatingKeywordIndex(\n      isRotatingKeywordControlled\n        ? rotatingKeywordIndex\n        : uncontrolledRotatingKeywordIndex,\n      keywordCount,\n    );\n    const selectedRotatingKeyword =\n      rotatingKeywords[selectedRotatingKeywordIndex] ?? text;\n    const longestRotatingKeyword = getLongestRotatingKeyword(rotatingKeywords);\n    const accessibleLabel = ariaLabel ?? text;\n    const animatedSegmentCount =\n      animation === \"rotating-keyword\"\n        ? keywordCount\n        : animation === \"blur-focus\"\n          ? blurFocusSegmented\n            ? segments.filter((segment) => segment.kind === \"text\").length\n            : 1\n          : animation === \"gradient-highlight\" ||\n              animation === \"scroll-responsive\"\n            ? 1\n            : animation === \"svg-stroke-draw\"\n              ? svgStrokeDrawLines.length\n              : animation === \"typewriter\" || animation === \"scramble-decrypt\"\n                ? splitTypewriterCharacters(text).length\n                : segments.filter((segment) => segment.kind === \"text\").length;\n    const visualSplitBy =\n      animation === \"rotating-keyword\"\n        ? \"keyword\"\n        : animation === \"blur-focus\"\n          ? blurFocusSegmented\n            ? resolvedSplitBy\n            : \"phrase\"\n          : animation === \"gradient-highlight\" ||\n              animation === \"scroll-responsive\"\n            ? \"phrase\"\n            : animation === \"svg-stroke-draw\"\n              ? \"line\"\n              : animation === \"typewriter\" || animation === \"scramble-decrypt\"\n                ? \"character\"\n                : resolvedSplitBy;\n    const visualKey = [\n      animation,\n      repeatIteration,\n      text,\n      selectedRotatingKeywordIndex,\n      resolvedSplitBy,\n      resolvedStagger,\n      resolvedDuration,\n      kineticEmphasisIndices.join(\",\"),\n      svgStrokeDrawLines.join(\"|\"),\n      resolvedSvgViewBox,\n    ].join(\":\");\n\n    const handleRotatingKeywordIndexChange = useCallback(\n      (nextIndex: number) => {\n        const normalizedIndex = normalizeRotatingKeywordIndex(\n          nextIndex,\n          keywordCount,\n        );\n\n        if (!isRotatingKeywordControlled) {\n          setUncontrolledRotatingKeywordIndex(normalizedIndex);\n        }\n\n        onRotatingKeywordIndexChange?.(normalizedIndex);\n      },\n      [isRotatingKeywordControlled, keywordCount, onRotatingKeywordIndexChange],\n    );\n\n    useEffect(() => {\n      window.clearTimeout(repeatTimeoutRef.current);\n      repeatTimeoutRef.current = undefined;\n\n      return () => {\n        window.clearTimeout(repeatTimeoutRef.current);\n        repeatTimeoutRef.current = undefined;\n      };\n    }, [\n      active,\n      animation,\n      delay,\n      once,\n      repeat,\n      repeatDelayMs,\n      reducedMotion,\n      reducedMotionStrategy,\n      resolvedDuration,\n      resolvedSplitBy,\n      resolvedStagger,\n      showCaret,\n      svgViewBox,\n      text,\n      trigger,\n      kineticEmphasisIndices,\n    ]);\n\n    const handleAnimationComplete = useCallback(() => {\n      onAnimationComplete?.();\n\n      if (!canRepeat) {\n        return;\n      }\n\n      window.clearTimeout(repeatTimeoutRef.current);\n      repeatTimeoutRef.current = window.setTimeout(() => {\n        setRepeatIteration((iteration) => iteration + 1);\n      }, repeatDelayMs);\n    }, [canRepeat, onAnimationComplete, repeatDelayMs]);\n\n    return createElement(\n      as,\n      // eslint-disable-next-line react-hooks/refs -- React forwards this ref during commit; createElement/cloneElement does not read ref.current.\n      {\n        ...props,\n        ref,\n        className: heroTextAnimationClassNames({ className }),\n        \"data-animation\": animation,\n        \"data-active\": active ? \"true\" : \"false\",\n        \"data-once\": once ? \"true\" : \"false\",\n        \"data-repeat\": repeat ? \"true\" : \"false\",\n        \"data-repeat-delay\": repeatDelay,\n        \"data-reduced-motion\": reducedMotion ? \"true\" : \"false\",\n        \"data-reduced-motion-strategy\": reducedMotionStrategy,\n        \"data-rotating-keyword-count\":\n          animation === \"rotating-keyword\" ? keywordCount : undefined,\n        \"data-rotating-keyword-index\":\n          animation === \"rotating-keyword\"\n            ? selectedRotatingKeywordIndex\n            : undefined,\n        \"data-emphasis-count\":\n          animation === \"kinetic-emphasis-pop\"\n            ? kineticEmphasisIndices.length\n            : undefined,\n        \"data-segment-count\": animatedSegmentCount,\n        \"data-slot\": \"hero-text-animation\",\n        \"data-split-by\": visualSplitBy,\n        \"data-testid\": dataTestId,\n        \"data-trigger\": trigger,\n      },\n      <span\n        data-slot=\"hero-text-animation-accessible-text\"\n        className={heroTextAnimationAccessibleTextClasses}\n      >\n        {accessibleLabel}\n      </span>,\n      <span\n        aria-hidden={exposesSvgAlternative ? undefined : \"true\"}\n        data-slot=\"hero-text-animation-visual\"\n        className={heroTextAnimationVisualClasses}\n      >\n        {renderStatic && animation === \"rotating-keyword\" ? (\n          renderStaticRotatingKeyword({\n            keyword: selectedRotatingKeyword,\n            keywordIndex: selectedRotatingKeywordIndex,\n            longestKeyword: longestRotatingKeyword,\n            prefix: rotatingKeywordPrefix,\n            suffix: rotatingKeywordSuffix,\n          })\n        ) : renderStatic && animation === \"typewriter\" ? (\n          renderStaticTypewriter(text)\n        ) : renderStatic && animation === \"scramble-decrypt\" ? (\n          renderStaticScramble(text)\n        ) : renderStatic && animation === \"gradient-highlight\" ? (\n          renderStaticGradientHighlight(text)\n        ) : renderStatic && animation === \"blur-focus\" ? (\n          blurFocusSegmented ? (\n            renderStaticBlurFocusSegments({\n              segments,\n              splitBy: resolvedSplitBy,\n            })\n          ) : (\n            renderStaticBlurFocus(text)\n          )\n        ) : renderStatic && animation === \"scroll-responsive\" ? (\n          renderStaticScrollResponsive(text)\n        ) : renderStatic && animation === \"kinetic-emphasis-pop\" ? (\n          renderStaticKineticEmphasis({\n            emphasisIndices: kineticEmphasisIndices,\n            segments,\n          })\n        ) : renderStatic && animation === \"svg-stroke-draw\" ? (\n          renderStaticSvgStrokeDraw({\n            accessibleTitle: svgAccessibleTitle,\n            lines: svgStrokeDrawLines,\n            viewBox: resolvedSvgViewBox,\n            viewBoxValid: svgViewBoxValid,\n          })\n        ) : renderStatic ? (\n          renderStaticSegments({ segments, splitBy: resolvedSplitBy })\n        ) : animation === \"masked-curtain\" ? (\n          <MaskedCurtainSegments\n            key={visualKey}\n            active={active}\n            delay={delay}\n            duration={resolvedDuration}\n            once={once}\n            reducedMotion={reducedMotion}\n            segments={segments}\n            stagger={resolvedStagger}\n            trigger={trigger}\n            onAnimationComplete={handleAnimationComplete}\n            onAnimationStart={onAnimationStart}\n          />\n        ) : animation === \"typewriter\" ? (\n          <TypewriterSegments\n            key={visualKey}\n            active={active}\n            delay={delay}\n            duration={resolvedDuration}\n            once={once}\n            reducedMotion={reducedMotion}\n            showCaret={showCaret}\n            text={text}\n            trigger={trigger}\n            onAnimationComplete={handleAnimationComplete}\n            onAnimationStart={onAnimationStart}\n          />\n        ) : animation === \"scramble-decrypt\" ? (\n          <ScrambleDecryptSegments\n            key={visualKey}\n            active={active}\n            delay={delay}\n            duration={resolvedDuration}\n            once={once}\n            reducedMotion={reducedMotion}\n            text={text}\n            trigger={trigger}\n            onAnimationComplete={handleAnimationComplete}\n            onAnimationStart={onAnimationStart}\n          />\n        ) : animation === \"gradient-highlight\" ? (\n          <GradientHighlightSegments\n            key={visualKey}\n            active={active}\n            delay={delay}\n            duration={resolvedDuration}\n            once={once}\n            reducedMotion={reducedMotion}\n            text={text}\n            trigger={trigger}\n            onAnimationComplete={handleAnimationComplete}\n            onAnimationStart={onAnimationStart}\n          />\n        ) : animation === \"blur-focus\" ? (\n          blurFocusSegmented ? (\n            <BlurFocusStaggeredSegments\n              key={visualKey}\n              active={active}\n              delay={delay}\n              duration={resolvedDuration}\n              once={once}\n              reducedMotion={reducedMotion}\n              segments={segments}\n              splitBy={resolvedSplitBy}\n              stagger={resolvedStagger}\n              trigger={trigger}\n              onAnimationComplete={handleAnimationComplete}\n              onAnimationStart={onAnimationStart}\n            />\n          ) : (\n            <BlurFocusSegments\n              key={visualKey}\n              active={active}\n              delay={delay}\n              duration={resolvedDuration}\n              once={once}\n              reducedMotion={reducedMotion}\n              text={text}\n              trigger={trigger}\n              onAnimationComplete={handleAnimationComplete}\n              onAnimationStart={onAnimationStart}\n            />\n          )\n        ) : animation === \"svg-stroke-draw\" ? (\n          <SvgStrokeDrawSegments\n            key={visualKey}\n            accessibleTitle={svgAccessibleTitle}\n            active={active}\n            delay={delay}\n            duration={resolvedDuration}\n            lines={svgStrokeDrawLines}\n            once={once}\n            trigger={trigger}\n            viewBox={resolvedSvgViewBox}\n            viewBoxValid={svgViewBoxValid}\n            onAnimationComplete={handleAnimationComplete}\n            onAnimationStart={onAnimationStart}\n          />\n        ) : animation === \"scroll-responsive\" ? (\n          <ScrollResponsiveSegments\n            key={visualKey}\n            reducedMotion={reducedMotion}\n            text={text}\n          />\n        ) : animation === \"kinetic-emphasis-pop\" ? (\n          <KineticEmphasisSegments\n            key={visualKey}\n            active={active}\n            delay={delay}\n            duration={resolvedDuration}\n            emphasisIndices={kineticEmphasisIndices}\n            once={once}\n            reducedMotion={reducedMotion}\n            segments={segments}\n            stagger={resolvedStagger}\n            trigger={trigger}\n            onAnimationComplete={handleAnimationComplete}\n            onAnimationStart={onAnimationStart}\n          />\n        ) : animation === \"rotating-keyword\" ? (\n          <RotatingKeywordSegments\n            active={active}\n            autoRotateKeywords={autoRotateKeywords}\n            duration={resolvedDuration}\n            keyword={selectedRotatingKeyword}\n            keywordCount={keywordCount}\n            keywordIndex={selectedRotatingKeywordIndex}\n            keywords={rotatingKeywords}\n            longestKeyword={longestRotatingKeyword}\n            once={once}\n            prefix={rotatingKeywordPrefix}\n            reducedMotion={reducedMotion}\n            rotatingKeywordInterval={rotatingKeywordInterval}\n            suffix={rotatingKeywordSuffix}\n            trigger={trigger}\n            onAnimationComplete={onAnimationComplete}\n            onAnimationStart={onAnimationStart}\n            onKeywordIndexChange={handleRotatingKeywordIndexChange}\n          />\n        ) : (\n          <StaggeredSegments\n            key={visualKey}\n            active={active}\n            delay={delay}\n            duration={resolvedDuration}\n            once={once}\n            reducedMotion={reducedMotion}\n            segments={segments}\n            splitBy={resolvedSplitBy}\n            stagger={resolvedStagger}\n            trigger={trigger}\n            onAnimationComplete={handleAnimationComplete}\n            onAnimationStart={onAnimationStart}\n          />\n        )}\n      </span>,\n    );\n  },\n);\n\nHeroTextAnimation.displayName = \"HeroTextAnimation\";\n"
    },
    {
      "path": "packages/components/src/components/hero-text-animation/index.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/hero-text-animation/index.ts",
      "content": "export {\n  HeroTextAnimation,\n  HeroTextAnimationProvider,\n  heroTextAnimationClassNames,\n  heroTextAnimationMotionTokens,\n  splitHeroText,\n  type HeroTextAnimationElement,\n  type HeroTextAnimationKind,\n  type HeroTextAnimationProps,\n  type HeroTextAnimationProviderProps,\n  type HeroTextAnimationProviderReducedMotion,\n  type HeroTextAnimationReducedMotionStrategy,\n  type HeroTextAnimationSegment,\n  type HeroTextAnimationSplitBy,\n  type HeroTextAnimationSvgPath,\n  type HeroTextAnimationTrigger,\n} from \"./hero-text-animation\";\n"
    },
    {
      "path": "packages/components/src/utils/cn.ts",
      "type": "registry:file",
      "target": "~/components/dethink/utils/cn.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
    }
  ]
}
