{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-morph",
  "title": "Text Morph",
  "description": "Animates a label from one value to the next, glyph by glyph.",
  "files": [
    {
      "path": "registry/default/text-morph/text-morph.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  caretUnits,\n  decimalFor,\n  isBreak,\n  isDigit,\n  matchText,\n  textUnits,\n  type GlyphKind,\n  type TextMorphSplit,\n} from \"./lib/match\";\nimport {\n  resolveOptions,\n  type ResolvedOptions,\n  type TextMorphMode,\n  type TextMorphOverrides,\n} from \"./lib/options\";\nimport {\n  HOME,\n  awayState,\n  fadesFor,\n  staggerDelays,\n  stillOptions,\n} from \"./lib/timing\";\nimport {\n  GROUP_FADE_IN,\n  GROUP_FADE_OUT,\n  GROUP_SCALE,\n  planRuns,\n  planShapes,\n  type Shape,\n} from \"./lib/shapes\";\nimport { planTrips, rollWith, type Trip } from \"./lib/anchors\";\nimport {\n  anchorHint,\n  centreDelta,\n  contentSize,\n  inkEscapes,\n  isOnScreen,\n  lineCount,\n  sizeOf,\n  visibleBounds,\n  visualState,\n  type Bounds,\n  type Side,\n} from \"./lib/measure\";\nimport { scheduleMorph, type MorphSteps } from \"./lib/scheduler\";\nimport { armMark, arrivalClip, edgeBand, readArm, type Arm } from \"./lib/edges\";\nimport { followShare, ownShift } from \"./lib/ride\";\nimport \"./text-morph.css\";\n\n/*\n * Animated text for labels that change in place.\n *\n * The server renders the label already split into one span per glyph, as\n * fixed HTML that this component owns from then on. The browser receives\n * identical markup, so hydration changes nothing and nothing is measured at\n * load. Work happens only when the value changes:\n *\n *   1. Snapshot every visible glyph's on-screen box, including whatever\n *      animation it's mid-way through. Starting from there is what makes a\n *      change that lands mid-animation pick up smoothly instead of jumping.\n *   2. Reuse the glyph nodes the old and new text share, create the new\n *      ones, and move the removed ones to a ghost layer to animate out.\n *   3. Measure the final layout, then animate each glyph from its snapshot.\n *\n * The label flows inline like the text around it, grouped into words so\n * lines only break between them, and it never changes display: switching\n * an element between inline and inline-block repaints its text snapped\n * differently, a visible shimmer as it settles. A change that stays on one\n * line eases the space it takes with its start margin, so the text after\n * it slides rather than jumps. A start margin rather than an end one: line\n * breaking counts it before the glyphs, so a growing value never overruns\n * the space it had and breaks away from the text before it. The stage rides\n * against the label's moving start (`left`, which inline boxes honour) on\n * the same curve, so the glyphs never drift with it. Both\n * run on the main thread from one clock: a transform would run on the\n * compositor and keep going while a busy main thread held the layout\n * still. A change that wraps keeps the lines as the layout, and glyphs\n * travel to their new places across them.\n *\n * Leaving glyphs (ghosts) sit in a layer measured and sized each change,\n * each in a slot at its own line that fades it out above and below. A new\n * glyph always arrives fresh, even where its own text is still leaving, so\n * quick changes look like slow ones. The slots fade only while a change\n * plays (`data-playing`); at rest they're inert wrappers.\n */\n\n/** Escapes text for use as element content (never in an attribute). */\nfunction escapeHtml(text: string): string {\n  return text.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\");\n}\n\n/** Server markup: words of glyphs, with spaces between them. */\nfunction glyphsHtml(text: string, split: TextMorphSplit): string {\n  let html = \"\";\n  let word = \"\";\n  for (const glyph of textUnits(text, split)) {\n    if (isBreak(glyph)) {\n      if (word) html += `<span data-word>${word}</span>`;\n      word = \"\";\n      html += `<span data-glyph data-space>${escapeHtml(glyph)}</span>`;\n    } else {\n      word += `<span data-glyph>${escapeHtml(glyph)}</span>`;\n    }\n  }\n  if (word) html += `<span data-word>${word}</span>`;\n  return html;\n}\n\nfunction createGlyph(text: string): HTMLSpanElement {\n  const span = document.createElement(\"span\");\n  span.setAttribute(\"data-glyph\", \"\");\n  if (isBreak(text)) span.setAttribute(\"data-space\", \"\");\n  span.textContent = text;\n  return span;\n}\n\nconst spaceNode = (node: HTMLElement): boolean =>\n  node.hasAttribute(\"data-space\");\n\n/**\n * Lay glyphs out as words, the same structure the server renders, each\n * glyph in a slot: room above and below its line that fades it out while a\n * change plays, taking no space of its own. Only a glyph that rolls or is\n * drawn off its line has its slot fade (`data-fade`), so glyphs that hold\n * still carry no mask. One moving to another line turns the fade off\n * (`data-travel`); one sliding along its line gets room beside it for the\n * slide (`--reach`).\n */\nfunction layOut(layer: HTMLElement, nodes: HTMLElement[]): void {\n  const parts: HTMLElement[] = [];\n  let word: HTMLElement | null = null;\n  for (const node of nodes) {\n    if (spaceNode(node)) {\n      word = null;\n      parts.push(node);\n      continue;\n    }\n    if (!word) {\n      word = document.createElement(\"span\");\n      word.setAttribute(\"data-word\", \"\");\n      parts.push(word);\n    }\n    const slot = document.createElement(\"span\");\n    slot.className = \"text-morph-slot\";\n    slot.append(node);\n    word.append(slot);\n  }\n  layer.replaceChildren(...parts);\n}\n\n/** Its scale's pivot, written only when it changes. */\nfunction setOrigin(node: HTMLElement, origin: string): void {\n  if (node.style.transformOrigin !== origin) {\n    node.style.transformOrigin = origin;\n  }\n}\n\n/** Share of its fade a glyph interrupted mid-fade takes to finish. */\nconst CATCH_UP = 0.25;\n\n/**\n * How a change plays: in full, as a crossfade only (the reader prefers\n * reduced motion: remove the movement, keep the change visible), or not at\n * all (disableAnimation, or nobody can see it).\n */\ntype Playback = \"full\" | \"reduced\" | \"none\";\n\n/** The edge fade's ramp, and how far past the box edge it ends (em). */\nconst EDGE_RAMP = 0.3;\nconst EDGE_SLACK = 0.4;\n/** Room around a ghost for its blur, tilt and scale (em). */\nconst INK_MARGIN = 0.3;\n/**\n * A ghost slot's room beside its glyph (em), for tilt, scale and blur: the\n * CSS gives `.text-morph-ghost` the same padding.\n */\nconst SLOT_PAD_X = 0.5;\n/**\n * Its room above and below (em): roll's, and at least what morph's minimum\n * slot height (1.4em, in the CSS) adds in a tight line height.\n */\nconst SLOT_PAD_Y = 0.3;\nconst SLOT_MIN_HEIGHT = 1.4;\n/** A kept glyph whose top moves further than this (em) changes line. */\nconst LINE_CHANGE = 0.3;\n\n/**\n * A ghost slot's glyph box and trip (layer coordinates), for sizing the layer\n * around every ghost. Only --x/--y reach the CSS.\n */\ntype GhostBox = {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n  tripX: number;\n  tripY: number;\n};\nconst ghostBoxes = new WeakMap<HTMLElement, GhostBox>();\n\n/** A ghost slot at x/y (its glyph's box, layer coordinates). */\nfunction placeSlot(\n  slot: HTMLElement,\n  box: GhostBox,\n  x: number,\n  y: number,\n): void {\n  box.x = x;\n  box.y = y;\n  slot.style.setProperty(\"--x\", `${x}px`);\n  slot.style.setProperty(\"--y\", `${y}px`);\n}\n\n/** Lift the edge fade (its band animation already cancelled). */\nfunction disarm(ghostLayer: HTMLElement): void {\n  ghostLayer.style.removeProperty(\"--text-morph-edge\");\n  delete ghostLayer.dataset.armedStart;\n  delete ghostLayer.dataset.armedEnd;\n}\n\n/**\n * Fold the ghost layer away, its animations already cancelled. (Asking for\n * them would flush style, which a change avoids after moving nodes.)\n */\nfunction resetLayer(ghostLayer: HTMLElement): void {\n  disarm(ghostLayer);\n  ghostLayer.style.width = \"\";\n  ghostLayer.style.height = \"\";\n}\n\n/**\n * Where each label was last seen pinned as it resized (0 start, 1 end, 0.5\n * centred), from how far each edge travelled: what a roll run's travel is\n * measured against.\n */\nconst anchors = new WeakMap<HTMLElement, number>();\n\n/** Tags a label's current resize, so an older one can't release it. */\nlet sizings = 0;\n/** Tags a label's current edge clip, so an older change can't lift it. */\nlet edgeClips = 0;\n\n/** A label's parts: the root, the stage that rides, and its two layers. */\ntype MorphElements = {\n  root: HTMLElement;\n  stage: HTMLElement;\n  glyphLayer: HTMLElement;\n  /** Holds still where the value starts; ghost coordinates count from it. */\n  ghostAnchor: HTMLElement;\n  ghostLayer: HTMLElement;\n};\n\nfunction* morphTo(\n  { root, stage, glyphLayer, ghostAnchor, ghostLayer }: MorphElements,\n  next: string[],\n  options: ResolvedOptions,\n  place: { decimal: string; caret: number | undefined },\n  playback: () => Playback,\n): MorphSteps {\n  // Everything this change starts, so the caller can tell when it's over.\n  const started: Animation[] = [];\n  const run = (\n    el: Element,\n    keyframes: Keyframe[],\n    timing: KeyframeAnimationOptions,\n  ): Animation => {\n    const animation = el.animate(keyframes, timing);\n    started.push(animation);\n    return animation;\n  };\n  if (!root.isConnected) return started;\n  const old = Array.from(\n    glyphLayer.querySelectorAll<HTMLElement>(\"[data-glyph]\"),\n  );\n\n  // 1. Read: where everything is on screen right now, and what's running.\n  const play = playback();\n  const animations = root.getAnimations({ subtree: true });\n  if (play === \"none\") {\n    yield;\n    // Write: swapped without a change to watch, so nothing is measured.\n    // Everything running stops, an earlier change's ghosts and edge fade\n    // included.\n    for (const animation of animations) animation.cancel();\n    layOut(glyphLayer, next.map(createGlyph));\n    delete root.dataset.sizing;\n    stage.style.left = \"\";\n    delete root.dataset.edgeClip;\n    delete glyphLayer.dataset.playing;\n    ghostLayer.replaceChildren();\n    resetLayer(ghostLayer);\n    return started;\n  }\n  const reduced = play === \"reduced\";\n  const o = reduced ? stillOptions(options) : options;\n  const rootBefore = root.getBoundingClientRect();\n  const styleBefore = getComputedStyle(root);\n  const startBefore = parseFloat(styleBefore.marginInlineStart) || 0;\n  const linesBefore = lineCount(glyphLayer);\n  // A label laid out as a box (a block, an inline-block, a flex item) can\n  // take a height, so a change that adds or removes a line eases it. An inline label's height is its lines'.\n  const boxed = styleBefore.display !== \"inline\";\n  const heightBefore = sizeOf(styleBefore, rootBefore, \"height\");\n  const originBefore = ghostAnchor.getBoundingClientRect();\n  const before = old.map(visualState);\n  // Ghosts still leaving; ones already gone are cleared in step 4.\n  const gone: HTMLElement[] = [];\n  const slots = (Array.from(ghostLayer.children) as HTMLElement[]).filter(\n    (slot) => {\n      if (slot.dataset.gone === undefined) return true;\n      gone.push(slot);\n      return false;\n    },\n  );\n  const ghosts = slots.flatMap((slot) => {\n    const node = slot.firstElementChild;\n    const box = ghostBoxes.get(slot);\n    return node instanceof HTMLElement && box\n      ? [{ slot, node, box, drawn: visualState(node) }]\n      : [];\n  });\n\n  // The new glyph list.\n  const match = matchText(\n    o.mode,\n    old.map((node) => node.textContent ?? \"\"),\n    next,\n    {\n      decimal: place.decimal,\n      trend: o.trend === \"up\" ? 1 : o.trend === \"down\" ? -1 : 0,\n      anchor: anchors.get(root) ?? anchorHint(styleBefore),\n      caret:\n        place.caret === undefined ? undefined : caretUnits(next, place.caret),\n    },\n  );\n  const used = new Set(match.kept);\n  const kept = match.kept.map((from) => (from === -1 ? null : old[from]));\n  const leaving = old.flatMap((node, i) =>\n    used.has(i) || spaceNode(node)\n      ? []\n      : [{ node, kind: match.oldKinds[i], index: i, was: before[i] }],\n  );\n  const nodes = next.map((glyph, i) => kept[i] ?? createGlyph(glyph));\n\n  // What to stop: everything on the glyphs, their slots and the label, but\n  // not earlier ghosts, which carry on leaving. Found here with the reads:\n  // asking for animations flushes style, which in step 2 would restyle once\n  // per label.\n  const slotOf = (node: HTMLElement): HTMLElement[] => {\n    const slot = node.parentElement;\n    return slot?.classList.contains(\"text-morph-slot\") ? [slot] : [];\n  };\n  const stops = new Set<Element>([\n    ...nodes,\n    ...leaving.map((l) => l.node),\n    ...nodes.flatMap(slotOf),\n    ...leaving.flatMap((l) => slotOf(l.node)),\n    stage,\n    root,\n    ghostLayer,\n  ]);\n  const keptNodes = new Set(kept.filter((node) => node !== null));\n  const stopping: Animation[] = [];\n  // A kept glyph's roll in progress, kept to carry on (step 8) if its place\n  // doesn't change: restarted from where it's drawn, a held key sent every\n  // digit still settling off again on a fresh curve each press, lurching\n  // forward, and one within half a pixel of home snapped there.\n  const rolling = new Map<\n    HTMLElement,\n    { keyframes: Keyframe[]; timing: EffectTiming; time: CSSNumberish | null }[]\n  >();\n  for (const animation of animations) {\n    const effect = animation.effect as KeyframeEffect | null;\n    const target = effect?.target;\n    if (!effect || !target || !stops.has(target)) continue;\n    stopping.push(animation);\n    if (!(target instanceof HTMLElement) || !keptNodes.has(target)) continue;\n    const keyframes = effect.getKeyframes();\n    if (!keyframes.some((k) => \"translate\" in k)) continue;\n    const rolls = rolling.get(target) ?? [];\n    rolls.push({\n      keyframes,\n      timing: effect.getTiming(),\n      time: animation.currentTime,\n    });\n    rolling.set(target, rolls);\n  }\n  yield;\n\n  // 2. Write: stop what's running (a style change, not a structural one).\n  for (const animation of stopping) animation.cancel();\n  yield;\n\n  // 3. Read: where leaving glyphs sit in the layout, with nothing moving\n  // them.\n  const homes = leaving.map(({ node }) => node.getBoundingClientRect());\n  // And which line each kept glyph sits on, whatever a roll in progress is\n  // drawing it at.\n  const keptHomes = new Map(\n    nodes.flatMap((node, i) =>\n      kept[i] && !spaceNode(node) ? [[node, node.getBoundingClientRect()]] : [],\n    ),\n  );\n  yield;\n\n  // 4. Write: the new glyphs, back to flowing inline, the resting layout.\n  // Every structural change happens here, so the batch restyles once.\n  layOut(glyphLayer, nodes);\n  // Free to wrap again, so step 5 sees how the new value falls.\n  delete root.dataset.sizing;\n  stage.style.left = \"\";\n  delete root.dataset.edgeClip;\n  for (const slot of gone) slot.remove();\n  // The slots fade from step 8 on, while this change plays.\n  delete glyphLayer.dataset.playing;\n  // Leaving glyphs move into slots in the ghost layer (placed in step 8).\n  const newSlots = leaving.map((glyph, k) => {\n    const home = homes[k];\n    const slot = document.createElement(\"span\");\n    slot.className = \"text-morph-ghost\";\n    slot.append(glyph.node);\n    ghostLayer.append(slot);\n    const box: GhostBox = {\n      x: 0,\n      y: 0,\n      width: home.width,\n      height: home.height,\n      tripX: 0,\n      tripY: 0,\n    };\n    ghostBoxes.set(slot, box);\n    return { ...glyph, slot, box, home };\n  });\n  yield;\n\n  // 5. Read: a change that stays on one line is animated as a box; one that\n  // wraps keeps its lines as they fall. An empty value has no line box at\n  // all; it counts as one line.\n  const oneLine = lineCount(glyphLayer) <= 1;\n  const easesAsBox = !reduced && linesBefore <= 1 && oneLine;\n  // And the rest of the final layout, with the start margin the author set.\n  const rootAfter = root.getBoundingClientRect();\n  const glyphsAfter = glyphLayer.getBoundingClientRect();\n  const styleAfter = getComputedStyle(root);\n  const startAfter = parseFloat(styleAfter.marginInlineStart) || 0;\n  const heightAfter = sizeOf(styleAfter, rootAfter, \"height\");\n  const reheights =\n    boxed && !reduced && Math.abs(heightAfter - heightBefore) > 0.5;\n  const origin = ghostAnchor.getBoundingClientRect();\n  // Reading direction, for the stagger's sweep.\n  const rtl = getComputedStyle(glyphLayer).direction === \"rtl\";\n  const after = nodes.map((node) => node.getBoundingClientRect());\n  const em = parseFloat(getComputedStyle(root).fontSize) || 16;\n\n  // Glyphs that travel further than their slot's room go unmasked.\n  // A kept glyph moving to another line (wrapped text) goes unmasked, or\n  // its slot would hide it on the way. One sliding along its line keeps the\n  // fade, since it may be mid-roll and meant to fade back in, and its slot\n  // gets room beside it for the slide.\n  const changesLine: HTMLElement[] = [];\n  const reach: [HTMLElement, number][] = [];\n  nodes.forEach((node, i) => {\n    const was = kept[i] ? before[match.kept[i]] : undefined;\n    const home = keptHomes.get(node);\n    if (!was || !home) return;\n    if (Math.abs(home.top - after[i].top) > LINE_CHANGE * em) {\n      changesLine.push(node);\n      return;\n    }\n    const slide = Math.abs(centreDelta(was.rect, after[i])[0]);\n    if (slide > 0.5) reach.push([node, Math.ceil(slide)]);\n  });\n  const letter = nodes.findIndex((node) => !spaceNode(node));\n  const line = letter === -1 ? em * 1.2 : after[letter].height;\n\n  // Which way glyphs roll (`trend`, read off the value when `auto`): a rise\n  // brings new glyphs up from below and sends old ones up and away. Morph's\n  // `down`: digits fall in from above and leave downward while a\n  // number's other marks arrive from below, so each reads as its own event.\n  const rise = match.trend === 1;\n  const marksRise = o.mode === \"morph\" && o.trend === \"down\";\n  const arriveFor = (glyph: string): 1 | -1 =>\n    rise || (marksRise && !isDigit(glyph)) ? 1 : -1;\n  const away = rise ? -1 : 1;\n\n  // What arrives or leaves travels with its nearest surviving neighbour\n  // on its line, looking before it first when arriving and after it\n  // first when leaving. Its slot takes the trip and the glyph its own\n  // entrance inside it, so a digit rolls within a\n  // number that moves line, where adding the two up cancelled them (a\n  // morph digit rolls exactly one line). Morph and blend anchor everything; roll\n  // only digits, to the rest of their own number, and only across lines:\n  // its glyphs still roll in place on their line (a number that resizes\n  // doesn't drag them after its `$`), and a number that moves to another\n  // line takes its digits with it.\n  //\n  // Morph also scales a whole word arriving or leaving about its own\n  // centre, as one shape; in a one-word value, only a run of GROUP_MIN or\n  // more replaced glyphs, further and faster.\n  // Blend plans too: each changed run of letters scales about its centre.\n  const shaping = (o.mode === \"morph\" || o.mode === \"blend\") && !reduced;\n  // How far each survivor moved in the layout, from its old place to its\n  // final one. The layout, not where it's drawn: a digit still falling in is drawn above its\n  // place, and anchoring the next one to that stacked each fall on the\n  // last, so typing fast brought digits in from ever higher.\n  const startsAt = new Map<number, [number, number]>();\n  nodes.forEach((node, i) => {\n    const home = kept[i] && !spaceNode(node) ? keptHomes.get(node) : null;\n    if (home) startsAt.set(i, centreDelta(home, after[i]));\n  });\n  const isArriving = (i: number): boolean => !kept[i] && !spaceNode(nodes[i]);\n  const shapes = new Map<HTMLElement, Shape>();\n  if (shaping) {\n    // Planned by index (lib/shapes), then kept by node.\n    const plan = (\n      glyphs: HTMLElement[],\n      changed: (i: number) => boolean,\n      kinds: GlyphKind[],\n      boxOf: (i: number) => DOMRect | undefined,\n    ): void => {\n      const isSpace = (i: number): boolean => spaceNode(glyphs[i]);\n      // Blend: each changed run, letters or digits, whole word or not.\n      // Morph: whole words and long runs.\n      const planned =\n        o.mode === \"blend\"\n          ? planRuns({\n              count: glyphs.length,\n              isSpace,\n              changed,\n              boxOf,\n            })\n          : planShapes({\n              count: glyphs.length,\n              isSpace,\n              changed,\n              kinds,\n              byWords: match.byWords,\n              boxOf,\n            });\n      for (const [i, shape] of planned) shapes.set(glyphs[i], shape);\n    };\n    plan(nodes, isArriving, match.nextKinds, (i) => after[i]);\n    const homeAt = new Map(newSlots.map(({ index, home }) => [index, home]));\n    plan(\n      old,\n      (i) => homeAt.has(i),\n      match.oldKinds,\n      (i) => homeAt.get(i),\n    );\n  }\n  // Arriving and leaving glyphs travel with their neighbours (lib/anchors),\n  // planned by index, then leaving ones kept by node.\n  const trips = planTrips({\n    mode: o.mode,\n    reduced,\n    line,\n    kept: match.kept,\n    nextKinds: match.nextKinds,\n    oldKinds: match.oldKinds,\n    moves: startsAt,\n    nextTop: (i) => after[i]?.top,\n    keptTop: (i) => keptHomes.get(old[i])?.top,\n    arriving: isArriving,\n    leaving: newSlots.map(({ index, home }) => ({ index, top: home.top })),\n    grouped: (side, i) =>\n      shapes.get(side === \"next\" ? nodes[i] : old[i])?.group ?? false,\n  });\n  const arrivals = trips.arrivals;\n  // What holds the value, read at most once per change: it walks every\n  // ancestor, pseudo-elements too.\n  let seenBounds: Bounds | undefined;\n  const visible = (): Bounds => (seenBounds ??= visibleBounds(root));\n  // Arriving text carried in from past the edge a reader sees around the\n  // value (a right-pinned button's: `Copied` → `Copy page` brings `page`\n  // in from past its right edge) stays hidden past that edge while the\n  // change plays, the way leaving text carried out dissolves there under the\n  // edge fade. Blend needs it: its new text shows from its first frame,\n  // where morph's waits 100ms, by when it has come most of the way in.\n  // Clipped, since a mask can't hide what's outside its element.\n  const edgeClip =\n    o.mode === \"blend\" &&\n    easesAsBox &&\n    o.edgeFade !== \"never\" &&\n    arrivals.size > 0\n      ? arrivalClip(\n          [...arrivals].map(([i, [dx]]) => ({\n            left: after[i].left + dx,\n            right: after[i].right + dx,\n          })),\n          visible(),\n          rootBefore,\n          rootAfter,\n        )\n      : null;\n  const departures = new Map<HTMLElement, Trip>();\n  for (const { node, index } of newSlots) {\n    const trip = trips.departures.get(index);\n    if (trip) departures.set(node, trip);\n  }\n\n  // Ghost coordinates start at the layer's place in the flow, which holds\n  // still on screen for the whole change, so earlier ghosts shift by however\n  // far it moved, and hold theirs (placed in step 8).\n  const shiftX = originBefore.left - origin.left;\n  const shiftY = originBefore.top - origin.top;\n\n  // Edge fade (boxes only): the box before and after, and how far old ink\n  // reaches past it, in layer coordinates.\n  const boxStart = rootBefore.left - origin.left;\n  const boxEnd = rootBefore.right - origin.left;\n  const finalStart = rootAfter.left - origin.left;\n  const finalEnd = rootAfter.right - origin.left;\n  // And where leaving ink ends up when it travels with a neighbour: past an\n  // edge that doesn't move (a right-pinned button's), carried out by the\n  // survivor sliding toward it.\n  const carried = leaving.flatMap(({ node, was }) => {\n    const trip = departures.get(node);\n    const { x, y, width, height } = was.rect;\n    return trip ? [new DOMRect(x + trip[0], y + trip[1], width, height)] : [];\n  });\n  const inkRects = [\n    ...ghosts.map((g) => g.drawn.rect),\n    ...leaving.map((l) => l.was.rect),\n    ...carried,\n  ];\n  // The glyph boxes decide whether ink sticks out; the room also covers\n  // their blur and tilt.\n  const glyphStart =\n    Math.min(Infinity, ...inkRects.map((r) => r.left)) - origin.left;\n  const glyphEnd =\n    Math.max(-Infinity, ...inkRects.map((r) => r.right)) - origin.left;\n  // How the last change armed each edge, for its ghosts still leaving: a\n  // band held at the container's edge stays there (kept on screen, since\n  // layer coordinates move) and one following the box keeps following it.\n  const wasArmed = (side: Side): Arm =>\n    readArm(\n      side === \"start\"\n        ? ghostLayer.dataset.armedStart\n        : ghostLayer.dataset.armedEnd,\n      origin.left,\n    );\n  // How each edge fades: not at all, following the box's edge (a neighbour\n  // moves with it), or held at the container's edge (layer coordinates):\n  // there only ink crossing it needs fading, and a band following the box\n  // swept across letters still well inside (clearing a long value).\n  const armed = (side: Side): Arm => {\n    if (!easesAsBox || o.edgeFade === \"never\" || inkRects.length === 0) {\n      return false;\n    }\n    const held = wasArmed(side);\n    if (held !== false) return held;\n    const travels =\n      side === \"start\"\n        ? Math.abs(boxStart - finalStart) > 0.5\n        : Math.abs(boxEnd - finalEnd) > 0.5;\n    const overhangs =\n      side === \"start\"\n        ? glyphStart < finalStart - 0.5\n        : glyphEnd > finalEnd + 0.5;\n    const carriedOut = carried.some((rect) =>\n      side === \"start\"\n        ? rect.left - origin.left < finalStart - 0.5\n        : rect.right - origin.left > finalEnd + 0.5,\n    );\n    if (!(travels && overhangs) && !carriedOut) return false;\n    if (o.edgeFade === \"always\") return \"box\";\n    const escapes = inkEscapes(\n      root,\n      side,\n      origin.left + (side === \"start\" ? glyphStart : glyphEnd),\n      visible,\n    );\n    if (escapes === false) return false;\n    return escapes === \"neighbour\" ? \"box\" : escapes - origin.left;\n  };\n  const armStart = armed(\"start\");\n  const armEnd = armed(\"end\");\n  // The space it takes eases from what it visibly took (its box plus any\n  // start margin still easing) to its new width.\n  const from = easesAsBox\n    ? rootBefore.width + (startBefore - startAfter) - rootAfter.width\n    : 0;\n  const resizes = Math.abs(from) > 0.5;\n  // A box eases its width, and the stage rides by a\n  // share of it in CSS (step 8). An inline label can't take a width, so it\n  // eases its start margin, and the stage rides against the move by the\n  // margin's exact value.\n  const widthAfter = sizeOf(styleAfter, rootAfter, \"width\");\n  // What the stage's percentages count: the label's content box.\n  const contentAfter = contentSize(styleAfter, rootAfter, \"width\");\n  const sizeProp = boxed ? \"width\" : \"marginInlineStart\";\n  const sizeTo = boxed ? widthAfter : startAfter;\n  yield;\n\n  // 6. Write: the label at the start of its resize.\n  const authorSize = root.style[sizeProp];\n  const authorHeight = root.style.height;\n  if (resizes) root.style[sizeProp] = `${sizeTo + from}px`;\n  // Its text sits at its start while it resizes (text-align in the CSS), so\n  // the stage's ride alone places it, whichever way the width goes.\n  if (resizes && boxed) root.dataset.sizing = \"\";\n  if (reheights) root.style.height = `${heightBefore}px`;\n  yield;\n\n  // 7. Read: where the label begins there, which the stage rides against.\n  // Read even when it doesn't resize: a neighbour changing in the same\n  // update may be easing its own space, which moves this label too.\n  const rootAt0 =\n    oneLine || reheights ? root.getBoundingClientRect() : rootAfter;\n  const glyphsAt0 =\n    oneLine || reheights ? glyphLayer.getBoundingClientRect() : glyphsAfter;\n  // Which edge the label is pinned by, for the next change's run.\n  if (resizes) {\n    const leftTravel = Math.abs(rootAfter.left - rootAt0.left);\n    const rightTravel = Math.abs(rootAfter.right - rootAt0.right);\n    const rootRtl = styleAfter.direction === \"rtl\";\n    const startTravel = rootRtl ? rightTravel : leftTravel;\n    const travel = leftTravel + rightTravel;\n    if (travel > 0.5) anchors.set(root, startTravel / travel);\n  }\n  yield;\n\n  // 8. Write: everything else, attributes and styles only. Nothing below\n  // reads layout or changes the DOM's structure.\n  root.style[sizeProp] = authorSize;\n  root.style.height = authorHeight;\n  glyphLayer.dataset.playing = \"\";\n  const clip = edgeClip === null ? null : String(++edgeClips);\n  if (clip !== null && edgeClip !== null) {\n    root.dataset.edgeClip = clip;\n    root.style.setProperty(\"--text-morph-clip\", edgeClip);\n  }\n  for (const { slot, box } of ghosts) {\n    placeSlot(slot, box, box.x + shiftX, box.y + shiftY);\n  }\n  for (const node of changesLine) {\n    node.parentElement?.setAttribute(\"data-travel\", \"\");\n  }\n  for (const { node, box } of newSlots) {\n    const [dx, dy] = departures.get(node) ?? [0, 0];\n    box.tripX = Math.ceil(Math.abs(dx));\n    box.tripY = Math.ceil(Math.abs(dy));\n  }\n  for (const [node, room] of reach) {\n    node.parentElement?.style.setProperty(\"--reach\", `${room}px`);\n  }\n\n  if (resizes) {\n    const resize = { duration: o.width.duration, easing: o.width.easing };\n    // While its space eases open, the value stays on one line. No-wrap\n    // changes line breaking, not display, and the value sits on one line\n    // either way.\n    const sizing = String(++sizings);\n    root.dataset.sizing = sizing;\n    const ease = run(\n      root,\n      [{ [sizeProp]: `${sizeTo + from}px` }, { [sizeProp]: `${sizeTo}px` }],\n      resize,\n    );\n    const release = (): void => {\n      if (root.dataset.sizing !== sizing) return;\n      delete root.dataset.sizing;\n      stage.style.left = \"\";\n    };\n    ease.finished.then(release, release);\n  }\n\n  // A box's height eases the same way, from what it was to what its new\n  // lines take; the value's lines are laid out as they end up throughout.\n  if (reheights) {\n    run(\n      root,\n      [{ height: `${heightBefore}px` }, { height: `${heightAfter}px` }],\n      { duration: o.width.duration, easing: o.width.easing },\n    );\n  }\n\n  // The label moves while space eases (its own margin or height, a centred\n  // or pinned container around it, or a neighbour changing in the same\n  // update easing its space); ride the other way on the width's curve, so\n  // the glyphs and ghosts hold the final places they were measured at.\n  // A move that is just the label's own margin (all of it pinned at the\n  // start or end, half of it centred) rides by the margin's exact value:\n  // the measured one is rounded to layout units (1/64px), and the two\n  // easing from values that far apart round apart frame to frame, so the\n  // glyphs flickered a device pixel side to side (116 flips in one change\n  // at 125% scaling).\n  // A box rides by how far its glyphs moved (its alignment inside mostly\n  // holds them); an inline label by how far it moved.\n  const measuredX = boxed\n    ? glyphsAfter.left - glyphsAt0.left\n    : rootAfter.left - rootAt0.left;\n  const ownX = resizes && !boxed ? ownShift(from, measuredX) : undefined;\n  // A box whose glyphs move with its width rides by that share of its width\n  // as it eases, in CSS.\n  const follows = boxed && resizes ? followShare(from, measuredX) : undefined;\n  if (follows !== undefined) {\n    stage.style.left = `calc(${follows} * (${contentAfter}px - 100%))`;\n  }\n  const rideX = follows !== undefined ? 0 : (ownX ?? measuredX);\n  const rideY = boxed\n    ? glyphsAfter.top - glyphsAt0.top\n    : rootAfter.top - rootAt0.top;\n  if (Math.abs(rideX) > 0.5 || Math.abs(rideY) > 0.5) {\n    run(\n      stage,\n      [\n        { left: `${rideX}px`, top: `${rideY}px` },\n        { left: \"0px\", top: \"0px\" },\n      ],\n      { duration: o.width.duration, easing: o.width.easing },\n    );\n  }\n\n  const motion = { duration: o.motion.duration, easing: o.motion.easing };\n  // A glyph in a shape slides toward its centre as it scales, out of its\n  // slot, whose fade would cut it off; it doesn't roll, so it goes unmasked.\n  // So does one in a blend run, scaling about the run's centre.\n  const unmaskedBy = (shape: Shape | undefined): boolean =>\n    shape !== undefined && (shape.group || o.mode === \"blend\");\n  // Where an arriving glyph starts, or a leaving one ends: a group recedes\n  // about its centre; anything else takes its mode's entrance, rolling\n  // with its trip.\n  const awayFor = (\n    shape: Shape | undefined,\n    kind: GlyphKind,\n    trip: Trip | undefined,\n    direction: 1 | -1,\n  ): Keyframe =>\n    shape?.group\n      ? { translate: \"0 0\", scale: String(GROUP_SCALE), rotate: \"0deg\" }\n      : awayState(o, kind, rollWith(trip, direction, line), line);\n  // A filter animates only when there's blur to show: morph's default has\n  // none, and a filter animation per glyph costs even when it changes\n  // nothing.\n  const blur = o.blur > 0 ? `blur(${o.blur}em)` : null;\n  const fade = (opacity: number, filter: string | null): Keyframe =>\n    filter === null ? { opacity } : { opacity, filter };\n  const sharp = (filter: string | null): string | null =>\n    filter === null ? null : \"blur(0px)\";\n  const lineStart = (rect: DOMRect, bounds: DOMRect): number =>\n    rtl ? bounds.right - rect.right : rect.left - bounds.left;\n  const delays = staggerDelays(\n    o,\n    nodes.flatMap((node, i) =>\n      kept[i] || spaceNode(node) ? [] : [lineStart(after[i], rootAfter)],\n    ),\n    leaving.map(({ was }) => lineStart(was.rect, rootBefore)),\n  );\n\n  // Shared glyphs slide from wherever they were, including mid-animation.\n  let entering = 0;\n  nodes.forEach((node, i) => {\n    if (spaceNode(node)) return;\n    const was = kept[i] ? before[match.kept[i]] : undefined;\n    const kind = match.nextKinds[i];\n    const { fadeIn } = fadesFor(o, kind);\n    // Scale pivots on the glyph's own centre unless it arrives in a shape.\n    const shape = shapes.get(node);\n    setOrigin(node, shape?.origin ?? \"\");\n    const unmasked = unmaskedBy(shape);\n    if (unmasked) node.parentElement?.setAttribute(\"data-travel\", \"\");\n    // Only what rolls, or is drawn off its line, fades in its slot.\n    const fadesInSlot = (): void => {\n      if (!unmasked) node.parentElement?.setAttribute(\"data-fade\", \"\");\n    };\n    if (!was) {\n      fadesInSlot();\n      const delay = delays.entering[entering++];\n      const awayFrame = awayFor(\n        shape,\n        kind,\n        arrivals.get(i),\n        arriveFor(next[i]),\n      );\n      // Under reduced motion it only fades in, where it lands.\n      if (!reduced) {\n        run(node, [awayFrame, HOME], {\n          ...motion,\n          delay,\n          fill: \"backwards\",\n        });\n      }\n      const shift = arrivals.get(i);\n      const slot = node.parentElement;\n      if (shift && slot) {\n        run(\n          slot,\n          [{ translate: `${shift[0]}px ${shift[1]}px` }, { translate: \"0 0\" }],\n          // With its neighbour from the start: the stagger delays its own\n          // entrance, not the trip, or it lagged behind the neighbour it\n          // travels with (a swept `!` ran over the `d` before it).\n          { ...motion, fill: \"backwards\" },\n        );\n      }\n      const fadeTiming = shape?.group\n        ? {\n            duration: o.motion.duration * GROUP_FADE_IN,\n            easing: \"linear\",\n            delay,\n          }\n        : {\n            duration: fadeIn.duration,\n            easing: fadeIn.easing,\n            delay: delay + fadeIn.delay,\n          };\n      run(node, [fade(0, blur), fade(1, sharp(blur))], {\n        ...fadeTiming,\n        fill: \"backwards\",\n      });\n      return;\n    }\n    // Under reduced motion a kept glyph takes its new place directly.\n    const [dx, dy] = centreDelta(was.rect, after[i]);\n    const home = keptHomes.get(node);\n    const rolls = rolling.get(node);\n    const stays =\n      home !== undefined &&\n      centreDelta(home, after[i]).every((d) => Math.abs(d) < 0.01);\n    if (!reduced && rolls && stays) {\n      // Its place holds: the roll it was on carries on where it was.\n      fadesInSlot();\n      for (const { keyframes, timing, time } of rolls) {\n        run(node, keyframes, timing).currentTime = time;\n      }\n    } else if (\n      !reduced &&\n      (Math.abs(dx) > 0.5 ||\n        Math.abs(dy) > 0.5 ||\n        was.scale !== \"1\" ||\n        was.rotate !== \"0deg\")\n    ) {\n      // A slide along its line needs no fade; drawn off it (mid-roll,\n      // scaled or tilted) it does.\n      if (Math.abs(dy) > 0.5 || was.scale !== \"1\" || was.rotate !== \"0deg\") {\n        fadesInSlot();\n      }\n      run(\n        node,\n        [\n          {\n            translate: `${dx}px ${dy}px`,\n            scale: was.scale,\n            rotate: was.rotate,\n          },\n          HOME,\n        ],\n        // Roll: on the box's curve, not the roll's spring, or a\n        // kept run outruns the box resizing around it.\n        o.mode === \"roll\"\n          ? { duration: o.width.duration, easing: o.width.easing }\n          : motion,\n      );\n    }\n    // Mid-fade (or mid-blur, from an earlier change) it catches up: the next\n    // change means a newer value is what to read, so it finishes over a\n    // quarter of its fade at most (50ms in morph) rather than hanging\n    // half-faded. Restarting the whole fade left a run of quick changes (a\n    // held key) crawling in, so it snaps to full in one frame instead.\n    const blurred = was.filter !== \"blur(0px)\" ? was.filter : null;\n    if (was.opacity < 0.999 || blurred !== null) {\n      const left = Math.max(1 - was.opacity, blurred !== null ? CATCH_UP : 0);\n      run(node, [fade(was.opacity, blurred), fade(1, sharp(blurred))], {\n        duration: fadeIn.duration * Math.min(left, CATCH_UP),\n        easing: fadeIn.easing,\n      });\n    }\n  });\n\n  // Leaving glyphs sit at the place they had in the layout, and start from\n  // wherever they were drawn.\n  for (const { slot, box, home } of newSlots) {\n    placeSlot(slot, box, home.left - origin.left, home.top - origin.top);\n  }\n\n  // Size the layer around every slot (a mask cuts whatever falls outside\n  // its element) and, when the edge fade is armed, the band's reach.\n  const boxes = [...ghosts, ...newSlots].map((g) => g.box);\n  if (boxes.length === 0) {\n    resetLayer(ghostLayer);\n    return started;\n  }\n  const slack = EDGE_SLACK * em;\n  const padX = SLOT_PAD_X * em;\n  const padY = Math.max(SLOT_PAD_Y * em, (SLOT_MIN_HEIGHT * em - line) / 2);\n  let minX = Infinity;\n  let minY = Infinity;\n  let maxX = -Infinity;\n  let maxY = -Infinity;\n  for (const { x, y, width, height, tripX, tripY } of boxes) {\n    const room = padX + tripX + INK_MARGIN * em;\n    const roomY = padY + tripY;\n    minX = Math.min(minX, x - room);\n    maxX = Math.max(maxX, x + width + room);\n    minY = Math.min(minY, y - roomY);\n    maxY = Math.max(maxY, y + height + roomY);\n  }\n  const fades = armStart !== false || armEnd !== false;\n  if (fades) {\n    minX = Math.min(minX, boxStart - slack, finalStart - slack);\n    maxX = Math.max(maxX, boxEnd + slack, finalEnd + slack);\n    if (typeof armStart === \"number\") minX = Math.min(minX, armStart);\n    if (typeof armEnd === \"number\") maxX = Math.max(maxX, armEnd);\n  }\n  const ox = Math.floor(minX) - 1;\n  const oy = Math.floor(minY) - 1;\n  const layerWidth = Math.ceil(maxX) + 1 - ox;\n  ghostLayer.style.setProperty(\"--ox\", `${ox}px`);\n  ghostLayer.style.setProperty(\"--oy\", `${oy}px`);\n  ghostLayer.style.width = `${layerWidth}px`;\n  ghostLayer.style.height = `${Math.ceil(maxY) + 1 - oy}px`;\n\n  // The edge fade: a band on each armed edge that follows the box edge on\n  // the width's curve, ending a little past it, or holds at the container's.\n  if (fades) {\n    const ramp = EDGE_RAMP * em;\n    // The mask's band for a box spanning start..end (layer coordinates).\n    const maskAt = (start: number, end: number): Keyframe => {\n      const { left, right } = edgeBand(\n        start,\n        end,\n        { start: armStart, end: armEnd },\n        { left: ox, width: layerWidth },\n        slack,\n      );\n      return {\n        maskPosition: `${left}px 0`,\n        maskSize: `${right - left}px 100%`,\n      };\n    };\n    const markStart = armMark(armStart, origin.left);\n    const markEnd = armMark(armEnd, origin.left);\n    if (markStart === undefined) delete ghostLayer.dataset.armedStart;\n    else ghostLayer.dataset.armedStart = markStart;\n    if (markEnd === undefined) delete ghostLayer.dataset.armedEnd;\n    else ghostLayer.dataset.armedEnd = markEnd;\n    ghostLayer.style.setProperty(\n      \"--text-morph-edge\",\n      `linear-gradient(to right, ${armStart !== false ? `transparent, #000 ${ramp}px` : \"#000\"}, ${\n        armEnd !== false ? `#000 calc(100% - ${ramp}px), transparent` : \"#000\"\n      })`,\n    );\n    run(ghostLayer, [maskAt(boxStart, boxEnd), maskAt(finalStart, finalEnd)], {\n      duration: o.width.duration,\n      easing: o.width.easing,\n      fill: \"forwards\",\n    });\n  } else {\n    disarm(ghostLayer);\n  }\n\n  newSlots.forEach(({ slot, node, kind, home, was }, i) => {\n    const { fadeOut } = fadesFor(o, kind);\n    const delay = delays.leaving[i];\n    // Drawn where it was: the offset its animation had it at. Under\n    // reduced motion it fades out there.\n    const [dx, dy] = centreDelta(was.rect, home);\n    const drawn: Keyframe = {\n      translate: `${dx}px ${dy}px`,\n      scale: was.scale,\n      rotate: was.rotate,\n    };\n    // A shape's pivot applies only to a glyph at rest, or its first frame\n    // would jump.\n    const atRest = was.scale === \"1\" && was.rotate === \"0deg\";\n    const shape = atRest ? shapes.get(node) : undefined;\n    setOrigin(node, shape?.origin ?? \"\");\n    if (unmaskedBy(shape)) slot.setAttribute(\"data-travel\", \"\");\n    // It leaves the way its trip goes.\n    const awayFrame = awayFor(shape, kind, departures.get(node), away);\n    const move = run(\n      node,\n      [drawn, reduced ? drawn : awayFrame],\n      // Both: its starting place holds through the stagger delay too.\n      { ...motion, delay, fill: \"both\" },\n    );\n    const trip = departures.get(node);\n    if (trip) {\n      run(\n        slot,\n        [{ translate: \"0 0\" }, { translate: `${trip[0]}px ${trip[1]}px` }],\n        // With its neighbour from the start, whatever its own delay.\n        { ...motion, fill: \"both\" },\n      );\n    }\n    const blurred = was.filter !== \"blur(0px)\" ? was.filter : null;\n    // One caught before it showed (still in its fade-in delay) leaves from\n    // full: leaving from nothing, a run of quick changes (spam\n    // clicking) showed nothing fading out at all.\n    const shown = was.opacity === 0 ? 1 : was.opacity;\n    const out = run(\n      node,\n      [\n        fade(shown, blurred ?? (blur && \"blur(0px)\")),\n        fade(0, blur ?? (blurred && \"blur(0px)\")),\n      ],\n      {\n        ...(shape?.group\n          ? { duration: o.motion.duration * GROUP_FADE_OUT, easing: \"linear\" }\n          : { duration: fadeOut.duration, easing: fadeOut.easing }),\n        delay,\n        fill: \"both\",\n      },\n    );\n    // A ghost that has left stays in place, invisible, until the last one\n    // has: removing them together is one change to the DOM's structure (one\n    // restyle) instead of one per ghost.\n    const finish = (): void => {\n      if (slot.parentNode !== ghostLayer) return;\n      slot.dataset.gone = \"\";\n      const slots = Array.from(ghostLayer.children) as HTMLElement[];\n      if (slots.every((ghost) => ghost.dataset.gone !== undefined)) {\n        for (const animation of ghostLayer.getAnimations()) animation.cancel();\n        ghostLayer.replaceChildren();\n        resetLayer(ghostLayer);\n      }\n    };\n    // Cancelled by a later change, which already moved on without it.\n    Promise.all([move.finished, out.finished]).then(finish, finish);\n  });\n  if (clip !== null) {\n    const lift = (): void => {\n      if (root.dataset.edgeClip === clip) delete root.dataset.edgeClip;\n    };\n    void Promise.allSettled(started.map((a) => a.finished)).then(lift);\n  }\n  return started;\n}\n\nexport type TextMorphProps = Omit<\n  useRender.ComponentProps<\"span\">,\n  \"children\"\n> & {\n  /** The text. A number is formatted with `locale` and `format`. */\n  value: string | number;\n  /** How changes animate; each mode brings its own defaults. */\n  mode?: TextMorphMode;\n  /** Letters (`glyph`, the default), words, or `auto`: see `TextMorphSplit`. */\n  split?: TextMorphSplit;\n  /**\n   * How long movement takes, in ms: the mode's whole clock (fades, width,\n   * stagger) scales with it, so the look holds. Shorter for labels that\n   * answer a click or mirror typing.\n   */\n  duration?: number;\n  /**\n   * Fine tuning: any option, nested fields included, over the mode's\n   * defaults (`MODE_DEFAULTS`).\n   */\n  options?: TextMorphOverrides;\n  /**\n   * The locale a number `value` is formatted in, and whose decimal\n   * separator numbers in a string `value` are aligned on. Fixed rather than\n   * the browser's, so the server and the browser render the same text.\n   */\n  locale?: string;\n  /** How a number `value` is formatted (`Intl.NumberFormat` options). */\n  format?: Intl.NumberFormatOptions;\n  /** Swap the text without animating. */\n  disableAnimation?: boolean;\n  /**\n   * When changes crossfade in place instead (nothing travels, scales,\n   * tilts, blurs or resizes): `user` when the reader prefers reduced\n   * motion, `always`, or `never`.\n   */\n  reducedMotion?: \"user\" | \"always\" | \"never\";\n  /** A change started animating. */\n  onMorphStart?: () => void;\n  /**\n   * A change finished, every glyph and the box settled. Fires right away\n   * for a change that didn't animate (`disableAnimation`, off screen). Each\n   * change ends in exactly one of this and `onMorphCancel`, unless the label\n   * unmounts or is hidden first.\n   */\n  onMorphComplete?: () => void;\n  /** A change was interrupted by the next one. */\n  onMorphCancel?: () => void;\n  /**\n   * For a field someone is typing in: where the caret sits in `value` after\n   * the edit, as a string index (an input's `selectionStart`). Glyphs are\n   * matched around it, so typing 1 in front of 20 inserts a digit instead of\n   * renumbering the column. Leave it unset for values that change on their\n   * own.\n   */\n  cursorIndex?: number;\n};\n\n/** One formatter per locale and format: making one is Intl's slow part. */\nconst formatters = new Map<string, Intl.NumberFormat>();\n\nfunction formatValue(\n  value: string | number,\n  locale: string,\n  format: Intl.NumberFormatOptions | undefined,\n): string {\n  if (typeof value === \"string\") return value;\n  const key = `${locale}|${format ? JSON.stringify(format) : \"\"}`;\n  let formatter = formatters.get(key);\n  if (!formatter) {\n    formatter = new Intl.NumberFormat(locale, format);\n    formatters.set(key, formatter);\n  }\n  return formatter.format(value);\n}\n\nfunction TextMorph({\n  value: rawValue,\n  mode = \"blend\",\n  split = \"glyph\",\n  duration,\n  options,\n  locale = \"en\",\n  format,\n  disableAnimation = false,\n  reducedMotion = \"user\",\n  onMorphStart,\n  onMorphComplete,\n  onMorphCancel,\n  cursorIndex,\n  className,\n  render,\n  ...props\n}: TextMorphProps): React.ReactElement {\n  const value = formatValue(rawValue, locale, format);\n  const rootRef = React.useRef<HTMLSpanElement>(null);\n  const stageRef = React.useRef<HTMLSpanElement>(null);\n  const glyphsRef = React.useRef<HTMLSpanElement>(null);\n  const ghostAnchorRef = React.useRef<HTMLSpanElement>(null);\n  const ghostLayerRef = React.useRef<HTMLSpanElement>(null);\n  // Set once: after that this component owns the glyph markup.\n  const [initialHtml] = React.useState(() => ({\n    __html: glyphsHtml(value, split),\n  }));\n  // What the glyph markup currently shows, and how it was split.\n  const shown = React.useRef({ value, split });\n  const resolved = resolveOptions(mode, options, duration);\n  const latest = React.useRef({\n    options: resolved,\n    disableAnimation,\n    reducedMotion,\n    onMorphStart,\n    onMorphComplete,\n    onMorphCancel,\n    cursorIndex,\n  });\n  React.useLayoutEffect(() => {\n    latest.current = {\n      options: resolved,\n      disableAnimation,\n      reducedMotion,\n      onMorphStart,\n      onMorphComplete,\n      onMorphCancel,\n      cursorIndex,\n    };\n  });\n\n  // The change in flight: the next one cancels it. Unmounting, or an\n  // Activity or Suspense boundary hiding the label, silences it: it still\n  // settles, but calls nothing back (dropping it instead let a label shown\n  // again report it complete).\n  const inFlight = React.useRef<{\n    cancel: () => void;\n    silence: () => void;\n  } | null>(null);\n  React.useLayoutEffect(\n    () => () => {\n      inFlight.current?.silence();\n      inFlight.current = null;\n    },\n    [],\n  );\n\n  React.useLayoutEffect(() => {\n    const root = rootRef.current;\n    const stage = stageRef.current;\n    const glyphLayer = glyphsRef.current;\n    const ghostAnchor = ghostAnchorRef.current;\n    const ghostLayer = ghostLayerRef.current;\n    if (\n      !root ||\n      !stage ||\n      !glyphLayer ||\n      !ghostAnchor ||\n      !ghostLayer ||\n      (value === shown.current.value && split === shown.current.split)\n    )\n      return;\n    // A new split alone re-cuts the same text: rebuild it in place, quietly.\n    const resplit = value === shown.current.value;\n    shown.current = { value, split };\n    inFlight.current?.cancel();\n    inFlight.current = null;\n\n    const current = latest.current;\n    const reduce =\n      current.reducedMotion === \"always\" ||\n      (current.reducedMotion === \"user\" &&\n        window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches);\n    let over = false;\n    let silent = resplit;\n    const handle = {\n      cancel: (): void => {\n        if (over) return;\n        over = true;\n        if (!silent) latest.current.onMorphCancel?.();\n      },\n      silence: (): void => {\n        silent = true;\n      },\n    };\n    inFlight.current = handle;\n    const settle = (): void => {\n      if (over) return;\n      over = true;\n      if (inFlight.current === handle) inFlight.current = null;\n      delete glyphLayer.dataset.playing;\n      delete root.dataset.animating;\n      if (!silent) latest.current.onMorphComplete?.();\n    };\n    scheduleMorph({\n      root,\n      steps: morphTo(\n        { root, stage, glyphLayer, ghostAnchor, ghostLayer },\n        textUnits(value, split),\n        current.options,\n        { decimal: decimalFor(locale), caret: current.cursorIndex },\n        // Read in the batch's first read phase, with the others.\n        () =>\n          resplit || current.disableAnimation || !isOnScreen(root)\n            ? \"none\"\n            : reduce\n              ? \"reduced\"\n              : \"full\",\n      ),\n      done: (started) => {\n        if (over) return;\n        if (started.length === 0) {\n          settle();\n          return;\n        }\n        root.dataset.animating = \"\";\n        if (!silent) latest.current.onMorphStart?.();\n        void Promise.allSettled(started.map((a) => a.finished)).then(settle);\n      },\n    });\n  }, [value, split, locale]);\n\n  const defaultProps = {\n    \"data-slot\": \"text-morph\",\n    \"data-mode\": mode,\n    className: cn(className),\n    children: (\n      <>\n        {/* Keyed by the value: a page machine-translated in place swaps\n            this text for its own nodes, which React would then leave\n            stale. Remounted, the copy screen readers hear stays current. */}\n        <span key={value} className=\"sr-only\">\n          {value}\n        </span>\n        <span\n          ref={stageRef}\n          aria-hidden=\"true\"\n          translate=\"no\"\n          className=\"text-morph-stage\"\n        >\n          {/* Each glyph is its own box, which bidi treats as direction-\n              neutral: without its own direction, a Latin value in a\n              right-to-left paragraph would be laid out backwards. auto\n              reads it off the value's first strong character. */}\n          <span\n            ref={glyphsRef}\n            dir=\"auto\"\n            className=\"text-morph-glyphs\"\n            dangerouslySetInnerHTML={initialHtml}\n          />\n          <span ref={ghostAnchorRef} className=\"text-morph-ghosts\">\n            <span ref={ghostLayerRef} className=\"text-morph-ghost-sheet\" />\n          </span>\n        </span>\n      </>\n    ),\n  };\n\n  return useRender({\n    defaultTagName: \"span\",\n    render,\n    ref: rootRef,\n    props: mergeProps<\"span\">(defaultProps, props),\n  });\n}\n\nexport { TextMorph };\nexport { MODE_DEFAULTS } from \"./lib/options\";\nexport type { TextMorphSplit } from \"./lib/match\";\nexport type {\n  TextMorphMode,\n  TextMorphOptions,\n  TextMorphOverrides,\n  TextMorphTiming,\n} from \"./lib/options\";\n",
      "type": "registry:ui",
      "target": "components/ui/cubby-ui/text-morph/text-morph.tsx"
    },
    {
      "path": "registry/default/text-morph/lib/anchors.ts",
      "content": "import type { GlyphKind } from \"./match\";\nimport type { TextMorphMode } from \"./options\";\n\n/** A trip on screen: how far a glyph travels, [x, y] in px. */\nexport type Trip = [number, number];\n\n/**\n * A trip to another line, not a centred value recentring by half a line as\n * its line count changes.\n */\nexport function crossesLines([, dy]: Trip, line: number): boolean {\n  return Math.abs(dy) > line * 0.75;\n}\n\n/**\n * Whether every arriving and leaving glyph travels with its neighbours, on\n * its line too. Morph and blend: new text appears where it ends up while\n * the words that stay slide into place, so without it they ran into each\n * other mid-change (`Draft saved.` → `Changes saved!` drew `Changesaved`).\n * Roll's glyphs roll in place on their line.\n */\nconst anchorsAll = (mode: TextMorphMode): boolean =>\n  mode === \"morph\" || mode === \"blend\";\n\n/**\n * Which way a glyph rolls (1 up from below, -1 down from above): with its\n * trip when that crosses lines, so a number that moves up scrolls up as it\n * changes (rolling against its trip, it would hold still on screen while its\n * slot slid past), and `otherwise` the way the change sends it.\n */\nexport function rollWith(\n  trip: Trip | undefined,\n  otherwise: 1 | -1,\n  line: number,\n): 1 | -1 {\n  return trip && crossesLines(trip, line) ? (trip[1] > 0 ? 1 : -1) : otherwise;\n}\n\n/**\n * Where a glyph at `i` may find a neighbour to travel with, as an index\n * range: anywhere in the value in morph and blend; in roll mode only a\n * digit, and only within its own number. None under reduced motion.\n */\nexport function neighbourScope(\n  mode: TextMorphMode,\n  reduced: boolean,\n  kinds: GlyphKind[],\n  i: number,\n): [number, number] | null {\n  if (reduced) return null;\n  if (anchorsAll(mode)) return [0, kinds.length - 1];\n  if (kinds[i] !== \"number\") return null;\n  let first = i;\n  let last = i;\n  while (first > 0 && kinds[first - 1] === \"number\") first--;\n  while (last < kinds.length - 1 && kinds[last + 1] === \"number\") last++;\n  return [first, last];\n}\n\n/**\n * The trip of the nearest glyph around `index` (within `range`) that has\n * one, looking after it first or before it first.\n */\nexport function nearestTrip(\n  index: number,\n  [first, last]: [number, number],\n  tripAt: (i: number) => Trip | undefined,\n  forwardFirst: boolean,\n): Trip | undefined {\n  const scan = (step: 1 | -1): Trip | undefined => {\n    for (let j = index + step; j >= first && j <= last; j += step) {\n      const trip = tripAt(j);\n      if (trip) return trip;\n    }\n    return undefined;\n  };\n  const [one, two]: [1 | -1, 1 | -1] = forwardFirst ? [1, -1] : [-1, 1];\n  return scan(one) ?? scan(two);\n}\n\n/**\n * What arrives or leaves travels with its nearest surviving neighbour on its\n * line: arriving glyphs look before them first and start where that\n * neighbour started; leaving glyphs look after them first and go where it\n * goes. Morph and blend anchor everything; roll only digits, and only across\n * lines. A glyph in a shape swapped as one doesn't travel.\n *\n * Survivors' trips (`moves`, by new index) are how far each moved in the\n * layout, not where it's drawn. A neighbour on another line doesn't count:\n * in wrapped text the glyph before a line's first ends the line above, and\n * travelling with it would carry the glyph off the start of its own line.\n */\nexport function planTrips({\n  mode,\n  reduced,\n  line,\n  kept,\n  nextKinds,\n  oldKinds,\n  moves,\n  nextTop,\n  keptTop,\n  arriving,\n  leaving,\n  grouped,\n}: {\n  mode: TextMorphMode;\n  reduced: boolean;\n  /** Line height, px. */\n  line: number;\n  /** For each new glyph, the old index it keeps, or -1. */\n  kept: number[];\n  nextKinds: GlyphKind[];\n  oldKinds: GlyphKind[];\n  /** How far each survivor moved, by new index. */\n  moves: Map<number, Trip>;\n  /** Where each new glyph's line is (its top), by new index. */\n  nextTop: (i: number) => number | undefined;\n  /** Where each surviving old glyph's line was, by old index. */\n  keptTop: (i: number) => number | undefined;\n  /** Whether the new glyph at `i` arrives (not kept, not a space). */\n  arriving: (i: number) => boolean;\n  /** The leaving glyphs: old index, and where their line was. */\n  leaving: { index: number; top: number }[];\n  /** Whether a glyph belongs to a shape swapped as one (new or old side). */\n  grouped: (side: \"next\" | \"old\", i: number) => boolean;\n}): { arrivals: Map<number, Trip>; departures: Map<number, Trip> } {\n  const sameLine = (a: number | undefined, b: number | undefined): boolean =>\n    a !== undefined && b !== undefined && Math.abs(a - b) < line / 2;\n  const travels = (trip: Trip): boolean =>\n    anchorsAll(mode) || crossesLines(trip, line);\n\n  const arrivals = new Map<number, Trip>();\n  for (let i = 0; i < nextKinds.length; i++) {\n    const range = neighbourScope(mode, reduced, nextKinds, i);\n    if (!range || !arriving(i) || grouped(\"next\", i)) continue;\n    const trip = nearestTrip(\n      i,\n      range,\n      (j) => (sameLine(nextTop(j), nextTop(i)) ? moves.get(j) : undefined),\n      false,\n    );\n    if (trip && travels(trip)) arrivals.set(i, trip);\n  }\n\n  const newIndexOf = new Map(\n    kept.flatMap((from, to) => (from === -1 ? [] : [[from, to]])),\n  );\n  const departures = new Map<number, Trip>();\n  for (const { index, top } of leaving) {\n    const range = neighbourScope(mode, reduced, oldKinds, index);\n    if (!range || grouped(\"old\", index)) continue;\n    const start = nearestTrip(\n      index,\n      range,\n      (j) => {\n        const to = newIndexOf.get(j);\n        if (to === undefined) return undefined;\n        return sameLine(keptTop(j), top) ? moves.get(to) : undefined;\n      },\n      true,\n    );\n    if (start && travels(start)) departures.set(index, [-start[0], -start[1]]);\n  }\n  return { arrivals, departures };\n}\n",
      "type": "registry:lib",
      "target": "components/ui/cubby-ui/text-morph/lib/anchors.ts"
    },
    {
      "path": "registry/default/text-morph/lib/edges.ts",
      "content": "/**\n * What happens at the edges of a label that resizes: arriving text hidden\n * past an edge it holds still, and the edge fade on leaving text. Pure\n * arithmetic on boxes; the engine measures them.\n */\n\n/** A horizontal span on screen, or in the ghost layer's coordinates. */\nexport type Span = { left: number; right: number };\n\n/**\n * The clip for arriving text carried in from past the edge a reader sees\n * around the value (`bounds`, a pinned button's): it stays hidden past that\n * edge while the change plays. Only on an edge the label holds still (the\n * one it's pinned by, the same `before` and `after`), so the clip holds still\n * with it. Null when nothing arrives from past a held edge.\n */\nexport function arrivalClip(\n  arriving: Span[],\n  bounds: Span,\n  before: Span,\n  after: Span,\n): string | null {\n  const pastStart = arriving.some((span) => span.left < bounds.left - 0.5);\n  const pastEnd = arriving.some((span) => span.right > bounds.right + 0.5);\n  const clipLeft = pastStart && Math.abs(before.left - after.left) < 0.5;\n  const clipRight = pastEnd && Math.abs(before.right - after.right) < 0.5;\n  if (!clipLeft && !clipRight) return null;\n  const right = clipRight ? `${after.right - bounds.right}px` : \"-100vw\";\n  const left = clipLeft ? `${bounds.left - after.left}px` : \"-100vw\";\n  return `inset(-100vh ${right} -100vh ${left})`;\n}\n\n/**\n * How an edge of the ghost layer fades: not at all, following the box's\n * edge (a neighbour moves with it), or held at the container's edge (its\n * position, layer coordinates).\n */\nexport type Arm = false | \"box\" | number;\n\n/**\n * The fade's opaque band for a box spanning start..end, in layer\n * coordinates: an edge following the box ends `slack` past it, a held one\n * stays put, and an unarmed one reaches the layer's side.\n */\nexport function edgeBand(\n  start: number,\n  end: number,\n  arms: { start: Arm; end: Arm },\n  layer: { left: number; width: number },\n  slack: number,\n): Span {\n  const left =\n    arms.start === false\n      ? 0\n      : typeof arms.start === \"number\"\n        ? arms.start - layer.left\n        : start - slack - layer.left;\n  const right =\n    arms.end === false\n      ? layer.width\n      : typeof arms.end === \"number\"\n        ? arms.end - layer.left\n        : end + slack - layer.left;\n  return { left, right };\n}\n\n/**\n * An arm as the next change finds it: a held edge is kept as its place on\n * screen (`originX` is the layer's origin now), since layer coordinates move\n * between changes. Undefined for an edge that doesn't fade.\n */\nexport function armMark(arm: Arm, originX: number): string | undefined {\n  if (arm === false) return undefined;\n  return arm === \"box\" ? \"box\" : String(arm + originX);\n}\n\n/** An arm read back from its mark, in the layer's coordinates now. */\nexport function readArm(mark: string | undefined, originX: number): Arm {\n  if (mark === undefined) return false;\n  return mark === \"box\" ? \"box\" : parseFloat(mark) - originX;\n}\n",
      "type": "registry:lib",
      "target": "components/ui/cubby-ui/text-morph/lib/edges.ts"
    },
    {
      "path": "registry/default/text-morph/lib/match.ts",
      "content": "import type { TextMorphMode } from \"./options\";\n\n/*\n * Which glyphs of the old text survive into the new one. Pure: works on\n * grapheme strings, so it can be tested without a DOM.\n *\n * Numbers are matched by place value: the digits line up on the\n * decimal point, so 1,204 → 1,318 keeps the thousands and the comma, and a\n * digit that changes rolls in place. Everything else is matched by the\n * mode's text rule: roll keeps the shared ends and one run between them\n * morph matches whole words first, then letters within the\n * words it pairs, and letters anywhere only in a one-word value.\n *\n * A field someone is typing in knows where the edit happened, so with a\n * caret the whole value is matched around it instead:\n * typing 1 in front of 20 inserts a digit rather than renumbering the\n * column.\n */\n\n/** `number`: part of a number matched by place value. */\nexport type GlyphKind = \"text\" | \"number\";\n\nexport type MatchResult = {\n  /** For each new glyph, the index of the old glyph it keeps, or -1. */\n  kept: number[];\n  nextKinds: GlyphKind[];\n  oldKinds: GlyphKind[];\n  /** 1: the value went up (new glyphs arrive from below), -1: down. */\n  trend: 1 | -1;\n  /** Matched by words: always in blend, in morph once a value has several. */\n  byWords: boolean;\n};\n\nexport type MatchOptions = {\n  /** The locale's decimal separator. */\n  decimal: string;\n  /** 1 up, -1 down, 0 read it off the numbers. */\n  trend: -1 | 0 | 1;\n  /**\n   * Where the caret sits in the new value after an edit. Matches around it\n   * instead of by place value and the mode's text rule.\n   */\n  caret?: number;\n  /**\n   * Where the value is pinned as its length changes: 0 its start, 1 its end,\n   * 0.5 its middle (centred). A roll run's travel is measured on screen,\n   * against that, not along the text.\n   */\n  anchor?: number;\n};\n\ntype NumberToken = { start: number; end: number };\n\ntype Granularity = \"grapheme\" | \"word\";\n\n/**\n * Made on first use, and without Intl.Segmenter (Firefox before 125) left\n * out: made when the module loaded, it threw there, taking down the whole\n * bundle that imported it.\n */\nconst segmenters = new Map<Granularity, Intl.Segmenter | null>();\n\n/** Split into graphemes or words; by code point without Intl.Segmenter. */\nfunction segment(text: string, granularity: Granularity): string[] {\n  if (!segmenters.has(granularity)) {\n    segmenters.set(\n      granularity,\n      typeof Intl.Segmenter === \"function\"\n        ? new Intl.Segmenter(undefined, { granularity })\n        : null,\n    );\n  }\n  const segmenter = segmenters.get(granularity);\n  if (!segmenter) return Array.from(text);\n  return Array.from(segmenter.segment(text), (s) => s.segment);\n}\n\nconst graphemes = (text: string): string[] => segment(text, \"grapheme\");\n\n/**\n * A letter or mark from a script whose letters shape together: ones that\n * join (Arabic and the scripts written with it, Syriac, N'Ko, Mongolian,\n * ...) and ones that stack or fuse consonants into conjuncts (Devanagari and\n * the other Indic scripts, Sinhala, Tibetan, Thai, Lao, Khmer, Myanmar).\n * Each glyph is its own box, shaped alone, so a conjunct split across two\n * showed a loose virama or subscript. Their digits don't shape together, so\n * they're left out.\n */\nconst JOINING =\n  /(?=[\\p{L}\\p{M}])[\\p{Script=Arabic}\\p{Script=Syriac}\\p{Script=Nko}\\p{Script=Mongolian}\\p{Script=Mandaic}\\p{Script=Adlam}\\p{Script=Hanifi_Rohingya}\\p{Script=Thaana}\\p{Script=Devanagari}\\p{Script=Bengali}\\p{Script=Gurmukhi}\\p{Script=Gujarati}\\p{Script=Oriya}\\p{Script=Tamil}\\p{Script=Telugu}\\p{Script=Kannada}\\p{Script=Malayalam}\\p{Script=Sinhala}\\p{Script=Tibetan}\\p{Script=Thai}\\p{Script=Lao}\\p{Script=Khmer}\\p{Script=Myanmar}]/u;\n\n/**\n * What a value is split into. `glyph`: a box per letter, so letters can\n * move on their own (Copy → Copied keeps Cop), at the cost of kerning, which\n * a font applies only within one box. `word`: a box per word, kerned as\n * plain text, changing word by word. `auto`: words once the value has a\n * space, tab or line break, letters otherwise; a change across that line\n * (Copied → Copied link) shares no box, so it crossfades. Numbers go digit\n * by digit in all three. It takes effect from the next change of value.\n */\nexport type TextMorphSplit = \"glyph\" | \"word\" | \"auto\";\n\nconst LETTER = /\\p{L}/u;\nconst HAS_BREAK = /[ \\t\\n]/;\n\n/**\n * The units a value animates in: one per grapheme, except that a word\n * written in a script whose letters shape together stays whole. Its letters\n * take their joined or stacked forms only when drawn together, so splitting\n * it would render each alone. By word, every word with a letter stays\n * whole (v2 and OAuth2 included); spaces, punctuation, emoji and numbers are\n * still per grapheme, so numbers match by place value as before.\n */\nexport function textUnits(\n  text: string,\n  split: TextMorphSplit = \"glyph\",\n): string[] {\n  const byWord = split === \"word\" || (split === \"auto\" && HAS_BREAK.test(text));\n  if (!byWord && !JOINING.test(text)) return graphemes(text);\n  const words = segment(text, \"word\");\n  return words.flatMap((word) => {\n    const whole = JOINING.test(word) || (byWord && LETTER.test(word));\n    return whole ? [word] : graphemes(word);\n  });\n}\n\n/**\n * A caret given as a string index (an input's `selectionStart`, in UTF-16\n * code units) as a count of the units before it, which matching works in:\n * an emoji, an accented letter or a whole Arabic word before the caret is\n * several code units but one unit.\n */\nexport function caretUnits(units: string[], caret: number): number {\n  let length = 0;\n  let count = 0;\n  for (const unit of units) {\n    length += unit.length;\n    if (length > caret) break;\n    count++;\n  }\n  return count;\n}\n\nconst SIGN = \"+-−\";\nconst GROUP = \".,'   \\u066C\\u066B\";\n/** Any script's decimal digits (0-9, ٠-٩, ۰-۹, ...). */\nconst DIGIT = /^\\p{Nd}$/u;\n/** Percent signs a number can end with, Latin and Arabic. */\nconst PERCENT = \"%\\u066A\";\nconst CURRENCY = /^\\p{Sc}$/u;\n/** A punctuation mark, in any script. */\nconst PUNCTUATION = /^\\p{P}$/u;\n\n/** A decimal digit, in any script. */\nexport const isDigit = (g: string): boolean => DIGIT.test(g);\n\n/** Marks a number may open with, besides a currency symbol. */\nconst PREFIX = SIGN + \"(#\" + PERCENT;\n/** Marks a number may close with, besides a currency symbol. */\nconst SUFFIX = PERCENT + \".,!?:;)\\\"'\\u201D\\u2019\";\n\n/** A space, tab or line break: where words and lines may break. */\nexport const isBreak = (g: string): boolean =>\n  g === \" \" || g === \"\\t\" || g === \"\\n\";\n\n/**\n * Numbers in a grapheme list. A number is a whole word:\n * digits with group or decimal separators between them, opened only by a\n * sign, currency symbol, `(` or `#` and closed only by punctuation, so\n * `v1.2.3`, `2024-01-01` and `COVID-19` stay text. The number itself takes\n * in a sign and currency symbol before it and a `%` after it.\n */\nexport function findNumbers(glyphs: string[]): NumberToken[] {\n  const tokens: NumberToken[] = [];\n  const isAffix = (g: string, marks: string): boolean =>\n    marks.includes(g) || CURRENCY.test(g);\n  let wordStart = 0;\n  for (let i = 0; i <= glyphs.length; i++) {\n    if (i < glyphs.length && !isBreak(glyphs[i])) continue;\n    const wordFrom = wordStart;\n    const wordEnd = i;\n    let first = wordFrom;\n    let last = wordEnd;\n    wordStart = i + 1;\n    while (first < last && isAffix(glyphs[first], PREFIX)) first++;\n    while (last > first && isAffix(glyphs[last - 1], SUFFIX)) last--;\n    if (first >= last || !isDigit(glyphs[first]) || !isDigit(glyphs[last - 1]))\n      continue;\n    const core = glyphs.slice(first, last);\n    if (!core.every((g) => isDigit(g) || GROUP.includes(g))) continue;\n    let start = first;\n    // Leading currency symbol, then sign, either order.\n    for (let k = 0; k < 2; k++) {\n      const before = glyphs[start - 1];\n      if (start > wordFrom && (CURRENCY.test(before) || SIGN.includes(before)))\n        start--;\n    }\n    let end = last;\n    if (end < wordEnd && PERCENT.includes(glyphs[end])) end++;\n    tokens.push({ start, end });\n  }\n  return tokens;\n}\n\n/** Past this many digits gained or lost, a number is replaced, not moved. */\nconst MAGNITUDE_JUMP = 3;\n\n/**\n * How many glyphs two lists share at their start, then at their end without\n * overlapping it. `keeps` can end either run early at a glyph.\n */\nfunction commonEnds(\n  old: string[],\n  next: string[],\n  keeps: (glyph: string) => boolean = () => true,\n): [start: number, end: number] {\n  let start = 0;\n  while (\n    start < old.length &&\n    start < next.length &&\n    old[start] === next[start] &&\n    keeps(old[start])\n  ) {\n    start++;\n  }\n  let end = 0;\n  while (\n    end < old.length - start &&\n    end < next.length - start &&\n    old[old.length - 1 - end] === next[next.length - 1 - end] &&\n    keeps(old[old.length - 1 - end])\n  ) {\n    end++;\n  }\n  return [start, end];\n}\n\n/**\n * Pair two numbers' glyphs by place, as [new, old]\n * index pairs. The shared prefix and suffix (a currency symbol, a `%`)\n * hold. Digits pair on the decimal point: by column while the count stays\n * the same, and as the longest run carried from the units column when it\n * changes, so `12,345` → `1,234` keeps 1234 sliding over. A separator holds\n * its distance from the point (sliding the comma one group along on\n * `999,999` → `1,000,000`) unless digits carried a reshape, when it would\n * have to cross them and leaves instead. Gaining or losing three digits or\n * more replaces the number: the two no longer read as one figure moving.\n */\nexport function matchPlaces(\n  old: string[],\n  next: string[],\n  decimal: string,\n): [number, number][] {\n  const pairs = new Map<number, number>();\n  // Affixes hold (a currency symbol, a `%`); digits never count as one.\n  const [start, end] = commonEnds(old, next, (g) => !isDigit(g));\n  for (let k = 0; k < start; k++) pairs.set(k, k);\n  for (let k = 1; k <= end; k++) pairs.set(next.length - k, old.length - k);\n  const oldEnd = old.length - end;\n  const nextEnd = next.length - end;\n\n  // The decimal point, or the end where there is none.\n  const pivotOf = (glyphs: string[], end: number): number => {\n    for (let i = end - 1; i >= start; i--) if (glyphs[i] === decimal) return i;\n    return end;\n  };\n  const digitsIn = (glyphs: string[], from: number, to: number): number[] => {\n    const out: number[] = [];\n    for (let i = from; i < to; i++) if (isDigit(glyphs[i])) out.push(i);\n    return out;\n  };\n  const oldPivot = pivotOf(old, oldEnd);\n  const nextPivot = pivotOf(next, nextEnd);\n  const oldInt = digitsIn(old, start, oldPivot);\n  const nextInt = digitsIn(next, start, nextPivot);\n  // A side with no digits is a field being typed into or emptied.\n  if (\n    oldInt.length > 0 &&\n    nextInt.length > 0 &&\n    Math.abs(oldInt.length - nextInt.length) >= MAGNITUDE_JUMP\n  ) {\n    return [...pairs];\n  }\n\n  // Returns whether digits survived a change in their count.\n  const pairDigits = (a: number[], b: number[], reshapes: boolean): boolean => {\n    if (a.length === b.length || !reshapes) {\n      for (let k = 0; k < Math.min(a.length, b.length); k++) {\n        if (old[a[k]] === next[b[k]]) pairs.set(b[k], a[k]);\n      }\n      return false;\n    }\n    // From the units column, so ties on repeated digits resolve there.\n    const carried = lcs(\n      a.map((i) => old[i]).reverse(),\n      b.map((i) => next[i]).reverse(),\n    );\n    for (const [to, from] of carried) {\n      pairs.set(b[b.length - 1 - to], a[a.length - 1 - from]);\n    }\n    return carried.length > 0;\n  };\n  const separator = (from: number, to: number): void => {\n    if (!isDigit(old[from]) && old[from] === next[to]) pairs.set(to, from);\n  };\n\n  if (!pairDigits(oldInt, nextInt, true)) {\n    for (let k = 1; oldPivot - k >= start && nextPivot - k >= start; k++) {\n      separator(oldPivot - k, nextPivot - k);\n    }\n  }\n  // A fraction's columns are fixed by the point: 1.5 → 1.25 gains a\n  // hundredths rather than sliding the 5.\n  if (oldPivot < oldEnd && nextPivot < nextEnd) {\n    pairs.set(nextPivot, oldPivot);\n    for (let k = 1; oldPivot + k < oldEnd && nextPivot + k < nextEnd; k++) {\n      separator(oldPivot + k, nextPivot + k);\n    }\n    pairDigits(\n      digitsIn(old, oldPivot + 1, oldEnd),\n      digitsIn(next, nextPivot + 1, nextEnd),\n      false,\n    );\n  }\n  return [...pairs];\n}\n\n/**\n * A decimal digit's value, in any script: digits run in blocks of ten from\n * zero, so it's the distance from the start of its run.\n */\nexport function digitValue(digit: string): number {\n  const code = digit.codePointAt(0) ?? 0;\n  let start = code;\n  while (DIGIT.test(String.fromCodePoint(start - 1))) start--;\n  return (code - start) % 10;\n}\n\n/** The numeric value of a number token, or NaN. */\nexport function parseNumber(glyphs: string[], decimal: string): number {\n  let text = \"\";\n  for (const g of glyphs) {\n    if (isDigit(g)) text += digitValue(g);\n    else if (g === decimal) text += \".\";\n    else if (g === \"-\" || g === \"−\") text = `-${text}`;\n  }\n  return Number(text);\n}\n\n/** Keep the shared start and end. Returns [newIndex, oldIndex] pairs. */\nfunction matchEnds(\n  old: string[],\n  next: string[],\n  anchor: number,\n): [number, number][] {\n  const pairs: [number, number][] = [];\n  const [start, end] = commonEnds(old, next);\n  for (let k = 0; k < start; k++) pairs.push([k, k]);\n  for (let k = 0; k < end; k++) {\n    pairs.push([next.length - 1 - k, old.length - 1 - k]);\n  }\n\n  // Between them, one shared run flush with neither end (a floating\n  // run): xxlightxx → yylightyy keeps \"light\". It must be at\n  // least two glyphs (one shared letter is a coincidence) and may travel at\n  // most its own length plus two slots, so a word never swims across the\n  // value while everything around it dissolves.\n  const run = floatingRun(\n    old.slice(start, old.length - end),\n    next.slice(start, next.length - end),\n    anchor,\n  );\n  if (run) {\n    for (let k = 0; k < run.length; k++) {\n      pairs.push([start + run.to + k, start + run.from + k]);\n    }\n  }\n  return pairs;\n}\n\nconst MIN_RUN = 2;\nconst RUN_SLACK = 2;\n\n/**\n * The longest shared run within its travel cap; the shorter trip on ties.\n * Travel is how far it moves on screen: in centred or end-pinned text the\n * value grows or shrinks about that point, so a run shifted along the text\n * by the change in length (or half of it) holds still.\n */\nfunction floatingRun(\n  old: string[],\n  next: string[],\n  anchor: number,\n): { from: number; to: number; length: number } | null {\n  const grown = next.length - old.length;\n  const travelOf = (from: number, to: number): number =>\n    Math.abs(to - from - anchor * grown);\n  let best: { from: number; to: number; length: number } | null = null;\n  // lengths[j]: the shared run ending at old[i - 1] and next[j - 1].\n  let previous = new Array<number>(next.length + 1).fill(0);\n  for (let i = 1; i <= old.length; i++) {\n    const lengths = new Array<number>(next.length + 1).fill(0);\n    for (let j = 1; j <= next.length; j++) {\n      if (old[i - 1] !== next[j - 1]) continue;\n      const length = previous[j - 1] + 1;\n      lengths[j] = length;\n      const from = i - length;\n      const to = j - length;\n      const travel = travelOf(from, to);\n      if (length < MIN_RUN || travel > length + RUN_SLACK) continue;\n      if (\n        !best ||\n        length > best.length ||\n        (length === best.length && travel < travelOf(best.from, best.to))\n      ) {\n        best = { from, to, length };\n      }\n    }\n    previous = lengths;\n  }\n  return best;\n}\n\n/** Match each glyph to the first unused old copy of it, in order. */\nfunction matchAnywhere(old: string[], next: string[]): [number, number][] {\n  const pool = new Map<string, number[]>();\n  old.forEach((glyph, i) => {\n    const at = pool.get(glyph);\n    if (at) at.push(i);\n    else pool.set(glyph, [i]);\n  });\n  const pairs: [number, number][] = [];\n  next.forEach((glyph, i) => {\n    const from = pool.get(glyph)?.shift();\n    if (from !== undefined) pairs.push([i, from]);\n  });\n  return pairs;\n}\n\n/** Longest common subsequence of two lists, as [bIndex, aIndex] pairs. */\nfunction lcs(a: string[], b: string[]): [number, number][] {\n  // lengths[i][j]: the LCS of a[i..] and b[j..].\n  const lengths = Array.from({ length: a.length + 1 }, () =>\n    new Array<number>(b.length + 1).fill(0),\n  );\n  for (let i = a.length - 1; i >= 0; i--) {\n    for (let j = b.length - 1; j >= 0; j--) {\n      lengths[i][j] =\n        a[i] === b[j]\n          ? lengths[i + 1][j + 1] + 1\n          : Math.max(lengths[i + 1][j], lengths[i][j + 1]);\n    }\n  }\n  const pairs: [number, number][] = [];\n  let i = 0;\n  let j = 0;\n  while (i < a.length && j < b.length) {\n    if (a[i] === b[j]) {\n      pairs.push([j, i]);\n      i++;\n      j++;\n    } else if (lengths[i + 1][j] >= lengths[i][j + 1]) {\n      i++;\n    } else {\n      j++;\n    }\n  }\n  return pairs;\n}\n\n/** Stands in for a whole number when comparing words: numbers pair by place. */\nconst NUMBER_KEY = \"\\u0000#\";\n\ntype Word = {\n  /** Its glyphs' indices in the value. */\n  glyphs: number[];\n  /** What it's compared by: its text, with each number as one token. */\n  key: string[];\n};\n\n/** Words: runs of glyphs between spaces. */\nfunction wordsOf(glyphs: string[], kinds: GlyphKind[]): Word[] {\n  const words: Word[] = [];\n  let word: Word | null = null;\n  glyphs.forEach((glyph, i) => {\n    if (isBreak(glyph)) {\n      word = null;\n      return;\n    }\n    if (!word) {\n      word = { glyphs: [], key: [] };\n      words.push(word);\n    }\n    // A number counts once, however many glyphs it has.\n    if (kinds[i] === \"text\") word.key.push(glyph);\n    else if (word.glyphs.length === 0 || kinds[i - 1] !== \"number\") {\n      word.key.push(NUMBER_KEY);\n    }\n    word.glyphs.push(i);\n  });\n  return words;\n}\n\n/** Shared letters over the longer word's length; 1 for numbers alone. */\nfunction similarity(a: string[], b: string[]): number {\n  if (a.length === 0 || b.length === 0) return 0;\n  return lcs(a, b).length / Math.max(a.length, b.length);\n}\n\n/** Below this a new word enters whole rather than morphing from an old one. */\nconst MIN_SIMILARITY = 0.4;\n\n/** How many surviving words sit before each word: the gap it's in. */\nfunction gaps(count: number, survivors: Set<number>): number[] {\n  const out: number[] = [];\n  let before = 0;\n  for (let i = 0; i < count; i++) {\n    out.push(before);\n    if (survivors.has(i)) before++;\n  }\n  return out;\n}\n\n/**\n * The glyphs two runs share at their start and end, as [next, old] pairs,\n * and, when what's between is the same length on both sides (a clock, a\n * date, a version), any glyph that's the same in the same place there:\n * `09:59` → `10:00` keeps its colon, `v1.3.0` → `v2.0.0` its dots. A\n * letter or digit never moves to another place, as a reused one would; a\n * punctuation mark found once in what's between on each side does, since\n * it's structure: `12.4M` → `1.1B` slides its decimal point over, as the\n * number reshapes.\n */\nfunction sharedEnds(old: string[], next: string[]): [number, number][] {\n  const [start, end] = commonEnds(old, next);\n  const pairs: [number, number][] = [];\n  for (let k = 0; k < start; k++) pairs.push([k, k]);\n  for (let k = 1; k <= end; k++) {\n    pairs.push([next.length - k, old.length - k]);\n  }\n  if (old.length === next.length) {\n    for (let k = start; k < old.length - end; k++) {\n      if (old[k] === next[k]) pairs.push([k, k]);\n    }\n  }\n  const taken = new Set(pairs.map(([, from]) => from));\n  const placed = new Set(pairs.map(([to]) => to));\n  const oldMiddle = old.slice(start, old.length - end);\n  const nextMiddle = next.slice(start, next.length - end);\n  const once = (run: string[], g: string): boolean =>\n    run.indexOf(g) === run.lastIndexOf(g);\n  nextMiddle.forEach((g, k) => {\n    const to = start + k;\n    const from = start + oldMiddle.indexOf(g);\n    if (\n      !PUNCTUATION.test(g) ||\n      placed.has(to) ||\n      from < start ||\n      taken.has(from) ||\n      !once(oldMiddle, g) ||\n      !once(nextMiddle, g)\n    ) {\n      return;\n    }\n    pairs.push([to, from]);\n  });\n  return pairs;\n}\n\n/**\n * Match by words: words that survive in order keep every glyph, and\n * so do words that only moved (`hello world` → `world hello` swaps them\n * whole); a new word takes the letters it shares with the most similar old\n * word in the same gap between survivors, when they share enough; anything\n * else enters or leaves as a whole word. Only text glyphs pair here; numbers\n * were paired by place.\n *\n * `within` is what a changed word keeps of the old word it pairs with:\n * `letters` (morph), the letters they share anywhere, in order; `ends`\n * (blend), only what they share at the start and end, so the change is one\n * run in the middle: `2024-01-01` → `2024-01-02` changes the last digit,\n * where letters reused from anywhere in a word read as busy.\n */\nfunction matchWords(\n  old: string[],\n  next: string[],\n  oldKinds: GlyphKind[],\n  nextKinds: GlyphKind[],\n  within: \"letters\" | \"ends\",\n): [number, number][] {\n  const oldWords = wordsOf(old, oldKinds);\n  const nextWords = wordsOf(next, nextKinds);\n  // Keeping only ends (blend), a word is the same word whatever\n  // punctuation it ends or starts with: `saved.` → `saved!` keeps `saved`\n  // and swaps the mark. A word of punctuation alone is itself.\n  const core = (key: string[]): string[] => {\n    let start = 0;\n    let end = key.length;\n    while (start < end && PUNCTUATION.test(key[start])) start++;\n    while (end > start && PUNCTUATION.test(key[end - 1])) end--;\n    return start === end ? key : key.slice(start, end);\n  };\n  const keyOf = (word: Word): string =>\n    (within === \"letters\" ? word.key : core(word.key)).join(\"\\u0001\");\n  const pairsOf = new Map<number, number>();\n\n  const inOrder = lcs(oldWords.map(keyOf), nextWords.map(keyOf));\n  for (const [to, from] of inOrder) pairsOf.set(to, from);\n  const oldGaps = gaps(oldWords.length, new Set(inOrder.map(([, f]) => f)));\n  const nextGaps = gaps(nextWords.length, new Set(inOrder.map(([t]) => t)));\n  const taken = new Set(inOrder.map(([, from]) => from));\n\n  // Words that moved: the same word, anywhere.\n  nextWords.forEach((word, to) => {\n    if (pairsOf.has(to)) return;\n    const from = oldWords.findIndex(\n      (old, i) => !taken.has(i) && keyOf(old) === keyOf(word),\n    );\n    if (from === -1) return;\n    pairsOf.set(to, from);\n    taken.add(from);\n  });\n\n  // Words that changed: the most similar old word in the same gap. Keeping\n  // ends (blend), a gap holding one changed word on each side pairs them\n  // however little they share, since only their shared ends are kept: a\n  // one-word value like `1.2K` → `12.4M` keeps its `1` rather than\n  // replaying whole for falling short of the similarity bar.\n  const changed = new Set<number>();\n  const free =\n    (words: Word[], gapsOf: number[], used: (i: number) => boolean) =>\n    (gap: number): number =>\n      words.filter((_, i) => gapsOf[i] === gap && !used(i)).length;\n  const oldFree = free(oldWords, oldGaps, (i) => taken.has(i));\n  const nextFree = free(nextWords, nextGaps, (i) => pairsOf.has(i));\n  nextWords.forEach((word, to) => {\n    if (pairsOf.has(to)) return;\n    let best = -1;\n    let bestSimilarity = MIN_SIMILARITY;\n    const alone =\n      within === \"ends\" &&\n      oldFree(nextGaps[to]) === 1 &&\n      nextFree(nextGaps[to]) === 1;\n    oldWords.forEach((old, from) => {\n      if (taken.has(from) || oldGaps[from] !== nextGaps[to]) return;\n      if (alone) {\n        best = from;\n        return;\n      }\n      const shared = similarity(old.key, word.key);\n      if (shared > bestSimilarity) {\n        best = from;\n        bestSimilarity = shared;\n      }\n    });\n    if (best === -1) return;\n    pairsOf.set(to, best);\n    taken.add(best);\n    changed.add(to);\n  });\n\n  // Within each pair, the letters they share: in order, or at the ends of a\n  // changed word.\n  const pairs: [number, number][] = [];\n  for (const [to, from] of pairsOf) {\n    const a = oldWords[from].glyphs.filter((i) => oldKinds[i] === \"text\");\n    const b = nextWords[to].glyphs.filter((i) => nextKinds[i] === \"text\");\n    const shared =\n      within === \"ends\" && changed.has(to)\n        ? sharedEnds(\n            a.map((k) => old[k]),\n            b.map((k) => next[k]),\n          )\n        : lcs(\n            a.map((k) => old[k]),\n            b.map((k) => next[k]),\n          );\n    for (const [j, i] of shared) pairs.push([b[j], a[i]]);\n  }\n  return pairs;\n}\n\n/**\n * Match around the caret of an edit. Group separators are set aside first,\n * since a field that formats as you type moves them around: the rest is\n * shared before the edit and shifted by its length after it, and the\n * separators pair up from the end.\n */\nfunction matchCaret(\n  old: string[],\n  next: string[],\n  caret: number,\n  decimal: string,\n): [number, number][] {\n  const isSeparator = (g: string): boolean =>\n    g !== decimal && GROUP.includes(g);\n  const oldRest = old.flatMap((g, i) => (isSeparator(g) ? [] : [i]));\n  const nextRest = next.flatMap((g, i) => (isSeparator(g) ? [] : [i]));\n  const pairs: [number, number][] = [];\n  const pair = (to: number, from: number): void => {\n    if (old[oldRest[from]] === next[nextRest[to]]) {\n      pairs.push([nextRest[to], oldRest[from]]);\n    }\n  };\n\n  // The caret, counted in non-separator glyphs.\n  const at = nextRest.filter((i) => i < caret).length;\n  // Glyphs before the edit stay where they were; after it, they shift by\n  // its length. Typing `grew` glyphs ends at the caret, deleting starts\n  // there, and typing over a selection (grew 0) keeps everything in place.\n  const grew = nextRest.length - oldRest.length;\n  const before = grew > 0 ? at - grew : at;\n  for (let k = 0; k < before; k++) pair(k, k);\n  for (let k = at; k < nextRest.length; k++) pair(k, k - grew);\n\n  const oldSeparators = old.flatMap((g, i) => (isSeparator(g) ? [i] : []));\n  const nextSeparators = next.flatMap((g, i) => (isSeparator(g) ? [i] : []));\n  for (\n    let k = 1;\n    k <= oldSeparators.length && k <= nextSeparators.length;\n    k++\n  ) {\n    const from = oldSeparators[oldSeparators.length - k];\n    const to = nextSeparators[nextSeparators.length - k];\n    if (old[from] === next[to]) pairs.push([to, from]);\n  }\n  return pairs;\n}\n\nexport function matchText(\n  mode: TextMorphMode,\n  old: string[],\n  next: string[],\n  options: MatchOptions,\n): MatchResult {\n  const kept = next.map(() => -1);\n  const oldKinds: GlyphKind[] = old.map(() => \"text\");\n  const nextKinds: GlyphKind[] = next.map(() => \"text\");\n  const oldNumbers = findNumbers(old);\n  const nextNumbers = findNumbers(next);\n  const paired = Math.min(oldNumbers.length, nextNumbers.length);\n\n  const caret = options.caret;\n  // Blend always works by words; morph once a value has more than one.\n  const byWords =\n    caret === undefined &&\n    (mode === \"blend\" ||\n      (mode === \"morph\" &&\n        (wordsOf(next, nextKinds).length > 1 ||\n          wordsOf(old, oldKinds).length > 1)));\n\n  // Numbers, paired in order, matched by place.\n  let trend: 1 | -1 | 0 = options.trend;\n  // Matching treats a number without a partner as text (a `$` left behind\n  // by `$4` → `$` still matches), but its glyphs animate as a number's:\n  // the 4 rolls out rather than fading like a letter.\n  // A value that gains or loses a number, or changes none, reads as a rise.\n  const result = (): MatchResult => {\n    const asNumbers = (kinds: GlyphKind[], tokens: NumberToken[]) => {\n      const shown = [...kinds];\n      for (const { start, end } of tokens.slice(paired)) {\n        shown.fill(\"number\", start, end);\n      }\n      return shown;\n    };\n    return {\n      kept,\n      nextKinds: asNumbers(nextKinds, nextNumbers),\n      oldKinds: asNumbers(oldKinds, oldNumbers),\n      trend: trend === -1 ? -1 : 1,\n      byWords,\n    };\n  };\n  for (let n = 0; n < paired; n++) {\n    const a = oldNumbers[n];\n    const b = nextNumbers[n];\n    const aGlyphs = old.slice(a.start, a.end);\n    const bGlyphs = next.slice(b.start, b.end);\n    if (caret === undefined) {\n      for (const [to, from] of matchPlaces(aGlyphs, bGlyphs, options.decimal)) {\n        kept[b.start + to] = a.start + from;\n      }\n    }\n    for (let i = a.start; i < a.end; i++) oldKinds[i] = \"number\";\n    for (let i = b.start; i < b.end; i++) nextKinds[i] = \"number\";\n\n    if (trend === 0) {\n      const before = parseNumber(aGlyphs, options.decimal);\n      const after = parseNumber(bGlyphs, options.decimal);\n      // Not a number it can read (`1.2.3`, `192.168.0.1`) has no direction:\n      // NaN compares unequal to itself and never greater, so it read as a\n      // fall and held there.\n      if (!Number.isNaN(before) && !Number.isNaN(after) && after !== before) {\n        trend = after > before ? 1 : -1;\n      }\n    }\n  }\n\n  // An edit at a caret: everything is matched around it.\n  if (caret !== undefined) {\n    for (const [to, from] of matchCaret(old, next, caret, options.decimal)) {\n      kept[to] = from;\n    }\n    return result();\n  }\n\n  // Everything else, by the mode's text rule.\n  if (byWords) {\n    const within = mode === \"morph\" ? \"letters\" : \"ends\";\n    for (const [to, from] of matchWords(\n      old,\n      next,\n      oldKinds,\n      nextKinds,\n      within,\n    )) {\n      kept[to] = from;\n    }\n    return result();\n  }\n  const oldText = old.flatMap((_, i) => (oldKinds[i] === \"text\" ? [i] : []));\n  const nextText = next.flatMap((_, i) => (nextKinds[i] === \"text\" ? [i] : []));\n  // A one-word value with digits in it that isn't a number (a clock, a\n  // version) keeps its letters in order: a digit reused from\n  // another column would read as the time running backwards.\n  const hasDigits = [...old, ...next].some(isDigit);\n  const oldTexts = oldText.map((i) => old[i]);\n  const nextTexts = nextText.map((i) => next[i]);\n  const pairs =\n    mode === \"roll\"\n      ? matchEnds(oldTexts, nextTexts, options.anchor ?? 0)\n      : hasDigits\n        ? lcs(oldTexts, nextTexts)\n        : matchAnywhere(oldTexts, nextTexts);\n  for (const [to, from] of pairs) {\n    kept[nextText[to]] = oldText[from];\n  }\n\n  return result();\n}\n\n/** The decimal separator for a locale. */\nexport function decimalFor(locale: string): string {\n  return (\n    new Intl.NumberFormat(locale)\n      .formatToParts(1.1)\n      .find((part) => part.type === \"decimal\")?.value ?? \".\"\n  );\n}\n",
      "type": "registry:lib",
      "target": "components/ui/cubby-ui/text-morph/lib/match.ts"
    },
    {
      "path": "registry/default/text-morph/lib/measure.ts",
      "content": "/**\n * Reading the page for a change: where glyphs are drawn, whether a label\n * would be seen, what holds it, and how its box is sized. Reads only; the\n * engine decides when, so they land in its read phases.\n */\n\nexport type VisualState = {\n  rect: DOMRect;\n  opacity: number;\n  scale: string;\n  rotate: string;\n  filter: string;\n};\n\nexport function visualState(node: HTMLElement): VisualState {\n  const style = getComputedStyle(node);\n  return {\n    rect: node.getBoundingClientRect(),\n    opacity: Number(style.opacity),\n    scale: style.scale === \"none\" ? \"1\" : style.scale,\n    rotate: style.rotate === \"none\" ? \"0deg\" : style.rotate,\n    filter: style.filter === \"none\" ? \"blur(0px)\" : style.filter,\n  };\n}\n\n/** How far `a`'s centre sits from `b`'s (scale and tilt pivot on centres). */\nexport function centreDelta(a: DOMRect, b: DOMRect): [number, number] {\n  return [\n    a.left + a.width / 2 - (b.left + b.width / 2),\n    a.top + a.height / 2 - (b.top + b.height / 2),\n  ];\n}\n\nconst CLIPS = /auto|scroll|hidden|clip/;\n\n/** Whether an element cuts off what overflows it (a scroller, a clip). */\nconst clips = (style: CSSStyleDeclaration): boolean =>\n  CLIPS.test(style.overflowX) || CLIPS.test(style.overflowY);\n\n/**\n * Whether a change here would be seen: rendered, and within the viewport\n * and every ancestor that clips it, so a label scrolled out of\n * view inside a scrolling panel swaps without animating.\n */\nexport function isOnScreen(el: HTMLElement): boolean {\n  // A background tab: animations there don't finish until it's shown, so\n  // every change would leave its ghosts piling up in the meantime.\n  if (document.visibilityState === \"hidden\") return false;\n  if (el.checkVisibility && !el.checkVisibility()) return false;\n  let top = 0;\n  let left = 0;\n  let bottom = window.innerHeight;\n  let right = window.innerWidth;\n  for (let node = el.parentElement; node; node = node.parentElement) {\n    if (!clips(getComputedStyle(node))) continue;\n    const box = node.getBoundingClientRect();\n    top = Math.max(top, box.top);\n    left = Math.max(left, box.left);\n    bottom = Math.min(bottom, box.bottom);\n    right = Math.min(right, box.right);\n  }\n  const rect = el.getBoundingClientRect();\n  return (\n    rect.bottom > top &&\n    rect.right > left &&\n    rect.top < bottom &&\n    rect.left < right\n  );\n}\n\nexport type Side = \"start\" | \"end\";\n\n/**\n * Whether a box shows an edge: a background, a side border, a shadow (a\n * ring, a shadow-drawn outline) or a frosted backdrop.\n */\nfunction showsEdge(style: CSSStyleDeclaration): boolean {\n  return (\n    style.backgroundImage !== \"none\" ||\n    (style.backgroundColor !== \"rgba(0, 0, 0, 0)\" &&\n      style.backgroundColor !== \"transparent\") ||\n    parseFloat(style.borderLeftWidth) > 0 ||\n    parseFloat(style.borderRightWidth) > 0 ||\n    style.boxShadow !== \"none\" ||\n    (style.backdropFilter !== \"\" && style.backdropFilter !== \"none\")\n  );\n}\n\n/** Whether a pseudo-element draws a surface for its box (a button's fill). */\nfunction pseudoShowsEdge(\n  node: Element,\n  which: \"::before\" | \"::after\",\n): boolean {\n  const style = getComputedStyle(node, which);\n  return (\n    style.content !== \"none\" && style.content !== \"normal\" && showsEdge(style)\n  );\n}\n\n/**\n * The inner edges of what a reader sees as holding the value: the nearest ancestor that shows an edge, itself or through a\n * pseudo-element (our Button paints its fill on `::before`), or clips; else\n * the viewport. A plain block around it has no edge to see.\n */\nexport type Bounds = { left: number; right: number };\n\nexport function visibleBounds(el: HTMLElement): Bounds {\n  for (let node = el.parentElement; node; node = node.parentElement) {\n    const style = getComputedStyle(node);\n    if (\n      showsEdge(style) ||\n      clips(style) ||\n      pseudoShowsEdge(node, \"::before\") ||\n      pseudoShowsEdge(node, \"::after\")\n    ) {\n      const box = node.getBoundingClientRect();\n      return {\n        left: box.left + parseFloat(style.borderLeftWidth),\n        right: box.right - parseFloat(style.borderRightWidth),\n      };\n    }\n  }\n  return { left: 0, right: window.innerWidth };\n}\n\n/**\n * Whether old ink escaping the box on this side would land on something:\n * a neighbour on the line, or past the edge a reader sees holding the value\n * (a pill, a card, a clipping wrapper), whose position it returns. With room\n * around it, ink is left to dissolve on its own opacity. Reads the final\n * layout.\n */\nexport function inkEscapes(\n  root: HTMLElement,\n  side: Side,\n  inkEdge: number,\n  visible: () => Bounds,\n): false | \"neighbour\" | number {\n  let el: Element = root;\n  for (let depth = 0; depth < 8; depth++) {\n    // `side` is physical (start: left, end: right); in right-to-left text\n    // the next sibling sits to the left.\n    const rtl =\n      el.parentElement !== null &&\n      getComputedStyle(el.parentElement).direction === \"rtl\";\n    const toRight = (side === \"end\") !== rtl;\n    let sibling = toRight ? el.nextSibling : el.previousSibling;\n    while (sibling) {\n      const visible =\n        sibling instanceof Element\n          ? sibling.getBoundingClientRect().width >= 1\n          : Boolean(sibling.textContent?.trim());\n      if (visible) return \"neighbour\";\n      sibling = toRight ? sibling.nextSibling : sibling.previousSibling;\n    }\n    // Neighbours share the line up to the first block around it.\n    const parent = el.parentElement;\n    if (!parent) break;\n    const { display } = getComputedStyle(parent);\n    if (!display.startsWith(\"inline\") && display !== \"contents\") break;\n    el = parent;\n  }\n  // Past the container's edge: the edge itself, where the fade belongs.\n  const bounds = visible();\n  if (side === \"end\")\n    return inkEdge > bounds.right + 0.5 ? bounds.right : false;\n  return inkEdge < bounds.left - 0.5 ? bounds.left : false;\n}\n\n/**\n * How many lines an inline element's text runs over: rows, not the\n * fragments `getClientRects` reports (a line can come back in pieces, split\n * around a space or a line break).\n */\nexport function lineCount(el: HTMLElement): number {\n  const tops: number[] = [];\n  for (const rect of el.getClientRects()) {\n    if (!tops.some((top) => Math.abs(top - rect.top) < 1)) tops.push(rect.top);\n  }\n  return tops.length;\n}\n\ntype Axis = \"width\" | \"height\";\n\n/** A box's content width or height, from its rect: less padding and borders. */\nexport function contentSize(\n  style: CSSStyleDeclaration,\n  rect: DOMRect,\n  axis: Axis,\n): number {\n  const sides = axis === \"width\" ? [\"left\", \"right\"] : [\"top\", \"bottom\"];\n  let size = rect[axis];\n  for (const side of sides) {\n    size -= parseFloat(style.getPropertyValue(`padding-${side}`)) || 0;\n    size -= parseFloat(style.getPropertyValue(`border-${side}-width`)) || 0;\n  }\n  return size;\n}\n\n/** The width or height to set to make a box this big, by its box-sizing. */\nexport function sizeOf(\n  style: CSSStyleDeclaration,\n  rect: DOMRect,\n  axis: Axis,\n): number {\n  return style.boxSizing === \"border-box\"\n    ? rect[axis]\n    : contentSize(style, rect, axis);\n}\n\n/** Until a label has resized, a guess from its alignment. */\nexport function anchorHint(style: CSSStyleDeclaration): number {\n  const rtl = style.direction === \"rtl\";\n  switch (style.textAlign) {\n    case \"center\":\n      return 0.5;\n    case \"end\":\n      return 1;\n    case \"right\":\n      return rtl ? 0 : 1;\n    case \"left\":\n      return rtl ? 1 : 0;\n    default:\n      return 0;\n  }\n}\n",
      "type": "registry:lib",
      "target": "components/ui/cubby-ui/text-morph/lib/measure.ts"
    },
    {
      "path": "registry/default/text-morph/lib/options.ts",
      "content": "/**\n * How changes animate. `blend` (the default) changes only what changed (a\n * similar word keeps what it shares at its ends), crossfading each changed\n * run as one unit, a touch smaller and blurred: calm enough for anything.\n * `morph` matches whole words, then letters within similar words (anywhere,\n * in a one-word value): shared ones slide to their new place, the rest scale\n * and fade, travelling with the nearest glyph that stays. `roll` keeps the\n * old and new text's shared start and end, and rolls the glyphs between\n * vertically, on a spring. In every mode numbers change digit by digit, and\n * each mode brings its own defaults for every option.\n */\nexport type TextMorphMode = \"blend\" | \"roll\" | \"morph\";\n\n/** One CSS timing: a duration in ms and any CSS easing, `linear()` included. */\nexport type TextMorphTiming = { duration: number; easing: string };\n\n/** Everything a mode tunes. Each mode sets all of it (`MODE_DEFAULTS`). */\nexport type TextMorphOptions = {\n  /** Glyph movement: roll travel, enter/exit scale, shared glyphs sliding. */\n  motion: TextMorphTiming;\n  /** Opacity and blur of arriving glyphs, optionally starting late. */\n  fadeIn: TextMorphTiming & { delay: number };\n  /** Opacity and blur of leaving glyphs. */\n  fadeOut: TextMorphTiming;\n  /** The box's width. */\n  width: TextMorphTiming;\n  /**\n   * `each`: every successive entering or leaving glyph starts `ms` later.\n   * `spread`: a sweep from left to right across `ms` in total, by where\n   * each glyph sits along the changed stretch.\n   */\n  stagger: { mode: \"each\" | \"spread\"; ms: number };\n  /** Roll: travel (em), and the scale and tilt glyphs arrive and leave with. */\n  roll: { distance: number; scale: number; rotate: number };\n  /**\n   * Morph: the scale letters grow from and shrink to. Digits of a number\n   * roll instead, `distance` line heights, on their own fades.\n   */\n  morph: {\n    scale: number;\n    digits: {\n      distance: number;\n      fadeIn: TextMorphTiming;\n      fadeOut: TextMorphTiming;\n    };\n  };\n  /**\n   * Blend: a changed run, letters or digits, grows into place from `scale`\n   * about its own centre, as one unit; changed digits also drift\n   * `distance` (em) the way the value went.\n   */\n  blend: { distance: number; scale: number };\n  /** Blur on arriving and leaving glyphs, in em. */\n  blur: number;\n  /**\n   * Which way glyphs roll. `auto` reads it off the value:\n   * a number that grew brings new glyphs up from below, one that shrank\n   * brings them down, and anything else rolls up. `up` and `down` hold one\n   * way. Roll defaults to `auto`; morph to `down`: digits\n   * fall in from above and leave downward, and a number's other marks\n   * (separators, currency) arrive from below.\n   */\n  trend: \"auto\" | \"up\" | \"down\";\n  /**\n   * Fade old ink at an edge the box is shrinking away from. `auto` only\n   * where it would land on something: a neighbour on the line, or the edge\n   * of the pill or card holding the value.\n   */\n  edgeFade: \"auto\" | \"always\" | \"never\";\n};\n\n/** Morph's digits: a full line of travel, fading out over 45% of the\n * duration and in over the first 25%. */\nconst MORPH_DIGITS: TextMorphOptions[\"morph\"][\"digits\"] = {\n  distance: 1,\n  fadeIn: { duration: 100, easing: \"linear\" },\n  fadeOut: { duration: 180, easing: \"linear\" },\n};\n\n/** The curve the box width eases on: fast out, no overshoot. */\nconst WIDTH_EASING = \"cubic-bezier(0.22, 1, 0.36, 1)\";\n/** Ease-out expo: morph's movement, width and everything. */\nconst EXPO_EASING = \"cubic-bezier(0.19, 1, 0.22, 1)\";\n\n/**\n * Roll's defaults: a 550ms hand-tuned spring (1.5%\n * overshoot) on transform, opacity and blur alike; glyphs travel 0.35em while\n * scaling to 0.6 and tilting 2deg, blurred 0.1em; delays fan out over 30% of\n * the duration; the box eases on its own non-overshooting curve.\n */\nconst ROLL_SPRING =\n  \"linear(0,.1052,.3155,.532,.7112,.8414,.9265,.9765,1.0023,1.013,1.0151,1.0133,1.01,1.0068,1.0041,1.0022,1.001,1)\";\nconst ROLL_OPTIONS: TextMorphOptions = {\n  motion: { duration: 550, easing: ROLL_SPRING },\n  fadeIn: { duration: 550, easing: ROLL_SPRING, delay: 0 },\n  fadeOut: { duration: 550, easing: ROLL_SPRING },\n  width: { duration: 550, easing: WIDTH_EASING },\n  stagger: { mode: \"spread\", ms: 165 },\n  roll: { distance: 0.35, scale: 0.6, rotate: 2 },\n  morph: { scale: 0.6, digits: MORPH_DIGITS },\n  blend: { distance: 0.15, scale: 1 },\n  blur: 0.1,\n  trend: \"auto\",\n  edgeFade: \"auto\",\n};\n\n/**\n * Morph's defaults: 400ms ease-out expo for movement\n * and width; arriving and leaving glyphs scale by 0.95; opacity is linear,\n * leaving over the first quarter of the duration and arriving over half of it\n * from a quarter in; no blur, no stagger.\n */\nconst MORPH_OPTIONS: TextMorphOptions = {\n  motion: { duration: 400, easing: EXPO_EASING },\n  fadeIn: { duration: 200, easing: \"linear\", delay: 100 },\n  fadeOut: { duration: 100, easing: \"linear\" },\n  width: { duration: 400, easing: EXPO_EASING },\n  stagger: { mode: \"each\", ms: 0 },\n  roll: { distance: 0.35, scale: 0.95, rotate: 0 },\n  morph: { scale: 0.95, digits: MORPH_DIGITS },\n  blend: { distance: 0.15, scale: 1 },\n  blur: 0,\n  trend: \"down\",\n  edgeFade: \"auto\",\n};\n\n/**\n * Cubby's own: a soft crossfade. Only what changed moves: each changed run\n * comes in as one unit, smaller (0.9, about its own centre) and blurred\n * (0.1em), and settles into focus as the old text blurs away under it, all\n * at once. Changed digits crossfade the same way and also drift 0.08em the\n * way the value went, so a number keeps a hint of direction. New text\n * enters over 240ms and old leaves over 150ms, and everything, the box's\n * width and the words that stay sliding to their places included, runs on\n * one strong ease-out.\n */\nconst BLEND_EASING = WIDTH_EASING;\nconst BLEND_OPTIONS: TextMorphOptions = {\n  motion: { duration: 240, easing: BLEND_EASING },\n  fadeIn: { duration: 240, easing: BLEND_EASING, delay: 0 },\n  fadeOut: { duration: 150, easing: BLEND_EASING },\n  width: { duration: 240, easing: BLEND_EASING },\n  stagger: { mode: \"each\", ms: 0 },\n  roll: { distance: 0.35, scale: 1, rotate: 0 },\n  morph: { scale: 1, digits: MORPH_DIGITS },\n  blend: { distance: 0.08, scale: 0.9 },\n  blur: 0.1,\n  trend: \"auto\",\n  edgeFade: \"auto\",\n};\n\ntype DeepReadonly<T> = {\n  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];\n};\n\nfunction deepFreeze<T extends object>(value: T): DeepReadonly<T> {\n  for (const field of Object.values(value)) {\n    if (typeof field === \"object\" && field !== null) deepFreeze(field);\n  }\n  return Object.freeze(value) as DeepReadonly<T>;\n}\n\n/** Each mode's defaults for every option. Read-only: shared by every label. */\nexport const MODE_DEFAULTS: {\n  readonly [M in TextMorphMode]: DeepReadonly<TextMorphOptions>;\n} = deepFreeze({\n  blend: BLEND_OPTIONS,\n  roll: ROLL_OPTIONS,\n  morph: MORPH_OPTIONS,\n});\n\ntype Overrides<T> = {\n  [K in keyof T]?: T[K] extends object ? Overrides<T[K]> : T[K];\n};\n\n/**\n * Any subset of the options, nested fields included: `{ motion: { duration:\n * 200 } }` keeps the mode's easing, `{ stagger: { ms: 50 } }` its stagger\n * mode.\n */\nexport type TextMorphOverrides = Overrides<TextMorphOptions>;\n\n/** A mode with every option filled in: what a change runs on. */\nexport type ResolvedOptions = TextMorphOptions & { mode: TextMorphMode };\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction merge<T>(base: T, overrides: Overrides<T> | undefined): T {\n  if (!overrides) return base;\n  const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };\n  for (const [key, value] of Object.entries(overrides)) {\n    if (value === undefined) continue;\n    out[key] =\n      isRecord(value) && isRecord(out[key])\n        ? merge(out[key], value as Overrides<unknown>)\n        : value;\n  }\n  return out as T;\n}\n\n/**\n * A mode's options with `overrides` applied, and, with `duration`, the\n * whole clock scaled so movement takes that long: every other duration,\n * delay and stagger in proportion, so the look holds on a shorter or longer\n * clock.\n */\nexport function resolveOptions(\n  mode: TextMorphMode = \"blend\",\n  overrides?: TextMorphOverrides,\n  duration?: number,\n): ResolvedOptions {\n  const merged = merge(MODE_DEFAULTS[mode] as TextMorphOptions, overrides);\n  const timed =\n    duration === undefined\n      ? merged\n      : scaleTiming(merged, duration / merged.motion.duration);\n  return { ...timed, mode };\n}\n\n/** Every duration, delay and stagger scaled by `factor`. */\nexport function scaleTiming(\n  o: TextMorphOptions,\n  factor: number,\n): TextMorphOptions {\n  const t = (timing: TextMorphTiming): TextMorphTiming => ({\n    ...timing,\n    duration: Math.round(timing.duration * factor),\n  });\n  return {\n    ...o,\n    motion: t(o.motion),\n    fadeIn: { ...t(o.fadeIn), delay: Math.round(o.fadeIn.delay * factor) },\n    fadeOut: t(o.fadeOut),\n    width: t(o.width),\n    stagger: { ...o.stagger, ms: Math.round(o.stagger.ms * factor) },\n    morph: {\n      ...o.morph,\n      digits: {\n        ...o.morph.digits,\n        fadeIn: t(o.morph.digits.fadeIn),\n        fadeOut: t(o.morph.digits.fadeOut),\n      },\n    },\n  };\n}\n",
      "type": "registry:lib",
      "target": "components/ui/cubby-ui/text-morph/lib/options.ts"
    },
    {
      "path": "registry/default/text-morph/lib/ride.ts",
      "content": "/**\n * How the stage rides against a label easing its space, so the glyphs hold\n * the places they were measured at. `from` is how much space the label\n * starts with beyond its final size; `measured` is how far its glyphs (or\n * the label) moved when it took that space.\n */\n\n/**\n * An inline label's move that is just its own start margin (all of it\n * pinned at the start or end, half of it centred), as the margin's exact\n * value. The measured one is rounded to layout units (1/64px), and the two\n * easing from values that far apart round apart frame to frame, flickering\n * the glyphs a device pixel side to side.\n */\nexport function ownShift(from: number, measured: number): number | undefined {\n  return [from, -from, from / 2, -from / 2].find(\n    (x) => Math.abs(x - measured) < 0.05,\n  );\n}\n\n/**\n * The share of its width a box's glyphs move by as it resizes (all of the\n * change pinned at the end, half centred), for a ride in CSS: layout rounds\n * the ride and the width it follows from the same number each frame, where\n * a ride animated beside the width rounded on its own and stepped the\n * glyphs back and forth.\n */\nexport function followShare(\n  from: number,\n  measured: number,\n): number | undefined {\n  const share = -measured / from;\n  return [-1, -0.5, 0.5, 1].find((f) => Math.abs(f - share) < 0.01);\n}\n",
      "type": "registry:lib",
      "target": "components/ui/cubby-ui/text-morph/lib/ride.ts"
    },
    {
      "path": "registry/default/text-morph/lib/scheduler.ts",
      "content": "/**\n * A change runs in phases, yielding between them: reads, then writes, then\n * reads again. Every label that changes in the same update is stepped\n * through the phases together, so all of them read before any of them\n * writes. Reading after the DOM changes forces the browser to restyle, and\n * on a page with complex `:has()` selectors each such restyle can cover the\n * whole document; batched, a set of changes costs one instead of several\n * per label.\n */\nexport type MorphSteps = Generator<void, Animation[], void>;\n\nexport type MorphJob = {\n  root: HTMLElement;\n  steps: MorphSteps;\n  done: (started: Animation[]) => void;\n};\n\nconst queue: MorphJob[] = [];\n\nfunction flushMorphs(): void {\n  let active = queue.splice(0);\n  while (active.length > 0) {\n    active = active.filter((job) => {\n      let step: IteratorResult<void, Animation[]>;\n      try {\n        step = job.steps.next();\n      } catch (error) {\n        // One label failing mustn't strand the rest of its batch mid-change.\n        reportError(error);\n        job.done([]);\n        return false;\n      }\n      if (!step.done) return true;\n      job.done(step.value);\n      return false;\n    });\n  }\n}\n\n/** Run a change with the others from this update, before the next paint. */\nexport function scheduleMorph(job: MorphJob): void {\n  // A second change to the same label before the batch runs goes after it.\n  if (queue.some((queued) => queued.root === job.root)) flushMorphs();\n  queue.push(job);\n  if (queue.length === 1) queueMicrotask(flushMorphs);\n}\n",
      "type": "registry:lib",
      "target": "components/ui/cubby-ui/text-morph/lib/scheduler.ts"
    },
    {
      "path": "registry/default/text-morph/lib/shapes.ts",
      "content": "import type { GlyphKind } from \"./match\";\n\n/**\n * Morph mode, one-word values: a run of this many replaced glyphs,\n * with no survivor inside, is swapped as one shape rather than glyph by\n * glyph. It recedes further, to GROUP_SCALE about its own centre, and its\n * fades take these shares of the movement's duration.\n */\nexport const GROUP_MIN = 6;\nexport const GROUP_SCALE = 0.8;\nexport const GROUP_FADE_IN = 0.35;\nexport const GROUP_FADE_OUT = 0.45;\n\n/** How a glyph arriving or leaving as part of a shape scales. */\nexport type Shape = {\n  /** Its scale's pivot, the shape's centre from its own box (CSS). */\n  origin: string;\n  /** A run swapped as one shape (further, faster), not a word. */\n  group: boolean;\n};\n\n/** Where a glyph sits: any rect (a DOMRect will do). */\nexport type Box = { left: number; right: number; top: number; bottom: number };\n\n/**\n * The runs of changed glyphs (arriving, or leaving) to shape: whole words,\n * a word being a run between spaces with every glyph changed, or runs of at\n * least `min` changed glyphs between survivors.\n */\nexport function shapeRuns(\n  count: number,\n  isSpace: (i: number) => boolean,\n  changed: (i: number) => boolean,\n  byWords: boolean,\n  min = GROUP_MIN,\n): number[][] {\n  const runs: number[][] = [];\n  let run: number[] = [];\n  let whole = true;\n  const flush = (): void => {\n    if (byWords ? whole && run.length > 0 : run.length >= min) {\n      runs.push(run);\n    }\n    run = [];\n    whole = true;\n  };\n  for (let i = 0; i < count; i++) {\n    if (isSpace(i)) flush();\n    else if (changed(i)) run.push(i);\n    else if (byWords) whole = false;\n    else flush();\n  }\n  flush();\n  return runs;\n}\n\n/**\n * Each run's pivots, into `shapes`: the centre of the whole run, from each\n * member's own box. A glyph without a box is left out of its run.\n */\nfunction addRuns(\n  runs: number[][],\n  boxOf: (i: number) => Box | undefined,\n  group: boolean,\n  shapes: Map<number, Shape>,\n): void {\n  for (const run of runs) {\n    const members = run.flatMap((index) => {\n      const box = boxOf(index);\n      return box ? [{ index, box }] : [];\n    });\n    if (members.length > 0) origins(members, group, shapes);\n  }\n}\n\n/** Each member's pivot: the centre of the whole run, from its own box. */\nfunction origins(\n  members: { index: number; box: Box }[],\n  group: boolean,\n  into: Map<number, Shape>,\n): void {\n  const left = Math.min(...members.map((m) => m.box.left));\n  const right = Math.max(...members.map((m) => m.box.right));\n  const top = Math.min(...members.map((m) => m.box.top));\n  const bottom = Math.max(...members.map((m) => m.box.bottom));\n  for (const { index, box } of members) {\n    into.set(index, {\n      origin: `${(left + right) / 2 - box.left}px ${(top + bottom) / 2 - box.top}px`,\n      group,\n    });\n  }\n}\n\n/**\n * Blend: every run of changed glyphs, a whole word or the changed middle\n * of one (`Copy` → `Copied` changes `ied`), scales about its own centre,\n * as one unit. Runs end at a survivor or a space. Pivots only; none is a\n * group.\n */\nexport function planRuns({\n  count,\n  isSpace,\n  changed,\n  boxOf,\n}: {\n  count: number;\n  isSpace: (i: number) => boolean;\n  changed: (i: number) => boolean;\n  boxOf: (i: number) => Box | undefined;\n}): Map<number, Shape> {\n  const shapes = new Map<number, Shape>();\n  addRuns(shapeRuns(count, isSpace, changed, false, 1), boxOf, false, shapes);\n  return shapes;\n}\n\n/**\n * The shapes the changed glyphs of one side of a change (the arriving ones,\n * or the leaving ones) scale as, by index. By words (a value of several\n * words): whole words of letters, as words, and runs of GROUP_MIN or more\n * changed digits, as groups. Otherwise (one word): runs of GROUP_MIN or more\n * changed glyphs, digits included, as groups, since morph splits numbers\n * glyph by glyph either way (`$12,345,678` → `$99` shrinks its old digits in\n * place rather than sending them after the `$`).\n */\nexport function planShapes({\n  count,\n  isSpace,\n  changed,\n  kinds,\n  byWords,\n  boxOf,\n}: {\n  count: number;\n  isSpace: (i: number) => boolean;\n  changed: (i: number) => boolean;\n  kinds: GlyphKind[];\n  byWords: boolean;\n  boxOf: (i: number) => Box | undefined;\n}): Map<number, Shape> {\n  const shapes = new Map<number, Shape>();\n  const add = (runs: number[][], group: boolean): void =>\n    addRuns(runs, boxOf, group, shapes);\n  if (!byWords) {\n    add(shapeRuns(count, isSpace, changed, false), true);\n    return shapes;\n  }\n  const of =\n    (kind: GlyphKind) =>\n    (i: number): boolean =>\n      changed(i) && kinds[i] === kind;\n  add(shapeRuns(count, isSpace, of(\"text\"), true), false);\n  add(shapeRuns(count, isSpace, of(\"number\"), false), true);\n  return shapes;\n}\n",
      "type": "registry:lib",
      "target": "components/ui/cubby-ui/text-morph/lib/shapes.ts"
    },
    {
      "path": "registry/default/text-morph/lib/timing.ts",
      "content": "import type { GlyphKind } from \"./match\";\nimport type { ResolvedOptions } from \"./options\";\n\n/**\n * Stagger delays for the glyphs that change, from where each sits (its\n * distance from the start of the line, in reading order). `each`: every\n * successive glyph `ms` later. `spread` (a sweep): delay from where\n * the glyph sits, across `ms` over the changed stretch only, so a one-digit\n * change in a long number isn't left waiting, and a glyph leaving and its\n * replacement in the same spot cross over together. The sweep spans `ms`\n * less one glyph's step, so two glyphs are half of\n * it apart, not all of it.\n */\nexport function staggerDelays(\n  o: ResolvedOptions,\n  entering: number[],\n  leaving: number[],\n): { entering: number[]; leaving: number[] } {\n  if (o.stagger.mode === \"each\") {\n    return {\n      entering: entering.map((_, k) => k * o.stagger.ms),\n      leaving: leaving.map((_, k) => k * o.stagger.ms),\n    };\n  }\n  const all = [...entering, ...leaving];\n  const min = Math.min(...all);\n  const span = Math.max(...all) - min;\n  const sweep = (count: number) => (left: number) =>\n    span > 0\n      ? (((o.stagger.ms * Math.max(count - 1, 0)) / count) * (left - min)) /\n        span\n      : 0;\n  return {\n    entering: entering.map(sweep(entering.length)),\n    leaving: leaving.map(sweep(leaving.length)),\n  };\n}\n\n/** A glyph at home: where every arriving glyph ends and kept glyph lands. */\nexport const HOME: Keyframe = { translate: \"0 0\", scale: \"1\", rotate: \"0deg\" };\n\n/**\n * Transform keyframe for a glyph away from home: `offset` is 1 below the\n * line, -1 above. Blend scales a glyph about its run's centre (its\n * transform origin), so a changed run grows in as one unit, and drifts\n * digits a little the way the value went. Morph letters scale in place; morph digits roll whole lines\n * (`line` is the line height in px).\n */\nexport function awayState(\n  o: ResolvedOptions,\n  kind: GlyphKind,\n  offset: 1 | -1,\n  line: number,\n): Keyframe {\n  if (o.mode === \"blend\") {\n    const drift = kind === \"number\" ? offset * o.blend.distance : 0;\n    return {\n      translate: drift === 0 ? \"0 0\" : `0 ${drift}em`,\n      scale: String(o.blend.scale),\n      rotate: \"0deg\",\n    };\n  }\n  if (o.mode === \"roll\") {\n    return {\n      translate: `0 ${offset * o.roll.distance}em`,\n      scale: String(o.roll.scale),\n      rotate: `${o.roll.rotate}deg`,\n    };\n  }\n  if (kind === \"number\") {\n    return {\n      translate: `0 ${offset * o.morph.digits.distance * line}px`,\n      scale: \"1\",\n      rotate: \"0deg\",\n    };\n  }\n  return { translate: \"0 0\", scale: String(o.morph.scale), rotate: \"0deg\" };\n}\n\n/** Fade timings for a glyph of this kind: morph's digits have their own. */\nexport function fadesFor(\n  o: ResolvedOptions,\n  kind: GlyphKind,\n): {\n  fadeIn: ResolvedOptions[\"fadeIn\"];\n  fadeOut: ResolvedOptions[\"fadeOut\"];\n} {\n  if (o.mode === \"morph\" && kind === \"number\") {\n    return {\n      fadeIn: { ...o.morph.digits.fadeIn, delay: 0 },\n      fadeOut: o.morph.digits.fadeOut,\n    };\n  }\n  return { fadeIn: o.fadeIn, fadeOut: o.fadeOut };\n}\n\n/**\n * The options with nothing that moves, scales, tilts or blurs, for reduced\n * motion. New glyphs fade in straight away: morph's delay leaves a gap after\n * the old ones have gone that its travel covers, and standing still in place\n * it read as a blink rather than a crossfade.\n */\nexport function stillOptions(o: ResolvedOptions): ResolvedOptions {\n  return {\n    ...o,\n    fadeIn: { ...o.fadeIn, delay: 0 },\n    stagger: { ...o.stagger, ms: 0 },\n    roll: { distance: 0, scale: 1, rotate: 0 },\n    morph: { ...o.morph, scale: 1, digits: { ...o.morph.digits, distance: 0 } },\n    blend: { distance: 0, scale: 1 },\n    blur: 0,\n  };\n}\n",
      "type": "registry:lib",
      "target": "components/ui/cubby-ui/text-morph/lib/timing.ts"
    },
    {
      "path": "registry/default/text-morph/text-morph.css",
      "content": "/* TextMorph (registry/default/text-morph). The server renders one span per\n   glyph, the same markup the browser keeps, so these styles apply from first\n   paint and nothing shifts on hydration. */\n\n/* A glyph's slot: how far it reaches above and below the glyph's box, and\n   how far it fades across. Blend and roll fade across 0.3em of room past\n   the box, for their blur and tilt; morph fades 0.15em inside the box\n   itself, so a digit rolling in shows nothing until it enters its line\n   (with room past it, it showed faintly, whole, above the line). */\n[data-slot=\"text-morph\"] {\n  --text-morph-slot-y: 0.3em;\n  --text-morph-fade: 0.3em;\n  position: relative;\n}\n[data-slot=\"text-morph\"][data-mode=\"morph\"] {\n  --text-morph-slot-y: 0em;\n  --text-morph-fade: 0.15em;\n}\n/* In a tight line height the ink reaches past the line box (a comma's tail\n   below it), and a fade inside the box faded it until the change settled,\n   then popped it. So the slot is at least 1.4em tall, reaching past the box\n   only as far as that takes: at a 1.6 line height it doesn't. */\n@supports (height: 1lh) {\n  [data-slot=\"text-morph\"][data-mode=\"morph\"] {\n    --text-morph-slot-y: max(0em, (1.4em - 1lh) / 2);\n  }\n}\n[data-slot=\"text-morph\"] {\n  --text-morph-fade-y: linear-gradient(\n    to bottom,\n    transparent,\n    #000 var(--text-morph-fade),\n    #000 calc(100% - var(--text-morph-fade)),\n    transparent\n  );\n}\n\n/* It flows like the text around it, wrapping between words where the\n   context lets text wrap, and never changes display: switching between\n   inline and inline-block repaints the text snapped differently. In a\n   layer, so a class can make it a box instead (`block`, `inline-block`),\n   which eases its height when its line count changes; the rest stays\n   unlayered, so page styles for spans can't reach the glyphs. */\n@layer components {\n  [data-slot=\"text-morph\"] {\n    display: inline;\n    vertical-align: baseline;\n  }\n}\n\n/* While a one-line change eases the label's space, it stays on one line.\n   text-wrap-mode leaves spaces alone; nowrap (the fallback) would drop a\n   leading one. A box's text sits at its start meanwhile: the stage rides it\n   to where its alignment puts it, from the width as layout rounds it (both\n   rounded on their own, the text stepped back and forth). */\n[data-slot=\"text-morph\"][data-sizing] {\n  white-space: nowrap;\n  text-align: start;\n}\n@supports (text-wrap-mode: nowrap) {\n  [data-slot=\"text-morph\"][data-sizing] {\n    white-space: inherit;\n    text-wrap-mode: nowrap;\n  }\n}\n\n/* The plain value for screen readers, exactly as written (sr-only's\n   nowrap would drop a leading space). The visible glyphs are what gets\n   selected and copied, so this copy isn't, or a copied selection would\n   paste the value twice. */\n[data-slot=\"text-morph\"] > .sr-only {\n  white-space: pre;\n  user-select: none;\n}\n\n.text-morph-stage {\n  position: relative;\n}\n\n/* Each glyph sits in a slot with room above and below its line and room\n   beside it for tilt, scale and blur, plus the length of any slide along\n   the line (--reach). Negative margins hand the room back, so the slot\n   takes no space. While a change plays (data-playing), the slot of a glyph\n   that rolls or is drawn off its line (data-fade) fades it out above and\n   below; glyphs that hold still or only slide carry no mask, and at rest\n   none do. Toggling an attribute costs a scoped restyle, where adding or\n   removing the slots restyles the page. A mask cuts whatever falls outside\n   its element, so a glyph moving to another line turns it off. */\n.text-morph-slot {\n  display: inline-block;\n  padding: var(--text-morph-slot-y) calc(0.5em + var(--reach, 0px));\n  margin: calc(-1 * var(--text-morph-slot-y)) calc(-0.5em - var(--reach, 0px));\n}\n.text-morph-glyphs[data-playing]\n  .text-morph-slot[data-fade]:not([data-travel]) {\n  mask-image: var(--text-morph-fade-y);\n}\n\n/* Where the browser can, the fade reaches sideways without end: it only\n   ever cuts above and below, so a glyph sliding\n   or shrinking sideways out of its slot is never sliced. Elsewhere the slot's\n   room (--reach) and unmasking a shape's glyphs cover it. no-clip lifts\n   the cut above and below as well, showing a glyph rolling in whole and\n   unfaded past its slot, so a clip puts that edge back, reaching sideways\n   without end. */\n@supports (mask-clip: no-clip) {\n  .text-morph-glyphs[data-playing]\n    .text-morph-slot[data-fade]:not([data-travel]),\n  .text-morph-ghost:not([data-travel]) {\n    mask-repeat: repeat-x;\n    mask-clip: no-clip;\n    clip-path: inset(0 -100vw);\n  }\n}\n\n/* An empty value still holds its line: with nothing\n   in it, the line would collapse and the text around it jump. */\n.text-morph-glyphs:empty::before {\n  content: \"\\200B\";\n}\n\n/* Spaces show as typed: a run of them isn't\n   collapsed to one, and a line break in the value breaks the line. Only\n   collapsing changes; wrapping is inherited, so a label easing its width\n   still holds one line. */\n[data-slot=\"text-morph\"] [data-space] {\n  white-space: pre-wrap;\n}\n@supports (white-space-collapse: preserve) {\n  [data-slot=\"text-morph\"] [data-space] {\n    white-space: inherit;\n    white-space-collapse: preserve;\n  }\n}\n\n/* A word never breaks; lines break at the spaces between words. */\n[data-slot=\"text-morph\"] [data-word] {\n  white-space: nowrap;\n}\n\n/* Glyphs keep their own compositing layer, animating or not. A glyph that\n   drops back into ordinary painting when its animation ends is redrawn\n   snapped to the pixel grid, often with different anti-aliasing: a visible\n   twitch just as it settles. From first paint, not\n   from a glyph's first animation: outside a layer text draws with subpixel\n   anti-aliasing and inside one in greyscale, so a label mixed the two. */\n[data-slot=\"text-morph\"] [data-glyph]:not([data-space]) {\n  display: inline-block;\n  will-change: transform, opacity;\n}\n\n/* Ghosts sit at --x/--y from the anchor's place in the flow (its static\n   position, where the value is on its line). The anchor never changes size;\n   the sheet inside it is offset and sized each change to take in every\n   ghost, and wears the edge fade (--text-morph-edge) only while a shrinking\n   box would leave ink over a neighbour. */\n.text-morph-ghosts {\n  position: absolute;\n  /* Inline, so its static position is where the value sits on its line. */\n  display: inline;\n  width: 0;\n  height: 0;\n  pointer-events: none;\n  /* Glyphs on their way out aren't part of the value. */\n  user-select: none;\n}\n.text-morph-ghost-sheet {\n  position: absolute;\n  left: var(--ox, 0px);\n  top: var(--oy, 0px);\n  width: 0;\n  height: 0;\n  direction: ltr;\n  mask-image: var(--text-morph-edge, none);\n  mask-repeat: no-repeat;\n}\n\n/* Each ghost gets a slot at its own line, faded above and below like the\n   live glyphs, with room beside it for tilt, scale and blur (0.5em, the\n   engine's SLOT_PAD_X, which sizes the ghost layer around it). A ghost\n   travelling with a neighbour moves its slot, fade and all (translate),\n   and leaves inside it. */\n.text-morph-ghost {\n  position: absolute;\n  left: calc(var(--x) - var(--ox, 0px) - 0.5em);\n  top: calc(var(--y) - var(--oy, 0px) - var(--text-morph-slot-y));\n  padding: var(--text-morph-slot-y) 0.5em;\n  white-space: nowrap;\n  mask-image: var(--text-morph-fade-y);\n}\n/* A ghost swapped as part of a shape slides toward the shape's centre as\n   it shrinks, out of its slot; it doesn't roll, so it goes unmasked. */\n.text-morph-ghost[data-travel] {\n  mask-image: none;\n}\n\n/* Blend: arriving text carried in from past a pinned edge a reader sees (a\n   button's) stays hidden past it while the change plays (the engine sets\n   the inset and lifts it when the change ends). */\n[data-slot=\"text-morph\"][data-edge-clip] {\n  clip-path: var(--text-morph-clip);\n}\n",
      "type": "registry:file",
      "target": "components/ui/cubby-ui/text-morph/text-morph.css"
    }
  ],
  "type": "registry:ui"
}