{"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-YXWGSDK4.cjs","../src/hooks/ui/use-is-truncated.ts","../src/hooks/ui/use-debounce.ts","../src/hooks/ui/use-isomorphic-layout-effect.ts","../src/hooks/ui/use-media-query.ts","../src/hooks/ui/use-header-height.ts","../src/hooks/ui/use-marquee-engine.ts","../src/hooks/ui/use-reset-on-page-hidden.ts","../src/hooks/ui/use-suppress-clone-focus.ts","../src/types/tool.types.ts","../src/components/tool-icon.tsx","../src/utils/common.ts","../src/hooks/use-near-viewport.ts","../src/utils/markdown-fences.ts","../src/utils/markdown-heading-id.ts","../src/utils/anchor-id.ts","../src/utils/scroll-into-view.ts","../src/utils/same-page-hash-nav.ts"],"names":["delay","useState","useEffect","header","bar","useCallback","useRef"],"mappings":"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EAAA;AAKF,EAAA;AACA,EAAA;AACA,EAAA;AACA,IAAA;AACC,IAAA;AACP,EAAA;AAKM,EAAA;AACE,IAAA;AAKA,IAAA;AACA,IAAA;AAGN,IAAA;AACF,EAAA;AAGA,EAAA;AAEM,EAAA;AAIF,EAAA;AACF,IAAA;AACA,IAAA;AACF,EAAA;AAGI,EAAA;AACA,EAAA;AAKE,EAAA;AACC,EAAA;AACA,EAAA;AACP,EAAA;AACE,IAAA;AACA,IAAA;AACF,EAAA;AAEM,EAAA;AACA,IAAA;AACF,MAAA;AACA,MAAA;AACF,IAAA;AACM,IAAA;AACA,IAAA;AACA,IAAA;AACN,IAAA;AACI,IAAA;AACF,MAAA;AACF,IAAA;AAEE,MAAA;AACA,MAAA;AACI,MAAA;AACF,QAAA;AACA,QAAA;AACF,MAAA;AACF,IAAA;AACF,EAAA;AACA,EAAA;AACF;AhB4dU;AACA;AiBnpBG;AAIA;AAUG;AACT,EAAA;AACC,EAAA;AACC,EAAA;AACT;AAqBgB;AACL,EAAA;AACH,EAAA;AACF,EAAA;AAEA,EAAA;AACA,EAAA;AACF,IAAA;AACI,IAAA;AACF,MAAA;AACI,MAAA;AACN,IAAA;AACM,EAAA;AAEN,IAAA;AACF,EAAA;AAEO,EAAA;AACT;AAsBgB;AACV,EAAA;AACI,EAAA;AACF,EAAA;AAKF,EAAA;AACA,EAAA;AACI,IAAA;AACA,EAAA;AACN,IAAA;AACF,EAAA;AAEM,EAAA;AAIJ,IAAA;AACF,EAAA;AACM,EAAA;AAGA,EAAA;AACF,EAAA;AACF,IAAA;AACE,MAAA;AACF,IAAA;AACF,EAAA;AACM,EAAA;AACA,EAAA;AAED,EAAA;AACD,EAAA;AACI,IAAA;AACF,IAAA;AACF,MAAA;AACF,IAAA;AACE,MAAA;AACF,IAAA;AAGA,IAAA;AACM,MAAA;AACF,QAAA;AACA,QAAA;AACD,MAAA;AACH,IAAA;AACF,EAAA;AACM,EAAA;AACF,EAAA;AACF,IAAA;AACF,EAAA;AAGA,EAAA;AACE,IAAA;AACA,IAAA;AACD,EAAA;AACM,EAAA;AACT;AjB8kBU;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-YXWGSDK4.cjs","sourcesContent":[null,"'use client';\n\nimport { useCallback, useEffect, useState } from 'react';\n\nexport interface UseIsTruncatedOptions {\n  /** Measure vertical clipping (`line-clamp-N`) instead of the single-line ellipsis. */\n  multiline?: boolean;\n}\n\n/**\n * Tracks whether an element's text is ACTUALLY clipped — the condition for\n * offering a \"full value\" tooltip. Text that fits needs no tooltip, and showing\n * one anyway just repeats what the user is already reading.\n *\n * Three things can change the answer, and all three are watched: the box\n * resizing (`ResizeObserver`), the text itself changing, and the measured\n * ELEMENT changing. None of them implies the others — new text inside a\n * width-constrained box leaves the box exactly as it was, so the observer never\n * fires; and a caller that swaps between render branches (adornment vs plain)\n * mounts a different node under the same text, which a `useRef` would leave\n * measured against the old, detached node. Pass the rendered value as `value`.\n *\n * `ref` is a CALLBACK ref, not a `RefObject` — that is what makes the element\n * itself a dependency. Attach it exactly like a normal ref; there is no\n * `.current` to read.\n *\n * ```tsx\n * const { ref, isTruncated } = useIsTruncated<HTMLHeadingElement>(title)\n * <FloatingTooltip content={title} disabled={!isTruncated}>\n *   <h1 ref={ref} className=\"truncate\">{title}</h1>\n * </FloatingTooltip>\n * ```\n */\nexport function useIsTruncated<T extends HTMLElement>(value: unknown, options?: UseIsTruncatedOptions) {\n  const multiline = options?.multiline ?? false;\n  const [element, setElement] = useState<T | null>(null);\n  const [isTruncated, setIsTruncated] = useState(false);\n\n  // Wrapped so the identity stays stable across renders: passing the raw\n  // `useState` setter as a ref would be stable too, but this keeps the public\n  // shape a plain `RefCallback<T>` instead of a state dispatcher.\n  const ref = useCallback((node: T | null) => setElement(node), []);\n\n  useEffect(() => {\n    if (!element) return undefined;\n    // The 1px tolerance absorbs sub-pixel layout rounding, which otherwise\n    // reports a permanent fractional \"overflow\" at some zoom levels / DPRs and\n    // leaves a tooltip armed on text that is fully visible.\n    const check = () => {\n      setIsTruncated(\n        multiline ? element.scrollHeight > element.clientHeight + 1 : element.scrollWidth > element.clientWidth + 1,\n      );\n    };\n    check();\n    const observer = new ResizeObserver(check);\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, [element, value, multiline]);\n\n  // Masked rather than reset from the effect: with no element there is nothing\n  // to clip, and deriving that during render avoids a second render pass every\n  // time the measured node unmounts.\n  return { ref, isTruncated: element ? isTruncated : false };\n}\n","'use client';\n\nimport { useState, useEffect } from 'react';\n\n/**\n * Hook to debounce a value\n * @param value - Value to debounce\n * @param delay - Delay in milliseconds\n * @returns Debounced value\n */\nexport function useDebounce<T>(value: T, delay = 500): T {\n  const [debouncedValue, setDebouncedValue] = useState<T>(value);\n\n  useEffect(() => {\n    // Set up timeout to update debounced value\n    const timer = setTimeout(() => {\n      setDebouncedValue(value);\n    }, delay);\n\n    // Clean up timeout on value change or unmount\n    return () => {\n      clearTimeout(timer);\n    };\n  }, [value, delay]);\n\n  return debouncedValue;\n}\n","'use client';\n\nimport { useEffect, useLayoutEffect } from 'react';\n\n/**\n * `useLayoutEffect` in the browser, `useEffect` on the server.\n *\n * `useLayoutEffect` never runs during server rendering — React cannot measure or\n * mutate a layout that has not been laid out — so calling it in a component that\n * SSRs logs a warning on every render pass. Client components still render on the\n * server in Next.js, so `'use client'` alone does not avoid it.\n *\n * Use this wherever the effect writes something the FIRST paint depends on (a CSS\n * custom property, a scroll position, a measured size). A plain `useEffect` there\n * runs after paint, which shows one frame of the pre-effect value; a plain\n * `useLayoutEffect` gets the timing right and pays for it with the warning. This\n * gets both: paint-blocking where there is a paint, a no-op where there is not.\n */\nexport const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;\n","'use client';\n\nimport { useState } from 'react';\nimport { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect';\n\n/**\n * Hook to check if a media query matches\n * @param query - Media query to check\n * @returns Whether the media query matches, or undefined during SSR/initial render\n */\nexport function useMediaQuery(query: string): boolean | undefined {\n  const [matches, setMatches] = useState<boolean | undefined>(undefined);\n\n  useIsomorphicLayoutEffect(() => {\n    const matchMedia = window.matchMedia(query);\n\n    const handleChange = () => {\n      setMatches(matchMedia.matches);\n    };\n\n    // Set initial value\n    handleChange();\n\n    matchMedia.addEventListener('change', handleChange);\n\n    return () => {\n      matchMedia.removeEventListener('change', handleChange);\n    };\n  }, [query]);\n\n  return matches;\n}\n\n/**\n * Predefined breakpoints for common screen sizes\n */\nexport const breakpoints = {\n  md: '(min-width: 800px)', // Tablet: 50rem\n  lg: '(min-width: 1280px)', // Desktop: 80rem\n};\n\n/** @deprecated Use useMdUp instead */\nexport function useSmUp(): boolean | undefined {\n  return useMdUp();\n}\n\nexport function useMdUp(): boolean | undefined {\n  const matches = useMediaQuery(breakpoints.md);\n  return matches === undefined ? undefined : matches;\n}\n\nexport function useLgUp(): boolean | undefined {\n  const matches = useMediaQuery(breakpoints.lg);\n  return matches === undefined ? undefined : matches;\n}\n","'use client';\n\nimport { useEffect, useState } from 'react';\n\n/**\n * Returns the combined height (px) of the sticky page header and announcement\n * bar (if present), updated live via ResizeObserver / MutationObserver.\n * Useful for offsetting fixed/absolute-positioned panels so they don't\n * overlap the header.\n */\nexport function useHeaderHeight(\n  // First-paint fallback = the unified top-navigation `big` bar (72px), the\n  // size every hub/marketing site renders; replaced by the live measurement.\n  defaultHeight = 72,\n  options: {\n    /**\n     * When false, no observers are attached and the default height is\n     * returned — for consumers that stay mounted on every page but only need\n     * the measurement while an overlay is open (avoids an always-on\n     * document-wide MutationObserver + forced reflow).\n     * @default true\n     */\n    enabled?: boolean;\n  } = {},\n): number {\n  const { enabled = true } = options;\n  const [height, setHeight] = useState(defaultHeight);\n\n  useEffect(() => {\n    if (!enabled) return undefined;\n\n    const measure = () => {\n      let total = 0;\n      const header = document.querySelector('header');\n      if (header) total += header.offsetHeight;\n      const bar = document.querySelector('[data-announcement-bar]');\n      if (bar instanceof HTMLElement) total += bar.offsetHeight;\n      setHeight(total > 0 ? total : defaultHeight);\n    };\n\n    measure();\n\n    const resizeObserver = new ResizeObserver(measure);\n    const header = document.querySelector('header');\n    if (header) resizeObserver.observe(header);\n    const bar = document.querySelector('[data-announcement-bar]');\n    if (bar) resizeObserver.observe(bar);\n\n    const mutationObserver = new MutationObserver(mutations => {\n      for (const mutation of mutations) {\n        if (mutation.type === 'childList' || mutation.type === 'attributes') {\n          measure();\n          const newBar = document.querySelector('[data-announcement-bar]');\n          if (newBar) resizeObserver.observe(newBar);\n        }\n      }\n    });\n    mutationObserver.observe(document.body, {\n      childList: true,\n      subtree: true,\n      attributes: true,\n      attributeFilter: ['data-announcement-bar'],\n    });\n\n    return () => {\n      resizeObserver.disconnect();\n      mutationObserver.disconnect();\n    };\n  }, [defaultHeight, enabled]);\n\n  return height;\n}\n","'use client';\n\nimport { useCallback, useEffect, useRef, type MutableRefObject } from 'react';\n\n/**\n * THE marquee animation core (single source of truth) — extracted verbatim\n * from <CardsStrip> so every endless-scroll surface (card strips, logo strips,\n * quick-action walls, chip marquees) shares ONE rAF position engine (\"unify\n * means delete variations\").\n *\n * The engine is axis- and driver-agnostic: it owns a FLOAT position ref, a\n * velocity envelope, seam wrapping, and an optional chevron-style glide; the\n * caller decides what a position \"means\" via `apply` — writing\n * `scroller.scrollLeft` (CardsStrip: a real scroller the user can also drag)\n * or a `translate` transform (MarqueeWall: decorative tracks, incl. vertical —\n * mandatory inside surfaces that forbid inner scrollers, e.g. the deck's\n * mobile single-fold panels).\n *\n * Engine semantics (do not \"simplify\"):\n * - Position lives in a FLOAT ref, never read-modify-write from the DOM:\n *   scrollLeft reads back rounded values, so sub-pixel increments\n *   (speed/fps < 1px) get eaten by rounding — stutter + measurably slower\n *   than the configured speed.\n * - Velocity envelope (~250ms time constant) eases between 0 and `speed`\n *   instead of binary stop/start — pause decelerates, resume accelerates from\n *   the current position (GSAP-marquee behavior; hard cuts read as jank).\n * - Glide consumes a signed remaining-distance, wrapping modulo the copy size\n *   each frame, so seam warps are invisible to it (browser smooth-scroll\n *   animates toward an ABSOLUTE target and lunges after a warp).\n * - `isPaused` is evaluated every frame — the caller owns the pause-reason\n *   set (hover, viewport, tab visibility, suppress windows); the engine only\n *   turns it into a smooth velocity target.\n */\nexport interface MarqueeEngineOptions {\n  /** Master switch — the rAF runs only while true. On (re)activation the\n   *  position resyncs from `readBack` when provided. */\n  active: boolean;\n  /** Cruise speed in px/s. */\n  speed: number;\n  /** Per-frame pause verdict (caller-owned reason set). `now` is the rAF\n   *  timestamp, for suppress-until comparisons. */\n  isPaused: (now: number) => boolean;\n  /** Single-copy size in px (the wrap modulus). Return <= 0 to disable\n   *  wrapping (position advances unwrapped — callers should size tracks so\n   *  this never happens while active). */\n  getWrapSize: () => number;\n  /** Lower bound of the wrap range: positions stay in\n   *  [wrapMin, wrapMin + wrapSize). Default 0. Scroller drivers with USER\n   *  scrolling pass their seam-warp buffer here so engine-written positions\n   *  never enter the re-center zones — an exact-edge warp check is racy\n   *  against the rAF (the engine nudges the scroller off the edge before the\n   *  scroll handler samples it → \"stuck at the left edge\"). */\n  getWrapMin?: () => number;\n  /** Write the position to the DOM (scrollLeft assignment or transform). */\n  apply: (pos: number) => void;\n  /** Read the externally-mutated position back (real scrollers: user drag /\n   *  native smooth scroll move scrollLeft under the engine). Omit for\n   *  transform drivers — nothing external can move them. */\n  readBack?: () => number;\n  /** Called at the end of every frame (e.g. hover re-resolution after the\n   *  track moved under a stationary pointer). */\n  onAfterFrame?: () => void;\n}\n\nexport interface MarqueeEngineHandle {\n  /** The engine's float position (px along the axis, 0 ≤ pos < wrap while\n   *  wrapping). Callers may write it together with their own DOM write (seam\n   *  warp on manual scroll). */\n  posRef: MutableRefObject<number>;\n  /** Accumulate a signed glide distance (chevron navigation). Runs through\n   *  the engine's own tween — never native smooth scroll while wrapping. */\n  glideBy: (px: number) => void;\n  /** Wrap an arbitrary position into [0, wrapSize). */\n  wrap: (pos: number) => number;\n}\n\n// Resync threshold (px): external movement beyond this adopts the DOM value.\nconst RESYNC_EPSILON = 1.5;\n\nexport function useMarqueeEngine(options: MarqueeEngineOptions): MarqueeEngineHandle {\n  const posRef = useRef(0);\n  const glideRemainingRef = useRef(0);\n  const speedEnvRef = useRef(0);\n\n  // Latest-options ref: the rAF loop always reads current values without the\n  // effect restarting on every render. Refreshed in an unconditional effect,\n  // declared before every effect that starts the loop, rather than in the\n  // render body — a render attempt React discards must not be able to hand the\n  // loop options that were never committed.\n  const optsRef = useRef(options);\n  useEffect(() => {\n    optsRef.current = options;\n  });\n\n  const wrap = useCallback((pos: number) => {\n    const half = optsRef.current.getWrapSize();\n    if (half <= 0) return pos;\n    const min = optsRef.current.getWrapMin?.() ?? 0;\n    let wrapped = pos;\n    while (wrapped >= min + half) wrapped -= half;\n    while (wrapped < min) wrapped += half;\n    return wrapped;\n  }, []);\n\n  const glideBy = useCallback((px: number) => {\n    glideRemainingRef.current += px;\n  }, []);\n\n  const active = options.active;\n  useEffect(() => {\n    if (!active) return undefined;\n    let raf = 0;\n    let last = performance.now();\n    const readBack = optsRef.current.readBack;\n    if (readBack) posRef.current = readBack();\n\n    const tick = (now: number) => {\n      const opts = optsRef.current;\n      const dt = Math.min(now - last, 100) / 1000; // clamp tab-wake jumps\n      last = now;\n\n      const paused = opts.isPaused(now);\n      // Ease the velocity toward its target (0 when paused) — smooth decel on\n      // pause, smooth accel on resume, always from the CURRENT position.\n      const targetSpeed = paused ? 0 : opts.speed;\n      speedEnvRef.current += (targetSpeed - speedEnvRef.current) * Math.min(1, dt / 0.25);\n      if (targetSpeed === 0 && speedEnvRef.current < 0.5) speedEnvRef.current = 0;\n\n      const external = opts.readBack ? opts.readBack() : null;\n      const resync = () => {\n        if (external !== null && Math.abs(external - posRef.current) > RESYNC_EPSILON) {\n          posRef.current = external;\n        }\n      };\n\n      const glide = glideRemainingRef.current;\n      if (glide !== 0) {\n        // Ease-out toward the glide target; runs even while \"paused\" (the\n        // interaction that queued it sets its own suppress window).\n        resync();\n        const speed = Math.max(Math.abs(glide) * 6, 240); // px/s, proportional\n        const step = Math.sign(glide) * Math.min(Math.abs(glide), speed * dt);\n        glideRemainingRef.current = Math.abs(glide - step) < 0.5 ? 0 : glide - step;\n        posRef.current = wrap(posRef.current + step);\n        opts.apply(posRef.current);\n      } else if (speedEnvRef.current > 0) {\n        // Resync after external movement (user scroll, seam warp).\n        resync();\n        posRef.current = wrap(posRef.current + speedEnvRef.current * dt);\n        opts.apply(posRef.current);\n      } else if (external !== null) {\n        // Idle: follow external movement so resumes start from where the\n        // user left the scroller.\n        posRef.current = external;\n      }\n      opts.onAfterFrame?.();\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [active, wrap]);\n\n  return { posRef, glideBy, wrap };\n}\n","import { useEffect } from 'react';\n\n/**\n * Runs `reset` whenever the tab is hidden or the window loses focus — exactly the\n * moments a `pointerleave` / `pointercancel` can be SWALLOWED (tab blur, an\n * overlay mounting under the cursor, an interrupted pointer), which otherwise\n * leaves a hover/\"inside\" flag stuck `true` and freezes a marquee FOREVER. The\n * next real pointer/hover re-arms the flag.\n *\n * SSOT self-heal for every marquee surface (MarqueeWall, CardsStrip) so the\n * blur + `visibilitychange` wiring lives in one place. `reset` almost always\n * only UN-sticks state. The one edge: on `blur` WITHOUT the tab hiding (a second\n * window focused beside this one), clearing the hover-pause can let the marquee\n * resume under a still-stationary cursor in the now-unfocused window — it\n * re-pauses on the next `pointermove`/`pointerenter`. We keep the `blur` reset\n * anyway because it's the only signal that catches a tab-blur that swallows the\n * `pointerleave` without a `visibilitychange`. Pass a STABLE `reset` (memoize\n * with `useCallback`) — the listeners re-attach whenever its identity changes.\n */\nexport function useResetOnPageHidden(reset: () => void): void {\n  useEffect(() => {\n    const onVisibility = () => {\n      if (document.visibilityState === 'hidden') reset();\n    };\n    window.addEventListener('blur', reset);\n    document.addEventListener('visibilitychange', onVisibility);\n    return () => {\n      window.removeEventListener('blur', reset);\n      document.removeEventListener('visibilitychange', onVisibility);\n    };\n  }, [reset]);\n}\n","'use client';\n\nimport { useEffect, useRef } from 'react';\n\n// Every focusable a clone might contain — matched so it can be pulled out of the\n// keyboard tab order (see useSuppressCloneFocus). `[contenteditable]:not(\n// [contenteditable=\"false\"])` catches the empty/`\"true\"`/`\"plaintext-only\"`\n// editable forms; `summary` is focusable inside a <details>.\nconst CLONE_FOCUSABLE_SELECTOR =\n  'a[href],button,input,select,textarea,iframe,summary,audio[controls],video[controls],[tabindex],[contenteditable]:not([contenteditable=\"false\"])';\n\n/**\n * Keep a marquee clone's content pointer-CLICKABLE while taking it out of the\n * accessibility tree and the keyboard tab order. Shared by every endless-loop\n * surface that renders a second track copy (<CardsStrip> managed cells,\n * <MarqueeWall> clone copies).\n *\n * `inert` (the previous approach) does all three at once: a11y-hidden,\n * un-focusable AND un-clickable. That third effect was the \"news marquee stops\n * being clickable after you scroll it for a while\" bug — the 2-copy endless\n * loop ALWAYS paints clone cards inside the viewport near the copy seam, so a\n * user who manually scrolls and parks there is looking at real-looking cards\n * that silently swallow every click. Clones must therefore stay clickable (a\n * click on ANY visible card must open its link — mouse, ⌘-click, middle-click\n * and context-menu all handled natively by the clone's own anchor).\n *\n * The a11y contract is still met without `inert`: the caller keeps\n * `aria-hidden` on the clone wrapper (out of the a11y tree) and this hook\n * forces every focusable descendant to `tabindex=\"-1\"` (out of the tab order →\n * no duplicate tab stops, no aria-hidden-focus violation). Re-applied on\n * subtree mutation. This is the standard loop-clone treatment used by\n * Swiper/Splide.\n */\nexport function useSuppressCloneFocus(active: boolean) {\n  const ref = useRef<HTMLDivElement | null>(null);\n  useEffect(() => {\n    const root = ref.current;\n    if (!root || !active) return undefined;\n    const suppress = () => {\n      root.querySelectorAll<HTMLElement>(CLONE_FOCUSABLE_SELECTOR).forEach(el => {\n        // Only stamp the ones not already stamped — avoids touching the DOM (and\n        // clobbering an intentional tabindex) on every mutation.\n        if (el.getAttribute('tabindex') !== '-1') el.setAttribute('tabindex', '-1');\n      });\n    };\n    suppress();\n    // Watch structure AND the attributes that can turn a descendant tabbable\n    // (an anchor gaining `href`, React restoring `tabindex=\"0\"`, an element\n    // becoming editable, media gaining `controls`). The `!== '-1'` guard above\n    // makes our own tabindex writes a no-op, so the observer never loops.\n    const mo = new MutationObserver(suppress);\n    mo.observe(root, {\n      childList: true,\n      subtree: true,\n      attributes: true,\n      attributeFilter: ['href', 'tabindex', 'contenteditable', 'controls'],\n    });\n    return () => mo.disconnect();\n  }, [active]);\n  return ref;\n}\n","/**\n * Centralized Tool Types\n *\n * Single source of truth for all tool-related types across the entire platform.\n * Used by ToolBadge, ToolIcon, and any component that needs tool type information.\n */\n\nexport const ToolTypeValues = {\n  FLEET_MDM: 'FLEET_MDM',\n  MESHCENTRAL: 'MESHCENTRAL',\n  AUTHENTIK: 'AUTHENTIK',\n  OPENFRAME: 'OPENFRAME',\n  OPENFRAME_CHAT: 'OPENFRAME_CHAT',\n  OPENFRAME_CLIENT: 'OPENFRAME_CLIENT',\n  OPENFRAME_RMM: 'OPENFRAME_RMM',\n  OSQUERY: 'OSQUERY',\n  SYSTEM: 'SYSTEM',\n} as const;\n\nexport type ToolType = (typeof ToolTypeValues)[keyof typeof ToolTypeValues];\n\n/**\n * Maps tool types to display labels\n */\nexport const toolLabels: Record<ToolType, string> = {\n  FLEET_MDM: 'Fleet',\n  MESHCENTRAL: 'MeshCentral',\n  AUTHENTIK: 'Authentik',\n  OPENFRAME: 'OpenFrame',\n  OPENFRAME_CHAT: 'OpenFrame Chat',\n  OPENFRAME_CLIENT: 'OpenFrame Client',\n  OPENFRAME_RMM: 'RMM',\n  OSQUERY: 'Osquery',\n  SYSTEM: 'System',\n};\n","import type { FC, ReactNode } from 'react';\nimport { type ToolType, ToolTypeValues } from '../types/tool.types';\nimport { OpenFrameLogo } from './icons';\nimport {\n  OsqueryLogoGreyIcon,\n  MeshcentralLogoGreyIcon,\n  FleetMdmLogoGreyIcon,\n  AuthentikLogoGreyIcon,\n} from './icons-v2-generated';\n\nconst renderOpenFrameLogo = (_size: number, className?: string) => (\n  <OpenFrameLogo\n    className={className ?? 'h-4 w-auto'}\n    lowerPathColor=\"var(--color-accent-primary)\"\n    upperPathColor=\"var(--color-text-primary)\"\n  />\n);\n\nconst toolIconMap: Record<ToolType, (size: number, className?: string) => ReactNode> = {\n  [ToolTypeValues.FLEET_MDM]: (size, className) => <FleetMdmLogoGreyIcon size={size} className={className} />,\n  [ToolTypeValues.MESHCENTRAL]: (size, className) => <MeshcentralLogoGreyIcon size={size} className={className} />,\n  [ToolTypeValues.OPENFRAME]: renderOpenFrameLogo,\n  [ToolTypeValues.OPENFRAME_CHAT]: renderOpenFrameLogo,\n  [ToolTypeValues.OPENFRAME_CLIENT]: renderOpenFrameLogo,\n  [ToolTypeValues.OPENFRAME_RMM]: renderOpenFrameLogo,\n  [ToolTypeValues.AUTHENTIK]: (size, className) => <AuthentikLogoGreyIcon size={size} className={className} />,\n  [ToolTypeValues.OSQUERY]: (size, className) => <OsqueryLogoGreyIcon size={size} className={className} />,\n  [ToolTypeValues.SYSTEM]: () => null,\n};\n\nexport interface ToolIconProps {\n  toolType: ToolType;\n  size?: number;\n  className?: string;\n}\n\nexport const ToolIcon: FC<ToolIconProps> = ({ toolType, size = 16, className }) => (\n  <>{toolIconMap[toolType]?.(size, className) ?? null}</>\n);\n\nToolIcon.displayName = 'ToolIcon';\n","import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\n/**\n * Utility functions for common operations\n */\n\n/**\n * Merge class names with Tailwind CSS\n * @param inputs - Class names to merge\n * @returns Merged class names\n */\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\n/**\n * Human-readable message for a caught value.\n *\n * `catch (e)` binds `unknown` under this tsconfig, and a thrown value is not\n * guaranteed to be an `Error`. Use this instead of `catch (e: any)` +\n * `e.message`, which crashes on a thrown string/object with no `message`.\n *\n * @param error - The caught value\n * @param fallback - Returned when the value carries no usable message\n */\nexport function errorMessage(error: unknown, fallback = 'Something went wrong'): string {\n  if (error instanceof Error && error.message) return error.message;\n  if (typeof error === 'string' && error) return error;\n  if (typeof error === 'object' && error !== null) {\n    const message = (error as { message?: unknown }).message;\n    if (typeof message === 'string' && message) return message;\n  }\n  return fallback;\n}\n\n/**\n * Human-readable message for a FAILED API response body.\n *\n * `Response.json()` is typed `any`, so `body.error` used to be read off an\n * `any` at every call site. The platform's route helpers answer errors with\n * `{ error, code, details }` (see `errorResponse` in the hub's\n * `lib/api/route-base.ts`); a few endpoints answer `{ message }` instead, so\n * both are accepted before giving up.\n *\n * @param payload - The parsed response body (or whatever came back)\n * @param fallback - Returned when the body carries no usable message\n */\nexport function apiErrorMessage(payload: unknown, fallback: string): string {\n  if (typeof payload === 'object' && payload !== null && 'error' in payload) {\n    const { error } = payload;\n    if (typeof error === 'string' && error) return error;\n  }\n  return errorMessage(payload, fallback);\n}\n\n/**\n * Delay execution for a specified time\n * @param ms - Milliseconds to delay\n * @returns Promise that resolves after the delay\n */\nexport function delay(ms: number): Promise<void> {\n  return new Promise(resolve => setTimeout(resolve, ms));\n}\n\n/**\n * Generate a random string of specified length\n * @param length - Length of the string\n * @returns Random string\n */\nexport function generateRandomString(length = 8): string {\n  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n  let result = '';\n  for (let i = 0; i < length; i++) {\n    result += chars.charAt(Math.floor(Math.random() * chars.length));\n  }\n  return result;\n}\n\n/**\n * Truncate a string to a specified length\n * @param str - String to truncate\n * @param maxLength - Maximum length\n * @param suffix - Suffix to add to truncated string\n * @returns Truncated string\n */\nexport function truncateString(str: string, maxLength: number, suffix = '...'): string {\n  if (str.length <= maxLength) return str;\n  return str.substring(0, maxLength - suffix.length) + suffix;\n}\n\n/**\n * Serialize a JSON-LD schema for a `<script type=\"application/ld+json\">`\n * block — THE one home of the escape rule (the hub re-exports this from\n * lib/utils/breadcrumbs). JSON.stringify does NOT HTML-escape: a stored\n * \"</script>\" inside any admin/user-entered field would terminate the tag\n * early (stored XSS). Every \"<\" becomes the JSON escape sequence\n * backslash-u003c — still valid JSON, inert in HTML.\n */\nexport function serializeJsonLd(schema: unknown): string {\n  return JSON.stringify(schema).replace(/</g, '\\\\u003c');\n}\n\n/**\n * Deep clone an object through a JSON round-trip.\n *\n * Lossy by construction, and deliberately so — this is the JSON projection of\n * `T`: `undefined` members and functions are dropped, `Date` becomes an ISO\n * string, `Map`/`Set` become `{}`, and a cyclic graph throws. Reach for\n * `structuredClone` when any of that matters.\n *\n * `JSON.parse` is typed `any`; the assertion states the contract above, which\n * is the closest a JSON clone can get to \"still a `T`\".\n *\n * @param obj - Object to clone (must be JSON-serializable)\n * @returns Cloned object\n */\nexport function deepClone<T>(obj: T): T {\n  return JSON.parse(JSON.stringify(obj)) as T;\n}\n\n/**\n * Get the Slack community join URL from environment variables\n * @returns Slack community join URL or fallback URL\n */\nexport function getSlackCommunityJoinUrl(): string {\n  const url = process.env.NEXT_PUBLIC_SLACK_COMMUNITY_JOIN_URL;\n  if (!url) {\n    console.warn('NEXT_PUBLIC_SLACK_COMMUNITY_JOIN_URL is not defined in environment variables');\n    return 'https://join.slack.com/t/openmsp/shared_invite/zt-36bl7mx0h-3~U2nFH6nqHqoTPXMaHEHA';\n  }\n  return url;\n}\n\n/**\n * Clamp a number into an inclusive range. THE one clamp for the lib and the hub\n * (step/page/contrast math all bottom out here).\n */\nexport function clamp(value: number, min: number, max: number): number {\n  return Math.min(Math.max(value, min), max);\n}\n\n/**\n * Typed key-subset of an object. Keys whose value is `undefined` on the source\n * are still copied when present, so `pick(params, dropped)` writes explicit\n * `null`s (the URL-state writer's \"remove this key\" signal).\n */\nexport function pick<T extends object, K extends keyof T>(source: T, keys: readonly K[]): Pick<T, K> {\n  const out = {} as Pick<T, K>;\n  for (const key of keys) {\n    if (key in source) out[key] = source[key];\n  }\n  return out;\n}\n\n/**\n * React Query options for data that MUST NOT be served from the client cache.\n *\n * The rule (hub memory `feedback_no_client_query_cache_for_mutable_entities`):\n * anything a user can mutate, or a cron can rewrite under them, is refetched on\n * mount. Spread this instead of retyping `staleTime: 0` — it is also the hub\n * QueryClient's default, so a hook that spreads it is explicit rather than\n * silently inheriting.\n */\nexport const NO_CLIENT_CACHE = { staleTime: 0 } as const;\n","/**\n * useNearViewport — module-level shared IntersectionObserver in hook form.\n *\n * Single IO instance per `rootMargin` value, shared across every component\n * that mounts the hook. Reduces overhead vs. one IO per component on\n * grid/list pages where many subscribers observe the viewport with the same\n * margin. Promoted from the inline singleton at\n * `multi-platform-hub/components/shared/video-bites-display.tsx:21-43`,\n * which is the only IO pattern in either repo today.\n *\n * Usage:\n * ```tsx\n * function MyCard() {\n *   const { ref, isNear } = useNearViewport('500px');\n *   return <div ref={ref}>{isNear ? <HeavyChild /> : <Placeholder />}</div>;\n * }\n * ```\n *\n * StrictMode safety: cleanup uses an identity check on the registered\n * callback so React's dev double-mount (mount → cleanup → re-mount) does\n * not drop the second mount's freshly-set subscription. The IO callback\n * also checks `subscribers.get(target)` before invoking so a fire that\n * races with unmount cannot crash on a torn-down component.\n *\n * The hook fires once — on first intersection it sets `isNear=true` and\n * unobserves the element. Callers that need re-observation should\n * unmount and remount (or fork the hook for two-way behavior).\n */\n\nimport { useEffect, useRef, useState, useCallback } from 'react';\n\n// Per-(rootMargin, threshold) IO map. Multiple call sites with different\n// margins/thresholds each get their own singleton observer.\nconst observers = new Map<string, IntersectionObserver>();\nconst subscribers = new WeakMap<Element, () => void>();\n\n// Slack for the explicit ratio check: an element sized to exactly meet the\n// threshold can report a ratio a hair under it (sub-pixel / float rounding), so\n// accept `ratio >= threshold - EPSILON`. With `threshold: 0` this is a no-op\n// (ratio is always ≥ 0 > -EPSILON) — the `isIntersecting` check alone gates.\nconst THRESHOLD_EPSILON = 0.01;\n\n/** Stable map key for an observer config. */\nfunction observerKey(rootMargin: string, threshold: number): string {\n  return `${rootMargin}|${threshold}`;\n}\n\nfunction getObserverFor(rootMargin: string, threshold: number): IntersectionObserver {\n  const key = observerKey(rootMargin, threshold);\n  const existing = observers.get(key);\n  if (existing) return existing;\n\n  const io = new IntersectionObserver(\n    entries => {\n      entries.forEach(entry => {\n        // Enforce the threshold EXPLICITLY. `isIntersecting` is spec-defined as\n        // `intersectionRatio > 0` (any overlap), so the initial `observe()`\n        // firing can report `true` at, say, 20% visible even with\n        // `threshold: 0.5` — which would prematurely pass the gate. Require both\n        // an intersection AND the ratio to actually meet the threshold (minus a\n        // small epsilon for the float rounding where the ratio lands a hair\n        // under the exact value).\n        if (!entry.isIntersecting) return;\n        if (entry.intersectionRatio < threshold - THRESHOLD_EPSILON) return;\n        // Race-safe: re-read the callback at fire time. A late IO firing\n        // after cleanup must not invoke a stale callback.\n        const cb = subscribers.get(entry.target);\n        if (cb) {\n          cb();\n          io.unobserve(entry.target);\n          subscribers.delete(entry.target);\n        }\n      });\n    },\n    { rootMargin, threshold },\n  );\n  observers.set(key, io);\n  return io;\n}\n\n/**\n * Default near-viewport lookahead — the SSOT for \"mount media on approach\".\n * Shared by this hook's default AND the raw two-way IntersectionObservers in\n * `cards-strip.tsx` / `video-bites-strip.tsx` (which need mount+unmount, not\n * this hook's fire-once semantics, but must agree on the distance).\n */\nexport const NEAR_VIEWPORT_ROOT_MARGIN = '500px';\n\nexport interface UseNearViewportResult<T extends Element = HTMLElement> {\n  /** Ref to attach to the element you want to gate on visibility. */\n  ref: (node: T | null) => void;\n  /** Flips to `true` once the element enters within `rootMargin` of the viewport. Never flips back. */\n  isNear: boolean;\n}\n\n/**\n * @param rootMargin Margin around the viewport (CSS-style string).\n *                   '500px' = element starts mounting 500px before scroll-in.\n *                   '1000px' = a full viewport's worth of lookahead.\n *                   '0px' = strict on-screen detection.\n * @param threshold  Fraction of the element that must be visible before firing\n *                   (0 = any pixel — the default; 0.5 = at least half on-screen).\n */\nexport function useNearViewport<T extends Element = HTMLElement>(\n  rootMargin: string = NEAR_VIEWPORT_ROOT_MARGIN,\n  threshold: number = 0,\n): UseNearViewportResult<T> {\n  const [isNear, setIsNear] = useState(false);\n  const elRef = useRef<T | null>(null);\n  const key = observerKey(rootMargin, threshold);\n\n  // Subscribe/unsubscribe on element change.\n  const ref = useCallback(\n    (node: T | null) => {\n      const prev = elRef.current;\n\n      // Unsubscribe previous, if any. Identity-check the callback so a\n      // StrictMode re-mount that has already re-registered keeps its sub.\n      if (prev) {\n        const stillOurs = subscribers.get(prev);\n        if (stillOurs) {\n          subscribers.delete(prev);\n          observers.get(key)?.unobserve(prev);\n        }\n      }\n\n      elRef.current = node;\n      if (!node) return;\n\n      const cb = () => setIsNear(true);\n      subscribers.set(node, cb);\n      getObserverFor(rootMargin, threshold).observe(node);\n    },\n    [key, rootMargin, threshold],\n  );\n\n  // Unsubscribe on unmount. Identity check guards the StrictMode race.\n  useEffect(() => {\n    return () => {\n      const el = elRef.current;\n      if (!el) return;\n      if (subscribers.get(el)) {\n        subscribers.delete(el);\n        observers.get(key)?.unobserve(el);\n      }\n    };\n  }, [key]);\n\n  return { ref, isNear };\n}\n","/**\n * Fenced-code tracker — THE definition of \"am I inside a code fence\".\n * Server-safe: no React, no DOM.\n *\n * Lives in its own module because it is a GENERAL CommonMark fence machine\n * with two unrelated consumers: the heading scanner\n * (`utils/markdown-heading-id`) and the streaming block splitter\n * (`components/ui/markdown/streaming`). It used to live inside\n * `markdown-heading-id`, which meant a streaming renderer imported its fence\n * state machine from a module named after heading slugs. `markdown-heading-id`\n * re-exports these names so the public `utils` surface is unchanged.\n */\n\n/**\n * CommonMark fenced-code opener/closer. Fence indent is capped at 3 spaces\n * (4+ is an indented code block, whose backticks are literal content), and the\n * run length is captured so a closer can be required to be at least as long as\n * its opener. Matched on the RAW line — `trimStart()` would let a fence inside\n * 4-space-indented code toggle the state.\n *\n * `\\r`-TOLERANT BY CONSTRUCTION. The previous spelling ended `(.*)$`, and `.`\n * excludes `\\r` while `$` (no `m` flag) anchors at end of input — so on a CRLF\n * document, split on `\\n`, NO line ever matched and the tracker was completely\n * fence-blind: `['```js\\r','const a = 1\\r','```\\r','after\\r']` classified as\n * four `text` lines where the LF spelling gives `open/inside/close/text`. The\n * sanitizer's closer haystack then never blanked the fenced region, so a\n * `</textarea>` written as a CRLF code sample satisfied `hasLaterCloser` and a\n * prose `<textarea>` above it stayed LIVE (reproduced in the DOM; the LF twin\n * of the same fixture escapes correctly). The `\\r` is absorbed by `\\r?$` rather\n * than normalized out of the text, because the sanitizer's mask is\n * LENGTH-PRESERVING and every offset in it is an index into the original\n * string — stripping `\\r` would shift them all.\n *\n * THE ONLY COPY. This constant plus its open/close state machine used to be\n * duplicated verbatim in `components/ui/markdown/streaming.ts`, kept in sync\n * by a code comment — which is exactly how the `~~~`-blind extractor drifted\n * in the first place. Both consumers now instantiate `createFenceTracker()`.\n */\nconst FENCE_RE = /^ {0,3}(`{3,}|~{3,})([^\\n]*?)\\r?$/;\n\n/**\n * CommonMark's BLANK LINE — spaces and tabs only. The single definition, shared\n * by every consumer of this module (the sanitizer's mask passes, the heading\n * scanner, the HTML-block range walk).\n *\n * NOT `String.prototype.trim()`. JS `trim()` strips the full Unicode\n * White_Space set — U+00A0 NBSP, U+000B VT, U+000C FF, U+2028/9, U+FEFF BOM —\n * none of which CommonMark (or remark) treats as blank. Every place that meant\n * \"is this a CommonMark blank line?\" and asked `trim() === ''` therefore\n * TERMINATED a construct one line early while remark kept it open. Verified\n * end-to-end: `<div>\\n\\u00A0\\n\\`<textarea>\\`\\n\\nrest\\n` left a LIVE `<textarea>`\n * in the DOM (`escapeUnknownHtmlTags` returned the input byte-identical),\n * because the tracked HTML-block range ended at the NBSP filler line and the\n * inline-code shelter below it stopped being inside a tracked block. Same for\n * VT / FF / BOM, for `<pre>` / `<details>` wrappers, and for a live third-party\n * `<iframe>`.\n *\n * `\\r` IS accepted: a CRLF document split on `\\n` spells its blank lines\n * `\"\\r\"`, and the mask is length-preserving so stripping `\\r` from the source\n * is not an option (see `FENCE_RE`'s `\\r?$`).\n */\nconst BLANK_LINE_RE = /^[ \\t\\r]*$/;\n\n/** True when `line` is a CommonMark blank line (spaces/tabs only, `\\r`-tolerant). */\nexport function isBlankLine(line: string): boolean {\n  return BLANK_LINE_RE.test(line);\n}\n\n/**\n * What one source line is, relative to fenced code:\n *  - `open`  — this line opened a fence\n *  - `close` — this line closed the open fence\n *  - `inside`— this line is fence content (including a would-be delimiter that\n *              does NOT close the open fence: wrong marker char, too short, or\n *              carrying an info string)\n *  - `text`  — this line is ordinary markdown outside any fence\n *\n * NOTE: no consumer discriminates the three non-`text` roles today — both the\n * heading scanner and the streaming splitter only test `!== 'text'`, and the\n * closer-haystack mask below only tests `=== 'open'` / `=== 'close'`. The\n * four-value union is kept because the state machine already computes the\n * distinction for free and a future consumer (e.g. fence-aware syntax\n * post-processing) would otherwise have to re-derive it.\n */\nexport type FenceLineRole = 'open' | 'close' | 'inside' | 'text';\n\nexport interface FenceTracker {\n  /** Advance across one raw source line (mutates) and classify it. */\n  push(line: string): FenceLineRole;\n  /** Marker CHARACTER of the currently open fence (`` ` `` / `~`), or null. */\n  openChar(): string | null;\n  /** Run length of the currently open fence (a closer must be ≥ this). */\n  openLength(): number;\n}\n\n/** A fresh fenced-code state machine. Server-safe: no React, no DOM. */\nexport function createFenceTracker(): FenceTracker {\n  let fenceChar: string | null = null;\n  let fenceLength = 0;\n  return {\n    push(line: string): FenceLineRole {\n      const fence = FENCE_RE.exec(line);\n      if (fence) {\n        const run = fence[1];\n        if (fenceChar === null) {\n          fenceChar = run[0];\n          fenceLength = run.length;\n          return 'open';\n        }\n        // A closer must use the SAME marker char, be at least as long as the\n        // opener, and (per CommonMark) carry no info string. \"No info string\"\n        // means spaces/tabs only — `trim()` would accept an NBSP tail and close\n        // a fence CommonMark keeps open, i.e. blank LESS of the haystack\n        // (fail-OPEN). Hence `isBlankLine`, not `trim()`.\n        if (run[0] === fenceChar && run.length >= fenceLength && isBlankLine(fence[2])) {\n          fenceChar = null;\n          fenceLength = 0;\n          return 'close';\n        }\n        return 'inside';\n      }\n      return fenceChar === null ? 'text' : 'inside';\n    },\n    openChar: () => fenceChar,\n    openLength: () => fenceLength,\n  };\n}\n","/**\n * Markdown heading-id slug SSOT (server-safe: no React, no DOM).\n *\n * This algorithm previously existed in THREE byte-identical copies:\n *   - `components/ui/simple-markdown-renderer.tsx` (generateHeadingId)\n *   - `components/ui/rich-markdown-renderer.tsx` (generateHeadingId)\n *   - `utils/markdown-section-extractor.ts` (extractSections)\n *\n * The extractor is the PRODUCER of `sectionIds` and the renderers are the\n * CONSUMERS — if the two ever drift, deep-link anchors and scroll-spy\n * targets silently diverge. All three now call these helpers; the parity\n * test `components/ui/__tests__/markdown-parity.test.tsx` asserts\n * extractor-vs-renderer ID agreement over the fixture corpus.\n */\n\n/** Emoji ranges stripped from heading text before slugification. */\nexport const HEADING_EMOJI_RE = /[\\u{1F300}-\\u{1F9FF}]|[\\u{2600}-\\u{26FF}]|[\\u{2700}-\\u{27BF}]/gu;\n\n/** Remove emoji characters and trim. */\nexport function stripHeadingEmojis(text: string): string {\n  return text.replace(HEADING_EMOJI_RE, '').trim();\n}\n\n/**\n * Core slug chain WITHOUT the emoji strip (the extractor exposes emoji\n * stripping as an option, so the two steps are kept separable):\n * lowercase → drop non-word/space/hyphen chars → spaces to hyphens →\n * trim leading/trailing hyphens. May return `''` for symbol-only input —\n * callers apply their own fallback (`section-N`).\n */\nexport function slugifyHeadingBase(text: string): string {\n  return text\n    .trim()\n    .toLowerCase()\n    .replace(/[^\\w\\s-]/g, '')\n    .replace(/\\s+/g, '-')\n    .replace(/^-+|-+$/g, '');\n}\n\n/** The full default chain used by the renderers: emoji strip + slugify. */\nexport function slugifyHeadingText(text: string): string {\n  return slugifyHeadingBase(stripHeadingEmojis(text));\n}\n\n// ---------------------------------------------------------------------------\n// Heading SCANNER + DEDUPER (the other half of the producer/consumer contract)\n// ---------------------------------------------------------------------------\n/**\n * Sharing the slug chain alone was not enough. The PRODUCER\n * (`utils/markdown-section-extractor`) and the CONSUMER\n * (`components/ui/markdown/heading-ids`) each carried their own copy of\n * \"which lines are headings\" and \"how do duplicates get suffixed\", and the\n * copies drifted:\n *   - the extractor toggled its code-block state on a bare\n *     `line.startsWith('```')`, so a `~~~`-fenced (or wider-backtick, or\n *     indented) block containing `## Setup` produced a SECTION from the\n *     extractor and NO id from the renderer — `sectionIdMap` then missed\n *     silently and the deep-link anchor pointed nowhere;\n *   - the extractor anchored ATX at column 0 while the renderer allowed the\n *     CommonMark 0..3-space indent;\n *   - the dedupe counter was hand-copied in both files, each with a comment\n *     saying \"the two must agree\".\n * Both now call `scanHeadings` + `createHeadingIdDeduper`. Server-safe: no\n * React, no DOM.\n */\n\n// The CommonMark fence machine used to live in THIS file, which meant a\n// streaming renderer imported its fence state from a module named after\n// heading slugs. It now lives in `./markdown-fences`; re-exported here so the\n// public `utils` surface (and every existing import) is unchanged.\nexport { createFenceTracker, isBlankLine, type FenceTracker, type FenceLineRole } from './markdown-fences';\nimport { createFenceTracker, isBlankLine } from './markdown-fences';\n\n/** A heading the renderer will emit, located in the source. */\nexport interface ScannedHeading {\n  /** 1-based line of the heading's FIRST line (setext: the title line). */\n  line: number;\n  level: number;\n  /** Raw title text, before `stripInlineMarkdown` / slugification. */\n  text: string;\n}\n\nexport interface ScanHeadingsOptions {\n  /**\n   * Skip a leading YAML frontmatter block (`---` on line 1 through the next\n   * `---`). Only a DOCUMENT-LEADING block counts: a bare `---` mid-document\n   * is a thematic break or a setext underline, and treating it as a\n   * frontmatter toggle (what the extractor used to do) silently swallowed\n   * every heading until the next one.\n   */\n  skipFrontmatter?: boolean;\n  /** Include raw-HTML headings (`<h2>Title</h2>`). Default true. */\n  includeRawHtml?: boolean;\n  /**\n   * Skip fenced-code blocks. Default true — a `##` inside a code fence is\n   * code, not a heading, and the renderer emits no id for it.\n   */\n  skipFences?: boolean;\n}\n\n/**\n * EVERY line-anchored regex in this module ends `\\r?$`, not `$`.\n *\n * Lines here come from `content.split('\\n')`, so on a CRLF document each one\n * carries a trailing `\\r`. `$` (no `m` flag) anchors at end of string and `\\r`\n * is not in `[ \\t]`, so a `$`-anchored pattern matches NOTHING on such a\n * document. That was a live REGRESSION: `main`'s looser `^(#{1,6})\\s+(.+)`\n * had no end anchor at all and tolerated the `\\r` (the title was `.trim()`ed\n * afterwards), so `'# Real\\r\\n\\r\\nbody\\r\\n\\r\\n## Second\\r\\n'` yielded\n * `[\"Real\",\"Second\"]` on `main` and `[]` here — every CRLF-stored doc / blog /\n * release body silently lost its TOC, its in-page anchors and its doc-SEO\n * heading links.\n *\n * The `\\r` is ABSORBED, not normalized away: `scanHeadings` reports 1-based\n * LINE numbers that the renderer's id map is keyed by, and the sanitizer's mask\n * (which shares `markdown-fences`) is length-preserving — neither may be fed a\n * rewritten source.\n */\n/** CommonMark ATX heading (closing `#` run is not part of the title). */\nconst ATX_HEADING_RE = /^ {0,3}(#{1,6})[ \\t]+(.*?)(?:[ \\t]+#+)?[ \\t]*\\r?$/;\n/** ATX with no title at all (`###`, `## ###`) — still a heading, empty text. */\nconst ATX_EMPTY_RE = /^ {0,3}(#{1,6})[ \\t]*#*[ \\t]*\\r?$/;\n/**\n * Raw-HTML heading (`<h2 id=\"x\">Title</h2>`) — rehype-raw renders these too.\n * GLOBAL: two `<h3>`s on one line are two headings, and a non-global scan\n * silently dropped the second (it then fell through to the renderer's\n * suffix-free fallback and emitted a DUPLICATE DOM id).\n */\nconst RAW_HEADING_RE = /<h([1-6])\\b[^>]*>([\\s\\S]*?)(?:<\\/h\\1>|$)/gi;\n/**\n * Blockquote markers and list-item markers preceding an ATX heading\n * (`> ## Setup`, `- ## Setup`, `1. ## Setup`). mdast emits a REAL `<h2>` for\n * these; scanning only column-0..3 missed them entirely, so two identical\n * `> ## Setup` headings both hit the suffix-free fallback and emitted the\n * duplicate DOM ids this whole module exists to prevent.\n */\nconst CONTAINER_PREFIX_RE = /^ {0,3}(?:(?:>[ \\t]?)+|(?:[-+*]|\\d{1,9}[.)])[ \\t]+)+/;\n/** Setext underline (`===` → h1, `---` → h2), AFTER container-prefix strip. */\nconst SETEXT_UNDERLINE_RE = /^ {0,3}(=+|-+)[ \\t]*\\r?$/;\n/** Thematic break — ends a paragraph run, never underlines it. */\nconst THEMATIC_BREAK_RE = /^ {0,3}(\\*|_){3,}[ \\t]*\\r?$/;\n/**\n * CommonMark HTML-block type 6 tag names — these open a raw HTML block no\n * matter what follows them on the line.\n */\nconst HTML_BLOCK_TAGS =\n  'address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul';\n\n/**\n * A line that OPENS a raw HTML block. mdast hands the whole block to the HTML\n * parser, so a `---` inside it is not a setext underline and the lines above\n * it are not a paragraph: `<div>\\nText\\n---\\n</div>` emits NO heading. The\n * scanner used to publish a phantom `Text` h2 there.\n *\n * The naive `^ {0,3}<` this replaced disqualified any run whose first line\n * merely STARTED with a tag, so `<span>x</span> more text` + `---` (a plain\n * paragraph in mdast, yielding an h2) was missed. The alternatives are spelled\n * out per CommonMark:\n *   - types 1-5: `<script|pre|style|textarea`, `<!--`, `<?`, `<!X`, `<![CDATA[`\n *   - type 6:    a `HTML_BLOCK_TAGS` name, whatever follows it\n *   - type 7:    ANY complete open/closing tag ALONE on the line (trailing\n *                whitespace only) — this is the clause the old regex got wrong\n * Attribute soup inside a type-7 tag is approximated with `[^>]*` rather than\n * a full attribute grammar; the error bias is unchanged (a missed run costs a\n * TOC entry, never a wrong id — the same direction as the documented\n * list-item-indent gap in `SetextRun.prefix`).\n */\nconst HTML_BLOCK_OPENER_RE = new RegExp(\n  '^ {0,3}(?:' +\n    // types 1-5\n    '<(?:script|pre|style|textarea)(?:[\\\\s>]|$)|<!--|<\\\\?|<![a-zA-Z]|<!\\\\[CDATA\\\\[' +\n    // type 6\n    `|</?(?:${HTML_BLOCK_TAGS})(?:[\\\\s/>]|$)` +\n    // type 7 — a complete tag standing alone on the line\n    '|<[a-zA-Z][a-zA-Z0-9-]*(?:\\\\s[^>]*)?/?>[ \\\\t]*\\\\r?$' +\n    '|</[a-zA-Z][a-zA-Z0-9-]*[ \\\\t]*>[ \\\\t]*\\\\r?$' +\n    ')',\n  'i',\n);\n\n/**\n * The paragraph RUN that a setext underline would convert into a heading.\n *\n * mdast positions a setext heading at the FIRST line of the paragraph and its\n * text is the WHOLE run (`Foo\\nbar\\n===` → `{line:1, text:\"Foo\\nbar\"}`). The\n * scanner used to remember only the LAST line, so the renderer's line-keyed\n * lookup missed the heading entirely (falling back to a slug of the rendered\n * children, `foo-bar`) while the extractor published `bar` — a dead deep\n * link, plus a phantom TOC entry at the wrong line.\n */\ninterface SetextRun {\n  /** 1-based line of the run's FIRST line — where mdast puts the heading. */\n  line: number;\n  /**\n   * Container prefix (`> `, `- `, …) every line of the run shares. The\n   * underline must carry the IDENTICAL prefix, which is why `> Quote` +\n   * `> ---` scans as an h2 while `> Quote` + a bare `---` does not.\n   *\n   * KNOWN, DELIBERATE GAP: a setext underline indented to a LIST ITEM's\n   * content column (`- item` / `  ---`) is a heading in mdast but has no\n   * container prefix at all, so this equality test misses it. Closing that\n   * would mean re-deriving list-item content columns here — the scanner\n   * simply emits nothing (the pre-existing behavior), which costs a TOC entry\n   * and never produces a WRONG id.\n   */\n  prefix: string;\n  /** Run lines with the container prefix stripped, in order. */\n  lines: string[];\n  /** Run opened with a raw-HTML block line — mdast emits no heading for it. */\n  disqualified: boolean;\n}\n\n/**\n * Collect every heading the markdown renderer will emit, in document order.\n * THE single definition of \"which lines are headings\" for both the extractor\n * and the renderer's id map.\n */\nexport function scanHeadings(content: string, options: ScanHeadingsOptions = {}): ScannedHeading[] {\n  const { skipFrontmatter = true, includeRawHtml = true, skipFences = true } = options;\n  const out: ScannedHeading[] = [];\n  const lines = content.split('\\n');\n  // ONE fenced-code state machine (utils/markdown-fences), shared with\n  // markdown/streaming.ts.\n  const fences = skipFences ? createFenceTracker() : null;\n\n  let start = 0;\n  if (skipFrontmatter && lines[0]?.trim() === '---') {\n    for (let i = 1; i < lines.length; i++) {\n      if (lines[i].trim() === '---' || lines[i].trim() === '...') {\n        start = i + 1;\n        break;\n      }\n    }\n  }\n\n  // The paragraph run a setext underline would convert (see SetextRun).\n  let setextRun: SetextRun | null = null;\n\n  for (let i = start; i < lines.length; i++) {\n    const line = lines[i];\n    if (fences) {\n      const role = fences.push(line);\n      if (role !== 'text') {\n        setextRun = null;\n        continue;\n      }\n    }\n\n    // Container prefix (`> `, `- `, `1. `) is stripped from EVERY construct\n    // below, so a blockquote/list-nested heading is scanned in both its ATX\n    // (`> ## Setup`) and setext (`> Quote title` / `> ---`) spellings.\n    const prefix = CONTAINER_PREFIX_RE.exec(line)?.[0] ?? '';\n    const body = prefix ? line.slice(prefix.length) : line;\n\n    // --- setext (`Title` / `===`): a REAL h1/h2 in mdast, positioned at the\n    // run's FIRST line — which is the line the heading-id map is keyed by.\n    // The underline must carry the SAME container prefix as the run it\n    // closes; `> Quote` followed by a bare `---` is a quote and a thematic\n    // break, not a heading.\n    const setext = SETEXT_UNDERLINE_RE.exec(body);\n    if (setext) {\n      if (setextRun && !setextRun.disqualified && setextRun.prefix === prefix) {\n        out.push({\n          line: setextRun.line,\n          level: setext[1][0] === '=' ? 1 : 2,\n          text: setextRun.lines.join('\\n'),\n        });\n      }\n      setextRun = null;\n      continue;\n    }\n\n    // --- ATX, including inside a blockquote / list item.\n    const atx = ATX_HEADING_RE.exec(body) ?? ATX_EMPTY_RE.exec(body);\n    if (atx) {\n      out.push({ line: i + 1, level: atx[1].length, text: (atx[2] ?? '').trim() });\n      setextRun = null;\n      continue;\n    }\n\n    if (includeRawHtml && line.indexOf('<') !== -1) {\n      RAW_HEADING_RE.lastIndex = 0;\n      let raw: RegExpExecArray | null;\n      let matched = false;\n      while ((raw = RAW_HEADING_RE.exec(line)) !== null) {\n        matched = true;\n        out.push({\n          line: i + 1,\n          level: Number(raw[1]),\n          text: raw[2].replace(/<[^>]*>/g, '').trim(),\n        });\n      }\n      if (matched) {\n        setextRun = null;\n        continue;\n      }\n    }\n\n    // Paragraph accumulation. A blank line or a thematic break ends the run;\n    // a change of container prefix starts a new block (a list or blockquote\n    // interrupts a paragraph in CommonMark).\n    // CommonMark's blank line (spaces/tabs), NOT `trim()` — an NBSP-only line\n    // is paragraph CONTENT to remark, so ending the run there would move a\n    // setext heading's reported line off the run's first line.\n    if (isBlankLine(body) || THEMATIC_BREAK_RE.test(body)) {\n      setextRun = null;\n      continue;\n    }\n    if (setextRun && setextRun.prefix === prefix) {\n      setextRun.lines.push(body.trim());\n    } else {\n      setextRun = {\n        line: i + 1,\n        prefix,\n        lines: [body.trim()],\n        disqualified: HTML_BLOCK_OPENER_RE.test(body),\n      };\n    }\n  }\n\n  return out;\n}\n\n/**\n * The duplicate-id suffix counter, as ONE closure both sides instantiate.\n * First occurrence keeps the bare slug; the Nth gets `-N`. Extractor and\n * renderer MUST use the same counter or `#anchor` deep links diverge.\n */\nexport function createHeadingIdDeduper(): (cleanId: string) => string {\n  const counts: Record<string, number> = {};\n  return (cleanId: string): string => {\n    if (counts[cleanId]) {\n      counts[cleanId]++;\n      return `${cleanId}-${counts[cleanId]}`;\n    }\n    counts[cleanId] = 1;\n    return cleanId;\n  };\n}\n","/**\n * GitHub-compatible anchor resolution for in-page `#hash` links.\n *\n * ## The mismatch this exists to bridge\n *\n * Doc authors write their table of contents against GitHub's slugger, which\n * keeps the separator left behind by a stripped emoji:\n *\n *   `## 📚 Table of Contents`  →  GitHub anchor `#-table-of-contents`\n *\n * Our heading ids come from the shared slug SSOT (`markdown-heading-id`),\n * whose `slugifyHeadingBase` trims leading and trailing hyphens — so the same\n * heading renders as `id=\"table-of-contents\"`. The \"On this page\" rail\n * navigates by those ids and works; the in-body TOC link points at an id that\n * does not exist and the browser silently does nothing.\n *\n * Rather than change the id shape (it is already baked into shared URLs and\n * the rail), resolution is made tolerant: compare a *normalized* form of both\n * sides. This module owns ONLY that tolerance — the slug chain itself is\n * imported, never re-implemented, so a change to the id shape lands in one\n * place and both sides move together.\n *\n * Callers do not reach for this directly: `getHashTargetElement`\n * (`utils/same-page-hash-nav`) is the ONE resolver every hash consumer goes\n * through, and it applies this as its last pass.\n */\nimport { slugifyHeadingText } from './markdown-heading-id';\n\n/**\n * Reduce an anchor id (ours or GitHub's) to a comparable form.\n *\n * Everything but the final hyphen-run collapse is the shared heading-slug\n * chain: emoji strip → lowercase → drop non-word chars → spaces to hyphens →\n * trim edge hyphens. The collapse is this module's own addition, covering\n * GitHub's *interior* divergence — `## 💬 Community & Support` slugs to\n * `#-community--support` (a double hyphen around the dropped `&`) against our\n * `community-support`.\n *\n *   `'-getting-started'`     → `'getting-started'`\n *   `'-community--support'`  → `'community-support'`\n *   `'Getting Started'`      → `'getting-started'`\n */\nexport function normalizeAnchorId(raw: string): string {\n  return slugifyHeadingText(raw)\n    .replace(/-+/g, '-')\n    .replace(/^-+|-+$/g, '');\n}\n\n/** Heading elements carrying an id — the candidate set for the fuzzy pass. */\nconst HEADING_SELECTOR = 'h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]';\n\n/** A `base-N` id, split into its base and ordinal. */\nconst DUPLICATE_SUFFIX_RE = /^(.+)-(\\d+)$/;\n\n/**\n * The two sides also disagree on where duplicate-heading numbering STARTS.\n * For a document with two `## Setup` headings GitHub emits `setup` and\n * `setup-1`, while `createHeadingIdDeduper` emits `setup` and `setup-2` (its\n * suffix is the occurrence COUNT, not the offset). So a GitHub `#setup-1`\n * needs our `setup-2` — every duplicate is off by exactly one.\n *\n * Runs LAST, after the exact lookup and the heading scan have both missed: a\n * heading that genuinely slugs to `step-2` is resolved long before this, and\n * the shift is the one pass that can match an id it was not asked for.\n */\nfunction findDuplicateShifted(normalized: string, doc: Document): HTMLElement | null {\n  const parts = DUPLICATE_SUFFIX_RE.exec(normalized);\n  if (!parts) return null;\n  const ordinal = Number(parts[2]);\n  if (!Number.isSafeInteger(ordinal)) return null;\n  return doc.getElementById(`${parts[1]}-${ordinal + 1}`);\n}\n\n/**\n * Resolve a `#hash` id that no exact lookup matched, by comparing normalized\n * forms. Returns `null` when nothing in the document matches, so the caller\n * can leave the browser's default behavior alone rather than hijacking a\n * click that was never ours to handle.\n *\n * Three passes, in order of how much they assume:\n *   1. a single `getElementById` on the normalized id — catches the whole\n *      emoji-hyphen family without walking the DOM;\n *   2. a heading scan — catches ids whose own raw form normalizes differently\n *      (e.g. `a---b`, from a title containing a literal ` - `);\n *   3. the duplicate-heading shift (see `findDuplicateShifted`) — last,\n *      because it is the only pass that resolves to an id the fragment did\n *      not name.\n */\nexport function findAnchorElementByNormalizedId(rawId: string, doc: Document): HTMLElement | null {\n  const normalized = normalizeAnchorId(rawId);\n  if (!normalized) return null;\n\n  if (normalized !== rawId) {\n    const direct = doc.getElementById(normalized);\n    if (direct) return direct;\n  }\n\n  for (const heading of Array.from(doc.querySelectorAll<HTMLElement>(HEADING_SELECTOR))) {\n    if (normalizeAnchorId(heading.id) === normalized) return heading;\n  }\n\n  // Last: the shift would happily match an unrelated `x-3` for a `x-2` that\n  // has no heading at all, so it runs only once the scan has come up empty.\n  return findDuplicateShifted(normalized, doc);\n}\n","/**\n * `scrollElementIntoView` — canonical \"scroll an element to the top of the\n * viewport, account for sticky chrome, survive layout shifts\" helper.\n *\n * One shared implementation so every caller (the ticket drawer expand, the\n * hub's `useUnifiedNav` / `use-nav-link` hash scroll, doc-tree, delivery\n * `?focus=`, sticky-section-nav, …) inherits the SAME cancellation-proof\n * motion.\n *\n * WHY A SELF-DRIVEN rAF TWEEN INSTEAD OF `window.scrollTo({behavior:'smooth'})`:\n * the native smooth scroll is CANCELLABLE, and in real pages it gets cancelled\n * constantly:\n *\n *   - Browser SCROLL ANCHORING: when content is inserted/removed above or\n *     around the target (a collapsible drawer expanding, an async image\n *     loading, a list re-rendering) the browser issues a synchronous scrollTop\n *     correction to keep the anchored element stable. Per CSSOM-View \"perform a\n *     scroll\" step 1 (\"abort any ongoing smooth scroll\"), that correction\n *     ABORTS an in-flight native smooth scroll — so it lands as an instant jump.\n *     Anchoring is suppressed when the scroll offset is 0, which is exactly why\n *     a native smooth scroll appears to work the FIRST time (page at top) and\n *     jumps on every repeat (page already scrolled). This was a multi-day\n *     \"smooth only works once\" bug on the /tickets drawer.\n *   - A second programmatic scroll on the same frame, or a `focus()` without\n *     `{preventScroll:true}`, cancels it the same way.\n *\n * A tween that re-asserts the position with INSTANT writes every frame is\n * immune: there is no \"ongoing native smooth scroll\" for anchoring/focus to\n * abort, and any correction that lands between our frames is overwritten on the\n * next frame. We also RECOMPUTE the target each frame, so an element whose\n * final position is still settling (drawer still expanding, images loading)\n * is tracked to its resting place instead of animating to a stale pixel.\n *\n * Honors `prefers-reduced-motion` (jumps instantly) and cancels on genuine user\n * scroll intent (wheel / touch) so we never fight the user.\n *\n * WINDOW *OR* A SCROLLABLE ANCESTOR: the helper is not hard-wired to the window\n * scroller. It walks up from the target to the nearest ancestor that is an\n * actual scroll container (`overflow-y: auto | scroll | overlay` AND\n * `scrollHeight > clientHeight`) and drives THAT element; only when none exists\n * does it fall back to `window`. This is what makes it work inside app shells\n * that put page content in a fixed-height `<main class=\"overflow-y-auto\">`\n * (e.g. OpenFrame's `AppLayout`) where the document/window never scrolls — the\n * old window-only version was a silent no-op there. Note `overflow: clip` /\n * `hidden` are deliberately NOT treated as scroll containers, so a list wrapper\n * that uses `overflow-clip` only to round its corners still bubbles the scroll\n * up to the real container (matches the `<HelpCenterCard>` list intent).\n */\n\nexport interface ScrollElementIntoViewOptions {\n  /** Pixels to subtract from the target element's `top` so it lands BELOW\n   *  sticky chrome. Defaults to 0. Pass `96` for the standard hub header. */\n  headerOffset?: number;\n  /** `'smooth'` (default) runs the self-driven tween; `'instant'` / `'auto'`\n   *  jump in one synchronous write (deep-link land, programmatic focus moves). */\n  behavior?: ScrollBehavior;\n  /** Optional adjustment applied to the computed pixel target each frame. The\n   *  callback receives the \"raw\" Y (`element.top + scrollY - headerOffset`) and\n   *  returns the FINAL target. Use when the caller knows about a layout shift\n   *  (e.g. a sibling drawer collapsing) the geometry can't yet reflect. */\n  adjustTargetY?: (rawTargetY: number) => number;\n  /** Tween duration in ms (smooth only). Default 320. */\n  durationMs?: number;\n}\n\n/** Module-level handle to the in-flight tween so a new call (or a user\n *  gesture) cancels the previous one — only ever one page-scroll animation at\n *  a time. */\nlet activeRaf = 0;\nlet teardownActive: (() => void) | null = null;\n\nfunction cancelActiveScroll(): void {\n  if (activeRaf) {\n    cancelAnimationFrame(activeRaf);\n    activeRaf = 0;\n  }\n  if (teardownActive) {\n    teardownActive();\n    teardownActive = null;\n  }\n}\n\nconst easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3);\n\n/** Nearest ancestor that is a *real* scroll container, or `null` when the\n *  window/document is the scroller. Only `auto | scroll | overlay` count —\n *  `clip` / `hidden` are intentionally excluded (a wrapper using `overflow-clip`\n *  purely to round corners must let the scroll bubble to the page). */\nfunction getScrollableAncestor(el: HTMLElement): HTMLElement | null {\n  for (let node = el.parentElement; node; node = node.parentElement) {\n    const overflowY = getComputedStyle(node).overflowY;\n    if (\n      (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') &&\n      node.scrollHeight > node.clientHeight\n    ) {\n      return node;\n    }\n  }\n  return null;\n}\n\n/**\n * Scroll the page so `target` lands at the top of the viewport (below sticky\n * chrome via `headerOffset`). SSR-safe; `null`/`undefined` target is a no-op so\n * callers can pass refs without defensive branching.\n */\nexport function scrollElementIntoView(\n  target: HTMLElement | null | undefined,\n  options: ScrollElementIntoViewOptions = {},\n): void {\n  if (typeof window === 'undefined' || !target) return;\n  const { headerOffset = 0, behavior = 'smooth', adjustTargetY, durationMs = 320 } = options;\n\n  // Pick the scroller ONCE: a fixed-height `<main overflow-y-auto>` shell scrolls\n  // the element, a plain document scrolls the window. The choice can't change\n  // mid-tween, so resolve it up front and route every read/write through it.\n  const container = getScrollableAncestor(target);\n  const readCurrent = (): number => (container ? container.scrollTop : window.scrollY);\n  const writeTo = (y: number): void => {\n    if (container) container.scrollTop = y;\n    else window.scrollTo(0, y);\n  };\n\n  // Target is recomputed every frame: the row's absolute position can move as\n  // the page reflows (a sibling drawer collapsing) and the reachable max grows\n  // as the just-opened drawer expands. Clamp to the LIVE max each frame.\n  const computeTarget = (): number => {\n    const raw = container\n      ? container.scrollTop +\n        (target.getBoundingClientRect().top - container.getBoundingClientRect().top) -\n        headerOffset\n      : target.getBoundingClientRect().top + window.scrollY - headerOffset;\n    const adjusted = adjustTargetY ? adjustTargetY(raw) : raw;\n    const maxScroll = container\n      ? Math.max(0, container.scrollHeight - container.clientHeight)\n      : Math.max(0, document.documentElement.scrollHeight - window.innerHeight);\n    return Math.min(Math.max(0, adjusted), maxScroll);\n  };\n\n  // Any prior animation loses — one page scroll at a time.\n  cancelActiveScroll();\n\n  const prefersReduced =\n    typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n  // Instant paths: a single synchronous write. No tween, no anchoring race.\n  if (behavior === 'instant' || behavior === 'auto' || prefersReduced) {\n    writeTo(computeTarget());\n    return;\n  }\n\n  // Smooth: self-driven tween with instant per-frame writes (anchoring-proof).\n  let startY: number | null = null;\n  let startTime = 0;\n\n  // Bail the moment the user takes over with a real scroll gesture — we must\n  // never fight them. (Not keydown: the ticket composer auto-focuses on open,\n  // and typing there should not abort the scroll.)\n  const onUserGesture = () => cancelActiveScroll();\n  window.addEventListener('wheel', onUserGesture, { passive: true });\n  window.addEventListener('touchmove', onUserGesture, { passive: true });\n  teardownActive = () => {\n    window.removeEventListener('wheel', onUserGesture);\n    window.removeEventListener('touchmove', onUserGesture);\n  };\n\n  const step = (now: number) => {\n    if (startY === null) {\n      startY = readCurrent();\n      startTime = now;\n    }\n    const targetY = computeTarget();\n    const t = Math.min(1, (now - startTime) / durationMs);\n    const y = startY + (targetY - startY) * easeOutCubic(t);\n    writeTo(y);\n    if (t < 1) {\n      activeRaf = requestAnimationFrame(step);\n    } else {\n      // Final exact write in case easing left a sub-pixel gap, then teardown.\n      writeTo(computeTarget());\n      activeRaf = 0;\n      if (teardownActive) {\n        teardownActive();\n        teardownActive = null;\n      }\n    }\n  };\n  activeRaf = requestAnimationFrame(step);\n}\n","import { findAnchorElementByNormalizedId } from './anchor-id';\nimport { scrollElementIntoView } from './scroll-into-view';\n\n/** Pages with a section-nav STRIP on top of the global hub header\n *  (dev-center roadmap/delivery/tickets, FAQ category-pill nav).\n *  Anchor lands BELOW both layers. */\nexport const STICKY_HEADER_OFFSET_PX = 96;\n\n/** Pages with only the global hub header (docs, blog, vendor detail).\n *  Anchor lands BELOW the header bar. */\nexport const HUB_HEADER_OFFSET_PX = 80;\n\n/**\n * Take only the FIRST hash segment from a fragment that may contain extra\n * `#` characters. `'' → ''`, `'#a' → '#a'`, `'#a#b' → '#a'`.\n *\n * No real DOM id contains `#`, so a multi-fragment hash is always a bug at\n * the composer site; `navigateSamePageHash` + `useScrollToHash` both call\n * this so URL bar and `getElementById` stay in sync.\n */\nexport function normalizeHashFragment(hash: string): string {\n  if (!hash) return '';\n  const second = hash.indexOf('#', 1);\n  return second < 0 ? hash : hash.slice(0, second);\n}\n\n/**\n * Resolve the DOM element a hash fragment id points at — THE hash resolver;\n * `navigateSamePageHash`, `useScrollToHash` and the markdown link renderer\n * all go through this one, so a fragment that resolves for a deep link\n * resolves for an in-body click too.\n *\n * Three passes, cheapest first:\n *   1. the raw id (ordinary anchors — the overwhelming common case);\n *   2. the percent-decoded form — href composers like `buildTicketOpenHref`\n *      encode the id into the fragment while rows render the raw id, so\n *      `#ticket-a%2Fb` must still find `id=\"ticket-a/b\"`. A malformed escape\n *      sequence just falls through;\n *   3. a NORMALIZED match against the document's headings — doc bodies are\n *      authored on GitHub, whose slugger keeps the hyphen left behind by a\n *      stripped emoji (`## 📚 Table of Contents` → `#-table-of-contents`)\n *      while our ids trim it. Without this every in-body TOC link in every\n *      GitHub-authored doc resolves to nothing and clicking one does nothing.\n *      See `utils/anchor-id`.\n */\nexport function getHashTargetElement(id: string): HTMLElement | null {\n  if (!id) return null;\n  const direct = document.getElementById(id);\n  if (direct) return direct;\n\n  let decoded = id;\n  try {\n    decoded = decodeURIComponent(id);\n    if (decoded !== id) {\n      const decodedEl = document.getElementById(decoded);\n      if (decodedEl) return decodedEl;\n    }\n  } catch {\n    // malformed escape — the raw lookup above already covered it\n    decoded = id;\n  }\n\n  return findAnchorElementByNormalizedId(decoded, document);\n}\n\nexport interface NavigateSamePageHashOptions {\n  /** Pixels to subtract for sticky chrome. */\n  headerOffset?: number;\n  /** `'push'` (default) — new history entry; `'replace'` — overwrite\n   *  current entry (use for TOC-style in-page navigators). */\n  history?: 'push' | 'replace';\n}\n\n/**\n * Same-page hash navigation primitive: pushState + synthetic `hashchange`\n * + anchoring-proof smooth scroll. Replaces `router.push` for hash CTAs\n * (Next.js suppresses smooth-scroll during navigation; `router.push` on\n * an exact-URL match is a no-op). Returns `true` when the helper claimed\n * the nav (same pathname + search); `false` for cross-page targets so\n * callers fall through to `router.push`.\n *\n * `target` accepts an origin-stripped path (`/x#anchor`) or a bare hash\n * (`#anchor`); bare-hash callers don't need to reconstruct `pathname +\n * search` themselves.\n */\nexport function navigateSamePageHash(target: string, options: NavigateSamePageHashOptions = {}): boolean {\n  if (typeof window === 'undefined') return false;\n  const { headerOffset = 0, history: historyMode = 'push' } = options;\n  const normalizedTarget = target.startsWith('#') ? window.location.pathname + window.location.search + target : target;\n  // `new URL(absoluteUrl, base)` ignores `base` per RFC 3986; an absolute\n  // cross-origin target sharing pathname/search would otherwise pass the\n  // check below and trip pushState's same-origin enforcement. Parse with\n  // an explicit base so malformed inputs cleanly fall through.\n  let url: URL;\n  try {\n    url = new URL(normalizedTarget, window.location.href);\n  } catch {\n    return false;\n  }\n  if (\n    url.origin !== window.location.origin ||\n    url.pathname !== window.location.pathname ||\n    url.search !== window.location.search\n  ) {\n    return false;\n  }\n  const current = window.location.pathname + window.location.search + window.location.hash;\n  // Heal a malformed multi-fragment hash so the URL bar is clean and\n  // `getElementById` resolves. Dev-warn fingers the upstream composer.\n  const normalizedHash = normalizeHashFragment(url.hash);\n  if (process.env.NODE_ENV === 'development' && normalizedHash !== url.hash) {\n    console.warn(\n      `[navigateSamePageHash] malformed fragment \"${url.hash}\" → normalizing to \"${normalizedHash}\". Fix the upstream composer.`,\n    );\n  }\n  const next = url.pathname + url.search + normalizedHash;\n  const id = normalizedHash && normalizedHash !== '#' ? normalizedHash.slice(1) : '';\n  // Hash-less targets are only ours on an EXACT URL re-click.\n  if (!id && next !== current) return false;\n  if (next !== current) {\n    const oldURL = window.location.href;\n    if (historyMode === 'replace') {\n      window.history.replaceState(null, '', next);\n    } else {\n      window.history.pushState(null, '', next);\n    }\n    // Synthetic `hashchange` — `pushState` doesn't fire it (HTML spec),\n    // so URL-hash-bound listeners (FAQ auto-expand, etc.) wouldn't react.\n    window.dispatchEvent(\n      new HashChangeEvent('hashchange', {\n        oldURL,\n        newURL: window.location.href,\n      }),\n    );\n  }\n  const el = id ? getHashTargetElement(id) : null;\n  if (id && !el && process.env.NODE_ENV === 'development') {\n    console.warn(`[navigateSamePageHash] anchor \"#${id}\" not found — scrolling to top.`);\n  }\n  // Missing anchor → tween to page top. `documentElement` is at 0 by\n  // definition, so one tween covers both branches.\n  scrollElementIntoView(el ?? document.documentElement, {\n    behavior: 'smooth',\n    headerOffset,\n  });\n  return true;\n}\n"]}