{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-morph",
  "title": "Text Morph",
  "description": "Per-character and place-value text morphing for labels, counters, and live text swaps, powered by Web Animations API.",
  "dependencies": [],
  "registryDependencies": ["utils"],
  "files": [
    {
      "path": "registry/primitives/texts/text-morph/index.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport React, { useCallback, useEffect, useRef } from \"react\";\nimport type {\n  ComponentPropsWithoutRef,\n  CSSProperties,\n  ElementType,\n} from \"react\";\n\nimport {\n  HTML_AMP_REGEX,\n  HTML_APOS_REGEX,\n  HTML_GT_REGEX,\n  HTML_LT_REGEX,\n  HTML_QUOT_REGEX,\n  NEWLINE_GLOBAL_REGEX,\n} from \"./text-morph/defs\";\nimport type { TextMorphOptions } from \"./text-morph/defs\";\nimport { MorphController } from \"./text-morph/runtime\";\n\n// --- React Helpers & Hook ----------------------------------------------------\n\nfunction childrenToValue(node: React.ReactNode): number | string {\n  if (typeof node === \"number\") {\n    return node;\n  }\n  return childrenToString(node);\n}\n\nfunction childrenToString(node: React.ReactNode): string {\n  if (typeof node === \"string\") {\n    return node;\n  }\n  if (typeof node === \"number\") {\n    return String(node);\n  }\n  if (!node || typeof node === \"boolean\") {\n    return \"\";\n  }\n  if (Array.isArray(node)) {\n    return node.map(childrenToString).join(\"\");\n  }\n  if (React.isValidElement(node)) {\n    throw new Error(\n      \"TextMorph only accepts text content. Found a React element — use strings, numbers, or expressions instead.\"\n    );\n  }\n  return String(node);\n}\n\nfunction escapeHTML(value: string): string {\n  return value\n    .replace(HTML_AMP_REGEX, \"&amp;\")\n    .replace(HTML_LT_REGEX, \"&lt;\")\n    .replace(HTML_GT_REGEX, \"&gt;\")\n    .replace(HTML_QUOT_REGEX, \"&quot;\")\n    .replace(HTML_APOS_REGEX, \"&#39;\");\n}\n\nexport function useTextMorph(props: TextMorphOptions) {\n  const ref = useRef<HTMLElement | null>(null);\n  const controllerRef = useRef(new MorphController());\n\n  const configKey = MorphController.serializeConfig(props);\n\n  const handlers = useRef(props);\n  handlers.current = props;\n\n  /* oxlint-disable react-hooks/exhaustive-deps -- re-attach only when serialized configKey changes; props identity changes every render */\n  useEffect(() => {\n    const controller = controllerRef.current;\n    if (ref.current) {\n      controller.attach(ref.current, {\n        ...props,\n        onAnimationCancel: () => {\n          handlers.current.onAnimationCancel?.();\n        },\n        onAnimationComplete: () => {\n          handlers.current.onAnimationComplete?.();\n        },\n        onAnimationStart: () => {\n          handlers.current.onAnimationStart?.();\n        },\n      });\n    }\n\n    return () => {\n      controller.destroy();\n    };\n  }, [configKey]);\n  /* oxlint-enable react-hooks/exhaustive-deps */\n\n  const update = useCallback((value: number | string, cursorIndex?: number) => {\n    controllerRef.current.update(value, cursorIndex);\n  }, []);\n\n  return { ref, update };\n}\n\n// --- React Component ---------------------------------------------------------\n\nexport type TextMorphProps = TextMorphOptions & {\n  /**\n   * Semantic HTML element for the root container.\n   * @default \"span\"\n   */\n  as?: ElementType;\n  /**\n   * The text or numeric content to morph between updates.\n   */\n  children: React.ReactNode;\n  /**\n   * CSS class name applied to the root container.\n   */\n  className?: string;\n  /**\n   * Caret position for a single-number value: place matching becomes caret matching.\n   */\n  cursorIndex?: number;\n  /**\n   * Live region politeness when the label updates.\n   * @default \"off\"\n   */\n  live?: \"assertive\" | \"off\" | \"polite\";\n  /**\n   * Forwarded ref to the container element.\n   */\n  ref?: React.Ref<HTMLElement>;\n  /**\n   * Inline styles applied to the root container.\n   */\n  style?: CSSProperties;\n} & Omit<ComponentPropsWithoutRef<\"span\">, \"children\" | \"style\">;\n\nexport function TextMorph({\n  children,\n  cursorIndex,\n  className,\n  style,\n  as: Component = \"span\",\n  charClassName,\n  duration,\n  ease,\n  scale,\n  numbers,\n  decimals,\n  locale,\n  debug,\n  disabled,\n  respectReducedMotion,\n  onAnimationStart,\n  onAnimationComplete,\n  onAnimationCancel,\n  live = \"off\",\n  ref,\n  ...restProps\n}: TextMorphProps) {\n  const morphOptions: TextMorphOptions = {\n    charClassName,\n    debug,\n    decimals,\n    disabled,\n    duration,\n    ease,\n    locale,\n    numbers,\n    onAnimationCancel,\n    onAnimationComplete,\n    onAnimationStart,\n    respectReducedMotion,\n    scale,\n  };\n\n  const { ref: internalRef, update } = useTextMorph(morphOptions);\n  const value = childrenToValue(children);\n  const cursorRef = useRef(cursorIndex);\n  cursorRef.current = cursorIndex;\n\n  const initialHTML = useRef({\n    __html: escapeHTML(String(value)).replace(NEWLINE_GLOBAL_REGEX, \"<br>\"),\n  });\n\n  useEffect(() => {\n    update(value, cursorRef.current);\n  }, [value, update]);\n\n  const setRefs = useCallback(\n    (node: HTMLElement | null) => {\n      internalRef.current = node;\n      if (typeof ref === \"function\") {\n        ref(node);\n      } else if (ref && \"current\" in ref) {\n        (ref as React.MutableRefObject<HTMLElement | null>).current = node;\n      }\n    },\n    [ref, internalRef]\n  );\n\n  const elementProps = {\n    \"aria-live\": live === \"off\" ? undefined : live,\n    className: cn(className),\n    dangerouslySetInnerHTML: initialHTML.current,\n    ref: setRefs,\n    style,\n    ...restProps,\n  };\n\n  return <Component {...elementProps} />;\n}\n\nexport default TextMorph;\n",
      "type": "registry:ui",
      "target": "components/sora-ui/texts/text-morph.tsx"
    },
    {
      "path": "registry/primitives/texts/text-morph/defs.ts",
      "content": "// --- Constants & Regexes -----------------------------------------------------\n\nexport const ATTR_ROOT = \"torph-root\";\nexport const ATTR_ITEM = \"torph-item\";\nexport const ATTR_ID = \"torph-id\";\nexport const ATTR_KIND = \"torph-kind\";\nexport const ATTR_SLOT = \"torph-slot\";\nexport const ATTR_EXITING = \"torph-exiting\";\nexport const ATTR_SR = \"torph-sr\";\nexport const ATTR_DEBUG = \"torph-debug\";\nexport const EMPTY_ID = \"empty\";\n\nexport const WHITESPACE_REGEX = /\\s+/;\nexport const BEZIER_REGEX = /^cubic-bezier\\(([^)]*)\\)$/;\nexport const LINEAR_REGEX = /^linear\\(([^)]*)\\)$/;\nexport const MATRIX_REGEX = /matrix\\(([^)]+)\\)/;\nexport const WORD_SPLIT_REGEX = /([ \\n])/;\nexport const HTML_AMP_REGEX = /&/g;\nexport const HTML_LT_REGEX = /</g;\nexport const HTML_GT_REGEX = />/g;\nexport const HTML_QUOT_REGEX = /\"/g;\nexport const HTML_APOS_REGEX = /'/g;\nexport const NEWLINE_GLOBAL_REGEX = /\\n/g;\n\nexport interface SpringParams {\n  damping?: number;\n  mass?: number;\n  precision?: number;\n  stiffness?: number;\n}\n\nexport interface SpringResult {\n  duration: number;\n  easing: string;\n}\n\nexport type EasingFn = (t: number) => number;\n\n// --- Types ------------------------------------------------------------------\n\nexport type SegmentKind = \"digit\" | \"symbol\";\n\nexport interface Segment {\n  id: string;\n  kind?: SegmentKind;\n  string: string;\n}\n\nexport type NumberSegment = Segment & { kind: SegmentKind };\n\nexport interface BaseMorphOptions {\n  disabled?: boolean;\n  duration?: number;\n  ease?: string | SpringParams;\n  element: HTMLElement;\n  locale?: Intl.LocalesArgument;\n  onAnimationComplete?: () => void;\n  onAnimationStart?: () => void;\n  respectReducedMotion?: boolean;\n}\n\nexport interface TextMorphOptions extends Omit<BaseMorphOptions, \"element\"> {\n  charClassName?: string;\n  debug?: boolean;\n  decimals?: number;\n  numbers?: boolean;\n  onAnimationCancel?: () => void;\n  scale?: boolean;\n}\n\nexport const BASE_DEFAULTS = {\n  disabled: false,\n  duration: 400,\n  ease: \"cubic-bezier(0.19, 1, 0.22, 1)\",\n  locale: \"en\",\n  respectReducedMotion: true,\n} as const;\n\nexport const DEFAULT_TEXT_MORPH_OPTIONS = {\n  ...BASE_DEFAULTS,\n  debug: false,\n  numbers: true,\n  scale: true,\n} as const;\n",
      "type": "registry:lib",
      "target": "components/sora-ui/texts/text-morph/defs.ts"
    },
    {
      "path": "registry/primitives/texts/text-morph/styles.ts",
      "content": "import { ATTR_DEBUG, ATTR_ITEM, ATTR_ROOT, ATTR_SLOT, ATTR_SR } from \"./defs\";\n\nconst TORPH_CSS = `\n[${ATTR_ROOT}] {\n  display: inline-block;\n  position: relative;\n  vertical-align: top;\n  will-change: width, height;\n  white-space: nowrap;\n  text-align: inherit;\n}\n\n[${ATTR_ITEM}]:not(br) {\n  display: inline-block;\n  position: relative;\n  will-change: opacity, transform;\n  transform: none;\n  opacity: 1;\n}\n\n[${ATTR_SR}] {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  margin: -1px;\n  padding: 0;\n  border: 0;\n  overflow: hidden;\n  clip-path: inset(50%);\n  white-space: pre;\n  -webkit-user-select: none;\n  user-select: none;\n}\n\n[${ATTR_SLOT}] {\n  clip-path: inset(0 -100vw);\n}\n\n[${ATTR_SLOT}] > span {\n  display: inline-block;\n  will-change: opacity, transform;\n}\n\n@supports (mask-clip: no-clip) or (-webkit-mask-clip: no-clip) {\n  [${ATTR_SLOT}] {\n    --torph-mask: linear-gradient(\n      to bottom,\n      transparent,\n      #000 var(--torph-fade, 0.15em),\n      #000 calc(100% - var(--torph-fade, 0.15em)),\n      transparent\n    );\n    -webkit-mask-image: var(--torph-mask);\n    mask-image: var(--torph-mask);\n    -webkit-mask-repeat: repeat-x;\n    mask-repeat: repeat-x;\n    -webkit-mask-clip: no-clip;\n    mask-clip: no-clip;\n  }\n}\n\n[${ATTR_ROOT}][${ATTR_DEBUG}] {\n  outline: 2px solid magenta;\n}\n\n[${ATTR_ROOT}][${ATTR_DEBUG}] [${ATTR_ITEM}] {\n  outline: 2px solid cyan;\n  outline-offset: -4px;\n}\n`;\n\nlet styleEl: HTMLStyleElement | null = null;\nlet styleRefCount = 0;\n\nexport function addStyles() {\n  if (typeof document === \"undefined\") {\n    return;\n  }\n  styleRefCount++;\n  if (styleEl) {\n    return;\n  }\n\n  styleEl = document.createElement(\"style\");\n  styleEl.dataset.torph = \"true\";\n  styleEl.textContent = TORPH_CSS;\n  document.head.append(styleEl);\n}\n\nexport function removeStyles() {\n  styleRefCount--;\n  if (styleRefCount > 0 || !styleEl) {\n    return;\n  }\n\n  styleEl.remove();\n  styleEl = null;\n}\n",
      "type": "registry:lib",
      "target": "components/sora-ui/texts/text-morph/styles.ts"
    },
    {
      "path": "registry/primitives/texts/text-morph/easing.ts",
      "content": "import { BEZIER_REGEX, LINEAR_REGEX, WHITESPACE_REGEX } from \"./defs\";\nimport type { EasingFn, SpringParams, SpringResult } from \"./defs\";\n\n// --- Reduced Motion ---------------------------------------------------------\n\nexport interface ReducedMotionState {\n  destroy: () => void;\n  readonly prefersReducedMotion: boolean;\n}\n\nexport function createReducedMotionListener(): ReducedMotionState {\n  if (typeof window === \"undefined\") {\n    return {\n      destroy: () => {\n        // no-op on server\n      },\n      prefersReducedMotion: false,\n    };\n  }\n\n  const mediaQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  const state = { destroy, prefersReducedMotion: mediaQuery.matches };\n\n  function onChange(event: MediaQueryListEvent) {\n    state.prefersReducedMotion = event.matches;\n  }\n\n  function destroy() {\n    mediaQuery.removeEventListener(\"change\", onChange);\n  }\n\n  mediaQuery.addEventListener(\"change\", onChange);\n  return state;\n}\n\n// --- Spring Physics & Easing ------------------------------------------------\n\nfunction springPosition(t: number, omega0: number, zeta: number): number {\n  if (zeta < 1) {\n    const omegaD = omega0 * Math.sqrt(1 - zeta * zeta);\n    return (\n      1 -\n      Math.exp(-zeta * omega0 * t) *\n        (Math.cos(omegaD * t) +\n          ((zeta * omega0) / omegaD) * Math.sin(omegaD * t))\n    );\n  }\n\n  const s = Math.sqrt(zeta * zeta - 1);\n  const r1 = -omega0 * (zeta + s);\n  const r2 = -omega0 * (zeta - s);\n  const B = -r1 / (r2 - r1);\n  const A = 1 - B;\n  return 1 - A * Math.exp(r1 * t) - B * Math.exp(r2 * t);\n}\n\nfunction computeDuration(\n  omega0: number,\n  zeta: number,\n  precision: number\n): number {\n  const step = 0.001;\n  const maxDuration = 10;\n  let settledSince = 0;\n\n  for (let t = 0; t < maxDuration; t += step) {\n    if (Math.abs(springPosition(t, omega0, zeta) - 1) > precision) {\n      settledSince = 0;\n    } else {\n      settledSince += step;\n      if (settledSince > 0.1) {\n        return Math.ceil((t - settledSince + step) * 1000);\n      }\n    }\n  }\n\n  return Math.ceil(maxDuration * 1000);\n}\n\nconst springCache = new Map<string, SpringResult>();\n\nexport function spring(params?: SpringParams): SpringResult {\n  const {\n    stiffness = 100,\n    damping = 10,\n    mass = 1,\n    precision = 0.001,\n  } = params ?? {};\n\n  const key = `${stiffness}:${damping}:${mass}:${precision}`;\n  const cached = springCache.get(key);\n  if (cached) {\n    return cached;\n  }\n\n  const omega0 = Math.sqrt(stiffness / mass);\n  const zeta = damping / (2 * Math.sqrt(stiffness * mass));\n\n  const duration = computeDuration(omega0, zeta, precision);\n  const numPoints = Math.min(100, Math.max(32, Math.round(duration / 15)));\n\n  const points: string[] = [];\n  for (let i = 0; i < numPoints; i++) {\n    const t = (i / (numPoints - 1)) * (duration / 1000);\n    const value = i === numPoints - 1 ? 1 : springPosition(t, omega0, zeta);\n    points.push(String(Math.round(value * 10_000) / 10_000));\n  }\n\n  while (points.length > 2 && points.at(-2) === \"1\") {\n    points.splice(-2, 1);\n  }\n\n  const result: SpringResult = {\n    duration,\n    easing: `linear(${points.join(\", \")})`,\n  };\n\n  springCache.set(key, result);\n  return result;\n}\n\nexport function resolveEase(\n  ease: string | SpringParams | undefined,\n  fallbackDuration: number\n): { duration: number; ease: string } {\n  if (typeof ease === \"object\") {\n    const resolved = spring(ease);\n    return { duration: resolved.duration, ease: resolved.easing };\n  }\n  return {\n    duration: fallbackDuration,\n    ease: ease ?? \"cubic-bezier(0.19, 1, 0.22, 1)\",\n  };\n}\n\nconst KEYWORDS: Record<string, [number, number, number, number]> = {\n  ease: [0.25, 0.1, 0.25, 1],\n  \"ease-in\": [0.42, 0, 1, 1],\n  \"ease-in-out\": [0.42, 0, 0.58, 1],\n  \"ease-out\": [0, 0, 0.58, 1],\n  linear: [0, 0, 1, 1],\n};\n\nconst axis = (p1: number, p2: number, t: number) => {\n  const u = 1 - t;\n  return 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t;\n};\n\nfunction cubicBezier(x1: number, y1: number, x2: number, y2: number): EasingFn {\n  return (t) => {\n    if (t <= 0) {\n      return 0;\n    }\n    if (t >= 1) {\n      return 1;\n    }\n    let lo = 0;\n    let hi = 1;\n    for (let i = 0; i < 24; i += 1) {\n      const mid = (lo + hi) / 2;\n      if (axis(x1, x2, mid) < t) {\n        lo = mid;\n      } else {\n        hi = mid;\n      }\n    }\n    return axis(y1, y2, (lo + hi) / 2);\n  };\n}\n\nfunction fillStops(stops: (number | null)[]): number[] {\n  const filled = [...stops];\n  if (filled[0] === null || filled[0] === undefined) {\n    filled[0] = 0;\n  }\n  if (filled.at(-1) === null) {\n    filled[filled.length - 1] = 1;\n  }\n\n  for (let i = 1; i < filled.length; i += 1) {\n    if (filled[i] !== null) {\n      continue;\n    }\n    let next = i;\n    while (filled[next] === null) {\n      next += 1;\n    }\n    const start = filled[i - 1] ?? 0;\n    const nextVal = filled[next] ?? 1;\n    const step = (nextVal - start) / (next - i + 1);\n    for (let j = i; j < next; j += 1) {\n      filled[j] = start + step * (j - i + 1);\n    }\n  }\n\n  return filled.map((stop, i, all) => {\n    const prev = all[i - 1] ?? 0;\n    const current = stop ?? 0;\n    return i === 0 ? current : Math.max(current, prev);\n  });\n}\n\nfunction linearEasing(body: string): EasingFn | null {\n  const values: number[] = [];\n  const stops: (number | null)[] = [];\n\n  for (const part of body.split(\",\")) {\n    const tokens = part.trim().split(WHITESPACE_REGEX).filter(Boolean);\n    const value = Number(tokens[0]);\n    if (!Number.isFinite(value)) {\n      return null;\n    }\n\n    const percents = tokens.slice(1);\n    if (percents.length > 2) {\n      return null;\n    }\n    if (percents.length === 0) {\n      values.push(value);\n      stops.push(null);\n      continue;\n    }\n    for (const percent of percents) {\n      if (!percent.endsWith(\"%\")) {\n        return null;\n      }\n      const stop = Number(percent.slice(0, -1));\n      if (!Number.isFinite(stop)) {\n        return null;\n      }\n      values.push(value);\n      stops.push(stop / 100);\n    }\n  }\n\n  if (values.length < 2) {\n    return null;\n  }\n  const positions = fillStops(stops);\n\n  return (t) => {\n    const firstPos = positions[0] ?? 0;\n    if (t <= firstPos) {\n      return values[0] ?? 0;\n    }\n    const lastPos = positions.at(-1) ?? 1;\n    if (t >= lastPos) {\n      return values.at(-1) ?? 1;\n    }\n\n    let i = 0;\n    const last = positions.length - 1;\n    while (i < last && (positions[i + 1] ?? 1) < t) {\n      i += 1;\n    }\n    const pNext = positions[i + 1] ?? 1;\n    const pCurrent = positions[i] ?? 0;\n    const vNext = values[i + 1] ?? 1;\n    const vCurrent = values[i] ?? 0;\n    const span = pNext - pCurrent;\n    if (span === 0) {\n      return vNext;\n    }\n    return vCurrent + ((vNext - vCurrent) * (t - pCurrent)) / span;\n  };\n}\n\nexport function parseEasing(ease: string): EasingFn | null {\n  const value = ease.trim().toLowerCase();\n\n  const keyword = KEYWORDS[value];\n  if (keyword) {\n    return cubicBezier(...keyword);\n  }\n\n  const bezier = BEZIER_REGEX.exec(value);\n  if (bezier) {\n    const n = bezier[1]?.split(\",\").map((part) => Number(part.trim())) ?? [];\n    if (\n      n.length !== 4 ||\n      n.some((v) => !Number.isFinite(v)) ||\n      n[0] === undefined ||\n      n[1] === undefined ||\n      n[2] === undefined ||\n      n[3] === undefined\n    ) {\n      return null;\n    }\n    return cubicBezier(n[0], n[1], n[2], n[3]);\n  }\n\n  const linear = LINEAR_REGEX.exec(value);\n  if (linear?.[1]) {\n    return linearEasing(linear[1]);\n  }\n\n  return null;\n}\n\nconst H = 1e-4;\n\nexport function slopeAt(easing: EasingFn, t: number): number {\n  const a = Math.min(Math.max(t, 0), 1 - H);\n  return (easing(a + H) - easing(a)) / H;\n}\n",
      "type": "registry:lib",
      "target": "components/sora-ui/texts/text-morph/easing.ts"
    },
    {
      "path": "registry/primitives/texts/text-morph/layout.ts",
      "content": "import {\n  ATTR_EXITING,\n  ATTR_ID,\n  ATTR_ITEM,\n  ATTR_KIND,\n  ATTR_SLOT,\n  ATTR_SR,\n  MATRIX_REGEX,\n} from \"./defs\";\nimport type { EasingFn, Segment } from \"./defs\";\nimport { parseEasing, slopeAt } from \"./easing\";\n\n// --- DOM Measurement & FLIP -------------------------------------------------\n\nexport function fadeDuration(duration: number, fraction: number): number {\n  return duration * fraction;\n}\n\nexport function parseTranslate(element: HTMLElement): {\n  tx: number;\n  ty: number;\n} {\n  const { transform } = getComputedStyle(element);\n  if (!transform || transform === \"none\") {\n    return { tx: 0, ty: 0 };\n  }\n  const match = transform.match(MATRIX_REGEX);\n  if (!match?.[1]) {\n    return { tx: 0, ty: 0 };\n  }\n  const v = match[1].split(\",\").map(Number);\n  return { tx: v[4] || 0, ty: v[5] || 0 };\n}\n\nexport function layoutSize(element: HTMLElement): {\n  height: number;\n  width: number;\n} {\n  const style = getComputedStyle(element);\n  const width = Number.parseFloat(style.width);\n  const height = Number.parseFloat(style.height);\n  if (Number.isNaN(width) || Number.isNaN(height)) {\n    const rect = element.getBoundingClientRect();\n    return { height: rect.height, width: rect.width };\n  }\n  return { height, width };\n}\n\nexport function cancelAnimations(element: HTMLElement): {\n  opacity: number;\n  tx: number;\n  ty: number;\n} {\n  const { tx, ty } = parseTranslate(element);\n  const opacity = Number(getComputedStyle(element).opacity) || 1;\n  for (const a of element.getAnimations()) {\n    a.cancel();\n  }\n  return { opacity, tx, ty };\n}\n\nexport type Measures = Record<string, { x: number; y: number }>;\n\nexport function measure(element: HTMLElement): Measures {\n  const children = [...element.children] as HTMLElement[];\n  const measures: Measures = {};\n  const rootRect = element.getBoundingClientRect();\n\n  for (let index = 0; index < children.length; index++) {\n    const child = children[index];\n    if (\n      !child ||\n      child.hasAttribute(ATTR_EXITING) ||\n      child.hasAttribute(ATTR_SR) ||\n      child.tagName === \"BR\"\n    ) {\n      continue;\n    }\n    const key = child.getAttribute(ATTR_ID) || `child-${index}`;\n    const rect = child.getBoundingClientRect();\n    const { tx, ty } = parseTranslate(child);\n    measures[key] = {\n      x: rect.left - rootRect.left - tx,\n      y: rect.top - rootRect.top - ty,\n    };\n  }\n\n  return measures;\n}\n\nexport function computeDelta(\n  prev: Measures,\n  current: Measures,\n  key: string\n): { dx: number; dy: number } {\n  const p = prev[key];\n  const c = current[key];\n  if (!(p && c)) {\n    return { dx: 0, dy: 0 };\n  }\n  return { dx: p.x - c.x, dy: p.y - c.y };\n}\n\nexport function findNearestAnchor(\n  targetIndex: number,\n  ids: string[],\n  persistentIds: Set<string>,\n  direction: \"backward-first\" | \"forward-first\" = \"backward-first\"\n): string | null {\n  const [firstDir, secondDir] =\n    direction === \"backward-first\"\n      ? ([\"backward\", \"forward\"] as const)\n      : ([\"forward\", \"backward\"] as const);\n\n  const search = (dir: \"backward\" | \"forward\"): string | null => {\n    if (dir === \"backward\") {\n      for (let j = targetIndex - 1; j >= 0; j--) {\n        const id = ids[j];\n        if (id && persistentIds.has(id)) {\n          return id;\n        }\n      }\n    } else {\n      for (let j = targetIndex + 1; j < ids.length; j++) {\n        const id = ids[j];\n        if (id && persistentIds.has(id)) {\n          return id;\n        }\n      }\n    }\n    return null;\n  };\n\n  return search(firstDir) ?? search(secondDir);\n}\n\nexport function resolveExitingAnchors(\n  oldChildren: HTMLElement[],\n  exiting: Set<HTMLElement>,\n  oldIds: string[],\n  newIds: Set<string>\n): Map<HTMLElement, string | null> {\n  const persistentOldIds = new Set(\n    oldIds.filter((id, i) => {\n      const child = oldChildren[i];\n      return newIds.has(id) && child && !exiting.has(child);\n    })\n  );\n\n  const anchors = new Map<HTMLElement, string | null>();\n  for (let i = 0; i < oldChildren.length; i++) {\n    const child = oldChildren[i];\n    if (!(child && exiting.has(child))) {\n      continue;\n    }\n    anchors.set(\n      child,\n      findNearestAnchor(i, oldIds, persistentOldIds, \"forward-first\")\n    );\n  }\n\n  return anchors;\n}\n\n// --- Container Size Transitions ----------------------------------------------\n\ninterface AxisState {\n  curve: EasingFn | null;\n  easing: string;\n  elapsed: number | null;\n  from: number;\n  to: number;\n  velocity: number;\n}\n\ninterface PendingTransition {\n  onCancel?: () => void;\n  snapshot: () => { height: AxisState; width: AxisState };\n  stop: () => void;\n}\n\ninterface Axis {\n  anim: Animation;\n  curve: EasingFn | null;\n  easing: string;\n  from: number;\n  startedAt: number;\n  to: number;\n}\n\nconst CARRY_MAX = 8;\nconst CARRY_OVERSHOOT = 0.1;\nconst CARRY_MIN_DELTA = 0.5;\nconst SAME_TARGET = 0.5;\n\nconst now = () => performance.now();\n\nexport function carry(\n  base: EasingFn,\n  normalisedVelocity: number\n): { curve: EasingFn; k: number } {\n  const k = Math.max(\n    0,\n    Math.min(CARRY_MAX, normalisedVelocity) - slopeAt(base, 0)\n  );\n  if (k <= 0) {\n    return { curve: base, k: 0 };\n  }\n\n  const bump = Math.max(3, Math.ceil(k / (Math.E * CARRY_OVERSHOOT)) - 1);\n  return {\n    curve: (t) => (t >= 1 ? 1 : base(t) + k * t * (1 - t) ** bump),\n    k,\n  };\n}\n\nexport function sampleEasing(curve: EasingFn, duration: number): string {\n  const points = Math.min(120, Math.max(32, Math.round(duration / 8)));\n  const values: string[] = [];\n  for (let i = 0; i < points; i += 1) {\n    const t = i / (points - 1);\n    const value = i === points - 1 ? 1 : curve(t);\n    values.push(`${Math.round(value * 10_000) / 10_000}`);\n  }\n  return `linear(${values.join(\", \")})`;\n}\n\nfunction animateAxis(\n  element: HTMLElement,\n  property: \"height\" | \"width\",\n  from: number,\n  to: number,\n  previous: AxisState | undefined,\n  duration: number,\n  ease: string,\n  base: EasingFn | null\n): Axis {\n  const run = (\n    fromPos: number,\n    toPos: number,\n    easingVal: string,\n    curveVal: EasingFn | null,\n    seek?: number\n  ): Axis => {\n    const anim = element.animate(\n      [{ [property]: `${fromPos}px` }, { [property]: `${toPos}px` }],\n      { duration, easing: easingVal, fill: \"both\" }\n    );\n    if (seek !== undefined) {\n      anim.currentTime = seek;\n    }\n    return {\n      anim,\n      curve: curveVal,\n      easing: easingVal,\n      from: fromPos,\n      startedAt: now() - (seek ?? 0),\n      to: toPos,\n    };\n  };\n\n  if (\n    previous &&\n    previous.elapsed !== null &&\n    Math.abs(previous.to - to) < SAME_TARGET\n  ) {\n    return run(\n      previous.from,\n      previous.to,\n      previous.easing,\n      previous.curve,\n      previous.elapsed\n    );\n  }\n\n  const delta = to - from;\n  let easing = ease;\n  let curve = base;\n\n  if (base && Math.abs(delta) > CARRY_MIN_DELTA) {\n    const carried = carry(base, ((previous?.velocity ?? 0) * duration) / delta);\n    if (carried.k > 0) {\n      curve = carried.curve;\n      easing = sampleEasing(carried.curve, duration);\n    }\n  }\n\n  return run(from, to, easing, curve);\n}\n\nfunction axisState(axisVal: Axis, duration: number): AxisState {\n  const gone = axisVal.anim.currentTime === null;\n  const run = now() - axisVal.startedAt;\n  const settled = gone || !Number.isFinite(run) || run >= duration || run < 0;\n  const elapsed = settled ? null : run;\n\n  return {\n    curve: axisVal.curve,\n    easing: axisVal.easing,\n    elapsed,\n    from: axisVal.from,\n    to: axisVal.to,\n    velocity:\n      elapsed === null || !axisVal.curve\n        ? 0\n        : ((axisVal.to - axisVal.from) *\n            slopeAt(axisVal.curve, elapsed / duration)) /\n          duration,\n  };\n}\n\nconst pendingContainerTransitions = new WeakMap<\n  HTMLElement,\n  PendingTransition\n>();\n\nfunction restoreSize(element: HTMLElement) {\n  element.style.width = \"\";\n  element.style.height = \"\";\n  element.style.transitionProperty = \"\";\n}\n\nexport function abortContainerTransition(element: HTMLElement) {\n  const entry = pendingContainerTransitions.get(element);\n  if (!entry) {\n    return;\n  }\n  pendingContainerTransitions.delete(element);\n  entry.stop();\n  entry.onCancel?.();\n}\n\nexport function clearContainerTransition(element: HTMLElement) {\n  const entry = pendingContainerTransitions.get(element);\n  if (entry) {\n    pendingContainerTransitions.delete(element);\n    entry.stop();\n  }\n  restoreSize(element);\n}\n\nexport function transitionContainerSize(\n  element: HTMLElement,\n  oldWidth: number,\n  oldHeight: number,\n  duration: number,\n  ease: string,\n  onComplete?: () => void,\n  onCancel?: () => void\n) {\n  const previous = pendingContainerTransitions.get(element)?.snapshot();\n  abortContainerTransition(element);\n\n  if (oldWidth === 0 || oldHeight === 0) {\n    restoreSize(element);\n    onCancel?.();\n    return;\n  }\n\n  element.style.transitionProperty = \"none\";\n  element.style.width = \"\";\n  element.style.height = \"\";\n  if (element.offsetWidth < 0) {\n    // reflow\n  }\n\n  const { width: newWidth, height: newHeight } = layoutSize(element);\n  const base = parseEasing(ease);\n\n  const width = animateAxis(\n    element,\n    \"width\",\n    oldWidth,\n    newWidth,\n    previous?.width,\n    duration,\n    ease,\n    base\n  );\n  const height = animateAxis(\n    element,\n    \"height\",\n    oldHeight,\n    newHeight,\n    previous?.height,\n    duration,\n    ease,\n    base\n  );\n\n  const stop = () => {\n    width.anim.cancel();\n    height.anim.cancel();\n  };\n\n  width.anim.onfinish = () => {\n    pendingContainerTransitions.delete(element);\n    stop();\n    restoreSize(element);\n    onComplete?.();\n  };\n\n  pendingContainerTransitions.set(element, {\n    onCancel,\n    snapshot: () => ({\n      height: axisState(height, duration),\n      width: axisState(width, duration),\n    }),\n    stop,\n  });\n}\n\nexport function holdContainerSize(\n  element: HTMLElement,\n  width: number,\n  height: number,\n  duration: number,\n  onComplete?: () => void,\n  onCancel?: () => void\n) {\n  abortContainerTransition(element);\n\n  element.style.transitionProperty = \"none\";\n  element.style.width = `${width}px`;\n  element.style.height = `${height}px`;\n\n  const timer = setTimeout(() => {\n    pendingContainerTransitions.delete(element);\n    restoreSize(element);\n    onComplete?.();\n  }, duration);\n\n  const held = (value: number): AxisState => ({\n    curve: null,\n    easing: \"linear\",\n    elapsed: null,\n    from: value,\n    to: value,\n    velocity: 0,\n  });\n\n  pendingContainerTransitions.set(element, {\n    onCancel,\n    snapshot: () => ({ height: held(height), width: held(width) }),\n    stop: () => clearTimeout(timer),\n  });\n}\n\n// --- DOM Reconciliation & Helpers --------------------------------------------\n\nfunction layoutOffset(child: HTMLElement, container: HTMLElement) {\n  let x = 0;\n  let y = 0;\n  for (\n    let node: HTMLElement | null = child;\n    node && node !== container;\n    node = node.offsetParent as HTMLElement | null\n  ) {\n    x += node.offsetLeft;\n    y += node.offsetTop;\n  }\n  return { x, y };\n}\n\nfunction createItem(\n  tagName: \"br\" | \"span\",\n  id: string,\n  charClassName?: string\n): HTMLElement {\n  const element = document.createElement(tagName);\n  element.setAttribute(ATTR_ITEM, \"\");\n  element.setAttribute(ATTR_ID, id);\n  element.setAttribute(\"aria-hidden\", \"true\");\n  if (charClassName && tagName !== \"br\") {\n    element.className = charClassName;\n  }\n  return element;\n}\n\nexport function detachFromFlow(\n  container: HTMLElement,\n  elements: HTMLElement[]\n) {\n  const snapshots = new Map<\n    HTMLElement,\n    {\n      height: number;\n      left: number;\n      opacity: number;\n      top: number;\n      width: number;\n    }\n  >();\n\n  for (const child of elements) {\n    if (child.tagName === \"BR\") {\n      continue;\n    }\n    const { x, y } = layoutOffset(child, container);\n    const { tx, ty } = parseTranslate(child);\n    const { width, height } = layoutSize(child);\n    const opacity = Number(getComputedStyle(child).opacity) || 1;\n    for (const a of child.getAnimations()) {\n      a.cancel();\n    }\n    snapshots.set(child, {\n      height,\n      left: x + tx,\n      opacity,\n      top: y + ty,\n      width,\n    });\n  }\n\n  for (let i = elements.length - 1; i >= 0; i--) {\n    const el = elements[i];\n    if (el && el.tagName === \"BR\") {\n      el.remove();\n      elements.splice(i, 1);\n    }\n  }\n\n  for (const child of elements) {\n    const snap = snapshots.get(child);\n    if (!snap) {\n      continue;\n    }\n    child.setAttribute(ATTR_EXITING, \"\");\n    child.style.position = \"absolute\";\n    child.style.pointerEvents = \"none\";\n    child.style.left = `${snap.left}px`;\n    child.style.top = `${snap.top}px`;\n    child.style.width = `${snap.width}px`;\n    child.style.height = `${snap.height}px`;\n    child.style.opacity = String(snap.opacity);\n  }\n}\n\nfunction syncSlot(element: HTMLElement, segment: Segment) {\n  if (!segment.kind) {\n    element.removeAttribute(ATTR_KIND);\n    element.removeAttribute(ATTR_SLOT);\n    element.textContent = segment.string;\n    return;\n  }\n\n  element.setAttribute(ATTR_KIND, segment.kind);\n  element.setAttribute(ATTR_SLOT, \"\");\n\n  let inner = element.firstElementChild as HTMLElement | null;\n  if (!inner) {\n    element.textContent = \"\";\n    inner = document.createElement(\"span\");\n    element.append(inner);\n  }\n  inner.textContent = segment.string;\n}\n\nexport function moverOf(element: HTMLElement): HTMLElement {\n  return element.hasAttribute(ATTR_SLOT)\n    ? ((element.firstElementChild as HTMLElement | null) ?? element)\n    : element;\n}\n\nexport function splitWordSpans(\n  element: HTMLElement,\n  splits: Map<string, Segment[]>,\n  charClassName?: string\n) {\n  if (splits.size === 0) {\n    return;\n  }\n\n  const children = [...element.children] as HTMLElement[];\n  const split = new Set<string>();\n\n  for (const child of children) {\n    if (child.hasAttribute(ATTR_EXITING)) {\n      continue;\n    }\n    const id = child.getAttribute(ATTR_ID);\n    if (!id || split.has(id)) {\n      continue;\n    }\n    const charSegs = splits.get(id);\n    if (!charSegs) {\n      continue;\n    }\n    split.add(id);\n\n    for (const seg of charSegs) {\n      const span = createItem(\"span\", seg.id, charClassName);\n      syncSlot(span, seg);\n      child.before(span);\n    }\n    child.remove();\n  }\n}\n\n// oxlint-disable-next-line sonarjs/cognitive-complexity -- DOM reconciliation logic\nexport function reconcileChildren(\n  element: HTMLElement,\n  oldChildren: HTMLElement[],\n  newIds: Set<string>,\n  segments: Segment[],\n  charClassName?: string\n) {\n  const reusable = new Map<string, HTMLElement>();\n  for (const child of oldChildren) {\n    const id = child.getAttribute(ATTR_ID) as string;\n    if (newIds.has(id) && !child.hasAttribute(ATTR_EXITING)) {\n      reusable.set(id, child);\n      child.remove();\n    }\n  }\n\n  const textNodes: ChildNode[] = [];\n  for (const node of element.childNodes) {\n    if (node.nodeType === Node.TEXT_NODE) {\n      textNodes.push(node);\n    }\n  }\n  for (const node of textNodes) {\n    node.remove();\n  }\n\n  for (const segment of segments) {\n    const existing = reusable.get(segment.id);\n    if (existing) {\n      reusable.delete(segment.id);\n    }\n\n    if (segment.string === \"\\n\") {\n      if (existing && existing.tagName === \"BR\") {\n        element.append(existing);\n      } else {\n        element.append(createItem(\"br\", segment.id));\n      }\n      continue;\n    }\n\n    if (existing && existing.tagName !== \"BR\") {\n      existing.style.transformOrigin = \"\";\n      if (charClassName) {\n        existing.className = charClassName;\n      }\n      syncSlot(existing, segment);\n      element.append(existing);\n    } else {\n      const span = createItem(\"span\", segment.id, charClassName);\n      syncSlot(span, segment);\n      element.append(span);\n    }\n  }\n}\n",
      "type": "registry:lib",
      "target": "components/sora-ui/texts/text-morph/layout.ts"
    },
    {
      "path": "registry/primitives/texts/text-morph/segments.ts",
      "content": "import { WORD_SPLIT_REGEX } from \"./defs\";\nimport type { NumberSegment, Segment, SegmentKind } from \"./defs\";\n\n// --- LCS (Longest Common Subsequence) ----------------------------------------\n\n// oxlint-disable-next-line sonarjs/cognitive-complexity -- LCS dynamic programming algorithm\nexport function lcsIndices(a: string[], b: string[]): [number[], number[]] {\n  const m = a.length;\n  const n = b.length;\n  const dp: number[][] = Array.from({ length: m + 1 }, () =>\n    Array.from({ length: n + 1 }, () => 0)\n  );\n\n  for (let i = m - 1; i >= 0; i--) {\n    const row = dp[i];\n    const nextRow = dp[i + 1];\n    if (!(row && nextRow)) {\n      continue;\n    }\n    for (let j = n - 1; j >= 0; j--) {\n      row[j] =\n        a[i] === b[j]\n          ? (nextRow[j + 1] ?? 0) + 1\n          : Math.max(nextRow[j] ?? 0, row[j + 1] ?? 0);\n    }\n  }\n\n  const ai: number[] = [];\n  const bi: number[] = [];\n  let i = 0;\n  let j = 0;\n  while (i < m && j < n) {\n    const nextRowVal = dp[i + 1]?.[j] ?? 0;\n    const nextColVal = dp[i]?.[j + 1] ?? 0;\n    if (a[i] === b[j]) {\n      ai.push(i);\n      bi.push(j);\n      i++;\n      j++;\n    } else if (nextRowVal >= nextColVal) {\n      i++;\n    } else {\n      j++;\n    }\n  }\n\n  return [ai, bi];\n}\n\n// --- Numbers & Formatting ----------------------------------------------------\n\nconst MINTED_PREFIX = \"\\u0000n\";\nlet nextNewId = 0;\n\nfunction mintId(): string {\n  return `${MINTED_PREFIX}${nextNewId++}`;\n}\n\nexport function isDigit(char: string): boolean {\n  return char >= \"0\" && char <= \"9\";\n}\n\nexport function hasDigit(value: string): boolean {\n  for (const char of value) {\n    if (isDigit(char)) {\n      return true;\n    }\n  }\n  return false;\n}\n\nconst CORE_SEPARATORS = \".,'\\u00A0\\u202F\\u2009\\u2007\";\nconst PREFIX_CHARS = \"+-\\u2212(#\";\nconst SUFFIX_CHARS = \"%.,!?:;)\\\"'\\u201D\\u2019\";\nconst CURRENCY = /\\p{Sc}/u;\n\nfunction isAffix(char: string, set: string): boolean {\n  return set.includes(char) || CURRENCY.test(char);\n}\n\nexport function numericSkeleton(word: string): string {\n  let out = \"\";\n  for (const char of word) {\n    if (!(isDigit(char) || CORE_SEPARATORS.includes(char))) {\n      out += char;\n    }\n  }\n  return out;\n}\n\nexport function isNumericWord(word: string): boolean {\n  let start = 0;\n  let end = word.length;\n\n  while (start < end && isAffix(word[start] ?? \"\", PREFIX_CHARS)) {\n    start++;\n  }\n  while (end > start && isAffix(word[end - 1] ?? \"\", SUFFIX_CHARS)) {\n    end--;\n  }\n\n  if (start >= end) {\n    return false;\n  }\n  if (!(isDigit(word[start] ?? \"\") && isDigit(word[end - 1] ?? \"\"))) {\n    return false;\n  }\n\n  for (let i = start; i < end; i++) {\n    const char = word[i] ?? \"\";\n    if (!(isDigit(char) || CORE_SEPARATORS.includes(char))) {\n      return false;\n    }\n  }\n\n  return true;\n}\n\nexport function classifyKind(char: string): SegmentKind {\n  return isDigit(char) ? \"digit\" : \"symbol\";\n}\n\nconst separators = new Map<string, string>();\n\nexport function decimalSeparator(locale: Intl.LocalesArgument): string {\n  const key = String(locale);\n  const cached = separators.get(key);\n  if (cached) {\n    return cached;\n  }\n\n  let separator = \".\";\n  try {\n    separator =\n      new Intl.NumberFormat(locale)\n        .formatToParts(1.1)\n        .find((part) => part.type === \"decimal\")?.value ?? \".\";\n  } catch {\n    // fallback\n  }\n\n  separators.set(key, separator);\n  return separator;\n}\n\nfunction isGrouping(char: string, decimalChar: string): boolean {\n  return char !== decimalChar && CORE_SEPARATORS.includes(char);\n}\n\nfunction keptIndices(chars: string[], decimalChar: string): number[] {\n  const indices: number[] = [];\n  for (let i = 0; i < chars.length; i++) {\n    const char = chars[i] ?? \"\";\n    if (!isGrouping(char, decimalChar)) {\n      indices.push(i);\n    }\n  }\n  return indices;\n}\n\nfunction groupingIndices(chars: string[], decimalChar: string): number[] {\n  const indices: number[] = [];\n  for (let i = 0; i < chars.length; i++) {\n    const char = chars[i] ?? \"\";\n    if (isGrouping(char, decimalChar)) {\n      indices.push(i);\n    }\n  }\n  return indices;\n}\n\n// oxlint-disable-next-line sonarjs/cognitive-complexity -- cursor caret matching\nfunction cursorMatch(\n  oldChars: string[],\n  newChars: string[],\n  cursor: number,\n  decimalChar: string\n): Map<number, number> {\n  const matches = new Map<number, number>();\n\n  const oldKept = keptIndices(oldChars, decimalChar);\n  const newKept = keptIndices(newChars, decimalChar);\n  const pair = (ni: number, oi: number) => {\n    const nIndex = newKept[ni];\n    const oIndex = oldKept[oi];\n    if (nIndex !== undefined && oIndex !== undefined) {\n      matches.set(nIndex, oIndex);\n    }\n  };\n\n  let keptCursor = 0;\n  while (keptCursor < newKept.length && (newKept[keptCursor] ?? 0) < cursor) {\n    keptCursor++;\n  }\n\n  const lenDiff = newKept.length - oldKept.length;\n\n  if (lenDiff > 0) {\n    const editStart = keptCursor - lenDiff;\n    for (let i = 0; i < editStart && i < oldKept.length; i++) {\n      pair(i, i);\n    }\n    for (let i = keptCursor; i < newKept.length; i++) {\n      const oldIdx = i - lenDiff;\n      if (oldIdx >= 0 && oldIdx < oldKept.length) {\n        pair(i, oldIdx);\n      }\n    }\n  } else if (lenDiff < 0) {\n    for (let i = 0; i < keptCursor && i < newKept.length; i++) {\n      pair(i, i);\n    }\n    for (let i = keptCursor; i < newKept.length; i++) {\n      const oldIdx = i - lenDiff;\n      if (oldIdx >= 0 && oldIdx < oldKept.length) {\n        pair(i, oldIdx);\n      }\n    }\n  } else {\n    for (let i = 0; i < newKept.length; i++) {\n      const nIdx = newKept[i];\n      const oIdx = oldKept[i];\n      if (\n        nIdx !== undefined &&\n        oIdx !== undefined &&\n        newChars[nIdx] === oldChars[oIdx]\n      ) {\n        pair(i, i);\n      }\n    }\n  }\n\n  const oldSeps = groupingIndices(oldChars, decimalChar);\n  const newSeps = groupingIndices(newChars, decimalChar);\n  for (let k = 1; k <= oldSeps.length && k <= newSeps.length; k++) {\n    const oldIdx = oldSeps.at(-k);\n    const newIdx = newSeps.at(-k);\n    if (\n      oldIdx !== undefined &&\n      newIdx !== undefined &&\n      oldChars[oldIdx] === newChars[newIdx]\n    ) {\n      matches.set(newIdx, oldIdx);\n    }\n  }\n\n  return matches;\n}\n\nconst MAGNITUDE_JUMP = 3;\n\nfunction digitIndices(chars: string[], from: number, to: number): number[] {\n  const indices: number[] = [];\n  for (let i = from; i < to; i++) {\n    const char = chars[i] ?? \"\";\n    if (isDigit(char)) {\n      indices.push(i);\n    }\n  }\n  return indices;\n}\n\nfunction integerDigits(chars: string[], start: number, pivot: number): number {\n  let count = 0;\n  for (let i = start; i < pivot; i++) {\n    const char = chars[i] ?? \"\";\n    if (isDigit(char)) {\n      count++;\n    }\n  }\n  return count;\n}\n\nfunction findPivot(\n  chars: string[],\n  start: number,\n  end: number,\n  decimalChar: string\n): number {\n  for (let i = end - 1; i >= start; i--) {\n    if (chars[i] === decimalChar) {\n      return i;\n    }\n  }\n  return end;\n}\n\nfunction placeMatch(\n  oldChars: string[],\n  newChars: string[],\n  decimalChar: string\n): Map<number, number> {\n  const matches = new Map<number, number>();\n\n  let start = 0;\n  while (\n    start < oldChars.length &&\n    start < newChars.length &&\n    oldChars[start] === newChars[start] &&\n    !isDigit(oldChars[start] ?? \"\")\n  ) {\n    matches.set(start, start);\n    start++;\n  }\n\n  let oldEnd = oldChars.length;\n  let newEnd = newChars.length;\n  while (\n    oldEnd > start &&\n    newEnd > start &&\n    oldChars[oldEnd - 1] === newChars[newEnd - 1] &&\n    !isDigit(oldChars[oldEnd - 1] ?? \"\")\n  ) {\n    matches.set(newEnd - 1, oldEnd - 1);\n    oldEnd--;\n    newEnd--;\n  }\n\n  const oldPivot = findPivot(oldChars, start, oldEnd, decimalChar);\n  const newPivot = findPivot(newChars, start, newEnd, decimalChar);\n\n  const oldDigits = integerDigits(oldChars, start, oldPivot);\n  const newDigits = integerDigits(newChars, start, newPivot);\n\n  if (\n    oldDigits > 0 &&\n    newDigits > 0 &&\n    Math.abs(oldDigits - newDigits) >= MAGNITUDE_JUMP\n  ) {\n    return matches;\n  }\n\n  function matchSeparator(oldIndex: number, newIndex: number) {\n    const char = oldChars[oldIndex] ?? \"\";\n    if (isDigit(char)) {\n      return;\n    }\n    if (char === newChars[newIndex]) {\n      matches.set(newIndex, oldIndex);\n    }\n  }\n\n  // oxlint-disable-next-line sonarjs/cognitive-complexity -- digit matching with LCS\n  function matchDigits(\n    oldFrom: number,\n    oldTo: number,\n    newFrom: number,\n    newTo: number,\n    towardsPivot: boolean\n  ): boolean {\n    const oldIndices = digitIndices(oldChars, oldFrom, oldTo);\n    const newIndices = digitIndices(newChars, newFrom, newTo);\n\n    if (oldIndices.length === newIndices.length || !towardsPivot) {\n      const pairs = Math.min(oldIndices.length, newIndices.length);\n      for (let k = 0; k < pairs; k++) {\n        const oi = oldIndices[k];\n        const ni = newIndices[k];\n        if (\n          oi !== undefined &&\n          ni !== undefined &&\n          oldChars[oi] === newChars[ni]\n        ) {\n          matches.set(ni, oi);\n        }\n      }\n      return false;\n    }\n\n    const oldRun = oldIndices.map((i) => oldChars[i] ?? \"\").toReversed();\n    const newRun = newIndices.map((i) => newChars[i] ?? \"\").toReversed();\n    const [ai, bi] = lcsIndices(oldRun, newRun);\n\n    for (let k = 0; k < ai.length; k++) {\n      const aIdx = ai[k];\n      const bIdx = bi[k];\n      if (aIdx !== undefined && bIdx !== undefined) {\n        const oi = oldIndices.at(-1 - aIdx);\n        const ni = newIndices.at(-1 - bIdx);\n        if (oi !== undefined && ni !== undefined) {\n          matches.set(ni, oi);\n        }\n      }\n    }\n\n    return ai.length > 0;\n  }\n\n  const reshaped = matchDigits(start, oldPivot, start, newPivot, true);\n  if (!reshaped) {\n    for (let k = 1; oldPivot - k >= start && newPivot - k >= start; k++) {\n      matchSeparator(oldPivot - k, newPivot - k);\n    }\n  }\n\n  if (oldPivot < oldEnd && newPivot < newEnd) {\n    matches.set(newPivot, oldPivot);\n\n    for (let k = 1; oldPivot + k < oldEnd && newPivot + k < newEnd; k++) {\n      matchSeparator(oldPivot + k, newPivot + k);\n    }\n    matchDigits(oldPivot + 1, oldEnd, newPivot + 1, newEnd, false);\n  }\n\n  return matches;\n}\n\nfunction simpleSegment(chars: string[]): NumberSegment[] {\n  return chars.map((char) => ({\n    id: mintId(),\n    kind: classifyKind(char),\n    string: char === \" \" ? \"\\u00A0\" : char,\n  }));\n}\n\n// oxlint-disable-next-line sonarjs/cognitive-complexity -- number segmentation logic\nexport function segmentNumber(\n  value: string,\n  prevSegments?: NumberSegment[],\n  cursorIndex?: number,\n  decimalChar = \".\"\n): NumberSegment[] {\n  const chars = value.split(\"\");\n\n  if (!prevSegments || prevSegments.length === 0) {\n    return simpleSegment(chars);\n  }\n\n  const oldChars = prevSegments.map((s) =>\n    s.string === \"\\u00A0\" ? \" \" : s.string\n  );\n\n  const matches =\n    cursorIndex === undefined\n      ? placeMatch(oldChars, chars, decimalChar)\n      : cursorMatch(oldChars, chars, cursorIndex, decimalChar);\n\n  const usedIds = new Set<string>();\n  for (const [, oldIdx] of matches) {\n    const prevSeg = prevSegments[oldIdx];\n    if (prevSeg) {\n      usedIds.add(prevSeg.id);\n    }\n  }\n\n  const result: NumberSegment[] = [];\n\n  for (let i = 0; i < chars.length; i++) {\n    const char = chars[i] ?? \"\";\n    const kind = classifyKind(char);\n    const displayChar = char === \" \" ? \"\\u00A0\" : char;\n\n    if (matches.has(i)) {\n      const oldIdx = matches.get(i);\n      const prevSeg = oldIdx === undefined ? undefined : prevSegments[oldIdx];\n      result.push({\n        id: prevSeg ? prevSeg.id : mintId(),\n        kind,\n        string: displayChar,\n      });\n    } else {\n      let id = mintId();\n      while (usedIds.has(id)) {\n        id = mintId();\n      }\n      usedIds.add(id);\n      result.push({ id, kind, string: displayChar });\n    }\n  }\n\n  return result;\n}\n\n// --- Text Segmentation & Diffing ---------------------------------------------\n\nexport function createIdAllocator() {\n  const used = new Set<string>();\n\n  return {\n    has(id: string) {\n      return used.has(id);\n    },\n    reserve(id: string) {\n      used.add(id);\n    },\n    take(base: string): string {\n      if (!used.has(base)) {\n        used.add(base);\n        return base;\n      }\n      let i = 1;\n      while (used.has(`${base}~${i}`)) {\n        i++;\n      }\n      const id = `${base}~${i}`;\n      used.add(id);\n      return id;\n    },\n  };\n}\n\nexport type IdAllocator = ReturnType<typeof createIdAllocator>;\n\nexport function groupIntoWords(segments: Segment[]): {\n  segments: Segment[];\n  word: string;\n}[] {\n  const groups: { segments: Segment[]; word: string }[] = [];\n  let current: Segment[] = [];\n\n  const flush = () => {\n    if (current.length === 0) {\n      return;\n    }\n    groups.push({\n      segments: current,\n      word: current.map((s) => s.string).join(\"\"),\n    });\n    current = [];\n  };\n\n  for (const seg of segments) {\n    if (seg.string === \"\\u00A0\" || seg.string === \"\\n\") {\n      flush();\n    } else {\n      current.push(seg);\n    }\n  }\n  flush();\n\n  return groups;\n}\n\nfunction expandNumbers(segments: Segment[]): Segment[] {\n  const out: Segment[] = [];\n  let run: Segment[] = [];\n\n  const flush = () => {\n    if (run.length === 0) {\n      return;\n    }\n    const word = run.map((s) => s.string).join(\"\");\n    if (isNumericWord(word)) {\n      out.push(...segmentNumber(word));\n    } else {\n      out.push(...run);\n    }\n    run = [];\n  };\n\n  for (const seg of segments) {\n    if (seg.string === \"\\u00A0\" || seg.string === \"\\n\") {\n      flush();\n      out.push(seg);\n    } else {\n      run.push(seg);\n    }\n  }\n  flush();\n\n  return out;\n}\n\nfunction allocSegmentId(\n  part: string,\n  index: number,\n  alloc: IdAllocator\n): string {\n  return alloc.has(part) ? alloc.take(`${part}-${index}`) : alloc.take(part);\n}\n\nfunction segmentsFromIntl(\n  iterator: Intl.SegmentIterator<Intl.SegmentData>,\n  offset: number,\n  alloc: IdAllocator\n): Segment[] {\n  const segments: Segment[] = [];\n\n  for (const data of iterator) {\n    const index = offset + data.index;\n    if (data.segment === \" \") {\n      segments.push({ id: alloc.take(`space-${index}`), string: \"\\u00A0\" });\n    } else {\n      segments.push({\n        id: allocSegmentId(data.segment, index, alloc),\n        string: data.segment,\n      });\n    }\n  }\n\n  return segments;\n}\n\nfunction segmentsFallback(\n  value: string,\n  byWord: boolean,\n  offset: number,\n  alloc: IdAllocator\n): Segment[] {\n  const parts = byWord ? value.split(\" \") : value.split(\"\");\n  const segments: Segment[] = [];\n  let index = offset;\n\n  for (const [i, part] of parts.entries()) {\n    if (byWord && i > 0) {\n      segments.push({ id: alloc.take(`space-${index}`), string: \"\\u00A0\" });\n      index += 1;\n    }\n    segments.push({ id: allocSegmentId(part, index, alloc), string: part });\n    index += part.length;\n  }\n\n  return segments;\n}\n\nfunction segmentLine(\n  line: string,\n  locale: Intl.LocalesArgument,\n  byWord: boolean,\n  offset: number,\n  alloc: IdAllocator\n): Segment[] {\n  if (Intl.Segmenter !== undefined) {\n    const segmenter = new Intl.Segmenter(locale, {\n      granularity: byWord ? \"word\" : \"grapheme\",\n    });\n    return segmentsFromIntl(\n      segmenter.segment(line)[Symbol.iterator](),\n      offset,\n      alloc\n    );\n  }\n\n  return segmentsFallback(line, byWord, offset, alloc);\n}\n\nexport function segmentText(\n  value: string,\n  locale: Intl.LocalesArgument,\n  numbers = true\n): Segment[] {\n  const hasNewlines = value.includes(\"\\n\");\n  const byWord = value.includes(\" \") || hasNewlines;\n  const alloc = createIdAllocator();\n\n  if (hasNewlines) {\n    const lines = value.split(\"\\n\");\n    const allSegments: Segment[] = [];\n    let offset = 0;\n\n    for (const [lineIndex, line] of lines.entries()) {\n      if (lineIndex > 0) {\n        allSegments.push({\n          id: alloc.take(`newline-${offset}`),\n          string: \"\\n\",\n        });\n        offset += 1;\n      }\n      if (line.length > 0) {\n        allSegments.push(...segmentLine(line, locale, true, offset, alloc));\n      }\n      offset += line.length;\n    }\n\n    return numbers ? expandNumbers(allSegments) : allSegments;\n  }\n\n  const segments = segmentLine(value, locale, byWord, 0, alloc);\n  return numbers ? expandNumbers(segments) : segments;\n}\n\nconst NUMBER_TOKEN = \"\\u0000#\";\n\nfunction splitIfWhole(\n  oldGroup: { segments: Segment[]; word: string },\n  splits: Map<string, Segment[]>\n): Segment[] {\n  if (oldGroup.segments.length !== 1 || oldGroup.word.length <= 1) {\n    return oldGroup.segments;\n  }\n\n  const wordSeg = oldGroup.segments[0];\n  if (!wordSeg) {\n    return oldGroup.segments;\n  }\n\n  const charSegs = oldGroup.word.split(\"\").map((char, i) => ({\n    id: `${wordSeg.id}:${i}`,\n    string: char,\n  }));\n  splits.set(wordSeg.id, charSegs);\n\n  return charSegs;\n}\n\nfunction asNumberSegments(segments: Segment[]): NumberSegment[] {\n  return segments.map((seg) => ({\n    ...seg,\n    kind: seg.kind ?? classifyKind(seg.string),\n  }));\n}\n\ntype WordPlan =\n  | { mode: \"fresh\" }\n  | { mode: \"morph\"; oi: number }\n  | { mode: \"number\"; oi: number }\n  | { mode: \"reuse\"; oi: number };\n\nfunction charSimilarity(a: string, b: string): number {\n  if (a.length === 0 || b.length === 0) {\n    return 0;\n  }\n  const [matched] = lcsIndices(a.split(\"\"), b.split(\"\"));\n  return matched.length / Math.max(a.length, b.length);\n}\n\nfunction gapIndices(count: number, matched: Set<number>): number[] {\n  const gaps: number[] = [];\n  let anchors = 0;\n  for (let i = 0; i < count; i++) {\n    gaps.push(anchors);\n    if (matched.has(i)) {\n      anchors++;\n    }\n  }\n  return gaps;\n}\n\nconst MIN_SIMILARITY = 0.4;\n\nfunction pairAffinity(a: string, b: string): number {\n  if (\n    (hasDigit(a) || hasDigit(b)) &&\n    numericSkeleton(a) === numericSkeleton(b)\n  ) {\n    return 1;\n  }\n  return charSimilarity(a, b);\n}\n\nconst MAX_MORPH_PAIRINGS = 2500;\nconst MAX_LCS_CELLS = 1_000_000;\n\nexport interface DiffResult {\n  segments: Segment[];\n  splits: Map<string, Segment[]>;\n}\n\nexport interface DiffOptions {\n  cursorIndex?: number;\n  numbers?: boolean;\n}\n\n// oxlint-disable-next-line sonarjs/cognitive-complexity -- text and number diffing engine\nexport function diffSegments(\n  oldSegments: Segment[],\n  newText: string,\n  locale: Intl.LocalesArgument,\n  options: DiffOptions = {}\n): DiffResult {\n  const newHasSpaces = newText.includes(\" \");\n  const newHasNewlines = newText.includes(\"\\n\");\n  const oldWords = groupIntoWords(oldSegments);\n\n  const numbersOn = options.numbers !== false;\n  const isNum = (word: string) => numbersOn && isNumericWord(word);\n  const token = (word: string) => (isNum(word) ? NUMBER_TOKEN : word);\n\n  const digitsInvolved =\n    numbersOn && (hasDigit(newText) || oldWords.some((g) => hasDigit(g.word)));\n\n  if (\n    oldWords.length <= 1 &&\n    !newHasSpaces &&\n    !newHasNewlines &&\n    !digitsInvolved\n  ) {\n    return {\n      segments: segmentText(newText, locale, numbersOn),\n      splits: new Map(),\n    };\n  }\n\n  const newWordStrings: string[] = [];\n  const newSeparators: string[][] = [];\n  const parts = newText.split(WORD_SPLIT_REGEX);\n  let pendingSeps: string[] = [];\n  for (const part of parts) {\n    if (part === \" \" || part === \"\\n\") {\n      pendingSeps.push(part);\n    } else if (part.length > 0) {\n      newSeparators.push(pendingSeps);\n      newWordStrings.push(part);\n      pendingSeps = [];\n    }\n  }\n  const trailingSeparators = pendingSeps;\n\n  const oldWordStrings = oldWords.map((g) => g.word);\n\n  if (oldWordStrings.length * newWordStrings.length > MAX_LCS_CELLS) {\n    return {\n      segments: segmentText(newText, locale, numbersOn),\n      splits: new Map(),\n    };\n  }\n\n  const [oldLcsIdx, newLcsIdx] = lcsIndices(\n    oldWordStrings.map(token),\n    newWordStrings.map(token)\n  );\n  const oldMatchedSet = new Set(oldLcsIdx);\n  const newMatchedSet = new Set(newLcsIdx);\n\n  const newToOldWord = new Map<number, number>();\n  for (let k = 0; k < newLcsIdx.length; k++) {\n    const nIndex = newLcsIdx[k];\n    const oIndex = oldLcsIdx[k];\n    if (nIndex !== undefined && oIndex !== undefined) {\n      newToOldWord.set(nIndex, oIndex);\n    }\n  }\n\n  let oldUnmatched = oldWordStrings.flatMap((_, i) =>\n    oldMatchedSet.has(i) ? [] : [i]\n  );\n  let newUnmatched = newWordStrings.flatMap((_, i) =>\n    newMatchedSet.has(i) ? [] : [i]\n  );\n\n  const exactUsed = new Set<number>();\n  for (const ni of newUnmatched) {\n    for (const oi of oldUnmatched) {\n      if (exactUsed.has(oi)) {\n        continue;\n      }\n      const newW = newWordStrings[ni];\n      const oldW = oldWordStrings[oi];\n      if (newW && oldW && token(newW) === token(oldW)) {\n        newToOldWord.set(ni, oi);\n        exactUsed.add(oi);\n        break;\n      }\n    }\n  }\n  if (exactUsed.size > 0) {\n    oldUnmatched = oldUnmatched.filter((i) => !exactUsed.has(i));\n    newUnmatched = newUnmatched.filter((i) => !newToOldWord.has(i));\n  }\n\n  const morphPairs = new Map<number, number>();\n  const usedOld = new Set<number>();\n\n  const oldGaps = gapIndices(oldWordStrings.length, oldMatchedSet);\n  const newGaps = gapIndices(newWordStrings.length, newMatchedSet);\n\n  if (oldUnmatched.length * newUnmatched.length <= MAX_MORPH_PAIRINGS) {\n    for (const ni of newUnmatched) {\n      let bestOi = -1;\n      let bestSim = MIN_SIMILARITY;\n\n      for (const oi of oldUnmatched) {\n        if (usedOld.has(oi)) {\n          continue;\n        }\n        if (oldGaps[oi] !== newGaps[ni]) {\n          continue;\n        }\n        const oldW = oldWordStrings[oi] ?? \"\";\n        const newW = newWordStrings[ni] ?? \"\";\n        const sim = pairAffinity(oldW, newW);\n        if (sim > bestSim) {\n          bestSim = sim;\n          bestOi = oi;\n        }\n      }\n\n      if (bestOi >= 0) {\n        morphPairs.set(ni, bestOi);\n        usedOld.add(bestOi);\n      }\n    }\n  }\n\n  const plans: WordPlan[] = newWordStrings.map((newWord, ni): WordPlan => {\n    const lcsOi = newToOldWord.get(ni);\n    const oi = lcsOi ?? morphPairs.get(ni);\n    if (oi === undefined) {\n      return { mode: \"fresh\" };\n    }\n    if (isNum(newWord)) {\n      return { mode: \"number\", oi };\n    }\n    return lcsOi === undefined ? { mode: \"morph\", oi } : { mode: \"reuse\", oi };\n  });\n\n  const cursorIndex =\n    plans.filter((plan) => plan.mode === \"number\").length === 1\n      ? options.cursorIndex\n      : undefined;\n  const decimalChar = decimalSeparator(locale);\n\n  const alloc = createIdAllocator();\n\n  for (const plan of plans) {\n    if (plan.mode === \"fresh\") {\n      continue;\n    }\n    const oldGroup = oldWords[plan.oi];\n    if (!oldGroup) {\n      continue;\n    }\n\n    if (plan.mode !== \"reuse\" && oldGroup.segments.length === 1) {\n      const wordSeg = oldGroup.segments[0];\n      if (wordSeg) {\n        for (let i = 0; i < oldGroup.word.length; i++) {\n          alloc.reserve(`${wordSeg.id}:${i}`);\n        }\n      }\n    } else {\n      for (const seg of oldGroup.segments) {\n        alloc.reserve(seg.id);\n      }\n    }\n  }\n\n  const segments: Segment[] = [];\n  const splits = new Map<string, Segment[]>();\n  let charOffset = 0;\n\n  function pushSeparators(seps: string[]) {\n    for (const sep of seps) {\n      if (sep === \"\\n\") {\n        segments.push({\n          id: alloc.take(`newline-${charOffset}`),\n          string: \"\\n\",\n        });\n      } else {\n        segments.push({\n          id: alloc.take(`space-${charOffset}`),\n          string: \"\\u00A0\",\n        });\n      }\n      charOffset++;\n    }\n  }\n\n  for (let ni = 0; ni < newWordStrings.length; ni++) {\n    pushSeparators(newSeparators[ni] ?? (ni > 0 ? [\" \"] : []));\n\n    const plan = plans[ni];\n    const newWord = newWordStrings[ni] ?? \"\";\n\n    if (plan && plan.mode === \"reuse\") {\n      const oldGroup = oldWords[plan.oi];\n      if (oldGroup) {\n        for (const seg of oldGroup.segments) {\n          segments.push(seg);\n        }\n      }\n    } else if (plan && plan.mode === \"number\") {\n      const oldGroup = oldWords[plan.oi];\n      if (oldGroup) {\n        segments.push(\n          ...segmentNumber(\n            newWord,\n            asNumberSegments(splitIfWhole(oldGroup, splits)),\n            cursorIndex === undefined ? undefined : cursorIndex - charOffset,\n            decimalChar\n          )\n        );\n      }\n    } else if (plan && plan.mode === \"morph\") {\n      const oldGroup = oldWords[plan.oi];\n      if (oldGroup) {\n        const oldWord = oldGroup.word;\n        const oldCharSegs = splitIfWhole(oldGroup, splits);\n\n        const oldChars = oldWord.split(\"\");\n        const newChars = newWord.split(\"\");\n        const [oldCharLcs, newCharLcs] = lcsIndices(oldChars, newChars);\n\n        const newCharToOldSeg = new Map<number, Segment>();\n        for (let k = 0; k < newCharLcs.length; k++) {\n          const oIdx = oldCharLcs[k];\n          const nIdx = newCharLcs[k];\n          if (oIdx !== undefined && nIdx !== undefined) {\n            const oldSeg = oldCharSegs[oIdx];\n            if (oldSeg) {\n              newCharToOldSeg.set(nIdx, oldSeg);\n            }\n          }\n        }\n\n        for (let ci = 0; ci < newChars.length; ci++) {\n          const charStr = newChars[ci] ?? \"\";\n          if (newCharToOldSeg.has(ci)) {\n            const oldSeg = newCharToOldSeg.get(ci);\n            if (oldSeg) {\n              segments.push({ id: oldSeg.id, string: charStr });\n            }\n          } else {\n            segments.push({\n              id: alloc.take(`${newWord}~${ci}`),\n              string: charStr,\n            });\n          }\n        }\n      }\n    } else if (isNum(newWord)) {\n      segments.push(...segmentNumber(newWord));\n    } else {\n      segments.push({ id: alloc.take(newWord), string: newWord });\n    }\n\n    charOffset += newWord.length;\n  }\n\n  pushSeparators(trailingSeparators);\n\n  return { segments, splits };\n}\n",
      "type": "registry:lib",
      "target": "components/sora-ui/texts/text-morph/segments.ts"
    },
    {
      "path": "registry/primitives/texts/text-morph/runtime.ts",
      "content": "import {\n  ATTR_DEBUG,\n  ATTR_EXITING,\n  ATTR_ID,\n  ATTR_KIND,\n  ATTR_ROOT,\n  ATTR_SR,\n  DEFAULT_TEXT_MORPH_OPTIONS,\n  EMPTY_ID,\n} from \"./defs\";\nimport type { Segment, TextMorphOptions } from \"./defs\";\nimport { createReducedMotionListener, resolveEase } from \"./easing\";\nimport type { ReducedMotionState } from \"./easing\";\nimport {\n  cancelAnimations,\n  clearContainerTransition,\n  computeDelta,\n  detachFromFlow,\n  fadeDuration,\n  findNearestAnchor,\n  holdContainerSize,\n  layoutSize,\n  measure,\n  moverOf,\n  parseTranslate,\n  reconcileChildren,\n  resolveExitingAnchors,\n  splitWordSpans,\n  transitionContainerSize,\n} from \"./layout\";\nimport type { Measures } from \"./layout\";\nimport { diffSegments, segmentText } from \"./segments\";\nimport { addStyles, removeStyles } from \"./styles\";\n\n// --- Animation Routines ------------------------------------------------------\n\nexport function animateExit(\n  child: HTMLElement,\n  options: {\n    duration: number;\n    dx: number;\n    dy: number;\n    ease: string;\n    scale: boolean;\n  }\n) {\n  const { dx, dy, duration, ease, scale } = options;\n\n  child.animate(\n    {\n      offset: 1,\n      transform: scale\n        ? `translate(${dx}px, ${dy}px) scale(0.95)`\n        : `translate(${dx}px, ${dy}px)`,\n    },\n    {\n      duration,\n      easing: ease,\n      fill: \"both\",\n    }\n  );\n\n  const fadeAnimation = child.animate(\n    {\n      offset: 1,\n      opacity: 0,\n    },\n    {\n      duration: fadeDuration(duration, 0.25),\n      easing: \"linear\",\n      fill: \"both\",\n    }\n  );\n\n  fadeAnimation.onfinish = () => {\n    child.remove();\n  };\n}\n\nexport function animateEnterOrPersist(\n  child: HTMLElement,\n  options: {\n    deltaX: number;\n    deltaY: number;\n    duration: number;\n    ease: string;\n    isNew: boolean;\n  }\n) {\n  const { deltaX, deltaY, isNew, duration, ease } = options;\n\n  const prev = cancelAnimations(child);\n\n  const startX = deltaX + prev.tx;\n  const startY = deltaY + prev.ty;\n  const startOpacity = isNew && prev.opacity >= 1 ? 0 : prev.opacity;\n\n  child.animate(\n    [\n      {\n        transform: `translate(${startX}px, ${startY}px) scale(${isNew ? 0.95 : 1})`,\n      },\n      { transform: \"none\" },\n    ],\n    {\n      duration,\n      easing: ease,\n      fill: \"both\",\n    }\n  );\n\n  if (startOpacity < 1) {\n    child.animate([{ opacity: startOpacity }, { opacity: 1 }], {\n      delay: isNew ? fadeDuration(duration, 0.25) : 0,\n      duration: fadeDuration(duration, isNew ? 0.5 : 0.25),\n      easing: \"linear\",\n      fill: \"both\",\n    });\n  }\n}\n\nconst EXIT_FADE = 0.45;\nconst ENTER_FADE = 0.25;\n\nexport function animateNumberExit(\n  slot: HTMLElement,\n  options: {\n    duration: number;\n    dx: number;\n    dy: number;\n    ease: string;\n    slideDistance: number;\n  }\n) {\n  const { dx, dy, slideDistance, duration, ease } = options;\n  const mover = moverOf(slot);\n\n  slot.animate(\n    { offset: 1, transform: `translate(${dx}px, ${dy}px)` },\n    { duration, easing: ease, fill: \"both\" }\n  );\n\n  mover.animate(\n    { offset: 1, transform: `translate(0px, ${slideDistance}px)` },\n    { duration, easing: ease, fill: \"both\" }\n  );\n\n  const fadeAnimation = mover.animate(\n    { offset: 1, opacity: 0 },\n    { duration: duration * EXIT_FADE, easing: \"linear\", fill: \"both\" }\n  );\n\n  fadeAnimation.onfinish = () => {\n    slot.remove();\n  };\n}\n\nexport function animateNumberEnter(\n  slot: HTMLElement,\n  options: {\n    deltaX: number;\n    deltaY: number;\n    duration: number;\n    ease: string;\n    kind: \"digit\" | \"symbol\";\n    slideDistance: number;\n  }\n) {\n  const { deltaX, deltaY, slideDistance, kind, duration, ease } = options;\n\n  animateNumberPersist(slot, { deltaX, deltaY, duration, ease });\n\n  const mover = moverOf(slot);\n  const prev = cancelAnimations(mover);\n\n  const from = kind === \"digit\" ? -slideDistance : slideDistance;\n\n  mover.animate(\n    { offset: 0, transform: `translate(0px, ${prev.ty + from}px)` },\n    { duration, easing: ease, fill: \"both\" }\n  );\n\n  const startOpacity = prev.opacity >= 1 ? 0 : prev.opacity;\n  if (startOpacity < 1) {\n    mover.animate([{ opacity: startOpacity }, { opacity: 1 }], {\n      duration: duration * ENTER_FADE,\n      easing: \"linear\",\n      fill: \"both\",\n    });\n  }\n}\n\nexport function animateNumberPersist(\n  slot: HTMLElement,\n  options: {\n    deltaX: number;\n    deltaY: number;\n    duration: number;\n    ease: string;\n  }\n) {\n  const { deltaX, deltaY, duration, ease } = options;\n\n  const { tx, ty } = parseTranslate(slot);\n  for (const a of slot.getAnimations()) {\n    a.cancel();\n  }\n\n  const startX = deltaX + tx;\n  const startY = deltaY + ty;\n\n  if (startX === 0 && startY === 0) {\n    return;\n  }\n\n  slot.animate(\n    { offset: 0, transform: `translate(${startX}px, ${startY}px)` },\n    { duration, easing: ease, fill: \"both\" }\n  );\n}\n\nexport const GROUP_MIN = 6;\nconst GROUP_SCALE = 0.8;\nconst GROUP_EXIT_FADE = 0.45;\nconst GROUP_ENTER_FADE = 0.35;\n\nfunction originsFor(elements: HTMLElement[]): string[] {\n  const rects = elements.map((element) => element.getBoundingClientRect());\n  const left = Math.min(...rects.map((r) => r.left));\n  const right = Math.max(...rects.map((r) => r.right));\n  const top = Math.min(...rects.map((r) => r.top));\n  const bottom = Math.max(...rects.map((r) => r.bottom));\n  const centreX = (left + right) / 2;\n  const centreY = (top + bottom) / 2;\n\n  return rects.map((r) => `${centreX - r.left}px ${centreY - r.top}px`);\n}\n\nexport function animateGroupExit(\n  elements: HTMLElement[],\n  options: { duration: number; ease: string }\n) {\n  const { duration, ease } = options;\n  const origins = originsFor(elements);\n\n  for (let i = 0; i < elements.length; i++) {\n    const element = elements[i];\n    if (!element) {\n      continue;\n    }\n    for (const animation of element.getAnimations()) {\n      animation.cancel();\n    }\n    const origin = origins[i];\n    if (origin) {\n      element.style.transformOrigin = origin;\n    }\n\n    element.animate(\n      { offset: 1, transform: `scale(${GROUP_SCALE})` },\n      { duration, easing: ease, fill: \"both\" }\n    );\n\n    const fade = element.animate(\n      { offset: 1, opacity: 0 },\n      { duration: duration * GROUP_EXIT_FADE, easing: \"linear\", fill: \"both\" }\n    );\n    fade.onfinish = () => {\n      element.remove();\n    };\n  }\n}\n\nexport function animateGroupEnter(\n  elements: HTMLElement[],\n  options: { duration: number; ease: string }\n) {\n  const { duration, ease } = options;\n  const origins = originsFor(elements);\n\n  for (let i = 0; i < elements.length; i++) {\n    const element = elements[i];\n    if (!element) {\n      continue;\n    }\n    const prev = cancelAnimations(element);\n    const origin = origins[i];\n    if (origin) {\n      element.style.transformOrigin = origin;\n    }\n\n    element.animate(\n      { offset: 0, transform: `scale(${GROUP_SCALE})` },\n      { duration, easing: ease, fill: \"both\" }\n    );\n\n    const startOpacity = prev.opacity >= 1 ? 0 : prev.opacity;\n    element.animate([{ opacity: startOpacity }, { opacity: 1 }], {\n      duration: duration * GROUP_ENTER_FADE,\n      easing: \"linear\",\n      fill: \"both\",\n    });\n  }\n}\n\nexport function replacedRuns(\n  all: HTMLElement[],\n  members: Set<HTMLElement>\n): HTMLElement[][] {\n  const runs: HTMLElement[][] = [];\n  let run: HTMLElement[] = [];\n\n  const flush = () => {\n    if (run.length >= GROUP_MIN) {\n      runs.push(run);\n    }\n    run = [];\n  };\n\n  for (const element of all) {\n    if (members.has(element)) {\n      run.push(element);\n    } else {\n      flush();\n    }\n  }\n  flush();\n\n  return runs;\n}\n\n// --- TextMorph Engine --------------------------------------------------------\n\nexport class TextMorphEngine {\n  private readonly element: HTMLElement;\n  private readonly options: Omit<TextMorphOptions, \"ease\"> & { ease?: string } =\n    {};\n  private data: number | string = \"\";\n  private currentMeasures: Measures = {};\n  private prevMeasures: Measures = {};\n  private previousSegments: Segment[] = [];\n  private isInitialRender = true;\n  private readonly reducedMotion: ReducedMotionState | null = null;\n  private srNode: HTMLElement | null = null;\n  private hasSetup = false;\n\n  constructor(options: TextMorphOptions & { element: HTMLElement }) {\n    const given = Object.fromEntries(\n      Object.entries(options).filter(([, value]) => value !== undefined)\n    ) as TextMorphOptions & { element: HTMLElement };\n\n    const { ease: rawEase, ...rest } = {\n      ...DEFAULT_TEXT_MORPH_OPTIONS,\n      ...given,\n    };\n    const { ease, duration } = resolveEase(rawEase, rest.duration ?? 400);\n\n    this.options = { ...rest, duration, ease };\n    this.element = options.element;\n\n    if (this.options.respectReducedMotion) {\n      this.reducedMotion = createReducedMotionListener();\n    }\n\n    if (!this.isDisabled()) {\n      this.setup();\n    }\n  }\n\n  private setup() {\n    if (this.hasSetup) {\n      return;\n    }\n    this.hasSetup = true;\n\n    this.element.setAttribute(ATTR_ROOT, \"\");\n    if (this.options.debug) {\n      this.element.setAttribute(ATTR_DEBUG, \"\");\n    }\n    addStyles();\n  }\n\n  destroy() {\n    this.reducedMotion?.destroy();\n    clearContainerTransition(this.element);\n    for (const anim of this.element.getAnimations()) {\n      anim.cancel();\n    }\n    this.srNode?.remove();\n    this.srNode = null;\n    this.element.removeAttribute(ATTR_ROOT);\n    this.element.removeAttribute(ATTR_DEBUG);\n    if (this.hasSetup) {\n      this.hasSetup = false;\n      removeStyles();\n    }\n  }\n\n  private isDisabled(): boolean {\n    return Boolean(\n      this.options.disabled || this.reducedMotion?.prefersReducedMotion\n    );\n  }\n\n  update(value: number | string, cursorIndex?: number) {\n    const formatted =\n      typeof value === \"number\"\n        ? value.toLocaleString(this.options.locale, {\n            maximumFractionDigits: this.options.decimals,\n            minimumFractionDigits: this.options.decimals,\n          })\n        : value;\n\n    if (formatted === this.data) {\n      return;\n    }\n    this.data = formatted;\n\n    if (this.isDisabled()) {\n      if (typeof formatted === \"string\") {\n        this.srNode = null;\n        this.element.textContent = formatted;\n        this.previousSegments = [];\n        this.isInitialRender = true;\n      }\n      return;\n    }\n\n    this.setup();\n\n    if (this.options.onAnimationStart && !this.isInitialRender) {\n      this.options.onAnimationStart();\n    }\n    this.createTextGroup(String(this.data), this.element, cursorIndex);\n  }\n\n  // oxlint-disable-next-line sonarjs/cognitive-complexity -- text group layout creation\n  private createTextGroup(\n    value: string,\n    element: HTMLElement,\n    cursorIndex?: number\n  ) {\n    const { width: oldWidth, height: oldHeight } = this.isInitialRender\n      ? { height: 0, width: 0 }\n      : layoutSize(element);\n    const numbers = this.options.numbers !== false;\n\n    this.syncAccessibleText(value);\n\n    let segments: Segment[];\n    let splits: Map<string, Segment[]>;\n\n    if (this.previousSegments.length > 0) {\n      const result = diffSegments(\n        this.previousSegments,\n        value,\n        this.options.locale,\n        { cursorIndex, numbers }\n      );\n      segments = result.segments;\n      splits = result.splits;\n    } else {\n      segments = segmentText(value, this.options.locale, numbers);\n      splits = new Map();\n    }\n\n    const isEmptyTransition = segments.length === 0;\n    if (isEmptyTransition) {\n      segments = [{ id: EMPTY_ID, string: \"\\u200B\" }];\n    }\n\n    splitWordSpans(element, splits, this.options.charClassName);\n\n    this.prevMeasures = this.isInitialRender ? {} : measure(this.element);\n    const oldChildren = ([...element.children] as HTMLElement[]).filter(\n      (child) => !child.hasAttribute(ATTR_SR)\n    );\n    const newIds = new Set(segments.map((b) => b.id));\n\n    const exiting = oldChildren.filter(\n      (child) =>\n        !(\n          newIds.has(child.getAttribute(ATTR_ID) as string) ||\n          child.hasAttribute(ATTR_EXITING)\n        )\n    );\n\n    const exitingSet = new Set(exiting);\n    const oldIds = oldChildren.map((c) => c.getAttribute(ATTR_ID) as string);\n    const exitingAnchorId = resolveExitingAnchors(\n      oldChildren,\n      exitingSet,\n      oldIds,\n      newIds\n    );\n\n    detachFromFlow(element, exiting);\n    reconcileChildren(\n      element,\n      oldChildren,\n      newIds,\n      segments,\n      this.options.charClassName\n    );\n\n    this.currentMeasures = this.isInitialRender ? {} : measure(this.element);\n\n    let lineCount = 1;\n    for (const segment of segments) {\n      if (segment.string === \"\\n\") {\n        lineCount += 1;\n      }\n    }\n    const slideDistance = this.isInitialRender\n      ? 0\n      : (element.offsetHeight || 20 * lineCount) / lineCount;\n\n    if (!this.isInitialRender) {\n      element.style.width = `${oldWidth}px`;\n      if (element.offsetWidth < 0) {\n        // reflow\n      }\n      const firstFrameMeasures = measure(this.element);\n      element.style.width = \"\";\n\n      this.updateStyles(segments, firstFrameMeasures, slideDistance);\n    }\n\n    const leavingRuns = this.isInitialRender\n      ? []\n      : replacedRuns(oldChildren, new Set(exiting));\n    const leavingTogether = new Set(leavingRuns.flat());\n\n    for (const run of leavingRuns) {\n      animateGroupExit(run, {\n        duration: this.options.duration ?? 400,\n        ease: this.options.ease ?? \"cubic-bezier(0.19, 1, 0.22, 1)\",\n      });\n    }\n\n    for (const child of exiting) {\n      if (this.isInitialRender || child.getAttribute(ATTR_ID) === EMPTY_ID) {\n        child.remove();\n        continue;\n      }\n      if (leavingTogether.has(child)) {\n        continue;\n      }\n\n      const anchorId = exitingAnchorId.get(child);\n      const { dx, dy } = anchorId\n        ? computeDelta(this.currentMeasures, this.prevMeasures, anchorId)\n        : { dx: 0, dy: 0 };\n\n      if (child.hasAttribute(ATTR_KIND)) {\n        animateNumberExit(child, {\n          duration: this.options.duration ?? 400,\n          dx,\n          dy,\n          ease: this.options.ease ?? \"cubic-bezier(0.19, 1, 0.22, 1)\",\n          slideDistance,\n        });\n      } else {\n        animateExit(child, {\n          duration: this.options.duration ?? 400,\n          dx,\n          dy,\n          ease: this.options.ease ?? \"cubic-bezier(0.19, 1, 0.22, 1)\",\n          scale: this.options.scale ?? true,\n        });\n      }\n    }\n\n    this.previousSegments = segments;\n\n    if (this.isInitialRender) {\n      this.isInitialRender = false;\n      element.style.width = \"\";\n      element.style.height = \"\";\n      return;\n    }\n\n    if (isEmptyTransition) {\n      holdContainerSize(\n        element,\n        oldWidth,\n        oldHeight,\n        this.options.duration ?? 400,\n        this.options.onAnimationComplete,\n        this.options.onAnimationCancel\n      );\n    } else {\n      transitionContainerSize(\n        element,\n        oldWidth,\n        oldHeight,\n        this.options.duration ?? 400,\n        this.options.ease ?? \"cubic-bezier(0.19, 1, 0.22, 1)\",\n        this.options.onAnimationComplete,\n        this.options.onAnimationCancel\n      );\n    }\n  }\n\n  // oxlint-disable-next-line sonarjs/cognitive-complexity -- updates element styles per frame\n  private updateStyles(\n    segments: Segment[],\n    firstFrameMeasures: Measures,\n    slideDistance: number\n  ) {\n    if (this.isInitialRender) {\n      return;\n    }\n\n    const children = ([...this.element.children] as HTMLElement[]).filter(\n      (child) => !child.hasAttribute(ATTR_SR)\n    );\n    const segmentIds = segments.map((b) => b.id);\n    const kinds = new Map(segments.map((b) => [b.id, b.kind]));\n\n    const settled = children.filter(\n      (child) =>\n        !child.hasAttribute(ATTR_EXITING) &&\n        child.tagName !== \"BR\" &&\n        child.getAttribute(ATTR_ID) !== EMPTY_ID\n    );\n    const arriving = new Set(\n      settled.filter(\n        (child) => !this.prevMeasures[child.getAttribute(ATTR_ID) || \"\"]\n      )\n    );\n    const arrivingTogether = new Set<HTMLElement>();\n    for (const run of replacedRuns(settled, arriving)) {\n      for (const child of run) {\n        arrivingTogether.add(child);\n      }\n      animateGroupEnter(run, {\n        duration: this.options.duration ?? 400,\n        ease: this.options.ease ?? \"cubic-bezier(0.19, 1, 0.22, 1)\",\n      });\n    }\n\n    const persistentIds = new Set(\n      segmentIds.filter((id) => this.prevMeasures[id])\n    );\n\n    const duration = this.options.duration ?? 400;\n    const ease = this.options.ease ?? \"cubic-bezier(0.19, 1, 0.22, 1)\";\n\n    for (let index = 0; index < children.length; index++) {\n      const child = children[index];\n      if (!child) {\n        continue;\n      }\n      if (child.hasAttribute(ATTR_EXITING)) {\n        continue;\n      }\n      if (child.tagName === \"BR\") {\n        continue;\n      }\n      if (arrivingTogether.has(child)) {\n        continue;\n      }\n      const key = child.getAttribute(ATTR_ID) || `child-${index}`;\n      if (key === EMPTY_ID) {\n        continue;\n      }\n      const isNew = !this.prevMeasures[key];\n\n      const deltaKey = isNew\n        ? findNearestAnchor(\n            segments.findIndex((b) => b.id === key),\n            segmentIds,\n            persistentIds\n          )\n        : key;\n\n      const { dx: deltaX, dy: deltaY } = deltaKey\n        ? computeDelta(this.prevMeasures, firstFrameMeasures, deltaKey)\n        : { dx: 0, dy: 0 };\n\n      const kind = kinds.get(key);\n\n      if (kind && isNew) {\n        animateNumberEnter(child, {\n          deltaX,\n          deltaY,\n          duration,\n          ease,\n          kind,\n          slideDistance,\n        });\n      } else if (kind) {\n        animateNumberPersist(child, {\n          deltaX,\n          deltaY,\n          duration,\n          ease,\n        });\n      } else {\n        animateEnterOrPersist(child, {\n          deltaX,\n          deltaY,\n          duration,\n          ease,\n          isNew,\n        });\n      }\n    }\n  }\n\n  private syncAccessibleText(value: string) {\n    if (!this.srNode || this.srNode.parentNode !== this.element) {\n      const node = document.createElement(\"span\");\n      node.setAttribute(ATTR_SR, \"\");\n      this.element.prepend(node);\n      this.srNode = node;\n    }\n\n    this.srNode.textContent = value;\n  }\n}\n\n// --- Controller --------------------------------------------------------------\n\nexport class MorphController {\n  private instance: TextMorphEngine | null = null;\n  private lastCursorIndex?: number;\n  private lastText: number | string = \"\";\n\n  attach(element: HTMLElement, options: TextMorphOptions) {\n    this.instance?.destroy();\n    this.instance = new TextMorphEngine({ element, ...options });\n\n    if (this.lastText !== \"\") {\n      this.instance.update(this.lastText, this.lastCursorIndex);\n    }\n  }\n\n  update(text: number | string, cursorIndex?: number) {\n    this.lastText = text;\n    this.lastCursorIndex = cursorIndex;\n    this.instance?.update(text, cursorIndex);\n  }\n\n  destroy() {\n    this.instance?.destroy();\n    this.instance = null;\n  }\n\n  static serializeConfig(options: TextMorphOptions): string {\n    return JSON.stringify({\n      charClassName: options.charClassName,\n      debug: options.debug,\n      decimals: options.decimals,\n      disabled: options.disabled,\n      duration: options.duration,\n      ease: options.ease,\n      locale: options.locale,\n      numbers: options.numbers,\n      respectReducedMotion: options.respectReducedMotion,\n      scale: options.scale,\n    });\n  }\n}\n",
      "type": "registry:lib",
      "target": "components/sora-ui/texts/text-morph/runtime.ts"
    }
  ],
  "meta": {
    "fileGroups": [
      {
        "title": "Main component",
        "files": ["index.tsx"]
      },
      {
        "title": "Internal implementation",
        "files": [
          "defs.ts",
          "styles.ts",
          "easing.ts",
          "layout.ts",
          "segments.ts",
          "runtime.ts"
        ]
      }
    ],
    "inspiration": {
      "type": "reimplemented",
      "label": "Torph",
      "url": "https://torph.dev",
      "stack": "React and Web Animations API"
    }
  },
  "type": "registry:ui"
}
