{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"changelog-stack","type":"registry:component","title":"Changelog stack","description":"Files each release card behind the stack like a hand riffling index cards.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/changelog-stack.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useSpring,\n  useTransform,\n  type AnimationPlaybackControls,\n  type MotionValue,\n  type PanInfo,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ChangeTag = \"New\" | \"Improved\" | \"Fixed\";\n\nexport type Release = {\n  version: string;\n  date: string;\n  title: string;\n  changes: { tag: ChangeTag; text: string }[];\n};\n\ntype Direction = { side: 1 | -1 };\n\n// Each card further back sits a little lower and smaller, so the edges of\n// the next cards peek out under the top one like a real stack.\nconst STEP_Y = 11;\nconst STEP_SCALE = 0.045;\n// Only this many cards show; the rest wait, invisible, at the last depth.\nconst VISIBLE = 3;\nconst CARD_HEIGHT = 244;\n// A flick counts past this distance or speed, so a lazy throw still files\n// the card without having to drag it all the way off.\nconst THROW_DISTANCE = 90;\nconst THROW_SPEED = 500;\n// How far, as a share of the card's width, the filed card travels sideways\n// before it tucks back in: enough to clear the stack so it visibly goes\n// behind, not through.\nconst TUCK_X = 0.72;\n// Delay between cards when jumping several versions, so a jump reads as a\n// quick riffle instead of a teleport.\nconst RIFFLE_MS = 110;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst SETTLE = { type: \"spring\", duration: 0.45, bounce: 0.12 } as const;\n\nfunction pose(depth: number) {\n  const d = Math.min(depth, VISIBLE);\n  return {\n    y: d * STEP_Y,\n    scale: 1 - d * STEP_SCALE,\n    opacity: depth < VISIBLE ? 1 : 0,\n  };\n}\n\nexport function ChangelogStack({\n  releases,\n  className,\n}: {\n  // Newest first, the way a changelog reads.\n  releases: Release[];\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion() ?? false;\n  const n = releases.length;\n  const [index, setIndex] = useState(0);\n  // Which side the next filed card leaves by. Read by the cards when their\n  // depth changes, so it's a ref rather than state.\n  const direction = useRef<Direction>({ side: -1 });\n  const queue = useRef<ReturnType<typeof setTimeout>[]>([]);\n  const stackRef = useRef<HTMLDivElement>(null);\n\n  const clearQueue = () => {\n    queue.current.forEach(clearTimeout);\n    queue.current = [];\n  };\n  useEffect(() => clearQueue, []);\n\n  // Older releases file the top card to the back; newer ones pull the back\n  // card out to the top.\n  const step = useCallback(\n    (by: 1 | -1, side: 1 | -1 = by === 1 ? -1 : 1) => {\n      direction.current = { side };\n      setIndex((i) => Math.min(Math.max(i + by, 0), n - 1));\n    },\n    [n],\n  );\n\n  const go = (by: 1 | -1, side?: 1 | -1) => {\n    clearQueue();\n    step(by, side);\n  };\n\n  const jump = (target: number) => {\n    clearQueue();\n    const by = target > index ? 1 : -1;\n    const count = Math.abs(target - index);\n    for (let k = 0; k < count; k++) {\n      queue.current.push(setTimeout(() => step(by), k * RIFFLE_MS));\n    }\n  };\n\n  const current = releases[index];\n\n  return (\n    <section\n      aria-roledescription=\"carousel\"\n      aria-label=\"What's new\"\n      className={cn(\"flex w-[min(420px,100%)] flex-col gap-5\", className)}\n    >\n      <header className=\"flex items-end justify-between gap-3 px-1\">\n        <div className=\"flex flex-col gap-1\">\n          <p className=\"text-[13px] font-medium text-muted-foreground\">What&apos;s new</p>\n          <RollingVersion\n            version={current.version}\n            versions={releases.map((r) => r.version)}\n            reduceMotion={reduceMotion}\n          />\n        </div>\n        <div className=\"flex gap-2\">\n          <NavButton\n            label=\"Newer release\"\n            onClick={() => go(-1)}\n            disabled={index === 0}\n          >\n            <path d=\"M10 3.5 5.5 8l4.5 4.5\" />\n          </NavButton>\n          <NavButton\n            label=\"Older release\"\n            onClick={() => go(1)}\n            disabled={index === n - 1}\n          >\n            <path d=\"M6 3.5 10.5 8 6 12.5\" />\n          </NavButton>\n        </div>\n      </header>\n\n      <div\n        ref={stackRef}\n        tabIndex={0}\n        role=\"group\"\n        aria-label=\"Release cards. Arrow keys riffle through versions.\"\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") {\n            e.preventDefault();\n            if (index < n - 1) go(1);\n          } else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") {\n            e.preventDefault();\n            if (index > 0) go(-1);\n          } else if (e.key === \"Home\") {\n            e.preventDefault();\n            jump(0);\n          } else if (e.key === \"End\") {\n            e.preventDefault();\n            jump(n - 1);\n          }\n        }}\n        className=\"relative rounded-[18px] outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-foreground\"\n        // Room below the top card for the peeking edges of the cards behind.\n        style={{ height: CARD_HEIGHT + STEP_Y * (VISIBLE - 1) }}\n      >\n        {releases.map((release, i) => (\n          <Card\n            key={release.version}\n            release={release}\n            // Cards already read sit behind the unread ones, in order, so\n            // going back pulls the most recently filed card out first.\n            depth={(i - index + n) % n}\n            total={n}\n            direction={direction}\n            stackRef={stackRef}\n            reduceMotion={reduceMotion}\n            canThrow={index < n - 1}\n            onThrow={(side) => go(1, side)}\n          />\n        ))}\n      </div>\n\n      <Timeline releases={releases} index={index} onSelect={jump} />\n\n      <p className=\"sr-only\" aria-live=\"polite\">\n        {`Version ${current.version}, ${current.date}: ${current.title}. ${current.changes\n          .map((c) => `${c.tag}: ${c.text}`)\n          .join(\". \")}`}\n      </p>\n    </section>\n  );\n}\n\nfunction Card({\n  release,\n  depth,\n  total,\n  direction,\n  stackRef,\n  reduceMotion,\n  canThrow,\n  onThrow,\n}: {\n  release: Release;\n  depth: number;\n  total: number;\n  direction: React.RefObject<Direction>;\n  stackRef: React.RefObject<HTMLDivElement | null>;\n  reduceMotion: boolean;\n  canThrow: boolean;\n  onThrow: (side: 1 | -1) => void;\n}) {\n  const start = pose(depth);\n  const x = useMotionValue(0);\n  const y = useMotionValue(start.y);\n  const scale = useMotionValue(start.scale);\n  const opacity = useMotionValue(start.opacity);\n  const z = useMotionValue(total - depth);\n  // The tilt comes from the sideways travel alone, like a card held by its\n  // bottom corner, so a drag and a filing tilt the same way.\n  const rotate = useTransform(x, [-300, 0, 300], [-7, 0, 7]);\n  const previous = useRef(depth);\n  const running = useRef<AnimationPlaybackControls[]>([]);\n\n  useEffect(() => {\n    const from = previous.current;\n    previous.current = depth;\n    if (from === depth) return;\n    running.current.forEach((a) => a.stop());\n    running.current = [];\n    const to = pose(depth);\n    const run = (a: AnimationPlaybackControls) => {\n      running.current.push(a);\n      return a;\n    };\n    const settle = () => {\n      z.set(total - depth);\n      run(animate(x, 0, SETTLE));\n      run(animate(y, to.y, SETTLE));\n      run(animate(scale, to.scale, SETTLE));\n      run(animate(opacity, to.opacity, { duration: 0.25, ease: EASE_OUT }));\n    };\n\n    if (reduceMotion) {\n      z.set(total - depth);\n      x.jump(0);\n      y.jump(to.y);\n      scale.jump(to.scale);\n      run(animate(opacity, to.opacity, { duration: 0.2 }));\n      return;\n    }\n\n    const filed = from === 0 && depth === total - 1;\n    const pulled = from === total - 1 && depth === 0;\n    if (!filed && !pulled) {\n      settle();\n      return;\n    }\n\n    // Out to the side first, lifted as a hand would hold it. Only once it\n    // clears the stack does it change layer, then it slides home. The first\n    // leg keeps any throw velocity so a flick carries straight through.\n    const side = direction.current.side;\n    const width = stackRef.current?.offsetWidth ?? 400;\n    z.set(filed ? total + 1 : 0);\n    const out = { duration: 0.2, ease: EASE_OUT };\n    const lead = run(\n      animate(x, side * width * TUCK_X, { ...out, velocity: x.getVelocity() }),\n    );\n    // A small lift: any higher and the tilted corner covers the version\n    // number rolling in the header, which is the thing to watch.\n    run(animate(y, -6, out));\n    run(animate(scale, filed ? 1.02 : 1 - STEP_SCALE, out));\n    run(animate(opacity, 1, out));\n    lead.finished.then(() => {\n      if (previous.current === depth) settle();\n    });\n  }, [\n    depth,\n    total,\n    direction,\n    stackRef,\n    reduceMotion,\n    x,\n    y,\n    scale,\n    opacity,\n    z,\n  ]);\n\n  useEffect(() => () => running.current.forEach((a) => a.stop()), []);\n\n  const top = depth === 0;\n\n  const onDragEnd = (_: unknown, info: PanInfo) => {\n    const far = Math.abs(info.offset.x) > THROW_DISTANCE;\n    const fast = Math.abs(info.velocity.x) > THROW_SPEED;\n    if (canThrow && (far || fast)) {\n      // Projects the release a little ahead so a flick back against a long\n      // drag goes the way the hand was moving at the end.\n      onThrow(info.offset.x + info.velocity.x * 0.1 < 0 ? -1 : 1);\n    } else {\n      animate(x, 0, {\n        type: \"spring\",\n        duration: 0.4,\n        bounce: 0.2,\n        velocity: info.velocity.x,\n      });\n    }\n  };\n\n  return (\n    <motion.article\n      aria-hidden={!top}\n      // Dragging stays on with reduced motion: the card only moves under\n      // the hand, and the filing itself is what gets simplified.\n      drag={top ? \"x\" : false}\n      dragMomentum={false}\n      // The oldest card resists: there's nothing older to file it behind.\n      dragElastic={canThrow ? 0.9 : 0.15}\n      dragConstraints={{ left: 0, right: 0 }}\n      onDragEnd={onDragEnd}\n      style={{\n        x,\n        y,\n        scale,\n        rotate,\n        opacity,\n        zIndex: z,\n        height: CARD_HEIGHT,\n        transformOrigin: \"50% 100%\",\n      }}\n      className={cn(\n        \"absolute inset-x-0 top-0 flex touch-pan-y flex-col overflow-hidden rounded-[18px] bg-background shadow-raised select-none\",\n        top ? \"cursor-grab active:cursor-grabbing\" : \"pointer-events-none\",\n      )}\n    >\n      <CardFace release={release} />\n    </motion.article>\n  );\n}\n\nfunction CardFace({ release }: { release: Release }) {\n  return (\n    <div className=\"flex h-full flex-col\">\n      {/* The title goes above the red rule, the way you head a real index\n          card; the version rides along in small type for when the card is\n          seen on its own, mid-flight or peeking from the stack. */}\n      <div className=\"flex items-end justify-between gap-3 px-5 pt-4 pb-3\">\n        <div className=\"min-w-0\">\n          <p className=\"font-mono text-[12px] leading-4 text-muted-foreground tabular-nums\">\n            v{release.version}\n          </p>\n          <h3 className=\"mt-0.5 truncate text-[17px] leading-6 font-semibold tracking-tight\">\n            {release.title}\n          </h3>\n        </div>\n        <time className=\"shrink-0 text-[13px] leading-6 text-muted-foreground tabular-nums\">\n          {release.date}\n        </time>\n      </div>\n      {/* The red header rule a ruled index card has, in marker ink. */}\n      <div aria-hidden className=\"h-[1.5px] bg-marker/70\" />\n      <div\n        className=\"flex flex-1 flex-col px-5 pt-1\"\n        // Faint ruled lines, one under each 32px row. The first layer\n        // covers the 4px above the first row, where the repeat would\n        // otherwise draw a second line hugging the red rule.\n        style={{\n          backgroundImage:\n            \"linear-gradient(var(--background), var(--background)), repeating-linear-gradient(to bottom, transparent 0 31px, color-mix(in oklab, var(--border) 80%, transparent) 31px 32px)\",\n          backgroundSize: \"100% 4px, 100% 32px\",\n          backgroundRepeat: \"no-repeat, repeat\",\n          backgroundPosition: \"0 0, 0 4px\",\n        }}\n      >\n        <ul className=\"flex flex-col\">\n          {release.changes.slice(0, 5).map((change) => (\n            <li key={change.text} className=\"flex h-8 items-center gap-2.5\">\n              <Tag tag={change.tag} />\n              <span className=\"truncate text-[13px] sm:text-[14px]\">\n                {change.text}\n              </span>\n            </li>\n          ))}\n        </ul>\n      </div>\n    </div>\n  );\n}\n\nfunction Tag({ tag }: { tag: ChangeTag }) {\n  return (\n    <span\n      className={cn(\n        \"inline-flex h-5 w-[68px] shrink-0 items-center justify-center rounded-full text-[12px] leading-none font-medium\",\n        tag === \"New\" && \"bg-foreground text-background\",\n        tag === \"Improved\" &&\n          \"bg-background text-foreground shadow-[inset_0_0_0_1px_var(--border)]\",\n        tag === \"Fixed\" && \"bg-muted text-muted-foreground\",\n      )}\n    >\n      {tag}\n    </span>\n  );\n}\n\n// Each part of the version rolls on its own strip of every value that part\n// ever takes, so 2.4.0 to 2.3.1 turns the minor wheel down and the patch\n// wheel up, the way an odometer would.\nfunction RollingVersion({\n  version,\n  versions,\n  reduceMotion,\n}: {\n  version: string;\n  versions: string[];\n  reduceMotion: boolean;\n}) {\n  const parts = version.split(\".\");\n  const columns = parts.map((_, i) =>\n    [...new Set(versions.map((v) => Number(v.split(\".\")[i] ?? 0)))].sort(\n      (a, b) => a - b,\n    ),\n  );\n  return (\n    <p className=\"flex items-center text-[28px] leading-none font-semibold tracking-tight tabular-nums\">\n      <span className=\"sr-only\">{`Version ${version}`}</span>\n      <span aria-hidden className=\"mr-0.5 text-muted-foreground\">\n        v\n      </span>\n      {parts.map((part, i) => (\n        <span key={i} aria-hidden className=\"flex items-center\">\n          {i > 0 && <span className=\"text-muted-foreground\">.</span>}\n          <Strip\n            values={columns[i]}\n            at={columns[i].indexOf(Number(part))}\n            reduceMotion={reduceMotion}\n          />\n        </span>\n      ))}\n    </p>\n  );\n}\n\nfunction Strip({\n  values,\n  at,\n  reduceMotion,\n}: {\n  values: number[];\n  at: number;\n  reduceMotion: boolean;\n}) {\n  const position = useSpring(at, { visualDuration: 0.4, bounce: 0.15 });\n  useEffect(() => {\n    if (reduceMotion) position.jump(at);\n    else position.set(at);\n  }, [at, reduceMotion, position]);\n  const widest = Math.max(...values.map((v) => String(v).length));\n\n  return (\n    <span\n      className=\"relative inline-block h-[1.2em] overflow-hidden [mask-image:linear-gradient(transparent,black_25%,black_75%,transparent)]\"\n      // Tabular digits in Geist run about 0.62em wide.\n      style={{ width: `${widest * 0.62}em` }}\n    >\n      {values.map((value, i) => (\n        <StripValue key={value} value={value} slot={i} position={position} />\n      ))}\n    </span>\n  );\n}\n\nfunction StripValue({\n  value,\n  slot,\n  position,\n}: {\n  value: number;\n  slot: number;\n  position: MotionValue<number>;\n}) {\n  // Higher values sit below, so moving to a newer release rolls upward.\n  const transform = useTransform(\n    position,\n    (p) => `translateY(${(slot - p) * 100}%)`,\n  );\n  return (\n    <motion.span\n      className=\"absolute inset-0 flex items-center justify-center\"\n      style={{ transform }}\n    >\n      {value}\n    </motion.span>\n  );\n}\n\nfunction Timeline({\n  releases,\n  index,\n  onSelect,\n}: {\n  releases: Release[];\n  index: number;\n  onSelect: (i: number) => void;\n}) {\n  const reduceMotion = useReducedMotion();\n  const n = releases.length;\n  // Oldest on the left so time runs the way it reads; the list is newest first.\n  const order = releases.map((_, i) => n - 1 - i);\n  const at = n - 1 - index;\n  // Dots sit in the middle of equal columns, so the track runs from the\n  // centre of the first column to the centre of the last.\n  const inset = `${50 / n}%`;\n\n  return (\n    <div className=\"relative\">\n      <div\n        aria-hidden\n        className=\"absolute top-[17px] h-px bg-border\"\n        style={{ left: inset, right: inset }}\n      />\n      <motion.div\n        aria-hidden\n        className=\"absolute top-[17px] h-px origin-left bg-foreground\"\n        style={{ left: inset, right: inset }}\n        initial={false}\n        animate={{ scaleX: n > 1 ? at / (n - 1) : 0 }}\n        transition={\n          reduceMotion\n            ? { duration: 0 }\n            : { type: \"spring\", duration: 0.45, bounce: 0 }\n        }\n      />\n      <ol\n        className=\"relative grid\"\n        style={{ gridTemplateColumns: `repeat(${n}, 1fr)` }}\n      >\n        {order.map((i) => {\n          const active = i === index;\n          return (\n            <li key={releases[i].version} className=\"flex justify-center\">\n              <button\n                type=\"button\"\n                onClick={() => onSelect(i)}\n                aria-current={active ? \"true\" : undefined}\n                aria-label={`Version ${releases[i].version}, ${releases[i].date}`}\n                className=\"group flex h-11 min-w-11 touch-manipulation flex-col items-center gap-2 rounded-lg px-1 pt-[13px] outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\"\n              >\n                <span\n                  className={cn(\n                    \"size-2.5 rounded-full shadow-[0_0_0_3px_var(--background)] transition-[background-color,scale] duration-200 ease-out\",\n                    active\n                      ? \"scale-125 bg-foreground\"\n                      : \"bg-border group-hover:bg-muted-foreground\",\n                  )}\n                />\n                <span\n                  className={cn(\n                    \"text-[12px] leading-none tabular-nums transition-colors duration-200 ease-out\",\n                    active ? \"text-foreground\" : \"text-muted-foreground\",\n                  )}\n                >\n                  {releases[i].version}\n                </span>\n              </button>\n            </li>\n          );\n        })}\n      </ol>\n    </div>\n  );\n}\n\nfunction NavButton({\n  label,\n  onClick,\n  disabled,\n  children,\n}: {\n  label: string;\n  onClick: () => void;\n  disabled?: boolean;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      onClick={onClick}\n      disabled={disabled}\n      className=\"flex size-10 touch-manipulation items-center justify-center rounded-full bg-muted text-foreground shadow-raised outline-hidden transition-[scale,opacity] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] disabled:opacity-40 disabled:active:scale-100\"\n    >\n      <svg\n        viewBox=\"0 0 16 16\"\n        className=\"size-4\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden\n      >\n        {children}\n      </svg>\n    </button>\n  );\n}\n\nconst RELEASES: Release[] = [\n  {\n    version: \"2.4.0\",\n    date: \"Sep 18, 2026\",\n    title: \"Offline drafts\",\n    changes: [\n      { tag: \"New\", text: \"Drafts save locally and sync on reconnect\" },\n      { tag: \"New\", text: \"Conflict view for edits from two devices\" },\n      { tag: \"Improved\", text: \"Large documents open 40% faster\" },\n      { tag: \"Fixed\", text: \"Pasted tables keep their column widths\" },\n    ],\n  },\n  {\n    version: \"2.3.1\",\n    date: \"Aug 29, 2026\",\n    title: \"Patch release\",\n    changes: [\n      { tag: \"Fixed\", text: \"Mentions no longer drop the last letter\" },\n      { tag: \"Fixed\", text: \"Code blocks lost their borders in dark mode\" },\n      { tag: \"Improved\", text: \"Clearer error when an embed fails\" },\n    ],\n  },\n  {\n    version: \"2.3.0\",\n    date: \"Aug 12, 2026\",\n    title: \"Shared templates\",\n    changes: [\n      { tag: \"New\", text: \"Publish a template to your workspace\" },\n      { tag: \"New\", text: \"Template variables for dates and names\" },\n      { tag: \"Improved\", text: \"Search ranks recent documents first\" },\n      { tag: \"Fixed\", text: \"Exported PDFs respect page breaks\" },\n    ],\n  },\n  {\n    version: \"2.2.0\",\n    date: \"Jul 21, 2026\",\n    title: \"Comments, rebuilt\",\n    changes: [\n      { tag: \"New\", text: \"Resolve threads straight from email\" },\n      { tag: \"Improved\", text: \"Comments follow text when it moves\" },\n      { tag: \"Fixed\", text: \"Badge counted resolved threads\" },\n    ],\n  },\n  {\n    version: \"2.1.0\",\n    date: \"Jun 30, 2026\",\n    title: \"Keyboard first\",\n    changes: [\n      { tag: \"New\", text: \"Command menu on Cmd K everywhere\" },\n      { tag: \"Improved\", text: \"Every toolbar action has a shortcut\" },\n      { tag: \"Improved\", text: \"Focus returns where you left it\" },\n      { tag: \"Fixed\", text: \"Tab order in the share dialog\" },\n    ],\n  },\n];\n\nexport default function ChangelogStackDemo() {\n  return <ChangelogStack releases={RELEASES} />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"marker":"#d93d31","shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"marker":"#ff6b5f","shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"color-marker":"var(--marker)","shadow-raised":"var(--shadow-raised)"}},"categories":["data"],"docs":"From ui lab: https://lab.xevrion.dev/lab/changelog-stack"}