@flamingo-stack/openframe-frontend-core 0.0.569 → 0.0.570
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-PLJAOZYZ.js → chunk-3H6NH2AD.js} +5 -5
- package/dist/{chunk-VNE2EHCD.cjs → chunk-3YO2ZIZL.cjs} +14 -14
- package/dist/{chunk-VNE2EHCD.cjs.map → chunk-3YO2ZIZL.cjs.map} +1 -1
- package/dist/{chunk-QY3VTOB6.cjs → chunk-4BJ4FQPG.cjs} +18 -18
- package/dist/{chunk-QY3VTOB6.cjs.map → chunk-4BJ4FQPG.cjs.map} +1 -1
- package/dist/{chunk-FE5QR7PT.js → chunk-5NVVVIHJ.js} +48 -28
- package/dist/{chunk-FE5QR7PT.js.map → chunk-5NVVVIHJ.js.map} +1 -1
- package/dist/{chunk-2DSDGWWU.cjs → chunk-6WXL6CIL.cjs} +97 -97
- package/dist/{chunk-2DSDGWWU.cjs.map → chunk-6WXL6CIL.cjs.map} +1 -1
- package/dist/{chunk-Z3SRMLMK.cjs → chunk-AQ6MIFNX.cjs} +41 -14
- package/dist/chunk-AQ6MIFNX.cjs.map +1 -0
- package/dist/{chunk-FMWIA5IH.cjs → chunk-CSCAOET4.cjs} +42 -42
- package/dist/{chunk-FMWIA5IH.cjs.map → chunk-CSCAOET4.cjs.map} +1 -1
- package/dist/{chunk-XMECOGLY.cjs → chunk-CYY2PEYQ.cjs} +3 -3
- package/dist/{chunk-XMECOGLY.cjs.map → chunk-CYY2PEYQ.cjs.map} +1 -1
- package/dist/{chunk-SMW5Z4NW.cjs → chunk-D463PBIY.cjs} +23 -23
- package/dist/{chunk-SMW5Z4NW.cjs.map → chunk-D463PBIY.cjs.map} +1 -1
- package/dist/{chunk-TDYTAED2.cjs → chunk-DP2L7US5.cjs} +3 -3
- package/dist/{chunk-TDYTAED2.cjs.map → chunk-DP2L7US5.cjs.map} +1 -1
- package/dist/{chunk-ME346OJF.cjs → chunk-GDPPFPLD.cjs} +40 -40
- package/dist/{chunk-ME346OJF.cjs.map → chunk-GDPPFPLD.cjs.map} +1 -1
- package/dist/{chunk-EBE22KOQ.cjs → chunk-HJCXSI7R.cjs} +4 -4
- package/dist/{chunk-EBE22KOQ.cjs.map → chunk-HJCXSI7R.cjs.map} +1 -1
- package/dist/{chunk-D6SBYELJ.cjs → chunk-I7CFA5DO.cjs} +32 -32
- package/dist/{chunk-D6SBYELJ.cjs.map → chunk-I7CFA5DO.cjs.map} +1 -1
- package/dist/{chunk-SW3K3JSP.cjs → chunk-IJA26YDA.cjs} +8 -8
- package/dist/{chunk-SW3K3JSP.cjs.map → chunk-IJA26YDA.cjs.map} +1 -1
- package/dist/{chunk-HOMYI5RR.js → chunk-KYDVOX5J.js} +2 -2
- package/dist/{chunk-OS5RFMW5.js → chunk-MT7IDGCM.js} +3 -3
- package/dist/{chunk-6STF67EF.js → chunk-MTORQ4LY.js} +4 -4
- package/dist/{chunk-AAROYQ4O.cjs → chunk-PS2TV7WG.cjs} +12 -12
- package/dist/{chunk-AAROYQ4O.cjs.map → chunk-PS2TV7WG.cjs.map} +1 -1
- package/dist/{chunk-P7ZENKIA.js → chunk-PTDRJCPW.js} +2 -2
- package/dist/{chunk-KO333Q2B.js → chunk-QIU5B2ZD.js} +2 -2
- package/dist/{chunk-74DB5H5T.cjs → chunk-RVCCJB6M.cjs} +165 -145
- package/dist/chunk-RVCCJB6M.cjs.map +1 -0
- package/dist/{chunk-PCL6WYYT.cjs → chunk-T4MB5TX6.cjs} +5 -5
- package/dist/{chunk-PCL6WYYT.cjs.map → chunk-T4MB5TX6.cjs.map} +1 -1
- package/dist/{chunk-3UPQAOES.js → chunk-TQER4FVN.js} +3 -3
- package/dist/{chunk-KCAGMTJ6.js → chunk-U6NPZMNT.js} +8 -8
- package/dist/{chunk-LJBNHO6B.js → chunk-VZQKENQU.js} +3 -3
- package/dist/{chunk-OWCN3BM6.js → chunk-XWMEHLDK.js} +6 -6
- package/dist/{chunk-TXEI4PES.js → chunk-YFLKONLB.js} +33 -6
- package/dist/{chunk-TXEI4PES.js.map → chunk-YFLKONLB.js.map} +1 -1
- package/dist/{chunk-C5GKBUT7.js → chunk-YYESWAAQ.js} +3 -3
- package/dist/{chunk-A2T266F3.js → chunk-Z3MSD6ZE.js} +6 -6
- package/dist/{chunk-GY22CIEF.cjs → chunk-Z7DXVKVT.cjs} +76 -76
- package/dist/{chunk-GY22CIEF.cjs.map → chunk-Z7DXVKVT.cjs.map} +1 -1
- package/dist/{chunk-4NN7V443.js → chunk-ZAYZ7JHF.js} +2 -2
- package/dist/{chunk-MZG3QQUR.js → chunk-ZTGWMXJK.js} +2 -2
- package/dist/components/case-studies/index.cjs +9 -9
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/chat/index.cjs +3 -3
- package/dist/components/chat/index.js +2 -2
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/docs/index.cjs +6 -6
- package/dist/components/docs/index.js +5 -5
- package/dist/components/embeds/claude-embed.d.ts.map +1 -1
- package/dist/components/embeds/embed-viewer-frame.d.ts +10 -2
- package/dist/components/embeds/embed-viewer-frame.d.ts.map +1 -1
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +5 -5
- package/dist/components/faq/index.js +4 -4
- package/dist/components/features/index.cjs +3 -3
- package/dist/components/features/index.js +2 -2
- package/dist/components/help-center-pages/index.cjs +24 -24
- package/dist/components/help-center-pages/index.js +15 -15
- package/dist/components/index.cjs +138 -138
- package/dist/components/index.js +13 -13
- package/dist/components/meeting-scheduler/index.cjs +35 -35
- package/dist/components/meeting-scheduler/index.js +4 -4
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/onboarding-guides/index.cjs +7 -7
- package/dist/components/onboarding-guides/index.js +6 -6
- package/dist/components/related-content/index.cjs +5 -5
- package/dist/components/related-content/index.js +4 -4
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/hooks/index.cjs +2 -2
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/use-claude-mirror-src.d.ts +34 -13
- package/dist/hooks/use-claude-mirror-src.d.ts.map +1 -1
- package/dist/index.cjs +3 -3
- package/dist/index.js +2 -2
- package/package.json +1 -1
- package/src/components/embeds/claude-embed.tsx +13 -4
- package/src/components/embeds/embed-viewer-frame.tsx +27 -2
- package/src/hooks/use-claude-mirror-src.ts +79 -19
- package/dist/chunk-74DB5H5T.cjs.map +0 -1
- package/dist/chunk-Z3SRMLMK.cjs.map +0 -1
- /package/dist/{chunk-PLJAOZYZ.js.map → chunk-3H6NH2AD.js.map} +0 -0
- /package/dist/{chunk-HOMYI5RR.js.map → chunk-KYDVOX5J.js.map} +0 -0
- /package/dist/{chunk-OS5RFMW5.js.map → chunk-MT7IDGCM.js.map} +0 -0
- /package/dist/{chunk-6STF67EF.js.map → chunk-MTORQ4LY.js.map} +0 -0
- /package/dist/{chunk-P7ZENKIA.js.map → chunk-PTDRJCPW.js.map} +0 -0
- /package/dist/{chunk-KO333Q2B.js.map → chunk-QIU5B2ZD.js.map} +0 -0
- /package/dist/{chunk-3UPQAOES.js.map → chunk-TQER4FVN.js.map} +0 -0
- /package/dist/{chunk-KCAGMTJ6.js.map → chunk-U6NPZMNT.js.map} +0 -0
- /package/dist/{chunk-LJBNHO6B.js.map → chunk-VZQKENQU.js.map} +0 -0
- /package/dist/{chunk-OWCN3BM6.js.map → chunk-XWMEHLDK.js.map} +0 -0
- /package/dist/{chunk-C5GKBUT7.js.map → chunk-YYESWAAQ.js.map} +0 -0
- /package/dist/{chunk-A2T266F3.js.map → chunk-Z3MSD6ZE.js.map} +0 -0
- /package/dist/{chunk-4NN7V443.js.map → chunk-ZAYZ7JHF.js.map} +0 -0
- /package/dist/{chunk-MZG3QQUR.js.map → chunk-ZTGWMXJK.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-AQ6MIFNX.cjs","../src/hooks/ui/use-auto-limit-tags.ts","../src/hooks/ui/use-debounce.ts","../src/hooks/ui/use-deferred-error.ts","../src/hooks/ui/use-focus-trap.ts","../src/hooks/ui/use-header-height.ts","../src/hooks/ui/use-horizontal-scrollbar.ts","../src/hooks/ui/use-image-edge-color.ts","../src/hooks/ui/use-is-truncated.ts","../src/hooks/ui/use-isomorphic-layout-effect.ts","../src/hooks/ui/use-keyboard-collision-padding.ts","../src/hooks/ui/use-local-storage.ts","../src/hooks/ui/use-marquee-engine.ts","../src/hooks/ui/use-media-query.ts","../src/hooks/ui/use-memoized-callback.ts","../src/hooks/ui/use-notification-permission.ts","../src/hooks/ui/use-onboarding-state.ts","../src/utils/onboarding-storage.ts","../src/hooks/ui/use-reset-on-page-hidden.ts","../src/hooks/ui/use-search.ts","../src/hooks/ui/use-suppress-clone-focus.ts","../src/hooks/ui/use-table-pagination.ts","../src/hooks/ui/use-throttle.ts","../src/hooks/ui/use-window-size.ts","../src/hooks/ui/index.ts","../src/hooks/platform/use-platform-config.ts","../src/utils/platform-config.tsx","../src/hooks/use-toast.ts","../src/components/ui/toaster.tsx","../src/types/tool.types.ts","../src/components/tool-icon.tsx","../src/hooks/use-contact-submission.ts","../src/utils/local-storage-adapter.ts","../src/utils/app-config.ts","../src/utils/embed-proxy-auth-storage.ts","../src/utils/embed-authed-fetch.ts","../src/utils/embed-content-fetch.ts","../src/hooks/use-copy-to-clipboard.ts","../src/hooks/use-batch-images.ts","../src/hooks/use-authenticated-image.ts","../src/hooks/use-authed-asset-src.ts","../src/hooks/use-authed-image-src.ts","../src/hooks/state/use-query-params.ts","../src/hooks/state/graphql-parser.ts","../src/hooks/state/flatten-schema.ts","../src/hooks/state/url-converter.ts","../src/hooks/state/introspection.ts","../src/hooks/state/use-api-params.ts","../src/hooks/state/use-cursor-pagination-state.ts","../src/hooks/nats/use-nats-client.ts","../src/hooks/use-near-viewport.ts","../src/hooks/use-access-code-integration.ts","../src/utils/access-code-client.ts","../src/hooks/use-og-placeholder-url.ts","../src/utils/og-placeholder.ts","../src/hooks/use-claude-mirror-src.ts","../src/utils/embed-url-converters.ts","../src/hooks/use-scroll-to-hash.ts","../src/utils/scroll-into-view.ts","../src/utils/markdown-fences.ts","../src/utils/markdown-heading-id.ts","../src/utils/anchor-id.ts","../src/utils/same-page-hash-nav.ts","../src/hooks/use-humanity-signals.ts","../src/utils/humanity-signals.ts"],"names":["measure","useState","useEffect","useRef","header","bar","useCallback","r","g","b","dismissOnboarding","markMultipleComplete","platforms","sonnerToast","jsx","Fragment","adapter","toast","useMemo","fetchPromise","introspector","isInputObjectType","clearParams"],"mappings":"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R;AACuB,EAAA;AAGnBH,EAAAA;AAECG,EAAAA;AACO,IAAA;AACJ,MAAA;AACD,MAAA;AAEnB,IAAA;AACC,IAAA;AACH,EAAA;AAEqBA,EAAAA;AACF,IAAA;AACN,IAAA;AACR,EAAA;AAEI,EAAA;AACX;A/D0hIwB;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;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;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;AACA;AACA;AACA;AACA;AACA;AACA","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-AQ6MIFNX.cjs","sourcesContent":[null,"\"use client\"\n\nimport { useCallback, useEffect, useRef, useState } from \"react\"\n\nexport interface UseAutoLimitTagsOptions {\n /** Total number of tags */\n count: number\n /** Fixed limit or \"auto\" for DOM-based measurement. Default \"auto\" */\n limitTags?: number | \"auto\"\n /** Placeholder text used to reserve input width (only used in \"auto\" mode) */\n placeholder?: string\n /**\n * Reserve trailing space for a text input (the autocomplete combobox). Set\n * `false` for a pure chip row that has no input — then only the chips and the\n * \"+N\" / \"⋯\" overflow badge compete for the available width. Default `true`.\n */\n reserveInputWidth?: boolean\n}\n\nexport interface UseAutoLimitTagsReturn {\n /** How many tags to show */\n visibleCount: number\n /** Ref for the zone that contains tags + input (must have overflow-hidden, gap, padding) */\n middleRef: React.RefObject<HTMLDivElement | null>\n /** Ref for the off-screen container that holds measurement copies of ALL tags */\n measureRef: React.RefObject<HTMLDivElement | null>\n /** Ref for the hidden span that measures placeholder text width */\n textMeasureRef: React.RefObject<HTMLSpanElement | null>\n /** Ref for the \"+N\" badge element (used to measure its width) */\n badgeRef: React.RefObject<HTMLButtonElement | null>\n /** Ref for the input element (used to read its min-width) */\n inputRef: React.RefObject<HTMLInputElement | null>\n}\n\n/**\n * Calculates how many tags fit in a single-line container.\n *\n * Requires three off-screen measurement elements rendered by the consumer:\n * 1. A `<div ref={measureRef}>` containing Tag copies for every item (to measure widths)\n * 2. A `<span ref={textMeasureRef}>` containing the placeholder text (to reserve input width)\n * 3. The `<button ref={badgeRef}>` for the \"+N\" badge (to measure badge width)\n *\n * The hook reads real CSS values (padding, gap) from the middleRef container,\n * so it works regardless of responsive breakpoints or custom styling.\n */\nexport function useAutoLimitTags({\n count,\n limitTags = \"auto\",\n placeholder = \"\",\n reserveInputWidth = true,\n}: UseAutoLimitTagsOptions): UseAutoLimitTagsReturn {\n const middleRef = useRef<HTMLDivElement>(null)\n const measureRef = useRef<HTMLDivElement>(null)\n const textMeasureRef = useRef<HTMLSpanElement>(null)\n const badgeRef = useRef<HTMLButtonElement>(null)\n const inputRef = useRef<HTMLInputElement>(null)\n const [visibleCount, setVisibleCount] = useState(count)\n\n const recalculate = useCallback(() => {\n // Fixed limit — skip DOM measurement\n if (limitTags !== \"auto\") {\n setVisibleCount(Math.min(limitTags, count))\n return\n }\n\n const middle = middleRef.current\n const measure = measureRef.current\n if (!middle || !measure) {\n setVisibleCount(count)\n return\n }\n if (count === 0) {\n setVisibleCount(0)\n return\n }\n\n // Read real CSS metrics from the middle zone\n const cs = getComputedStyle(middle)\n const padL = parseFloat(cs.paddingLeft) || 0\n const padR = parseFloat(cs.paddingRight) || 0\n const gap = parseFloat(cs.gap) || 0\n const middleW = middle.clientWidth\n\n // Reserve trailing space for the input (autocomplete only). A pure chip row\n // (`reserveInputWidth: false`) has no input, so nothing — and no gap before\n // it — is reserved.\n let inputReservedW = 0\n let trailingGap = 0\n if (reserveInputWidth) {\n const textW = textMeasureRef.current?.offsetWidth ?? 60\n const inputMinW = inputRef.current\n ? parseFloat(getComputedStyle(inputRef.current).minWidth) || 60\n : 60\n inputReservedW = Math.max(textW + 8, inputMinW)\n trailingGap = gap\n }\n\n // Available = middle zone − padding − input reserved − gap before input\n const available = middleW - padL - padR - inputReservedW - trailingGap\n\n // Measure every tag from the off-screen container\n const tagEls = Array.from(measure.children) as HTMLElement[]\n const widths = tagEls.map((el) => el.offsetWidth)\n\n // Fast check: do ALL tags fit?\n let total = 0\n for (let i = 0; i < widths.length; i++) {\n total += widths[i] + (i > 0 ? gap : 0)\n }\n if (total <= available) {\n setVisibleCount(count)\n return\n }\n\n // Not all fit → reserve space for the \"+N\" badge\n const badgeW = badgeRef.current?.offsetWidth ?? 40\n const spaceWithBadge = available - badgeW - gap\n\n let used = 0\n let fitCount = 0\n for (let i = 0; i < widths.length; i++) {\n const need = widths[i] + (i > 0 ? gap : 0)\n if (used + need > spaceWithBadge) break\n used += need\n fitCount++\n }\n\n setVisibleCount(Math.max(0, fitCount))\n }, [count, limitTags, placeholder, reserveInputWidth])\n\n // Recalculate when inputs change\n useEffect(() => {\n recalculate()\n }, [recalculate])\n\n // Recalculate on container resize\n useEffect(() => {\n const el = middleRef.current\n if (!el) return\n const ro = new ResizeObserver(recalculate)\n ro.observe(el)\n return () => ro.disconnect()\n }, [recalculate])\n\n return { visibleCount, middleRef, measureRef, textMeasureRef, badgeRef, inputRef }\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 * as React from 'react'\n\nexport interface UseDeferredErrorResult {\n /** The message to render (undefined while the reveal is deferred or the field is valid). */\n error: string | undefined\n /** Attach to the field's onBlur — reveals validation immediately and keeps it live from then on. */\n onBlur: () => void\n}\n\n/**\n * Defers showing a field validation message while the user is still typing.\n *\n * The message is revealed when the field loses focus (touched) or when the\n * value hasn't changed for `delay` ms; every keystroke restarts the timer.\n * Once touched, validation stays live. A message that clears (the value became\n * valid) disappears immediately.\n */\nexport function useDeferredError(error: string | undefined, value: string, delay = 1500): UseDeferredErrorResult {\n const [touched, setTouched] = React.useState(false)\n const [paused, setPaused] = React.useState(false)\n\n // Typing hides the message and restarts the pause timer.\n React.useEffect(() => {\n if (touched) return\n setPaused(false)\n const timer = setTimeout(() => setPaused(true), delay)\n return () => clearTimeout(timer)\n }, [value, delay, touched])\n\n const onBlur = React.useCallback(() => setTouched(true), [])\n\n return {\n error: error && (touched || paused) ? error : undefined,\n onBlur,\n }\n}\n","'use client'\n\nimport { useEffect, useRef, type RefObject } from 'react'\n\nconst FOCUSABLE_SELECTOR = [\n 'a[href]',\n 'button:not([disabled])',\n 'input:not([disabled])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(', ')\n\nexport interface UseFocusTrapOptions {\n /** Called when Escape is pressed while focus is inside the container.\n * When omitted, Escape passes through untouched so document-level\n * listeners still fire. */\n onEscape?: () => void\n /**\n * When false, Tab/Shift+Tab are NOT cycled within the container — for\n * non-modal dialogs (e.g. SlidingSidebar, whose z-50 header stays clickable\n * above the open drawer). Escape / initial focus / guarded restore still apply.\n * @default true\n */\n contain?: boolean\n}\n\n/**\n * Focus management for overlay surfaces (mobile nav panel, sliding sidebar,\n * burger menu, modals): initial focus on activate, container-scoped Tab\n * cycling + Escape, and guarded focus restore on deactivate.\n *\n * CONTRACT: the container element MUST have `tabIndex={-1}`. After a\n * click/tap on a non-focusable area inside the surface, browsers move focus\n * to the nearest focusable ancestor — with the container script-focusable\n * that ancestor is the container itself, so this keydown listener keeps\n * hearing Escape/Tab. Without it, focus falls to `body` and the trap goes\n * deaf.\n *\n * The listener being container-scoped makes it stacking-safe: it only fires\n * while focus is inside the surface, so a modal open above never leaks its\n * Escape into the surface below.\n */\nexport function useFocusTrap(\n containerRef: RefObject<HTMLElement | null>,\n active: boolean,\n { onEscape, contain = true }: UseFocusTrapOptions = {}\n): void {\n // Latest-ref so unstable inline callbacks never tear the trap down and\n // re-yank focus — same stable-callback discipline as `useCloseOnNavigation`.\n const onEscapeRef = useRef(onEscape)\n onEscapeRef.current = onEscape\n\n useEffect(() => {\n if (!active) return\n const container = containerRef.current\n if (!container) return\n\n const previouslyFocused = document.activeElement as HTMLElement | null\n\n const focusables = () =>\n Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))\n .filter((el) => el.getClientRects().length > 0)\n\n const first = focusables()[0]\n ;(first ?? container).focus({ preventScroll: true })\n\n const handleKeyDown = (e: KeyboardEvent) => {\n if (e.key === 'Escape') {\n // No preventDefault/stopPropagation — pass-through by design.\n onEscapeRef.current?.()\n return\n }\n if (e.key !== 'Tab' || !contain) return\n const items = focusables()\n if (items.length === 0) {\n e.preventDefault()\n container.focus({ preventScroll: true })\n return\n }\n const firstEl = items[0]\n const lastEl = items[items.length - 1]\n const current = document.activeElement\n if (e.shiftKey && (current === firstEl || current === container)) {\n e.preventDefault()\n lastEl.focus({ preventScroll: true })\n } else if (!e.shiftKey && current === lastEl) {\n e.preventDefault()\n firstEl.focus({ preventScroll: true })\n }\n }\n\n container.addEventListener('keydown', handleKeyDown)\n\n return () => {\n container.removeEventListener('keydown', handleKeyDown)\n // Guarded restore: only when focus is still inside the surface (or fell\n // to body because the surface unmounted) — never steal focus from a\n // dialog that opened meanwhile (e.g. the nav-footer → SSO modal flow).\n const current = document.activeElement\n if (\n previouslyFocused &&\n previouslyFocused.isConnected &&\n (current === null || current === document.body || container.contains(current))\n ) {\n previouslyFocused.focus({ preventScroll: true })\n }\n }\n }, [containerRef, active, contain])\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\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 { useRef, useState, useCallback } from 'react'\n\n/**\n * Custom horizontal scrollbar hook that drives a plain-DOM scrollbar.\n *\n * Returns a callback ref (`scrollRef`) to attach to the scrollable container,\n * plus refs, state, and event handlers for the track + thumb.\n *\n * Uses a **callback ref** so measurement and ResizeObserver setup happen\n * automatically when the scroll element mounts — even if it renders later\n * (e.g. after async data loads).\n */\nexport function useHorizontalScrollbar() {\n const scrollElRef = useRef<HTMLDivElement | null>(null)\n const trackRef = useRef<HTMLDivElement>(null)\n const thumbRef = useRef<HTMLDivElement>(null)\n const roRef = useRef<ResizeObserver | null>(null)\n const moRef = useRef<MutationObserver | null>(null)\n const [thumbRatio, setThumbRatio] = useState(0)\n\n // Edge-fade state\n const [canScrollLeft, setCanScrollLeft] = useState(false)\n const [canScrollRight, setCanScrollRight] = useState(false)\n const prevCanScrollLeftRef = useRef(false)\n const prevCanScrollRightRef = useRef(false)\n\n const isDraggingRef = useRef(false)\n const dragStartRef = useRef({ mouseX: 0, scrollLeft: 0 })\n const rafIdRef = useRef<number>(0)\n\n /** Compute the thumb left% from current scrollLeft and apply to DOM directly */\n const syncThumbToDOM = useCallback(() => {\n const el = scrollElRef.current\n const thumb = thumbRef.current\n if (!el || !thumb) return\n\n const maxScroll = el.scrollWidth - el.clientWidth\n if (maxScroll <= 0) {\n thumb.style.left = '0%'\n return\n }\n\n const ratio = el.clientWidth / el.scrollWidth\n const fraction = Math.min(Math.max(el.scrollLeft, 0), maxScroll) / maxScroll\n thumb.style.left = `${fraction * (1 - ratio) * 100}%`\n }, [])\n\n const syncEdgeFades = useCallback(() => {\n const el = scrollElRef.current\n if (!el) return\n\n const maxScroll = el.scrollWidth - el.clientWidth\n const ratio = el.clientWidth / el.scrollWidth\n if (ratio >= 1 || maxScroll <= 0) {\n if (prevCanScrollLeftRef.current) {\n prevCanScrollLeftRef.current = false\n setCanScrollLeft(false)\n }\n if (prevCanScrollRightRef.current) {\n prevCanScrollRightRef.current = false\n setCanScrollRight(false)\n }\n return\n }\n\n const fraction = Math.min(Math.max(el.scrollLeft, 0), maxScroll) / maxScroll\n const left = fraction > 0.001\n const right = fraction < 0.999\n\n if (left !== prevCanScrollLeftRef.current) {\n prevCanScrollLeftRef.current = left\n setCanScrollLeft(left)\n }\n if (right !== prevCanScrollRightRef.current) {\n prevCanScrollRightRef.current = right\n setCanScrollRight(right)\n }\n }, [])\n\n const measure = useCallback(() => {\n const el = scrollElRef.current\n if (!el) return\n const ratio = el.clientWidth / el.scrollWidth\n setThumbRatio(ratio >= 1 ? 0 : ratio)\n syncThumbToDOM()\n syncEdgeFades()\n }, [syncThumbToDOM, syncEdgeFades])\n\n // Callback ref\n const scrollRef = useCallback((node: HTMLDivElement | null) => {\n // Teardown previous\n if (roRef.current) {\n roRef.current.disconnect()\n roRef.current = null\n }\n if (moRef.current) {\n moRef.current.disconnect()\n moRef.current = null\n }\n if (rafIdRef.current) {\n cancelAnimationFrame(rafIdRef.current)\n rafIdRef.current = 0\n }\n\n scrollElRef.current = node\n\n if (node) {\n const ratio = node.clientWidth / node.scrollWidth\n setThumbRatio(ratio >= 1 ? 0 : ratio)\n\n const ro = new ResizeObserver(measure)\n roRef.current = ro\n const observeAll = () => {\n ro.disconnect()\n ro.observe(node)\n for (const child of Array.from(node.children)) ro.observe(child)\n }\n observeAll()\n\n moRef.current = new MutationObserver(() => {\n observeAll()\n measure()\n })\n moRef.current.observe(node, { childList: true })\n\n requestAnimationFrame(() => {\n syncThumbToDOM()\n syncEdgeFades()\n })\n } else {\n setThumbRatio(0)\n prevCanScrollLeftRef.current = false\n prevCanScrollRightRef.current = false\n setCanScrollLeft(false)\n setCanScrollRight(false)\n }\n }, [measure, syncThumbToDOM, syncEdgeFades])\n\n // RAF-throttled scroll handler\n const onScroll = useCallback(() => {\n if (isDraggingRef.current) return\n\n if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current)\n rafIdRef.current = requestAnimationFrame(() => {\n syncThumbToDOM()\n syncEdgeFades()\n })\n }, [syncThumbToDOM, syncEdgeFades])\n\n // Track click — read ratio from DOM, smooth scroll\n const onTrackClick = useCallback((e: React.MouseEvent) => {\n if ((e.target as HTMLElement).closest('[data-scrollbar-thumb]')) return\n\n const track = trackRef.current\n const el = scrollElRef.current\n if (!track || !el) return\n\n const ratio = el.clientWidth / el.scrollWidth\n if (ratio >= 1) return\n\n const rect = track.getBoundingClientRect()\n const thumbWidth = rect.width * ratio\n const maxThumbOffset = rect.width - thumbWidth\n if (maxThumbOffset <= 0) return\n\n const targetFraction = Math.max(0, Math.min(1,\n (e.clientX - rect.left - thumbWidth / 2) / maxThumbOffset\n ))\n const targetScrollLeft = targetFraction * (el.scrollWidth - el.clientWidth)\n el.scrollTo({ left: targetScrollLeft, behavior: 'smooth' })\n }, [])\n\n // Wheel on track → forward to scroll container\n const onTrackWheel = useCallback((e: React.WheelEvent) => {\n const el = scrollElRef.current\n if (!el) return\n e.preventDefault()\n el.scrollLeft += e.deltaX || e.deltaY\n }, [])\n\n // Drag: pointer down\n const onThumbPointerDown = useCallback((e: React.PointerEvent) => {\n e.preventDefault()\n e.stopPropagation()\n const el = scrollElRef.current\n if (!el) return\n isDraggingRef.current = true\n dragStartRef.current = { mouseX: e.clientX, scrollLeft: el.scrollLeft }\n const target = e.currentTarget as HTMLElement\n target.setPointerCapture(e.pointerId)\n target.style.cursor = 'grabbing'\n }, [])\n\n // Drag: pointer move\n const onThumbPointerMove = useCallback((e: React.PointerEvent) => {\n if (!isDraggingRef.current) return\n const el = scrollElRef.current\n const track = trackRef.current\n if (!el || !track) return\n\n const currentThumbRatio = el.clientWidth / el.scrollWidth\n if (currentThumbRatio >= 1) return\n\n const trackWidth = track.clientWidth\n const thumbWidth = trackWidth * currentThumbRatio\n const maxThumbTravel = trackWidth - thumbWidth\n if (maxThumbTravel <= 0) return\n\n const mouseDelta = e.clientX - dragStartRef.current.mouseX\n const scrollRange = el.scrollWidth - el.clientWidth\n el.scrollLeft = Math.min(\n Math.max(dragStartRef.current.scrollLeft + (mouseDelta / maxThumbTravel) * scrollRange, 0),\n scrollRange\n )\n\n syncThumbToDOM()\n syncEdgeFades()\n }, [syncThumbToDOM, syncEdgeFades])\n\n // Drag: pointer up\n const onThumbPointerUp = useCallback((e: React.PointerEvent) => {\n isDraggingRef.current = false\n const target = e.currentTarget as HTMLElement\n target.releasePointerCapture(e.pointerId)\n target.style.cursor = 'grab'\n }, [])\n\n return {\n scrollRef,\n trackRef,\n thumbRef,\n thumbRatio,\n canScrollLeft,\n canScrollRight,\n onScroll,\n onTrackClick,\n onTrackWheel,\n onThumbPointerDown,\n onThumbPointerMove,\n onThumbPointerUp,\n }\n}\n","\"use client\";\n\nimport { useState, useEffect } from 'react';\n\n/**\n * Extract the dominant edge color from an image using color bucketing.\n * Samples pixels along left and right edges (the visible letterbox areas),\n * groups them into color buckets, and returns the most common bucket.\n * This avoids muddy averages when edges have mixed colors (e.g., sky + sand).\n */\nfunction extractEdgeColor(img: HTMLImageElement): string {\n const canvas = document.createElement('canvas');\n const ctx = canvas.getContext('2d');\n if (!ctx) return '#000000';\n\n const maxSize = 100;\n const scale = Math.min(maxSize / img.naturalWidth, maxSize / img.naturalHeight);\n const w = Math.round(img.naturalWidth * scale);\n const h = Math.round(img.naturalHeight * scale);\n canvas.width = w;\n canvas.height = h;\n\n ctx.drawImage(img, 0, 0, w, h);\n\n const data = ctx.getImageData(0, 0, w, h).data;\n\n // Sample left edge, right edge, top edge, bottom edge (15% band)\n const edgeW = Math.max(2, Math.round(w * 0.15));\n const edgeH = Math.max(2, Math.round(h * 0.15));\n\n // Color bucketing: quantize to 32-step buckets for grouping similar colors\n const bucketSize = 32;\n const buckets = new Map<string, { r: number; g: number; b: number; count: number }>();\n\n for (let y = 0; y < h; y++) {\n for (let x = 0; x < w; x++) {\n const isEdge =\n x < edgeW || x >= w - edgeW ||\n y < edgeH || y >= h - edgeH;\n\n if (!isEdge) continue;\n\n const i = (y * w + x) * 4;\n const a = data[i + 3];\n if (a < 128) continue;\n\n const r = data[i];\n const g = data[i + 1];\n const b = data[i + 2];\n\n // Quantize to bucket\n const br = Math.floor(r / bucketSize) * bucketSize;\n const bg = Math.floor(g / bucketSize) * bucketSize;\n const bb = Math.floor(b / bucketSize) * bucketSize;\n const key = `${br},${bg},${bb}`;\n\n const existing = buckets.get(key);\n if (existing) {\n existing.r += r;\n existing.g += g;\n existing.b += b;\n existing.count++;\n } else {\n buckets.set(key, { r, g, b, count: 1 });\n }\n }\n }\n\n if (buckets.size === 0) return '#000000';\n\n // Find the most common color bucket\n let bestBucket: { r: number; g: number; b: number; count: number } | null = null;\n for (const bucket of buckets.values()) {\n if (!bestBucket || bucket.count > bestBucket.count) {\n bestBucket = bucket;\n }\n }\n\n if (!bestBucket || bestBucket.count === 0) return '#000000';\n\n // Return average color within the winning bucket\n const r = Math.round(bestBucket.r / bestBucket.count);\n const g = Math.round(bestBucket.g / bestBucket.count);\n const b = Math.round(bestBucket.b / bestBucket.count);\n\n return `rgb(${r}, ${g}, ${b})`;\n}\n\n/**\n * Hook that extracts the dominant edge color from an image URL.\n * Returns a CSS color string for use as a background behind object-contain images.\n *\n * Always sets crossOrigin='anonymous' — required for canvas pixel access.\n * Most CDNs (Supabase, Cloudflare, our image proxy) return CORS headers.\n * If the server doesn't support CORS, onerror fires and we use the fallback.\n *\n * @param imageUrl - URL of the image to analyze\n * @param fallback - Fallback color if extraction fails (default: '#000000')\n * @returns CSS color string (e.g., 'rgb(34, 28, 22)')\n */\nexport function useImageEdgeColor(imageUrl: string | undefined | null, fallback = '#000000'): string {\n const [color, setColor] = useState(fallback);\n\n useEffect(() => {\n if (!imageUrl) {\n setColor(fallback);\n return;\n }\n\n let cancelled = false;\n const img = new Image();\n img.crossOrigin = 'anonymous';\n\n img.onload = () => {\n if (cancelled) return;\n try {\n setColor(extractEdgeColor(img));\n } catch {\n setColor(fallback);\n }\n };\n\n img.onerror = () => {\n if (cancelled) return;\n setColor(fallback);\n };\n\n img.src = imageUrl;\n\n return () => { cancelled = true; };\n }, [imageUrl, fallback]);\n\n return color;\n}\n","'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) {\n setIsTruncated(false)\n return\n }\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\n ? element.scrollHeight > element.clientHeight + 1\n : 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 return { ref, isTruncated }\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 =\n typeof window !== 'undefined' ? useLayoutEffect : useEffect\n","'use client'\n\nimport { useSyncExternalStore } from 'react'\n\n/**\n * Extra `collisionPadding.bottom` (px) a Radix popper needs to clear the\n * software keyboard. 0 on desktop and anywhere the keyboard is down.\n *\n * Radix positions every popper with floating-ui at `strategy: 'fixed'`, and\n * floating-ui takes its collision viewport from `window.visualViewport`.\n * Neither mobile shell shrinks the box a keyboard opens into: iOS runs\n * Capacitor's Keyboard plugin at `resize: 'none'`, which leaves the WKWebView\n * frame alone, and Android's window is edge-to-edge, where the IME is only a\n * WindowInsets type. So `flip`/`shift`/`size` all believe the screen still\n * reaches the bottom, and a dropdown whose own search field just raised the\n * keyboard is placed straight behind it — worst inside modals, which sit near\n * the bottom of the screen by design.\n *\n * The shell publishes the keyboard height as `--of-keyboard-inset` on <html>\n * (openframe-frontend's `keyboard-inset.ts`; unset everywhere else, hence the\n * 0 default). Handing the part floating-ui does not already know about back as\n * collision padding is what makes the top of the keyboard the bottom edge of\n * the screen.\n *\n * ```tsx\n * const keyboardPadding = useKeyboardCollisionPadding()\n * <PopoverPrimitive.Content collisionPadding={{ bottom: keyboardPadding }} />\n * ```\n *\n * Pair it with `max-h-[var(--radix-popper-available-height)]` on the content:\n * padding alone flips a dropdown above its trigger, but a list taller than what\n * is left above the keyboard would then spill back over it.\n */\n\nconst KEYBOARD_INSET_VAR = '--of-keyboard-inset'\n\nfunction measure(): number {\n if (typeof document === 'undefined') return 0\n const inset = Number.parseFloat(\n getComputedStyle(document.documentElement).getPropertyValue(KEYBOARD_INSET_VAR),\n )\n // Also catches the NaN of an unset variable.\n if (!(inset > 0)) return 0\n // How far floating-ui's own viewport bottom sits below the top of the\n // keyboard. iOS reports the keyboard on the visual viewport and Android does\n // not; measuring the difference rather than branching on the platform keeps\n // both correct and can never reserve the same keyboard twice.\n const layoutHeight = document.documentElement.clientHeight\n const viewport = window.visualViewport\n const viewportBottom = viewport ? viewport.offsetTop + viewport.height : layoutHeight\n return Math.max(0, Math.round(viewportBottom - (layoutHeight - inset)))\n}\n\n// One document-wide subscription for every popper on the page: the keyboard is\n// a single global, and each open dropdown re-reading it would mean another\n// MutationObserver plus a forced style resolve per keystroke-driven re-render.\nconst listeners = new Set<() => void>()\nlet padding = 0\nlet teardown: (() => void) | null = null\n\nfunction sync(): void {\n const next = measure()\n if (next === padding) return\n padding = next\n for (const notify of listeners) notify()\n}\n\nfunction subscribe(onStoreChange: () => void): () => void {\n listeners.add(onStoreChange)\n if (listeners.size === 1) {\n padding = measure()\n // Two independent signals. The inset lands on <html>'s inline style, so the\n // attribute is the only notification for it; the visual viewport moves on\n // its own schedule (iOS reports the keyboard there, and a pinch-zoom shifts\n // it with no keyboard involved at all) and changes what is left to reserve.\n const observer = new MutationObserver(sync)\n observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })\n const viewport = window.visualViewport\n viewport?.addEventListener('resize', sync)\n viewport?.addEventListener('scroll', sync)\n teardown = () => {\n observer.disconnect()\n viewport?.removeEventListener('resize', sync)\n viewport?.removeEventListener('scroll', sync)\n }\n }\n return () => {\n listeners.delete(onStoreChange)\n if (listeners.size > 0) return\n teardown?.()\n teardown = null\n }\n}\n\nexport function useKeyboardCollisionPadding(): number {\n return useSyncExternalStore(\n subscribe,\n () => padding,\n () => 0,\n )\n}\n","\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\n\n/**\n * Hook to use localStorage with state\n * @param key - localStorage key\n * @param initialValue - Initial value if key doesn't exist\n * @returns [storedValue, setValue] tuple\n */\nexport function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] {\n // State to store our value\n // Use lazy initialization to read from localStorage synchronously on first render\n const [storedValue, setStoredValue] = useState<T>(() => {\n try {\n if (typeof window !== \"undefined\") {\n const item = window.localStorage.getItem(key)\n // Parse stored json or if none return initialValue\n return item ? JSON.parse(item) : initialValue\n }\n } catch (error) {\n console.error(`Error reading localStorage key \"${key}\":`, error)\n }\n return initialValue\n })\n\n // Use a ref to track if we've initialized from localStorage\n const isInitialized = useRef(true) // Set to true since we initialize in useState\n // Use a ref to track if the current state change came from a storage event\n const isFromStorageEvent = useRef(false)\n\n // Listen for storage events to sync with other tabs/components\n useEffect(() => {\n if (!isInitialized.current) return\n\n const handleStorageChange = (e: StorageEvent) => {\n if (e.key === key && e.newValue !== null) {\n try {\n const newValue = JSON.parse(e.newValue)\n isFromStorageEvent.current = true\n setStoredValue(newValue)\n console.log(`🔄 Updated localStorage key \"${key}\" from storage event`)\n } catch (error) {\n console.error(`Error parsing storage event for key \"${key}\":`, error)\n }\n }\n }\n\n const handleCustomStorageUpdate = (e: CustomEvent) => {\n if (e.detail.key === key) {\n try {\n if (e.detail.newValue === null || e.detail.newValue === undefined) {\n // localStorage was cleared or key was removed\n isFromStorageEvent.current = true\n setStoredValue(initialValue)\n console.log(`🔄 Cleared localStorage key \"${key}\" from custom storage event`)\n } else {\n const newValue = JSON.parse(e.detail.newValue)\n isFromStorageEvent.current = true\n setStoredValue(newValue)\n console.log(`🔄 Updated localStorage key \"${key}\" from custom storage event`)\n }\n } catch (error) {\n console.error(`Error parsing custom storage event for key \"${key}\":`, error)\n }\n }\n }\n\n window.addEventListener('storage', handleStorageChange)\n window.addEventListener('localStorageUpdate', handleCustomStorageUpdate as EventListener)\n \n return () => {\n window.removeEventListener('storage', handleStorageChange)\n window.removeEventListener('localStorageUpdate', handleCustomStorageUpdate as EventListener)\n }\n }, [key])\n\n // Update localStorage when state changes, but only after initialization and NOT from storage events\n useEffect(() => {\n if (!isInitialized.current) return\n\n // Skip localStorage write if this state change came from a storage event\n if (isFromStorageEvent.current) {\n isFromStorageEvent.current = false\n return\n }\n\n try {\n // Save state to localStorage\n if (typeof window !== \"undefined\") {\n window.localStorage.setItem(key, JSON.stringify(storedValue))\n }\n } catch (error) {\n console.error(`Error writing to localStorage key \"${key}\":`, error)\n }\n }, [key, storedValue])\n\n // Return a wrapped version of useState's setter function that\n // persists the new value to localStorage\n const setValue = (value: T | ((val: T) => T)) => {\n try {\n // Allow value to be a function so we have same API as useState\n const valueToStore = value instanceof Function ? value(storedValue) : value\n // Save state\n setStoredValue(valueToStore)\n } catch (error) {\n console.error(`Error setting value for localStorage key \"${key}\":`, error)\n }\n }\n\n return [storedValue, setValue]\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.\n const optsRef = useRef(options)\n optsRef.current = options\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 while (pos >= min + half) pos -= half\n while (pos < min) pos += half\n return pos\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\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","\"use client\"\n\nimport { useLayoutEffect, useState } from \"react\"\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(\n query: string,\n): boolean | undefined {\n const [matches, setMatches] = useState<boolean | undefined>(undefined)\n\n useLayoutEffect(() => {\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\n","\"use client\"\n\nimport { useCallback, useRef } from \"react\"\n\n/**\n * Hook to memoize a callback with dependencies\n * Similar to useCallback but with deep comparison of dependencies\n * @param callback - Function to memoize\n * @param dependencies - Dependencies array\n * @returns Memoized callback\n */\nexport function useMemoizedCallback<T extends (...args: any[]) => any>(callback: T, dependencies: any[]): T {\n // Store the callback and dependencies\n const callbackRef = useRef<T>(callback)\n const dependenciesRef = useRef<any[]>(dependencies)\n\n // Update the callback if it changes\n callbackRef.current = callback\n\n // Check if dependencies have changed\n const depsChanged = dependencies.some((dep, i) => !Object.is(dep, dependenciesRef.current[i]))\n\n // Update dependencies if they've changed\n if (depsChanged) {\n dependenciesRef.current = dependencies\n }\n\n // Return memoized callback\n return useCallback(((...args: any[]) => callbackRef.current(...args)) as T, [depsChanged])\n}\n","'use client'\n\nimport { useCallback, useEffect, useState } from 'react'\n\nexport interface UseNotificationPermissionResult {\n /** Whether the page-context Notification API exists in this browser (false during SSR and on iOS Safari). */\n supported: boolean\n permission: NotificationPermission\n /** Prompt the user for permission. Must be called from a user gesture or browsers will auto-deny. */\n request: () => Promise<NotificationPermission>\n}\n\n/**\n * Tracks the browser's desktop-notification permission, staying in sync when\n * the user changes the site setting externally (Permissions API change event\n * where available, visibility/focus re-read as the Safari fallback).\n */\nexport function useNotificationPermission(): UseNotificationPermissionResult {\n const [supported, setSupported] = useState(false)\n const [permission, setPermission] = useState<NotificationPermission>('default')\n\n useEffect(() => {\n if (typeof window === 'undefined' || !('Notification' in window)) return\n setSupported(true)\n const sync = () => setPermission(Notification.permission)\n sync()\n\n // The query resolves async; without the flag it would attach the listener after unmount.\n let cancelled = false\n let status: PermissionStatus | undefined\n navigator.permissions\n ?.query({ name: 'notifications' as PermissionName })\n .then((s) => {\n if (cancelled) return\n status = s\n s.addEventListener('change', sync)\n })\n .catch(() => undefined)\n\n document.addEventListener('visibilitychange', sync)\n window.addEventListener('focus', sync)\n return () => {\n cancelled = true\n status?.removeEventListener('change', sync)\n document.removeEventListener('visibilitychange', sync)\n window.removeEventListener('focus', sync)\n }\n }, [])\n\n const request = useCallback(async () => {\n if (typeof window === 'undefined' || !('Notification' in window)) {\n return 'denied' as NotificationPermission\n }\n // Older Safari only implements the callback form; resolving from both is harmless.\n const result = await new Promise<NotificationPermission>((resolve) => {\n const maybePromise = Notification.requestPermission(resolve)\n if (maybePromise && typeof maybePromise.then === 'function') void maybePromise.then(resolve)\n })\n setPermission(Notification.permission)\n return result\n }, [])\n\n return { supported, permission, request }\n}\n","'use client'\n\nimport { useState, useEffect, useCallback } from 'react'\nimport {\n saveOnboardingState,\n loadOnboardingState,\n markStepComplete as storageMarkComplete,\n markStepSkipped as storageMarkSkipped,\n dismissOnboarding as storageDismiss,\n markMultipleComplete as storageMarkMultiple,\n isStepComplete as storageIsComplete,\n isStepSkipped as storageIsSkipped,\n type OnboardingState\n} from '../../utils/onboarding-storage'\n\nexport interface OnboardingStepConfig {\n id: string\n title: string\n description: string\n actionIcon: (color?: string) => React.ReactNode\n actionText: string\n completedText: string\n onAction: () => void | Promise<void>\n onSkip?: () => void\n checkComplete?: () => boolean | Promise<boolean>\n}\n\nexport type { OnboardingState }\n\n/**\n * Hook for managing onboarding state with localStorage persistence\n * Uses simple storage utilities for atomic updates and reliable re-renders\n */\nexport function useOnboardingState(storageKey: string = 'openframe-onboarding-state') {\n const [state, setState] = useState<OnboardingState>(() => loadOnboardingState(storageKey))\n const [, forceUpdate] = useState(0)\n\n // Listen for storage changes from other tabs\n useEffect(() => {\n const handleStorageUpdate = (e: CustomEvent) => {\n if (e.detail.key === storageKey) {\n const newState = loadOnboardingState(storageKey)\n setState(newState)\n forceUpdate(prev => prev + 1)\n console.log('🔄 State updated from storage event:', newState)\n }\n }\n\n window.addEventListener('localStorageUpdate', handleStorageUpdate as EventListener)\n return () => {\n window.removeEventListener('localStorageUpdate', handleStorageUpdate as EventListener)\n }\n }, [storageKey])\n\n const markComplete = useCallback((stepId: string) => {\n console.log(`🎯 markComplete called for: \"${stepId}\"`)\n const newState = storageMarkComplete(storageKey, stepId)\n setState(newState)\n forceUpdate(prev => prev + 1)\n }, [storageKey])\n\n const markSkipped = useCallback((stepId: string) => {\n console.log(`⏭️ markSkipped called for: \"${stepId}\"`)\n const newState = storageMarkSkipped(storageKey, stepId)\n setState(newState)\n forceUpdate(prev => prev + 1)\n }, [storageKey])\n\n const dismissOnboarding = useCallback(() => {\n console.log(`🚫 dismissOnboarding called`)\n const newState = storageDismiss(storageKey)\n setState(newState)\n forceUpdate(prev => prev + 1)\n }, [storageKey])\n\n const markMultipleComplete = useCallback((stepIds: string[]) => {\n console.log(`🎯 markMultipleComplete called for:`, stepIds)\n const newState = storageMarkMultiple(storageKey, stepIds)\n setState(newState)\n forceUpdate(prev => prev + 1)\n console.log(`📝 State after batch:`, newState)\n }, [storageKey])\n\n const isStepComplete = useCallback((stepId: string): boolean => {\n return state.completedSteps.includes(stepId)\n }, [state.completedSteps])\n\n const isStepSkipped = useCallback((stepId: string): boolean => {\n return state.skippedSteps.includes(stepId)\n }, [state.skippedSteps])\n\n const allStepsComplete = useCallback((steps: OnboardingStepConfig[]): boolean => {\n return steps.every(step => isStepComplete(step.id) || isStepSkipped(step.id))\n }, [isStepComplete, isStepSkipped])\n\n return {\n state,\n markComplete,\n markSkipped,\n dismissOnboarding,\n isStepComplete,\n isStepSkipped,\n allStepsComplete,\n markMultipleComplete\n }\n}\n","/**\n * Onboarding state storage utilities\n * Simple localStorage wrapper following announcement-storage.ts pattern\n * Provides atomic writes to prevent race conditions\n */\n\nexport interface OnboardingState {\n completedSteps: string[]\n skippedSteps: string[]\n dismissed: boolean\n lastUpdated: string\n}\n\nconst DEFAULT_STATE: OnboardingState = {\n completedSteps: [],\n skippedSteps: [],\n dismissed: false,\n lastUpdated: new Date().toISOString()\n}\n\n/**\n * Save onboarding state to localStorage (atomic write)\n */\nexport function saveOnboardingState(key: string, state: OnboardingState): void {\n if (typeof window === 'undefined') return\n\n try {\n localStorage.setItem(key, JSON.stringify(state))\n console.log('💾 Saved onboarding state:', state)\n\n // Dispatch custom event for cross-tab sync\n if (typeof window !== 'undefined') {\n window.dispatchEvent(\n new CustomEvent('localStorageUpdate', {\n detail: { key, newValue: JSON.stringify(state) }\n })\n )\n }\n } catch (err) {\n console.warn('[onboarding-storage] Failed saving to localStorage:', err)\n }\n}\n\n/**\n * Load onboarding state from localStorage\n */\nexport function loadOnboardingState(key: string): OnboardingState {\n if (typeof window === 'undefined') return DEFAULT_STATE\n\n try {\n const raw = localStorage.getItem(key)\n if (!raw) return DEFAULT_STATE\n\n const parsed = JSON.parse(raw) as OnboardingState\n return parsed\n } catch (err) {\n console.warn('[onboarding-storage] Failed parsing localStorage data:', err)\n return DEFAULT_STATE\n }\n}\n\n/**\n * Mark multiple steps as complete in a single atomic update\n */\nexport function markMultipleComplete(\n key: string,\n stepIds: string[]\n): OnboardingState {\n const state = loadOnboardingState(key)\n const newState: OnboardingState = {\n ...state,\n completedSteps: [...new Set([...state.completedSteps, ...stepIds])],\n skippedSteps: state.skippedSteps.filter(id => !stepIds.includes(id)),\n lastUpdated: new Date().toISOString()\n }\n saveOnboardingState(key, newState)\n return newState\n}\n\n/**\n * Mark a single step as complete\n */\nexport function markStepComplete(key: string, stepId: string): OnboardingState {\n return markMultipleComplete(key, [stepId])\n}\n\n/**\n * Mark a step as skipped\n */\nexport function markStepSkipped(key: string, stepId: string): OnboardingState {\n const state = loadOnboardingState(key)\n const newState: OnboardingState = {\n ...state,\n skippedSteps: [...new Set([...state.skippedSteps, stepId])],\n completedSteps: state.completedSteps.filter(id => id !== stepId),\n lastUpdated: new Date().toISOString()\n }\n saveOnboardingState(key, newState)\n return newState\n}\n\n/**\n * Dismiss the onboarding walkthrough\n */\nexport function dismissOnboarding(key: string): OnboardingState {\n const state = loadOnboardingState(key)\n const newState: OnboardingState = {\n ...state,\n dismissed: true,\n lastUpdated: new Date().toISOString()\n }\n saveOnboardingState(key, newState)\n return newState\n}\n\n/**\n * Check if a step is complete\n */\nexport function isStepComplete(key: string, stepId: string): boolean {\n const state = loadOnboardingState(key)\n return state.completedSteps.includes(stepId)\n}\n\n/**\n * Check if a step is skipped\n */\nexport function isStepSkipped(key: string, stepId: string): boolean {\n const state = loadOnboardingState(key)\n return state.skippedSteps.includes(stepId)\n}\n\n/**\n * Check if onboarding is dismissed\n */\nexport function isOnboardingDismissed(key: string): boolean {\n const state = loadOnboardingState(key)\n return state.dismissed\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 { useState, useEffect, useCallback } from \"react\"\nimport { useDebounce } from \"./use-debounce\"\nimport type { SearchResult } from \"../../components/ui/search-input\"\n\nexport interface UseSearchConfig<T> {\n /** Async function that performs the search */\n searchFn: (query: string) => Promise<T[]>\n /** Maps each raw item to a SearchResult */\n mapResult: (item: T) => SearchResult\n /** Debounce delay in ms. Default 300 */\n debounceMs?: number\n /** Minimum characters before searching. Default 2 */\n minQueryLength?: number\n}\n\nexport interface UseSearchReturn {\n query: string\n setQuery: (q: string) => void\n results: SearchResult[]\n isLoading: boolean\n error: string | null\n clearResults: () => void\n}\n\n/**\n * Generic search state management hook.\n *\n * Debounces the query, calls `searchFn` when the debounced value meets\n * `minQueryLength`, and maps the raw results via `mapResult`.\n */\nexport function useSearch<T>(config: UseSearchConfig<T>): UseSearchReturn {\n const { searchFn, mapResult, debounceMs = 300, minQueryLength = 2 } = config\n\n const [query, setQuery] = useState(\"\")\n const [results, setResults] = useState<SearchResult[]>([])\n const [isLoading, setIsLoading] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n const debouncedQuery = useDebounce(query, debounceMs)\n\n const clearResults = useCallback(() => {\n setResults([])\n setError(null)\n }, [])\n\n useEffect(() => {\n // Clear when query is empty or below threshold\n if (!debouncedQuery || debouncedQuery.length < minQueryLength) {\n setResults([])\n setIsLoading(false)\n setError(null)\n return\n }\n\n let cancelled = false\n\n const run = async () => {\n setIsLoading(true)\n setError(null)\n\n try {\n const rawResults = await searchFn(debouncedQuery)\n\n if (!cancelled) {\n setResults(rawResults.map(mapResult))\n }\n } catch (err) {\n if (!cancelled) {\n setError(err instanceof Error ? err.message : \"Search failed\")\n setResults([])\n }\n } finally {\n if (!cancelled) {\n setIsLoading(false)\n }\n }\n }\n\n run()\n\n return () => {\n cancelled = true\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [debouncedQuery, minQueryLength])\n\n return { query, setQuery, results, isLoading, error, clearResults }\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\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","'use client'\n\nimport { useMemo } from 'react'\nimport type { CursorPaginationProps } from '../../components/ui/cursor-pagination'\n\n/**\n * Client-side pagination configuration\n */\nexport interface ClientPaginationConfig {\n type: 'client'\n currentPage: number\n totalPages: number\n itemCount: number\n itemName?: string\n onNext: () => void\n onPrevious: () => void\n showInfo?: boolean\n}\n\n/**\n * Server-side cursor pagination configuration\n */\nexport interface ServerPaginationConfig {\n type: 'server'\n hasNextPage: boolean\n hasLoadedBeyondFirst: boolean\n startCursor?: string | null\n endCursor?: string | null\n itemCount: number\n itemName?: string\n onNext: () => void | Promise<void>\n onReset: () => void | Promise<void>\n showInfo?: boolean\n}\n\nexport type PaginationConfig = ClientPaginationConfig | ServerPaginationConfig\n\n/**\n * Unified pagination hook that works for both client-side and server-side pagination\n *\n * @example Client-side pagination\n * ```typescript\n * const pagination = useTablePagination({\n * type: 'client',\n * currentPage: 1,\n * totalPages: 10,\n * itemCount: 20,\n * itemName: 'scripts',\n * onNext: () => setPage(p => p + 1),\n * onPrevious: () => setPage(p => p - 1),\n * onReset: () => setPage(1)\n * })\n * ```\n *\n * @example Server-side pagination\n * ```typescript\n * const pagination = useTablePagination({\n * type: 'server',\n * hasNextPage: pageInfo.hasNextPage,\n * hasLoadedBeyondFirst: true,\n * startCursor: pageInfo.startCursor,\n * endCursor: pageInfo.endCursor,\n * itemCount: devices.length,\n * itemName: 'devices',\n * onNext: fetchNextPage,\n * onReset: fetchFirstPage\n * })\n * ```\n */\nexport function useTablePagination(\n config: PaginationConfig | null\n): CursorPaginationProps | undefined {\n return useMemo(() => {\n if (!config) return undefined\n\n if (config.type === 'client') {\n // Client-side pagination: show if more than 1 page\n if (config.totalPages <= 1) return undefined\n\n return {\n hasNextPage: config.currentPage < config.totalPages,\n hasPreviousPage: config.currentPage > 1,\n isFirstPage: config.currentPage === 1,\n startCursor: (config.currentPage - 1).toString(),\n endCursor: config.currentPage.toString(),\n currentCount: config.itemCount,\n itemName: config.itemName || 'items',\n onNext: () => config.onNext(),\n onPrevious: () => config.onPrevious(),\n showInfo: config.showInfo ?? true\n }\n } else {\n // Server-side pagination: show if pageInfo exists\n return {\n hasNextPage: config.hasNextPage,\n hasPreviousPage: config.hasLoadedBeyondFirst,\n isFirstPage: !config.hasLoadedBeyondFirst,\n startCursor: config.startCursor,\n endCursor: config.endCursor,\n currentCount: config.itemCount,\n itemName: config.itemName || 'items',\n onNext: config.onNext,\n onPrevious: config.onReset, // Previous button goes back to first page\n showInfo: config.showInfo ?? true\n }\n }\n }, [config])\n}\n","\"use client\"\n\nimport { useState, useEffect, useRef } from \"react\"\n\n/**\n * Hook to throttle a value\n * @param value - Value to throttle\n * @param limit - Throttle limit in milliseconds\n * @returns Throttled value\n */\nexport function useThrottle<T>(value: T, limit = 200): T {\n const [throttledValue, setThrottledValue] = useState<T>(value)\n const lastUpdated = useRef<number>(Date.now())\n\n useEffect(() => {\n const now = Date.now()\n const elapsed = now - lastUpdated.current\n\n // If enough time has elapsed, update the throttled value\n if (elapsed >= limit) {\n setThrottledValue(value)\n lastUpdated.current = now\n } else {\n // Otherwise, set up a timeout to update after the limit\n const timerId = setTimeout(() => {\n setThrottledValue(value)\n lastUpdated.current = Date.now()\n }, limit - elapsed)\n\n return () => {\n clearTimeout(timerId)\n }\n }\n }, [value, limit])\n\n return throttledValue\n}\n","import { useLayoutEffect, useState } from \"react\"\n\n/**\n * Hook to get window dimensions\n * @returns Window width and height\n */\nexport function useWindowSize() {\n const [windowSize, setWindowSize] = useState({\n width: 0,\n height: 0,\n })\n const [isClient, setIsClient] = useState(false)\n\n useLayoutEffect(() => {\n setIsClient(true)\n if (!isClient) return\n\n const handleResize = () => {\n setWindowSize({\n width: window.innerWidth,\n height: window.innerHeight,\n })\n }\n\n // Set initial size\n handleResize()\n\n // Add event listener\n window.addEventListener(\"resize\", handleResize)\n\n // Remove event listener on cleanup\n return () => window.removeEventListener(\"resize\", handleResize)\n }, [isClient])\n\n return windowSize\n}","// UI Hooks exports\nexport * from './use-auto-limit-tags'\nexport * from './use-debounce'\nexport * from './use-deferred-error'\nexport * from './use-focus-trap'\nexport * from './use-header-height'\nexport * from './use-horizontal-scrollbar'\nexport * from './use-image-edge-color'\nexport * from './use-is-truncated'\nexport * from './use-isomorphic-layout-effect'\nexport * from './use-keyboard-collision-padding'\nexport * from './use-local-storage'\nexport * from './use-marquee-engine'\nexport * from './use-media-query'\nexport * from './use-memoized-callback'\nexport * from './use-notification-permission'\nexport * from './use-onboarding-state'\nexport * from './use-reset-on-page-hidden'\nexport * from './use-search'\nexport * from './use-suppress-clone-focus'\nexport * from './use-table-pagination'\nexport * from './use-throttle'\nexport * from './use-window-size'\n\n// Shared ref-counted, iOS-aware body scroll lock (react-aria). Re-exported so\n// hub/app consumers have one import path; lib internals may import the\n// package directly (npm dedupes to one module instance either way).\nexport { usePreventScroll } from '@react-aria/overlays'\n","import { useState, useEffect } from 'react';\nimport type { PlatformConfig, PlatformOption } from '../../types/platform';\nimport { transformPlatformConfigsToOptions } from '../../utils/platform-config';\nimport type { SelectableOption } from '../../components/features';\n\nexport interface UsePlatformConfigResult {\n platforms: PlatformConfig[];\n platformOptions: PlatformOption[];\n selectableOptions: SelectableOption[]; // Rich options with icons and colors\n isLoading: boolean;\n error: Error | null;\n}\n\n// Cache for platform configs to avoid repeated fetches\nlet platformCache: PlatformConfig[] | null = null;\nlet fetchPromise: Promise<PlatformConfig[]> | null = null;\n\n/**\n * Custom hook to fetch platform configurations from API\n * Provides both full platform configs and simplified options for dropdowns\n * Heavily cached to prevent excessive API calls - should only call once per session\n * \n * NOTE: This hook is designed to work without react-query dependency\n */\nexport function usePlatformConfig(): UsePlatformConfigResult {\n const [platforms, setPlatforms] = useState<PlatformConfig[]>(platformCache || []);\n const [isLoading, setIsLoading] = useState(!platformCache);\n const [error, setError] = useState<Error | null>(null);\n\n useEffect(() => {\n // If we already have cached platforms, use them\n if (platformCache) {\n setPlatforms(platformCache);\n setIsLoading(false);\n return;\n }\n\n // If a fetch is already in progress, wait for it\n if (fetchPromise) {\n fetchPromise\n .then(data => {\n setPlatforms(data);\n setIsLoading(false);\n })\n .catch(err => {\n setError(err);\n setIsLoading(false);\n });\n return;\n }\n\n // Start a new fetch\n console.log('🔧 Fetching platform configurations from API (should only happen once)');\n \n fetchPromise = fetch('/api/config/platforms')\n .then(response => {\n if (!response.ok) {\n throw new Error(`Failed to fetch platform config: ${response.statusText}`);\n }\n return response.json();\n })\n .then(data => {\n const platforms = data.platforms || data;\n console.log('✅ Platform configurations loaded:', platforms.length, 'platforms');\n platformCache = platforms;\n fetchPromise = null;\n return platforms;\n });\n\n fetchPromise\n .then(data => {\n setPlatforms(data);\n setIsLoading(false);\n })\n .catch(err => {\n console.error('❌ Failed to fetch platform config:', err);\n setError(err);\n setIsLoading(false);\n fetchPromise = null;\n });\n }, []);\n \n // Create options for dropdowns with \"All Platforms\" option\n const platformOptions: PlatformOption[] = [\n { value: 'all', label: 'All Platforms' },\n ...platforms.map((platform: PlatformConfig) => ({\n value: platform.value,\n label: platform.label\n }))\n ];\n\n // Create rich selectable options with icons and colors\n const selectableOptions = transformPlatformConfigsToOptions(platforms);\n\n return {\n platforms,\n platformOptions,\n selectableOptions,\n isLoading,\n error\n };\n}\n\n/**\n * Get platform configuration by value\n */\nexport function usePlatformByValue(value: string): PlatformConfig | undefined {\n const { platforms } = usePlatformConfig();\n return platforms.find(platform => platform.value === value);\n}\n\n/**\n * Check if a platform value is valid\n */\nexport function useValidatePlatform(value: string): boolean {\n const { platforms } = usePlatformConfig();\n return platforms.some(platform => platform.value === value);\n} ","import React from 'react';\nimport { OpenmspLogo, FlamingoLogo, OpenFrameLogo, MiamiCyberGangLogoFaceOnly } from '../components/icons';\nimport { Globe } from 'lucide-react';\nimport type { SelectableOption } from '../components/features';\nimport type { PlatformConfig } from '../types/platform';\n\n// Platform icons mapping with consistent colors matching app theme\nexport const platformIcons = {\n openframe: <OpenFrameLogo className=\"h-5 w-5\" lowerPathColor=\"#FFC008\" upperPathColor=\"#ffffff\" />,\n openmsp: <OpenmspLogo className=\"h-5 w-5\" />,\n flamingo: <FlamingoLogo className=\"h-5 w-5\" fill=\"#EC4899\" />,\n 'flamingo-teaser': <FlamingoLogo className=\"h-5 w-5\" fill=\"#EC4899\" />,\n 'marketing-hub': <FlamingoLogo className=\"h-5 w-5\" fill=\"#F357BB\" />,\n 'product-hub': <FlamingoLogo className=\"h-5 w-5\" fill=\"#5EA62E\" />,\n 'revenue-hub': <FlamingoLogo className=\"h-5 w-5\" fill=\"#FFC008\" />,\n 'people-hub': <FlamingoLogo className=\"h-5 w-5\" fill=\"#5EFAF0\" />,\n 'company-hub': <FlamingoLogo className=\"h-5 w-5\" fill=\"#f36666\" />,\n tmcg: <MiamiCyberGangLogoFaceOnly className=\"h-5 w-5\" />,\n universal: <Globe className=\"h-5 w-5 text-[#10B981]\" />\n};\n\n// Platform colors mapping\nexport const platformColors = {\n openmsp: 'bg-[#3B82F6]',\n openframe: 'bg-[#8B5CF6]',\n flamingo: 'bg-[#EC4899]',\n 'flamingo-teaser': 'bg-[#F59E0B]',\n 'marketing-hub': 'bg-[#F357BB]',\n 'product-hub': 'bg-[#5EA62E]',\n 'revenue-hub': 'bg-[#FFC008]',\n 'people-hub': 'bg-[#5EFAF0]',\n 'company-hub': 'bg-[#f36666]',\n tmcg: 'bg-[#FF6B6B]',\n universal: 'bg-[#10B981]'\n};\n\n// Platform display names for consistent naming across the app\nexport const platformDisplayNames = {\n openmsp: 'OpenMSP',\n openframe: 'OpenFrame',\n flamingo: 'Flamingo',\n 'flamingo-teaser': 'Flamingo Teaser',\n 'marketing-hub': 'Flamingo Marketing Hub',\n 'product-hub': 'Flamingo Product Hub',\n 'revenue-hub': 'Flamingo Revenue Hub',\n 'people-hub': 'Flamingo People Hub',\n 'company-hub': 'Flamingo Company Hub',\n tmcg: 'TMCG',\n universal: 'Universal'\n};\n\n// Platform descriptions for consistent messaging across the app\nexport const platformDescriptions = {\n openmsp: 'Comprehensive directory and comparison platform for managed service providers (MSPs) and technology vendors. Reduce vendor costs and discover open-source alternatives.',\n openframe: 'AI-driven open-source security operations center (SOC) and endpoint detection platform for MSPs.',\n flamingo: 'AI-driven open-source OS for MSPs. Swap bloated vendor tools for open ones. Automate the boring crap. Take your margin back.',\n 'flamingo-teaser': 'Preview of Flamingo - the AI-driven open-source OS for MSPs.',\n tmcg: 'The Miami Cyber Gang - A cybersecurity community focused on education and collaboration.',\n universal: 'Cross-platform universal content.'\n};\n\n// Platform slogans for branding consistency\nexport const platformSlogans = {\n openmsp: 'Find Your Perfect MSP Partner',\n openframe: 'Open-Source Security Operations',\n flamingo: 'Open-Source OS for MSPs',\n 'flamingo-teaser': 'Coming Soon: Open-Source OS for MSPs',\n tmcg: 'Miami Cyber Community',\n universal: 'Universal Platform'\n};\n\n// Platform hex colors for default configuration\nexport const platformHexColors = {\n openmsp: '#FFC008',\n openframe: '#FFC008',\n flamingo: '#FF6B9D',\n universal: '#FFC008',\n 'flamingo-teaser': '#F59E0B',\n 'marketing-hub': '#F357BB',\n 'product-hub': '#5EA62E',\n 'revenue-hub': '#FFC008',\n 'people-hub': '#5EFAF0',\n 'company-hub': '#f36666',\n tmcg: '#FF6B6B'\n};\n\n// Platform icon names for default configuration\nexport const platformIconNames = {\n openmsp: 'openmsp-logo',\n openframe: 'openframe-logo',\n flamingo: 'flamingo-logo',\n universal: 'globe',\n 'flamingo-teaser': 'flamingo-logo',\n 'marketing-hub': 'flamingo-logo',\n 'product-hub': 'flamingo-logo',\n 'revenue-hub': 'flamingo-logo',\n 'people-hub': 'flamingo-logo',\n 'company-hub': 'flamingo-logo',\n tmcg: 'tmcg-logo'\n};\n\n/**\n * Get default color for platform\n */\nexport function getDefaultColorForPlatform(platformName: string): string {\n return platformHexColors[platformName as keyof typeof platformHexColors] || platformHexColors.universal;\n}\n\n/**\n * Get default icon name for platform\n */\nexport function getDefaultIconForPlatform(platformName: string): string {\n return platformIconNames[platformName as keyof typeof platformIconNames] || platformIconNames.universal;\n}\n\nexport function transformPlatformConfigsToOptions(platformConfigs: PlatformConfig[]): SelectableOption[] {\n return platformConfigs.map((platform: PlatformConfig) => ({\n id: platform.id, // Database UUID for matching\n name: platform.name, // Platform name enum\n displayName: platform.display_name, // Human-readable name\n description: platform.description,\n icon: platformIcons[platform.name as keyof typeof platformIcons] || platformIcons.universal,\n color: platformColors[platform.name as keyof typeof platformColors] || platformColors.universal\n }));\n}\n\n/**\n * Get platform icon by name\n */\nexport function getPlatformIcon(platformName: string) {\n return platformIcons[platformName as keyof typeof platformIcons] || platformIcons.universal;\n}\n\n/**\n * Get platform color by name\n */\nexport function getPlatformColor(platformName: string) {\n return platformColors[platformName as keyof typeof platformColors] || platformColors.universal;\n}\n\n/**\n * Get platform display name by name\n */\nexport function getPlatformDisplayName(platformName: string): string {\n return platformDisplayNames[platformName as keyof typeof platformDisplayNames] || platformName;\n}\n\n/**\n * Get platform description by name\n */\nexport function getPlatformDescription(platformName: string): string {\n return platformDescriptions[platformName as keyof typeof platformDescriptions] || platformName;\n}\n\n/**\n * Get platform slogan by name\n */\nexport function getPlatformSlogan(platformName: string): string {\n return platformSlogans[platformName as keyof typeof platformSlogans] || platformName;\n}\n\n/**\n * Get small platform icon for filter buttons with white colors (4x4 size)\n */\nexport function getSmallPlatformIcon(platformName: string): React.ReactNode {\n const className = \"h-4 w-4 flex-shrink-0\";\n\n switch (platformName) {\n case 'openframe':\n return <OpenFrameLogo className={className} lowerPathColor=\"#FFC008\" upperPathColor=\"#ffffff\" />;\n case 'openmsp':\n return <OpenmspLogo className={className} frontBubbleColor=\"#f1f1f1\" innerFrontBubbleColor=\"#000000\" backBubbleColor=\"#FFC008\" />;\n case 'flamingo':\n case 'flamingo-teaser':\n return <FlamingoLogo className={`${className}`} fill=\"#EC4899\" />;\n case 'marketing-hub':\n return <FlamingoLogo className={className} fill=\"var(--ods-flamingo-pink-base)\" />;\n case 'product-hub':\n return <FlamingoLogo className={className} fill=\"var(--ods-attention-green-success)\" />;\n case 'revenue-hub':\n return <FlamingoLogo className={className} fill=\"var(--ods-attention-yellow-warning)\" />;\n case 'people-hub':\n return <FlamingoLogo className={className} fill=\"var(--ods-flamingo-cyan-base)\" />;\n case 'company-hub':\n return <FlamingoLogo className={className} fill=\"var(--ods-attention-red-error)\" />;\n case 'tmcg':\n return <MiamiCyberGangLogoFaceOnly className={className} />;\n case 'universal':\n default:\n return <Globe className={className} />;\n }\n}\n\n/**\n * Get platform icon for admin/selector components (standard 6x6 size)\n */\nexport function getPlatformIconComponent(platformName: string, className: string = \"h-6 w-6\"): React.ReactNode {\n switch (platformName) {\n case 'openframe':\n return <OpenFrameLogo className={className} />;\n case 'openmsp':\n // `OpenmspLogo` ignores `color`; it uses the three bubble props. On a dark\n // surface the default black front-bubble vanishes — use the canonical dark\n // treatment (matches getPlatformIcon above + every hub call-site).\n return <OpenmspLogo className={className} frontBubbleColor=\"#f1f1f1\" innerFrontBubbleColor=\"#000000\" backBubbleColor=\"#FFC008\" />;\n case 'flamingo':\n case 'flamingo-teaser':\n return <FlamingoLogo className={`${className} text-white`} />;\n case 'marketing-hub':\n return <FlamingoLogo className={className} fill=\"var(--ods-flamingo-pink-base)\" />;\n case 'product-hub':\n return <FlamingoLogo className={className} fill=\"var(--ods-attention-green-success)\" />;\n case 'revenue-hub':\n return <FlamingoLogo className={className} fill=\"var(--ods-attention-yellow-warning)\" />;\n case 'people-hub':\n return <FlamingoLogo className={className} fill=\"var(--ods-flamingo-cyan-base)\" />;\n case 'company-hub':\n return <FlamingoLogo className={className} fill=\"var(--ods-attention-red-error)\" />;\n case 'tmcg':\n return <MiamiCyberGangLogoFaceOnly size={24} className={className} />;\n case 'universal':\n default:\n return <Globe className={className} />;\n }\n}","import { toast as sonnerToast } from 'sonner'\nimport { showToast, type ToastVariant } from '../components/ui/toaster'\n\nexport interface ToastOptions {\n title?: string\n description?: string\n variant?: 'default' | 'success' | 'destructive' | 'info' | 'warning' | 'error'\n duration?: number\n dismissible?: boolean\n}\n\nconst normalizeVariant = (variant: ToastOptions['variant']): ToastVariant => {\n if (variant === 'destructive') return 'error'\n if (!variant) return 'default'\n return variant\n}\n\nexport const toast = (options: ToastOptions | string) => {\n if (typeof options === 'string') {\n return showToast({ title: options })\n }\n\n const { title, description, variant, duration, dismissible } = options\n return showToast({\n title,\n description,\n variant: normalizeVariant(variant),\n duration,\n dismissible,\n })\n}\n\nexport const useToast = () => ({\n toast,\n dismiss: sonnerToast.dismiss,\n promise: sonnerToast.promise,\n})\n","'use client'\n\nimport * as React from 'react'\nimport { Toaster as SonnerToaster, toast as sonnerToast } from 'sonner'\nimport type { ExternalToast } from 'sonner'\nimport { Chevron02DownIcon } from '../icons-v2-generated/arrows/chevron-02-down-icon'\nimport { XmarkIcon } from '../icons-v2-generated/signs-and-symbols/xmark-icon'\nimport { ToolIcon } from '../tool-icon'\nimport type { ToolType } from '../../types/tool.types'\nimport { cn } from '../../utils/cn'\n\nexport type ToastVariant = 'default' | 'success' | 'warning' | 'error' | 'info'\n\nexport const dotColorByVariant: Record<ToastVariant, string> = {\n default: 'bg-ods-text-secondary',\n success: 'bg-ods-success',\n warning: 'bg-ods-warning',\n error: 'bg-ods-error',\n info: 'bg-ods-text-secondary',\n}\n\nexport const progressColorByVariant: Record<ToastVariant, string> = {\n default: 'bg-ods-text-secondary',\n success: 'bg-ods-success',\n warning: 'bg-ods-warning',\n error: 'bg-ods-error',\n info: 'bg-ods-text-secondary',\n}\n\ninterface ToastHeaderProps {\n id: string | number\n variant: ToastVariant\n title?: React.ReactNode\n description?: React.ReactNode\n duration?: number\n dismissible?: boolean\n className?: string\n showProgress?: boolean\n}\n\nfunction ToastHeader({\n id,\n variant,\n title,\n description,\n duration = 4000,\n dismissible = true,\n className,\n showProgress = true,\n}: ToastHeaderProps) {\n return (\n <div\n className={cn(\n 'relative flex w-full items-start gap-2 overflow-hidden bg-ods-card p-3',\n className,\n )}\n >\n <div className=\"flex size-6 shrink-0 items-center justify-center\">\n <span className={cn('size-[9px] rounded-full', dotColorByVariant[variant])} />\n </div>\n\n <div className=\"flex min-w-0 flex-1 flex-col justify-center\">\n {title ? (\n <p className=\"truncate pr-5 text-h4 text-ods-text-primary\" title={typeof title === 'string' ? title : undefined}>{title}</p>\n ) : null}\n {description ? (\n <p className=\"text-h6 text-ods-text-secondary line-clamp-3\" title={typeof description === 'string' ? description : undefined}>{description}</p>\n ) : null}\n </div>\n\n {dismissible ? (\n <button\n type=\"button\"\n aria-label=\"Close\"\n onClick={() => sonnerToast.dismiss(id)}\n className=\"absolute right-[7px] top-[7px] flex size-4 items-center justify-center text-ods-text-secondary transition-colors hover:text-ods-text-primary\"\n >\n <XmarkIcon size={16} />\n </button>\n ) : null}\n\n {showProgress && duration !== Infinity && duration > 0 ? (\n <div\n className={cn(\n 'absolute inset-x-0 bottom-0 h-1 origin-left',\n progressColorByVariant[variant],\n )}\n style={{\n animation: `toast-progress ${duration}ms linear forwards`,\n }}\n />\n ) : null}\n </div>\n )\n}\n\nexport interface ToastCardProps {\n id: string | number\n variant?: ToastVariant\n title?: React.ReactNode\n description?: React.ReactNode\n duration?: number\n dismissible?: boolean\n className?: string\n}\n\nexport function ToastCard({\n id,\n variant = 'default',\n title,\n description,\n duration = 4000,\n dismissible = true,\n className,\n}: ToastCardProps) {\n return (\n <div\n role=\"status\"\n className={cn(\n 'w-[368px] max-w-[calc(100vw-32px)] overflow-hidden rounded-md border border-ods-border bg-ods-card shadow-lg',\n className,\n )}\n >\n <ToastHeader\n id={id}\n variant={variant}\n title={title}\n description={description}\n duration={duration}\n dismissible={dismissible}\n />\n </div>\n )\n}\n\nexport interface CommandApprovalToastProps {\n id: string | number\n variant?: ToastVariant\n title?: React.ReactNode\n description?: React.ReactNode\n command: string\n toolType?: ToolType\n approvalDescription?: React.ReactNode\n approveLabel?: string\n rejectLabel?: string\n onApprove?: () => void\n onReject?: () => void\n duration?: number\n dismissible?: boolean\n defaultExpanded?: boolean\n className?: string\n}\n\nexport function CommandApprovalToast({\n id,\n variant = 'warning',\n title = 'Tech Required',\n description = 'Approval is required to execute the command.',\n command,\n toolType,\n approvalDescription,\n approveLabel = 'Approve',\n rejectLabel = 'Reject',\n onApprove,\n onReject,\n duration = Infinity,\n dismissible = true,\n defaultExpanded = false,\n className,\n}: CommandApprovalToastProps) {\n const [expanded, setExpanded] = React.useState(defaultExpanded)\n\n const handleApprove = () => {\n onApprove?.()\n sonnerToast.dismiss(id)\n }\n\n const handleReject = () => {\n onReject?.()\n sonnerToast.dismiss(id)\n }\n\n return (\n <div\n role=\"status\"\n className={cn(\n 'flex w-[368px] max-w-[calc(100vw-32px)] flex-col overflow-hidden rounded-md border border-ods-border bg-ods-bg shadow-lg',\n className,\n )}\n >\n <ToastHeader\n id={id}\n variant={variant}\n title={title}\n description={description}\n duration={duration}\n dismissible={dismissible}\n showProgress={!expanded}\n className=\"border-b border-ods-border\"\n />\n\n <div\n className=\"grid transition-[grid-template-rows] duration-300 ease-out\"\n style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}\n aria-hidden={!expanded}\n >\n <div className=\"overflow-hidden\">\n <div className=\"flex h-11 w-full items-center gap-2 border-b border-ods-border bg-ods-card px-3 py-2\">\n <p className=\"min-w-0 flex-1 truncate text-h6 text-ods-text-primary\" title={command}>\n {command}\n </p>\n {toolType ? <ToolIcon toolType={toolType} size={16} /> : null}\n </div>\n\n <div className=\"flex flex-col gap-2 bg-ods-bg p-3\">\n {approvalDescription ? (\n <p className=\"text-h6 text-ods-text-secondary\">\n {approvalDescription}\n </p>\n ) : null}\n <div className=\"flex items-center gap-4\">\n <button\n type=\"button\"\n onClick={handleApprove}\n tabIndex={expanded ? 0 : -1}\n className=\"flex flex-1 items-center justify-center rounded-md bg-ods-accent px-2 py-2 text-h5 text-ods-text-on-accent transition-colors hover:bg-ods-accent-hover active:bg-ods-accent-active\"\n >\n {approveLabel}\n </button>\n <button\n type=\"button\"\n onClick={handleReject}\n tabIndex={expanded ? 0 : -1}\n className=\"flex flex-1 items-center justify-center rounded-md border border-ods-border bg-ods-card px-2 py-2 text-h5 text-ods-text-primary transition-colors hover:bg-ods-bg-hover\"\n >\n {rejectLabel}\n </button>\n </div>\n </div>\n </div>\n </div>\n\n {expanded ? null : (\n <button\n type=\"button\"\n onClick={() => setExpanded(true)}\n className=\"flex w-full items-center gap-2 bg-ods-card px-3 py-2 text-left text-h6 text-ods-text-primary transition-colors hover:bg-ods-bg-hover\"\n aria-expanded={false}\n >\n <span className=\"flex-1\">Show Command</span>\n <Chevron02DownIcon size={16} />\n </button>\n )}\n </div>\n )\n}\n\nexport type ToasterProps = React.ComponentProps<typeof SonnerToaster>\n\nexport function Toaster({\n position = 'bottom-right',\n offset = 24,\n gap = 8,\n toastOptions,\n ...rest\n}: ToasterProps = {}) {\n const { classNames: userClassNames, ...restToastOptions } = toastOptions ?? {}\n\n return (\n <>\n <style>{`\n @keyframes toast-progress {\n from { transform: scaleX(1); }\n to { transform: scaleX(0); }\n }\n `}</style>\n <SonnerToaster\n position={position}\n offset={offset}\n gap={gap}\n toastOptions={{\n unstyled: true,\n ...restToastOptions,\n classNames: {\n toast: 'w-full',\n ...userClassNames,\n },\n }}\n {...rest}\n />\n </>\n )\n}\n\nexport interface ShowToastOptions extends Omit<ExternalToast, 'description'> {\n title?: React.ReactNode\n description?: React.ReactNode\n variant?: ToastVariant\n}\n\nexport function showToast(options: ShowToastOptions | string) {\n const opts: ShowToastOptions =\n typeof options === 'string' ? { title: options } : options\n\n const {\n title,\n description,\n variant = 'default',\n duration = 4000,\n dismissible = true,\n ...rest\n } = opts\n\n return sonnerToast.custom(\n (id) => (\n <ToastCard\n id={id}\n variant={variant}\n title={title}\n description={description}\n duration={duration}\n dismissible={dismissible}\n />\n ),\n { duration, dismissible, ...rest },\n )\n}\n\nexport interface ShowCommandApprovalToastOptions\n extends Omit<ExternalToast, 'description'>,\n Omit<CommandApprovalToastProps, 'id'> {}\n\nexport function showCommandApprovalToast(options: ShowCommandApprovalToastOptions) {\n const {\n variant,\n title,\n description,\n command,\n toolType,\n approvalDescription,\n approveLabel,\n rejectLabel,\n onApprove,\n onReject,\n defaultExpanded,\n duration = Infinity,\n dismissible = true,\n ...rest\n } = options\n\n return sonnerToast.custom(\n (id) => (\n <CommandApprovalToast\n id={id}\n variant={variant}\n title={title}\n description={description}\n command={command}\n toolType={toolType}\n approvalDescription={approvalDescription}\n approveLabel={approveLabel}\n rejectLabel={rejectLabel}\n onApprove={onApprove}\n onReject={onReject}\n defaultExpanded={defaultExpanded}\n duration={duration}\n dismissible={dismissible}\n />\n ),\n { duration, dismissible, ...rest },\n )\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 * as React from \"react\";\nimport { ToolType, ToolTypeValues } from \"../types/tool.types\";\nimport { OpenFrameLogo } from \"./icons\";\nimport {\n\tOsqueryLogoGreyIcon,\n\tMeshcentralLogoGreyIcon,\n\tFleetMdmLogoGreyIcon,\n\tAuthentikLogoGreyIcon,\n} from \"./icons-v2-generated\";\n\nconst renderOpenFrameLogo = (_size: number, className?: string) => (\n\t// eslint-disable-next-line deprecation/deprecation\n\t<OpenFrameLogo\n\t\tclassName={className ?? \"h-4 w-auto\"}\n\t\tlowerPathColor=\"var(--color-accent-primary)\"\n\t\tupperPathColor=\"var(--color-text-primary)\"\n\t/>\n);\n\nconst toolIconMap: Record<ToolType, (size: number, className?: string) => React.ReactNode> = {\n\t[ToolTypeValues.FLEET_MDM]: (size, className) => <FleetMdmLogoGreyIcon size={size} className={className} />,\n\t[ToolTypeValues.MESHCENTRAL]: (size, className) => <MeshcentralLogoGreyIcon size={size} className={className} />,\n\t[ToolTypeValues.OPENFRAME]: renderOpenFrameLogo,\n\t[ToolTypeValues.OPENFRAME_CHAT]: renderOpenFrameLogo,\n\t[ToolTypeValues.OPENFRAME_CLIENT]: renderOpenFrameLogo,\n\t[ToolTypeValues.OPENFRAME_RMM]: renderOpenFrameLogo,\n\t[ToolTypeValues.AUTHENTIK]: (size, className) => <AuthentikLogoGreyIcon size={size} className={className} />,\n\t[ToolTypeValues.OSQUERY]: (size, className) => <OsqueryLogoGreyIcon size={size} className={className} />,\n\t[ToolTypeValues.SYSTEM]: () => null,\n};\n\nexport interface ToolIconProps {\n\ttoolType: ToolType;\n\tsize?: number;\n\tclassName?: string;\n}\n\nexport const ToolIcon: React.FC<ToolIconProps> = ({ toolType, size = 16, className }) =>\n\t<>{toolIconMap[toolType]?.(size, className) ?? null}</>;\n\nToolIcon.displayName = \"ToolIcon\";\n","import { useState, useCallback, useEffect } from 'react';\nimport { useToast } from \"./use-toast\";\nimport { useRouter } from '../embed-shims/next-navigation';\nimport { useRequiredEndpointsRuntime } from '../contexts/endpoints-runtime-context';\nimport { contentFetch } from '../utils/embed-content-fetch';\n\ninterface ContactSubmissionOptions {\n userId?: string;\n successRedirectUrl?: string;\n successToastMessage?: string;\n onSuccess?: () => void;\n}\n\n/**\n * Mirrors the API contract at POST /api/contact (see `ContactBaseSchema` in\n * the consuming app). Keep optional fields aligned with the Zod schema —\n * anything missing here OR there will be silently dropped on submission.\n *\n * Tracking fields (rdt_cid, utm_*, referrer_url) are forwarded as-is and\n * persisted by the API; they don't need to be declared on the form, but\n * the index signature below preserves any extra keys callers spread in.\n */\ninterface ContactFormData {\n name: string;\n email: string;\n helpCategory: string;\n message: string;\n linkedin_url?: string;\n companySize?: string;\n referralSource?: string;\n rdt_cid?: string;\n // Permissive index signature — tracking metadata, A/B variants, and\n // future per-form fields flow through unchanged. The API decides what\n // to keep via its Zod schema.\n [key: string]: unknown;\n}\n\n/**\n * useContactSubmission\n * --------------------\n * Provides a helper for submitting contact form data to `/api/contact`.\n * Handles loading state, success detection, toast notifications, and redirect.\n * Follows the same pattern as useWaitlistRegistration for consistency.\n */\nexport function useContactSubmission(options: ContactSubmissionOptions = {}) {\n const { userId, successRedirectUrl, successToastMessage, onSuccess } = options;\n const { toast } = useToast();\n const router = useRouter();\n // Endpoint URL injected via context — hub provides hub default, embedded\n // app provides its proxied path. Throws if no provider is mounted.\n const { contactUrl } = useRequiredEndpointsRuntime();\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [isSuccess, setIsSuccess] = useState(false);\n\n const submit = useCallback(async (formData: ContactFormData) => {\n if (isSubmitting) return;\n\n setIsSubmitting(true);\n\n try {\n const response = await contentFetch(contactUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ \n ...formData,\n user_id: userId\n }),\n });\n\n const data = await response.json();\n\n if (!response.ok) {\n throw new Error(data.error || 'Failed to submit contact form');\n }\n\n // Success\n setIsSuccess(true);\n const message = successToastMessage \n ? `Thank you! Your message has been sent. ${successToastMessage}`\n : 'Thank you! Your message has been sent successfully.';\n \n toast({\n title: \"Message sent!\",\n description: message,\n variant: 'success',\n });\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Something went wrong. Please try again.';\n toast({ \n title: 'Failed to send message', \n description: message, \n variant: 'destructive' \n });\n throw error; // allow caller to handle if needed\n } finally {\n setIsSubmitting(false);\n }\n }, [isSubmitting, toast, userId, successToastMessage, contactUrl]);\n\n // Handle redirect after success\n useEffect(() => {\n if (isSuccess && successRedirectUrl) {\n console.log('🚀 Contact submission successful, redirecting to:', successRedirectUrl);\n const timer = setTimeout(() => {\n console.log('🎯 Performing redirect now to:', successRedirectUrl);\n if (successRedirectUrl.startsWith('http')) {\n window.location.href = successRedirectUrl;\n } else {\n router.push(successRedirectUrl);\n }\n }, 1500);\n \n return () => clearTimeout(timer);\n }\n }, [isSuccess, successRedirectUrl, router]);\n\n // Call onSuccess callback if provided\n useEffect(() => {\n if (isSuccess && onSuccess) {\n onSuccess();\n }\n }, [isSuccess, onSuccess]);\n\n return { submit, isSubmitting, isSuccess, setIsSuccess } as const;\n}","/**\n * Thin JSON-typed Web-Storage adapter (localStorage or sessionStorage).\n *\n * Centralizes the SSR-guard + try/catch + silent quota-failure pattern\n * that every per-feature storage util would otherwise re-implement.\n *\n * Optional `namespace` prefix supports platform / user partitioning —\n * the resolver runs lazily at call time so the namespace can vary across\n * the lifetime of the page (e.g. proxy-auth switches user, which switches\n * the key suffix).\n *\n * Backend selection (`backend: 'local' | 'session'`):\n * - `'local'` (default): persists across browser sessions. Use for\n * UI state, chat history metadata, feature-flag opt-ins.\n * - `'session'`: cleared when the tab closes. Use for ANY auth-adjacent\n * value (bearer tokens, act-as identity, proxy credentials). Reduces\n * the XSS-exfiltration attack window from \"indefinite\" to \"until tab\n * close\" without losing per-session ergonomics.\n */\n\nexport type WebStorageBackend = 'local' | 'session'\n\nexport interface LocalStorageAdapter<T> {\n load(): T | null\n save(value: T): void\n clear(): void\n /** Resolved storage key for the current call. Useful for tests. */\n resolveKey(): string\n}\n\nexport interface LocalStorageAdapterOptions<T> {\n /** Base storage key. Combined with `namespace()` when provided. */\n key: string\n /** Optional dynamic namespace prefix appended via `.` separator.\n * Called on EVERY read/write so the key can vary across the page\n * lifetime (e.g. when the platform or user identity changes). */\n namespace?: () => string | null | undefined\n /** Runtime shape check. Falsey return → `load()` yields null. */\n validate?: (parsed: unknown) => parsed is T\n /** Diagnostic prefix written to `console.warn` on parse / write\n * failures. Defaults to `'[local-storage]'`. */\n logTag?: string\n /** Which Web-Storage backend to use. Defaults to `'local'`. Pass\n * `'session'` for anything auth-adjacent so the value evaporates\n * when the tab closes. */\n backend?: WebStorageBackend\n}\n\nfunction getStorage(backend: WebStorageBackend): Storage | null {\n if (typeof window === 'undefined') return null\n try {\n return backend === 'session' ? window.sessionStorage : window.localStorage\n } catch {\n // Some sandboxed contexts (Safari private mode older versions,\n // strict CSP) throw on storage access — treat as unavailable.\n return null\n }\n}\n\nexport function createLocalStorageAdapter<T>(\n options: LocalStorageAdapterOptions<T>,\n): LocalStorageAdapter<T> {\n const tag = options.logTag ?? '[local-storage]'\n const backend: WebStorageBackend = options.backend ?? 'local'\n const resolveKey = (): string => {\n const ns = options.namespace?.()\n return ns ? `${ns}.${options.key}` : options.key\n }\n\n return {\n resolveKey,\n load() {\n const storage = getStorage(backend)\n if (!storage) return null\n try {\n const raw = storage.getItem(resolveKey())\n if (!raw) return null\n const parsed = JSON.parse(raw) as unknown\n if (options.validate && !options.validate(parsed)) return null\n return parsed as T\n } catch (err) {\n console.warn(`${tag} parse failed for key ${resolveKey()}:`, err)\n return null\n }\n },\n save(value: T) {\n const storage = getStorage(backend)\n if (!storage) return\n try {\n storage.setItem(resolveKey(), JSON.stringify(value))\n } catch (err) {\n console.warn(`${tag} write failed for key ${resolveKey()}:`, err)\n }\n },\n clear() {\n const storage = getStorage(backend)\n if (!storage) return\n try {\n storage.removeItem(resolveKey())\n } catch (err) {\n console.warn(`${tag} clear failed for key ${resolveKey()}:`, err)\n }\n },\n }\n}\n","// Stub app config\nexport const APP_CONFIG = {\n app: {\n type: 'openmsp',\n name: 'OpenMSP',\n domain: 'openmsp.ai'\n },\n features: {\n announcements: true,\n notifications: true\n }\n} as const;\n\nexport function getAppConfig() {\n return APP_CONFIG;\n}\n\nexport function getAppType() {\n // The exact `process.env.NEXT_PUBLIC_APP_TYPE` member expression is kept so\n // Next/webpack can statically inline it. The try/catch makes the call safe\n // in non-Next React hosts (Vite/CRA embeds) where `process` does not exist\n // at runtime — those hosts get the 'openmsp' fallback, which is fine for\n // embeds: dismissal cookies are domain-scoped and an embed domain serves\n // one platform's announcements.\n try {\n return process.env.NEXT_PUBLIC_APP_TYPE || 'openmsp';\n } catch {\n return 'openmsp';\n }\n}","'use client'\n\n/**\n * Client-side persistence for embed-surface proxy credentials (a\n * PLATFORM API KEY + impersonation email). Used by every embedded\n * surface — the chat widget AND the ticket center AND any future\n * embedded React component that needs to identify itself as the\n * impersonated customer.\n *\n * When set, the surface attaches the creds as\n * `Authorization: Bearer <platform API key>` + `X-Chat-Act-As: <email>`\n * on every call to `/api/docs/chat`, `/api/chat/*`, and any other route\n * gated by `requireChatAuth` — proving to the server that this session\n * is acting on behalf of <email>. The key is minted in the hub's\n * `/admin/api-keys` and is platform-bound (`fpk_<platform>_…`): a\n * SERVICE key may act as any email; a PERSONAL key only as its owner.\n *\n * **Naming history:** the stored field is still named `secret` and the\n * wire-side header names are `X-Chat-*`. Those are client/server\n * contracts predating the per-platform key migration (the old shared\n * `CHAT_PROXY_SECRET` env is removed hub-side); renaming them would\n * break persisted state + the wire shape for zero behavior change. The\n * CLIENT-side helpers were renamed `Embed*` so non-chat surfaces (e.g.\n * ticket center) don't have to import a chat-prefixed symbol just to\n * send the same headers.\n *\n * Persists to **`localStorage`** so the bearer token + act-as identity\n * survive tab close, new-tab opens, and browser restarts — the\n * `/debug` paste-creds UI is an admin tool and re-pasting every tab\n * cycle was rejected as a dev-experience tradeoff that wasn't worth\n * the security gain. An XSS sink on this origin can read the value\n * indefinitely (vs only-this-tab with sessionStorage), but `/debug`\n * is admin-gated behind the platform's `askAI.enabled` flag, the key\n * is server-verified (hash-at-rest, platform-bound, revocable in\n * `/admin/api-keys`), and the act-as reach is bounded by the key's\n * tier. Explicit \"Clear\" button on the creds bar is the supported\n * logout path; closing the tab is no longer.\n *\n * Namespaced under `<platform>.chat.proxy-auth.v1` (the storage key is\n * unchanged from the old chat-prefixed helper — that's a storage\n * contract; renaming it would log everyone out).\n */\n\nimport { createLocalStorageAdapter } from './local-storage-adapter'\nimport { getAppType } from './app-config'\n\nexport interface EmbedProxyAuth {\n secret: string\n email: string\n /** Optional identity passthrough — empty/omitted = not sent. Server\n * parses these as `X-Chat-{First,Last}-Name` / `X-Chat-Avatar-Url` and\n * threads them through `resolveChatProxyIdentity`'s returned user. */\n firstName?: string\n lastName?: string\n avatarUrl?: string\n}\n\nfunction isValidPersistedAuth(value: unknown): value is EmbedProxyAuth {\n if (!value || typeof value !== 'object') return false\n const v = value as Record<string, unknown>\n if (\n typeof v.secret !== 'string' || v.secret.trim().length === 0 ||\n typeof v.email !== 'string' || v.email.trim().length === 0\n ) return false\n // Optional fields: when present must be strings. Empty string is treated\n // as absent later (in `getEmbedProxyAuth`).\n if (v.firstName != null && typeof v.firstName !== 'string') return false\n if (v.lastName != null && typeof v.lastName !== 'string') return false\n if (v.avatarUrl != null && typeof v.avatarUrl !== 'string') return false\n return true\n}\n\nconst adapter = createLocalStorageAdapter<EmbedProxyAuth>({\n // Storage key unchanged from the legacy chat-prefixed helper. Renaming\n // it would silently log every existing admin out — the key is a\n // storage contract, not a code identifier.\n key: 'chat.proxy-auth.v1',\n namespace: () => getAppType(),\n validate: isValidPersistedAuth,\n logTag: '[embed-proxy-auth-storage]',\n // localStorage — survives tab close, new tabs, and browser restarts.\n // Admin re-pasting creds every tab cycle was the dev-experience\n // tradeoff prior `sessionStorage` setup demanded — rejected. See\n // file-level doc comment for the security tradeoff rationale.\n backend: 'local',\n})\n\n/** Trim + null-coerce an optional identity field so consumers can do\n * `auth.firstName ?? ''` without worrying about whitespace-only strings. */\nfunction normalizeOptional(value: string | undefined): string | undefined {\n if (!value) return undefined\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : undefined\n}\n\n/**\n * Returns full credentials (secret + email + optional identity passthrough)\n * when secret + email are available. Returns `null` when nothing is saved —\n * callers treat that as \"fall back to cookie auth\".\n */\nexport function getEmbedProxyAuth(): EmbedProxyAuth | null {\n const persisted = adapter.load()\n if (!persisted) return null\n return {\n secret: persisted.secret,\n email: persisted.email.trim().toLowerCase(),\n firstName: normalizeOptional(persisted.firstName),\n lastName: normalizeOptional(persisted.lastName),\n avatarUrl: normalizeOptional(persisted.avatarUrl),\n }\n}\n\n/**\n * Returns the LAST email the admin saved. The proxy creds bar reads\n * this to pre-fill the email field on mount.\n */\nexport function getPersistedProxyEmail(): string | null {\n const persisted = adapter.load()\n return persisted?.email.trim().toLowerCase() ?? null\n}\n\n/** Save the proxy creds. Secret + email are required; identity-passthrough\n * fields are persisted only when non-empty. */\nexport function setEmbedProxyAuth(value: EmbedProxyAuth): void {\n adapter.save({\n secret: value.secret,\n email: value.email.trim().toLowerCase(),\n firstName: normalizeOptional(value.firstName),\n lastName: normalizeOptional(value.lastName),\n avatarUrl: normalizeOptional(value.avatarUrl),\n })\n}\n\n/** Drop the persisted creds. */\nexport function clearEmbedProxyAuth(): void {\n adapter.clear()\n}\n\n/**\n * Apply the embed-proxy auth (Bearer + X-Chat-Act-As) to a fetch call's\n * URL + headers. Used by every embedded-surface route that needs to\n * identify itself as the proxied customer (chat stream, agent-* routes,\n * ticket-center actions). When proxy auth is absent (regular\n * cookie-session users), returns the inputs unchanged so the cookie-auth\n * path still works.\n *\n * `X-Chat-Act-As` header (vs a URL query param) keeps PII out of access\n * logs, Sentry breadcrumbs, browser history, and CDN analytics.\n */\nexport function applyProxyAuth(\n url: string,\n baseHeaders: Record<string, string> = { 'Content-Type': 'application/json' },\n): { url: string; headers: Record<string, string> } {\n const auth = getEmbedProxyAuth()\n const headers = { ...baseHeaders }\n if (auth?.secret) {\n headers.Authorization = `Bearer ${auth.secret}`\n }\n if (auth?.email) {\n headers['X-Chat-Act-As'] = auth.email\n }\n // Optional identity passthrough — only attached when present so the\n // server's \"required vs optional\" header shape stays exact.\n if (auth?.firstName) headers['X-Chat-First-Name'] = auth.firstName\n if (auth?.lastName) headers['X-Chat-Last-Name'] = auth.lastName\n if (auth?.avatarUrl) headers['X-Chat-Avatar-Url'] = auth.avatarUrl\n return { url, headers }\n}\n","'use client'\n\n/**\n * Shared `fetch` wrapper for any embedded surface (chat, ticket center,\n * future widgets) that needs to carry the bearer-act-as identity\n * (proxy `Authorization` + `X-Chat-Act-As` headers from\n * `embed-proxy-auth-storage.ts`).\n *\n * Wire header names are `X-Chat-*` for historical reasons — that's a\n * server contract, not a UI namespace. The wrapper itself is generic.\n *\n * Drop-in replacement for `fetch()` — `Authorization` / `X-Chat-Act-As`\n * are merged into `init.headers` when proxy creds are stashed in\n * sessionStorage, otherwise the call falls through to the cookie-auth\n * path unchanged.\n *\n * Use this for any client-side fetch hitting `/api/chat/*`, `/api/docs/chat/*`,\n * or `/api/storage/generate-upload-url` (chat-attachment surface — shared\n * with the ticket center). Routes that do NOT need bearer-act-as\n * (e.g. `/api/profile/me`) keep using vanilla `fetch`.\n */\n\nimport { applyProxyAuth } from './embed-proxy-auth-storage'\n\n// =============================================================================\n// Host-supplied auth adapter (opt-in)\n// =============================================================================\n\n/**\n * Hosts that have their own auth model (cookie sessions, app-specific\n * JWT in localStorage, OAuth access tokens, …) can register an adapter\n * to override the lib's default `embedProxyAuth` flow. When set, the\n * adapter's `getHeaders()` result is merged onto every `embedAuthedFetch`\n * call AFTER the default proxy-auth header step (so adapter headers\n * win over both caller and proxy values), and `credentials` overrides\n * the default `'same-origin'` behaviour.\n *\n * Default (no adapter): MPH-style proxy-impersonation — bearer + act-as\n * read from localStorage, `credentials: 'same-origin'`. No consumer\n * needs to touch this unless they want a different auth model.\n *\n * Use cases:\n * - openframe-frontend has its own JWT in `localStorage.of_access_token`\n * and cookie-based session; register an adapter to attach the JWT\n * and request `credentials: 'include'` so cookies travel cross-origin\n * to the openframe gateway.\n * - Future embed hosts with OAuth access tokens, signed URLs, etc.\n *\n * Lifetime: setter is module-level (intentionally — `embedAuthedFetch`\n * is a plain utility, not a hook, so it can't read React context). Host\n * runtime providers should call `setEmbedAuthAdapter(...)` on mount and\n * `setEmbedAuthAdapter(null)` on unmount. Multiple hosts registering at\n * once is a programming error (one chat panel per app).\n */\nexport interface EmbedAuthAdapter {\n /** Headers merged onto every embedded-fetch call. Return `{}` to add\n * nothing. Called per-request so reactive token refresh sees the latest\n * value from your auth store / storage. Values typed as\n * `string | undefined` so the common narrowed shape\n * `{ Authorization: token ? 'Bearer …' : undefined }` (or a conditional\n * `token ? { Authorization: … } : {}`) assigns cleanly — `undefined`\n * values are filtered before being merged into the request headers. */\n getHeaders?: () => Record<string, string | undefined>\n /** `RequestInit.credentials` mode. Default when no adapter: callers'\n * `init.credentials` or `'same-origin'`. Use `'include'` for cookie\n * auth against a different origin (CORS + `SameSite=None` required). */\n credentials?: RequestCredentials\n /**\n * Optional 401 self-heal. When a request comes back `401`,\n * `embedAuthedFetch` calls this once, and — if it resolves `true` —\n * retries the SAME request exactly once with freshly-recomputed\n * headers (so a rotated bearer from `getHeaders()` is picked up).\n * Resolve `false` to surface the 401 to the caller unchanged.\n *\n * This is the capability the openframe `apiClient` has had all along\n * (refresh-the-access-token-then-retry); registering it here gives the\n * embedded chat/ticket surfaces the same self-healing auth instead of\n * dying on an expired token. Concurrent 401s are de-duplicated by the\n * wrapper, so this fires at most once per refresh cycle even when a\n * stampede of chat requests all expire together — your implementation\n * does NOT need its own in-flight guard (though a token-refresh manager\n * that already dedups is harmless).\n *\n * Keep it idempotent and side-effect-light: on failure the wrapper just\n * returns the original 401 — logout/redirect decisions belong to the\n * host's own auth layer, not to this fetch wrapper.\n */\n refresh?: () => Promise<boolean>\n /**\n * Exact extra origins (`scheme://host[:port]`, as produced by `URL.origin`)\n * the same-origin guard additionally accepts. The guard normally rejects\n * every cross-origin URL in production — but native-shell hosts serve the\n * page from a local pseudo-origin (`capacitor://localhost`,\n * `tauri://localhost`) with NO server behind it, so the gateway their\n * bearer already belongs to is unavoidably cross-origin. Listing it here\n * is the host's explicit sanction to send the adapter's credentials there.\n * Compared AFTER the http(s)-protocol check, so non-http(s) schemes can\n * never be allowlisted. Omit (or leave empty) everywhere else — same-origin\n * remains the rule, this is the narrow exception.\n */\n allowedOrigins?: string[]\n}\n\n/**\n * The registered adapter is parked on `globalThis`, NOT in a module-private\n * `let`. Reason: this lib ships multiple entry points (`/utils`,\n * `/components/chat`, …) and a consumer's bundler can inline this file into\n * more than one chunk — giving each chunk its OWN module scope. If the host\n * calls `setEmbedAuthAdapter` from the `/utils` copy while the chat's\n * `embedAuthedFetch` runs from the `/components/chat` copy, a module-local\n * `let` would be set on one copy and read as `null` on the other (the exact\n * \"credentials: same-origin, no Bearer, no refresh\" symptom). A single\n * `globalThis` slot is shared across every copy, so registration always\n * reaches the fetch path.\n */\nconst ADAPTER_GLOBAL_KEY = '__embedAuthedFetchAdapter__'\n\nfunction getRegisteredAuthAdapter(): EmbedAuthAdapter | null {\n if (typeof globalThis === 'undefined') return null\n return (globalThis as Record<string, unknown>)[ADAPTER_GLOBAL_KEY] as EmbedAuthAdapter | null ?? null\n}\n\nfunction storeRegisteredAuthAdapter(adapter: EmbedAuthAdapter | null): void {\n if (typeof globalThis === 'undefined') return\n ;(globalThis as Record<string, unknown>)[ADAPTER_GLOBAL_KEY] = adapter\n}\n\n/**\n * Register a host-owned auth adapter for `embedAuthedFetch`. Pass `null`\n * to clear (typically on provider unmount).\n *\n * Module-level state — there is one chat panel per app, so a single\n * registration is sufficient. Calling this twice with different non-null\n * adapters replaces the previous one (the most recent registration wins);\n * a `console.warn` flags the overwrite so duplicate-provider mounts get\n * caught in dev.\n */\nexport function setEmbedAuthAdapter(adapter: EmbedAuthAdapter | null): void {\n if (adapter && getRegisteredAuthAdapter() && process.env.NODE_ENV !== 'production') {\n console.warn(\n '[setEmbedAuthAdapter] overwriting a previously-registered auth ' +\n 'adapter. Two chat-runtime providers should not coexist — verify ' +\n 'mount order and pass `null` from the unmounting provider.',\n )\n }\n storeRegisteredAuthAdapter(adapter)\n}\n\n/**\n * Whether a host auth adapter is currently registered. Lets sibling helpers\n * (e.g. `contentFetch`) route through `embedAuthedFetch` ONLY when a host has\n * opted into embedded auth, and stay a plain `fetch` otherwise — so there's a\n * single auth knob (the adapter), not a second content-fetch registration.\n */\nexport function hasEmbedAuthAdapter(): boolean {\n return getRegisteredAuthAdapter() !== null\n}\n\n/**\n * Whether `url` is an asset the browser CANNOT load natively because its\n * auth rides in request headers.\n *\n * Native asset loads (`<img src>`, `<track src>`, CSS `background-image`)\n * can't carry custom headers, so a URL this returns `true` for must go\n * through `embedAuthedFetch` → blob object-URL instead (see\n * `useAuthedAssetSrc` / `useAuthedImageSrc`).\n *\n * The single signal is the registered adapter: when it supplies an\n * `Authorization` header, the host authenticates by HEADER, so nothing the\n * browser fetches on its own is authenticated. That holds for BOTH shapes a\n * header-auth host takes:\n *\n * - a cross-origin gateway URL — sanctioned via `allowedOrigins` (native\n * shells, whose page origin is `capacitor://` / `tauri://`);\n * - a same-origin / relative reverse-proxy path (`/content/api/...`) —\n * dev-ticket web, where the token lives in localStorage and there is NO\n * session cookie to fall back on. This case used to be excluded on the\n * assumption that same-origin implies \"cookies work\"; true for a cookie\n * session, false for every header-auth host, which is exactly when this\n * predicate is consulted at all.\n *\n * Everything else loads natively, unchanged: cookie-auth web (the adapter\n * supplies no `Authorization`), no adapter at all (the hub), and third-party\n * origins the bearer does NOT belong to (public images) — those stay out of\n * reach of the token by the same origin rules `embedAuthedFetch` enforces.\n */\nexport function needsBearerAssetFetch(url: string): boolean {\n if (typeof window === 'undefined') return false\n const adapter = getRegisteredAuthAdapter()\n if (!adapter || adapter.getHeaders?.().Authorization === undefined) return false\n let target: URL\n let pageOrigin: string\n try {\n target = new URL(url, window.location.href)\n // Same reason `assertSameOrigin` derives it this way: test environments\n // mock `window.location` as a plain object with no `origin` field.\n pageOrigin = new URL(window.location.href).origin\n } catch {\n return false\n }\n if (target.protocol !== 'http:' && target.protocol !== 'https:') return false\n if (target.origin === pageOrigin) return true\n return adapter.allowedOrigins?.includes(target.origin) ?? false\n}\n\n/**\n * `fetch` wrapper that attaches embed-proxy bearer headers (when\n * present in sessionStorage) and forces `credentials: 'same-origin'`\n * so Supabase auth cookies travel too.\n *\n * **Header merge direction (proxy WINS over caller):** the implementation\n * spreads `baseHeaders` first inside `applyProxyAuth`, then sets the\n * `Authorization` / `X-Chat-*` keys — so the proxy values take precedence\n * over anything the caller passed. The motivation is that the bearer +\n * act-as identity is the source of truth for embedded auth; a caller\n * accidentally passing a stale `Authorization` header should NOT override\n * the live proxy creds.\n *\n * **Cross-origin defense:** the wrapper assumes a same-origin `/api/…`\n * relative URL. Absolute URLs are accepted only when their origin matches\n * the current window's origin — or appears in the registered adapter's\n * `allowedOrigins` (the native-shell hatch; see that field's doc) — and\n * cross-origin URLs otherwise throw before the bearer leaves the page.\n * This is a defense-in-depth guard for future call sites — outside the\n * allowlisted-shell case there is no legitimate cross-origin use of this\n * fetch wrapper.\n *\n * **401 self-heal:** when a registered adapter supplies `refresh`, a `401`\n * response triggers a single token refresh + retry of the same request\n * (see `EmbedAuthAdapter.refresh`). This is the openframe `apiClient`'s\n * refresh-then-retry behaviour, lifted into the lib so embedded surfaces\n * no longer need a host-side `window.fetch` monkey-patch to survive an\n * expired access token mid-chat. With no adapter (or no `refresh`), the\n * 401 passes straight through unchanged.\n */\nexport function embedAuthedFetch(url: string, init: RequestInit = {}): Promise<Response> {\n // Same-origin guard runs SYNCHRONOUSLY (not awaited inside the async\n // helper below) so a bearer-leaking cross-origin URL throws before any\n // promise is created — callers and tests rely on the synchronous throw.\n assertSameOrigin(url)\n\n // `applyProxyAuth` accepts `Record<string, string>`; normalize the\n // caller's headers to that shape ONCE, up front. RequestInit accepts\n // `HeadersInit` which is broader (Headers instance OR array of tuples).\n // We re-derive the per-request headers from this base on every attempt\n // (initial + post-refresh retry) so a rotated bearer is picked up.\n //\n // When the caller passes no headers, fall back to the same default\n // `applyProxyAuth` uses internally — `Content-Type: application/json` —\n // so JSON POSTs keep their content-type when only `embedAuthedFetch(url)`\n // is used at the call site. GET callers that explicitly want no body\n // headers can pass `init.headers = {}` to opt out.\n let baseHeaders: Record<string, string>\n if (init.headers === undefined) {\n baseHeaders = { 'Content-Type': 'application/json' }\n } else {\n baseHeaders = {}\n if (init.headers instanceof Headers) {\n init.headers.forEach((v, k) => {\n baseHeaders[k] = v\n })\n } else if (Array.isArray(init.headers)) {\n for (const [k, v] of init.headers) baseHeaders[k] = v\n } else {\n Object.assign(baseHeaders, init.headers as Record<string, string>)\n }\n }\n\n return fetchWithRefresh(url, init, baseHeaders, false)\n}\n\n/**\n * Single in-flight refresh shared across all concurrent `embedAuthedFetch`\n * callers. A stampede of chat requests that all 401 at the same moment must\n * trigger the adapter's `refresh()` ONCE, not N times — otherwise an\n * expiring session fires a thundering herd of refresh calls at the auth\n * server. Resets to `null` once settled so the next genuine expiry can\n * refresh again.\n */\n// Stored on `globalThis` rather than a module-local so the \"single refresh\"\n// guarantee survives module duplication. Bundlers can ship more than one copy\n// of this module (e.g. across chunks or a host + embedded build); a per-module\n// variable would let each copy run its own refresh cycle, re-creating the\n// thundering-herd this dedupe exists to prevent.\nconst IN_FLIGHT_REFRESH_GLOBAL_KEY = '__embedAuthedFetchInFlightRefresh__'\n\nfunction getInFlightRefresh(): Promise<boolean> | null {\n if (typeof globalThis === 'undefined') return null\n return (\n ((globalThis as Record<string, unknown>)[IN_FLIGHT_REFRESH_GLOBAL_KEY] as\n | Promise<boolean>\n | null\n | undefined) ?? null\n )\n}\n\nfunction setInFlightRefresh(refresh: Promise<boolean> | null): void {\n if (typeof globalThis === 'undefined') return\n ;(globalThis as Record<string, unknown>)[IN_FLIGHT_REFRESH_GLOBAL_KEY] = refresh\n}\n\nfunction dedupedRefresh(): Promise<boolean> {\n const adapter = getRegisteredAuthAdapter()\n if (!adapter?.refresh) return Promise.resolve(false)\n let inFlightRefresh = getInFlightRefresh()\n if (!inFlightRefresh) {\n // Wrap in `Promise.resolve` so an adapter that throws synchronously\n // (rather than rejecting) still funnels through the shared slot and\n // clears it. A rejected refresh is treated as \"could not refresh\".\n inFlightRefresh = Promise.resolve()\n .then(() => adapter.refresh!())\n .catch(() => false)\n .finally(() => {\n setInFlightRefresh(null)\n })\n setInFlightRefresh(inFlightRefresh)\n }\n return inFlightRefresh\n}\n\n/**\n * Core fetch path: merge proxy-auth + adapter headers, issue the request,\n * and — on a `401` with a refresh-capable adapter — refresh once and retry\n * the identical request a single time. Mirrors the openframe `apiClient`'s\n * refresh-then-retry contract (`isRetry` guards against infinite loops).\n */\nasync function fetchWithRefresh(\n url: string,\n init: RequestInit,\n baseHeaders: Record<string, string>,\n isRetry: boolean,\n): Promise<Response> {\n // Re-run the merge each attempt: `applyProxyAuth` reads the latest stored\n // proxy creds and `getHeaders()` reads the latest bearer, so a retry after\n // refresh carries the rotated token rather than the stale one. `{...baseHeaders}`\n // keeps the caller's normalized headers immutable across attempts.\n const { url: authedUrl, headers } = applyProxyAuth(url, { ...baseHeaders })\n\n // Host-supplied auth adapter layer. Runs AFTER the proxy-auth merge so\n // adapter headers override both caller and proxy values — the adapter\n // is the host's explicit \"this is my auth model\" override, intentionally\n // last-writer-wins. When no adapter is registered, this is a zero-cost\n // no-op (object spread of `{}`).\n const adapter = getRegisteredAuthAdapter()\n if (adapter?.getHeaders) {\n // Filter `undefined` values — the adapter type allows them so consumers\n // don't have to narrow `{ Authorization: token ? '…' : undefined }`-shaped\n // returns, but `fetch` headers must be strings.\n for (const [k, v] of Object.entries(adapter.getHeaders())) {\n if (v !== undefined) headers[k] = v\n }\n }\n const credentials = adapter?.credentials ?? init.credentials ?? 'same-origin'\n\n const response = await fetch(authedUrl, {\n ...init,\n headers,\n // Default `same-origin` carries Supabase cookies for the MPH proxy-\n // auth model. Hosts on different origins (openframe-frontend ↔\n // openframe gateway) register `credentials: 'include'` via the\n // adapter to make their own cookies travel cross-origin (CORS +\n // `SameSite=None` must be configured server-side for that to work).\n credentials,\n })\n\n // 401 self-heal: refresh the token once and retry. Only when an adapter\n // opted into `refresh`, and only on the first attempt — a 401 on the\n // retry means the fresh token is also unauthorized, so surface it.\n if (response.status === 401 && !isRetry && adapter?.refresh) {\n const refreshed = await dedupedRefresh()\n if (refreshed) {\n return fetchWithRefresh(url, init, baseHeaders, true)\n }\n }\n\n return response\n}\n\n/**\n * Reject any URL that resolves to a cross-origin destination or to a\n * non-http(s) scheme. Every input is resolved against\n * `window.location.href` so the same rule covers path-only\n * (`/api/...`), absolute (`https://...`), protocol-relative\n * (`//host/...`), AND whitespace-prefixed forms (`\\t//evil.com/...`) —\n * the WHATWG fetch spec strips leading ASCII whitespace before\n * parsing, so any regex-based \"skip relative\" shortcut is bypassable\n * with a leading `\\t`/`\\n`/`\\r`/space. We resolve unconditionally\n * instead and compare origins.\n *\n * Also blocks `javascript:` / `data:` / `blob:` etc. — only `http(s):`\n * is allowed. This is explicit allowlisting rather than relying on\n * `origin === 'null'` to fall out wrong.\n *\n * Server-side rendering: when `typeof window === 'undefined'` we skip\n * the check — the bearer comes from sessionStorage which doesn't exist\n * on the server, so there's nothing to leak anyway.\n */\nfunction assertSameOrigin(url: string): void {\n if (typeof window === 'undefined') return\n let target: URL\n let pageOrigin: string\n try {\n target = new URL(url, window.location.href)\n // Derive the page origin from `href` rather than reading\n // `window.location.origin` directly so the check works in test\n // environments that mock `window.location` to a plain object\n // without an `origin` field (jsdom setups do this).\n pageOrigin = new URL(window.location.href).origin\n } catch {\n throw new Error(`embedAuthedFetch: refusing to fetch malformed URL (${JSON.stringify(url)})`)\n }\n if (target.protocol !== 'http:' && target.protocol !== 'https:') {\n throw new Error(\n `embedAuthedFetch: refusing non-http(s) URL (${target.protocol}) — pass a relative /api/* path instead`,\n )\n }\n if (target.origin !== pageOrigin) {\n // Host-sanctioned cross-origin target (`EmbedAuthAdapter.allowedOrigins`).\n // Native shells serve the page from a local pseudo-origin with no server\n // behind it, so their gateway — the same backend the host already trusts\n // with this bearer — is unavoidably cross-origin. Exact `URL.origin`\n // match, and only reachable for http(s) targets (checked above).\n if (getRegisteredAuthAdapter()?.allowedOrigins?.includes(target.origin)) {\n return\n }\n // Dev-mode escape hatch — embedded apps (e.g. openframe-frontend)\n // run on a different origin from their gateway during local dev,\n // and forcing a Next.js `rewrites()` workaround is more error-prone\n // than relaxing the guard for the dev build. In production\n // (`NODE_ENV === 'production'`) the guard stays absolute — same\n // defense-in-depth bearer-leak protection as before. The check is\n // baked at build time by Next/webpack/Turbopack so prod bundles\n // contain only the throwing branch (no dev string in the artifact).\n if (process.env.NODE_ENV !== 'production') {\n console.warn(\n `[embedAuthedFetch] cross-origin fetch to ${target.origin} ` +\n `allowed in dev (NODE_ENV !== 'production'). Production builds ` +\n `will reject this — wire a same-origin proxy before shipping.`,\n )\n return\n }\n throw new Error(\n `embedAuthedFetch: refusing cross-origin fetch to ${target.origin} — pass a relative /api/* path instead`,\n )\n }\n}\n","'use client'\n\n/**\n * `contentFetch` — the fetch the lib's SELF-FETCHING content surfaces use:\n * roadmap (list + vote + per-task refresh), delivery, product releases,\n * onboarding guides (catalog + detail), legal docs, and the release-detail\n * injected roadmap/delivery sections.\n *\n * It deliberately reuses the SINGLE embed-auth knob — the registered\n * `EmbedAuthAdapter` — instead of introducing a second registration:\n * - adapter registered (host opted into embedded auth, e.g. openframe-frontend\n * for its embedded chat) → route through `embedAuthedFetch`, so content GETs/\n * POSTs carry the SAME bearer/cookie + 401-refresh as the chat.\n * - no adapter (the public hub, or the embedding example whose proxy injects\n * auth server-side) → a plain, byte-for-byte unchanged `fetch`.\n *\n * So a host wires auth ONCE (the chat adapter) and content inherits it; there is\n * no content-specific fetcher to configure.\n */\n\nimport { embedAuthedFetch, hasEmbedAuthAdapter } from './embed-authed-fetch'\n\nexport const contentFetch: typeof fetch = (input, init) => {\n if (!hasEmbedAuthAdapter()) return fetch(input, init)\n // embedAuthedFetch takes a string url; coerce the broader `fetch` input shape\n // (content surfaces always pass strings, but handle URL/Request defensively).\n const url =\n typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url\n return embedAuthedFetch(url, init)\n}\n","'use client';\n\nimport { useCallback, useState } from 'react';\nimport { useToast } from './use-toast';\n\ninterface UseCopyToClipboardOptions {\n successTitle?: string;\n successDescription?: string;\n errorTitle?: string;\n errorDescription?: string;\n resetDelay?: number;\n}\n\nexport function useCopyToClipboard({\n successTitle = 'Copied',\n successDescription = 'Copied to clipboard',\n errorTitle = 'Copy failed',\n errorDescription = 'Could not copy to clipboard',\n resetDelay = 2000,\n}: UseCopyToClipboardOptions = {}) {\n const { toast } = useToast();\n const [copied, setCopied] = useState(false);\n\n const copy = useCallback(\n async (text: string) => {\n try {\n await navigator.clipboard.writeText(text);\n setCopied(true);\n toast({ title: successTitle, description: successDescription, variant: 'success' });\n setTimeout(() => setCopied(false), resetDelay);\n } catch {\n toast({ title: errorTitle, description: errorDescription, variant: 'destructive' });\n }\n },\n [successTitle, successDescription, errorTitle, errorDescription, resetDelay, toast],\n );\n\n return { copy, copied };\n}\n","import { useEffect, useMemo, useState, useRef } from 'react'\n\n/**\n * Configuration for batch image fetching\n */\nexport interface BatchImageFetchConfig {\n /** Base URL for tenant-specific API calls (e.g., 'https://tenant.openframe.dev' or '') */\n tenantHostUrl?: string\n /** Enable dev mode with Bearer token from localStorage */\n enableDevMode?: boolean\n /** localStorage key for access token (default: 'of_access_token') */\n accessTokenKey?: string\n}\n\n/**\n * Global configuration for batch image fetching\n * Can be set once at app initialization\n */\nlet globalBatchImageConfig: BatchImageFetchConfig = {}\n\n/**\n * Configure global settings for batch image fetching\n * Call this once in your app initialization (e.g., _app.tsx or layout.tsx)\n *\n * @example\n * ```typescript\n * // In app initialization\n * configureBatchImageFetch({\n * tenantHostUrl: process.env.NEXT_PUBLIC_TENANT_HOST_URL || '',\n * enableDevMode: process.env.NEXT_PUBLIC_ENABLE_DEV_TICKET_OBSERVER === 'true'\n * })\n * ```\n */\nexport function configureBatchImageFetch(config: BatchImageFetchConfig): void {\n globalBatchImageConfig = { ...globalBatchImageConfig, ...config }\n}\n\n/**\n * Get current batch image fetch configuration\n */\nfunction getBatchImageConfig(): Required<BatchImageFetchConfig> {\n return {\n tenantHostUrl: globalBatchImageConfig.tenantHostUrl || '',\n enableDevMode: globalBatchImageConfig.enableDevMode ?? false,\n accessTokenKey: globalBatchImageConfig.accessTokenKey || 'of_access_token'\n }\n}\n\n/**\n * Fetch multiple images with authentication in batch\n * Returns a map of original imageUrl to fetched blob URL\n *\n * @param imageUrls - Array of image URLs to fetch\n * @param config - Optional configuration override for this batch\n * @returns Promise resolving to map of original URL → blob URL\n *\n * @example\n * ```typescript\n * const images = await batchFetchAuthenticatedImages([\n * '/api/organizations/123/image',\n * '/api/organizations/456/image'\n * ])\n * // { '/api/organizations/123/image': 'blob:...', '/api/organizations/456/image': 'blob:...' }\n * ```\n */\nexport async function batchFetchAuthenticatedImages(\n imageUrls: string[],\n config?: BatchImageFetchConfig\n): Promise<Record<string, string | undefined>> {\n const results: Record<string, string | undefined> = {}\n\n if (imageUrls.length === 0) {\n return results\n }\n\n const { tenantHostUrl, enableDevMode, accessTokenKey } = {\n ...getBatchImageConfig(),\n ...config\n }\n\n const fetchPromises = imageUrls.map(async (imageUrl) => {\n try {\n // Construct full image URL\n let fullImageUrl: string\n if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {\n fullImageUrl = imageUrl\n } else if (imageUrl.startsWith('/api/')) {\n fullImageUrl = `${tenantHostUrl}${imageUrl}`\n } else if (imageUrl.startsWith('/')) {\n fullImageUrl = `${tenantHostUrl}/api${imageUrl}`\n } else {\n fullImageUrl = `${tenantHostUrl}/api/${imageUrl}`\n }\n\n // Add cache buster\n const cacheBuster = `?t=${Date.now()}`\n fullImageUrl = fullImageUrl + cacheBuster\n\n // Prepare headers\n const headers: Record<string, string> = {\n 'Accept': 'image/*',\n 'Cache-Control': 'no-cache, no-store, must-revalidate',\n 'Pragma': 'no-cache'\n }\n\n // Add Bearer token in dev mode\n if (enableDevMode) {\n try {\n const accessToken = localStorage.getItem(accessTokenKey)\n if (accessToken) {\n headers['Authorization'] = `Bearer ${accessToken}`\n }\n } catch (error) {\n // Silently continue without token\n }\n }\n\n // Fetch image\n const response = await fetch(fullImageUrl, {\n method: 'GET',\n credentials: 'include', // Include cookies for authentication\n headers\n })\n\n if (!response.ok) {\n throw new Error(`Failed to fetch image: ${response.status}`)\n }\n\n // Convert to blob URL\n const blob = await response.blob()\n const objectUrl = URL.createObjectURL(blob)\n\n return { imageUrl, fetchedUrl: objectUrl }\n } catch (error) {\n console.warn(`Failed to fetch image ${imageUrl}:`, error)\n return { imageUrl, fetchedUrl: undefined }\n }\n })\n\n const fetchResults = await Promise.all(fetchPromises)\n\n fetchResults.forEach(({ imageUrl, fetchedUrl }) => {\n results[imageUrl] = fetchedUrl\n })\n\n return results\n}\n\n/**\n * React hook to batch fetch images with authentication\n *\n * Features:\n * - Automatically deduplicates URLs\n * - Caches fetched results\n * - Only fetches new/unfetched URLs\n * - Cleans up blob URLs on unmount\n *\n * @param imageUrls - Array of image URLs (can include null/undefined)\n * @param config - Optional configuration override\n * @returns Map of original URL → fetched blob URL\n *\n * @example\n * ```typescript\n * // In a component\n * const imageUrls = useMemo(() =>\n * organizations.map(org => org.imageUrl).filter(Boolean),\n * [organizations]\n * )\n * const fetchedImages = useBatchImages(imageUrls)\n *\n * // Use in render\n * <img src={fetchedImages[org.imageUrl]} alt={org.name} />\n * ```\n */\nexport function useBatchImages(\n imageUrls: (string | null | undefined)[],\n config?: BatchImageFetchConfig\n): Record<string, string | undefined> {\n const [fetchedImages, setFetchedImages] = useState<Record<string, string | undefined>>({})\n const [loading, setLoading] = useState(false)\n\n // Deduplicate and filter out null/undefined\n const uniqueUrls = useMemo(() =>\n Array.from(new Set(imageUrls.filter((url): url is string => Boolean(url)))),\n [imageUrls]\n )\n\n // Track URLs we've already requested to avoid duplicate fetches\n const requestedUrls = useRef<Set<string>>(new Set())\n\n useEffect(() => {\n if (uniqueUrls.length === 0) {\n setFetchedImages({})\n return\n }\n\n // Find URLs we haven't fetched yet\n const urlsToFetch = uniqueUrls.filter(url => !requestedUrls.current.has(url))\n\n if (urlsToFetch.length === 0) {\n return // All URLs already requested\n }\n\n // Mark these URLs as requested\n urlsToFetch.forEach(url => requestedUrls.current.add(url))\n\n setLoading(true)\n\n batchFetchAuthenticatedImages(urlsToFetch, config)\n .then(newResults => {\n setFetchedImages(prev => ({ ...prev, ...newResults }))\n })\n .catch(error => {\n console.error('Failed to batch fetch images:', error)\n })\n .finally(() => {\n setLoading(false)\n })\n\n // Cleanup blob URLs on unmount\n return () => {\n Object.values(fetchedImages).forEach(blobUrl => {\n if (blobUrl && blobUrl.startsWith('blob:')) {\n URL.revokeObjectURL(blobUrl)\n }\n })\n }\n }, [uniqueUrls, config])\n\n return fetchedImages\n}\n","import { useEffect, useState, useRef } from 'react'\n\n/**\n * Configuration for single image fetching\n * Uses same config as batch image fetching for consistency\n */\nexport interface AuthenticatedImageConfig {\n /** Base URL for tenant-specific API calls (e.g., 'https://tenant.openframe.dev' or '') */\n tenantHostUrl?: string\n /** Enable dev mode with Bearer token from localStorage */\n enableDevMode?: boolean\n /** localStorage key for access token (default: 'of_access_token') */\n accessTokenKey?: string\n}\n\n/**\n * Global configuration for authenticated image fetching\n * Shared with useBatchImages for consistency\n */\nlet globalImageConfig: AuthenticatedImageConfig = {}\n\n/**\n * Global cache for authenticated images\n * Stores blob URLs by cache key\n */\ninterface ImageCacheEntry {\n blobUrl: string\n timestamp: number\n refCount: number\n}\n\nconst imageCache = new Map<string, ImageCacheEntry>()\nconst pendingRequests = new Map<string, Promise<string | undefined>>()\n\n/**\n * Cache cleanup interval (5 minutes)\n */\nconst CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000\n\n/**\n * Cache entry max age (30 minutes)\n */\nconst CACHE_MAX_AGE = 30 * 60 * 1000\n\n/**\n * Clean up expired cache entries\n */\nfunction cleanupImageCache() {\n const now = Date.now()\n for (const [key, entry] of imageCache.entries()) {\n if (entry.refCount === 0 && now - entry.timestamp > CACHE_MAX_AGE) {\n URL.revokeObjectURL(entry.blobUrl)\n imageCache.delete(key)\n }\n }\n}\n\n/**\n * Periodic cache cleanup\n */\nif (typeof window !== 'undefined') {\n setInterval(cleanupImageCache, CACHE_CLEANUP_INTERVAL)\n}\n\n/**\n * Configure global settings for authenticated image fetching\n * Call this once in your app initialization (e.g., _app.tsx or layout.tsx)\n *\n * Note: This uses the same configuration as useBatchImages. If you've already\n * called configureBatchImageFetch(), you don't need to call this separately.\n *\n * @example\n * ```typescript\n * // In app initialization\n * configureAuthenticatedImage({\n * tenantHostUrl: process.env.NEXT_PUBLIC_TENANT_HOST_URL || '',\n * enableDevMode: process.env.NEXT_PUBLIC_ENABLE_DEV_TICKET_OBSERVER === 'true'\n * })\n * ```\n */\nexport function configureAuthenticatedImage(config: AuthenticatedImageConfig): void {\n globalImageConfig = { ...globalImageConfig, ...config }\n}\n\n/**\n * Get current authenticated image configuration\n */\nfunction getImageConfig(): Required<AuthenticatedImageConfig> {\n return {\n tenantHostUrl: globalImageConfig.tenantHostUrl || '',\n enableDevMode: globalImageConfig.enableDevMode ?? false,\n accessTokenKey: globalImageConfig.accessTokenKey || 'of_access_token'\n }\n}\n\n/**\n * React hook to fetch a single image with authentication\n *\n * Features:\n * - Fetches image with cookie authentication\n * - Optional Bearer token in dev mode\n * - Converts to blob URL for img src\n * - Automatic cleanup of blob URLs\n * - Cache-busting with refreshKey\n * - Loading and error states\n * - **Global caching** - Prevents duplicate requests for identical URLs\n * - **Automatic deduplication** - Multiple components using same URL share cached result\n * - **Reference counting** - Cached blobs cleaned up when no longer used\n *\n * @param imageUrl - The image URL to fetch (null/undefined = no fetch)\n * @param refreshKey - Optional key to force re-fetch (e.g., version number, timestamp)\n * @param config - Optional configuration override\n * @returns Object with imageUrl (blob), isLoading, and error\n *\n * @example\n * ```typescript\n * // Basic usage\n * const { imageUrl, isLoading, error } = useAuthenticatedImage(\n * organization?.imageUrl\n * )\n *\n * // With refresh key (e.g., after upload)\n * const { imageUrl } = useAuthenticatedImage(\n * organization?.imageUrl,\n * organization?.imageVersion // Timestamp or version number\n * )\n *\n * // In render\n * {imageUrl && <img src={imageUrl} alt=\"Organization\" />}\n * ```\n */\nexport function useAuthenticatedImage(\n imageUrl?: string | null,\n refreshKey?: string | number,\n config?: AuthenticatedImageConfig\n): {\n imageUrl: string | undefined\n isLoading: boolean\n error: string | null\n} {\n const [fetchedImageUrl, setFetchedImageUrl] = useState<string | undefined>()\n const [isLoading, setIsLoading] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const currentCacheKeyRef = useRef<string | null>(null)\n\n useEffect(() => {\n if (!imageUrl) {\n setFetchedImageUrl(undefined)\n setIsLoading(false)\n setError(null)\n \n if (currentCacheKeyRef.current) {\n const entry = imageCache.get(currentCacheKeyRef.current)\n if (entry) {\n entry.refCount--\n }\n currentCacheKeyRef.current = null\n }\n return\n }\n\n const { tenantHostUrl, enableDevMode, accessTokenKey } = {\n ...getImageConfig(),\n ...config\n }\n\n // Construct full image URL\n let fullImageUrl: string\n if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {\n fullImageUrl = imageUrl\n } else if (imageUrl.startsWith('/api/')) {\n fullImageUrl = `${tenantHostUrl}${imageUrl}`\n } else if (imageUrl.startsWith('/')) {\n fullImageUrl = `${tenantHostUrl}/api${imageUrl}`\n } else {\n fullImageUrl = `${tenantHostUrl}/api/${imageUrl}`\n }\n\n // Create cache key (use refreshKey if provided, otherwise no cache buster for caching)\n const cacheKey = refreshKey ? `${fullImageUrl}?v=${refreshKey}` : fullImageUrl\n \n if (currentCacheKeyRef.current && currentCacheKeyRef.current !== cacheKey) {\n const prevEntry = imageCache.get(currentCacheKeyRef.current)\n if (prevEntry) {\n prevEntry.refCount--\n }\n }\n \n currentCacheKeyRef.current = cacheKey\n\n const cachedEntry = imageCache.get(cacheKey)\n if (cachedEntry) {\n cachedEntry.refCount++\n cachedEntry.timestamp = Date.now()\n \n setFetchedImageUrl(cachedEntry.blobUrl)\n setIsLoading(false)\n setError(null)\n return\n }\n\n const pendingRequest = pendingRequests.get(cacheKey)\n if (pendingRequest) {\n setIsLoading(true)\n setError(null)\n \n pendingRequest\n .then(blobUrl => {\n if (blobUrl) {\n const entry = imageCache.get(cacheKey)\n if (entry) {\n entry.refCount++\n setFetchedImageUrl(blobUrl)\n }\n }\n setIsLoading(false)\n })\n .catch(err => {\n setError(err instanceof Error ? err.message : 'Failed to fetch image')\n setIsLoading(false)\n })\n return\n }\n\n setIsLoading(true)\n setError(null)\n\n const requestUrl = refreshKey ? cacheKey : `${fullImageUrl}?t=${Date.now()}`\n\n // Prepare headers\n const headers: Record<string, string> = {\n 'Accept': 'image/*',\n 'Cache-Control': 'no-cache, no-store, must-revalidate',\n 'Pragma': 'no-cache'\n }\n\n // Add Bearer token in dev mode\n if (enableDevMode) {\n try {\n const accessToken = localStorage.getItem(accessTokenKey)\n if (accessToken) {\n headers['Authorization'] = `Bearer ${accessToken}`\n }\n } catch (error) {\n // Silently continue without token\n }\n }\n\n const fetchPromise = fetch(requestUrl, {\n method: 'GET',\n credentials: 'include', // Include cookies for authentication\n headers\n })\n .then(response => {\n if (!response.ok) {\n throw new Error(`Failed to fetch image: ${response.status}`)\n }\n return response.blob()\n })\n .then(blob => {\n const objectUrl = URL.createObjectURL(blob)\n \n imageCache.set(cacheKey, {\n blobUrl: objectUrl,\n timestamp: Date.now(),\n refCount: 1\n })\n \n setFetchedImageUrl(objectUrl)\n setIsLoading(false)\n return objectUrl\n })\n .catch(err => {\n setError(err instanceof Error ? err.message : 'Failed to fetch image')\n setFetchedImageUrl(undefined)\n setIsLoading(false)\n throw err\n })\n .finally(() => {\n pendingRequests.delete(cacheKey)\n })\n\n pendingRequests.set(cacheKey, fetchPromise)\n\n }, [imageUrl, refreshKey, config])\n\n useEffect(() => {\n return () => {\n if (currentCacheKeyRef.current) {\n const entry = imageCache.get(currentCacheKeyRef.current)\n if (entry) {\n entry.refCount--\n }\n }\n }\n }, [])\n\n return { imageUrl: fetchedImageUrl, isLoading, error }\n}\n","'use client'\n\nimport { useEffect, useReducer } from 'react'\nimport { embedAuthedFetch, needsBearerAssetFetch } from '../utils/embed-authed-fetch'\n\n/**\n * Resolve ANY asset URL the browser would load natively — `<img src>`,\n * `<track src>`, a font, a poster — for hosts whose auth rides in request\n * HEADERS instead of cookies: native shells (`capacitor://`, `tauri://`) and\n * dev-ticket web, both in bearer mode. A native subresource load can't carry\n * an `Authorization` header, so a gateway URL that works on the cookie-auth\n * web 401s there.\n *\n * When `needsBearerAssetFetch` says the URL is bearer-authed, this hook\n * fetches it through `embedAuthedFetch` (bearer + deduped 401-refresh-retry +\n * the adapter's cross-origin guard — the SAME single auth knob every embedded\n * `fetch` in the lib rides) and returns a blob object-URL. Every other URL is\n * returned unchanged, so on the cookie-auth web the hook is a pass-through.\n *\n * Returns `undefined` while the fetch is in flight OR after it fails, so\n * callers show their existing placeholder / \"no captions\" branch instead of a\n * broken asset; a failed fetch retries on the next mount.\n *\n * Cache: module-level, session-lifetime, keyed by full URL — so the two\n * `<track>` URLs of one player, or the same avatar in twenty rows, cost one\n * request. Gateway URLs carry a `?v=<content-hash>` cache-buster, so changed\n * content is a new key; entries are never revoked (a VTT / avatar is a few KB\n * — refcount churn isn't worth it) and are dropped wholesale at session end\n * via `clearAuthedAssetCache`.\n */\n\nconst resolvedCache = new Map<string, string>()\nconst inFlight = new Map<string, Promise<void>>()\nlet cacheGeneration = 0\n\n/**\n * Drop every cached blob and revoke its object URL. Hosts call this at\n * session end (logout / forced re-login) so a follow-on login as a\n * different identity cannot be served blobs fetched under the previous\n * one's bearer. Fetches still in flight when the clear happens are fenced\n * by a generation counter — their results are revoked instead of cached.\n */\nexport function clearAuthedAssetCache(): void {\n cacheGeneration++\n for (const url of resolvedCache.values()) URL.revokeObjectURL(url)\n resolvedCache.clear()\n inFlight.clear()\n}\n\nfunction fetchAsBlobUrl(src: string, accept: string): Promise<void> {\n let pending = inFlight.get(src)\n if (!pending) {\n const startedGeneration = cacheGeneration\n pending = embedAuthedFetch(src, { headers: { Accept: accept } })\n .then(async response => {\n if (!response.ok) throw new Error(`asset fetch failed: ${response.status}`)\n const blobUrl = URL.createObjectURL(await response.blob())\n if (startedGeneration === cacheGeneration) {\n resolvedCache.set(src, blobUrl)\n } else {\n URL.revokeObjectURL(blobUrl)\n }\n })\n .finally(() => {\n if (inFlight.get(src) === pending) inFlight.delete(src)\n })\n inFlight.set(src, pending)\n }\n return pending\n}\n\n/**\n * @param src the asset URL (relative or absolute), or null/undefined\n * @param accept `Accept` header for the authed fetch — narrow it to what the\n * consumer renders (`image/*`, `text/vtt`); it also replaces\n * `embedAuthedFetch`'s default JSON `Content-Type` on the GET.\n */\nexport function useAuthedAssetSrc(src?: string | null, accept = '*/*'): string | undefined {\n const bearerSrc = src && needsBearerAssetFetch(src) ? src : null\n const [, rerender] = useReducer((c: number) => c + 1, 0)\n\n useEffect(() => {\n if (!bearerSrc || resolvedCache.has(bearerSrc)) return\n let cancelled = false\n fetchAsBlobUrl(bearerSrc, accept)\n .catch(() => {})\n .finally(() => {\n if (!cancelled) rerender()\n })\n return () => {\n cancelled = true\n }\n }, [bearerSrc, accept])\n\n if (!src) return undefined\n if (!bearerSrc) return src\n return resolvedCache.get(bearerSrc)\n}\n","'use client'\n\nimport { clearAuthedAssetCache, useAuthedAssetSrc } from './use-authed-asset-src'\n\n/**\n * Image flavor of `useAuthedAssetSrc` — see that hook for the full contract.\n * Kept as its own export because it is the lib's most-used consumer (avatars,\n * entity images, markdown `<img>`) and because hosts already import this name.\n *\n * Distinct from the legacy `useAuthenticatedImage`, which ALWAYS blob-fetches\n * with `credentials: 'include'` and its own global config. This hook is\n * pass-through-first and keys off the single auth knob the lib already has:\n * the registered `EmbedAuthAdapter`.\n */\nexport function useAuthedImageSrc(src?: string | null): string | undefined {\n return useAuthedAssetSrc(src, 'image/*')\n}\n\n/**\n * Session-end cache drop. Now an alias of `clearAuthedAssetCache` — one cache\n * backs every authed asset (images AND caption tracks), so hosts keep calling\n * this single function on logout / forced re-login.\n */\nexport const clearAuthedImageCache = clearAuthedAssetCache\n","/**\n * useQueryParams Hook - GraphQL Integration for URL State Management\n *\n * Automatically generates URL state management from GraphQL queries.\n * Parses query AST at runtime, flattens nested input types, and syncs with URL.\n *\n * @example\n * const LOGS_QUERY = gql`\n * query GetLogs($search: String, $filter: LogFilterInput) { ... }\n * `\n *\n * const { variables, setParam } = useQueryParams(LOGS_QUERY)\n * const { data } = useQuery(LOGS_QUERY, { variables })\n *\n * // URL: /logs?search=error&severity=critical\n * // variables: { search: 'error', filter: { severity: ['critical'] } }\n */\n\n'use client'\n\nimport { useEffect, useState, useMemo, useCallback } from 'react'\nimport { useRouter, useSearchParams } from '../../embed-shims/next-navigation'\nimport { DocumentNode } from 'graphql'\n\nimport { extractVariablesFromQuery } from './graphql-parser'\nimport { flattenQueryVariables, mergeDefaults, validateSchema, FlattenedParam } from './flatten-schema'\nimport { urlParamsToVariables, variablesToUrlParams, mergeVariables, clearParams } from './url-converter'\nimport { introspector } from './introspection'\n\n/**\n * Options for useQueryParams hook\n */\nexport interface UseQueryParamsOptions {\n /** Default values for parameters */\n defaultValues?: Record<string, any>\n\n /** GraphQL endpoint for introspection (defaults to process.env.NEXT_PUBLIC_API_URL/graphql) */\n introspectionEndpoint?: string\n\n /** HTTP headers for introspection (e.g., authentication) */\n introspectionHeaders?: Record<string, string>\n\n /** Skip introspection (use only AST parsing, no nested type flattening) */\n skipIntrospection?: boolean\n\n /** Custom parameter name mapping (override auto-generated names) */\n paramMapping?: Record<string, string>\n\n /** Enable debug logging */\n debug?: boolean\n}\n\n/**\n * Return type for useQueryParams hook\n */\nexport interface UseQueryParamsReturn<TVariables = Record<string, any>> {\n /** GraphQL variables ready for Apollo Client */\n variables: TVariables\n\n /** Raw URL parameters (before conversion to variables) */\n params: Record<string, any>\n\n /** Flattened parameter schema */\n schema: Record<string, FlattenedParam>\n\n /** Set a single parameter */\n setParam: (key: string, value: any) => void\n\n /** Set multiple parameters at once */\n setParams: (params: Record<string, any>) => void\n\n /** Clear specific parameters */\n clearParams: (keys: string[]) => void\n\n /** Reset all parameters (clear URL) */\n resetParams: () => void\n\n /** Whether schema is ready (introspection complete) */\n isReady: boolean\n\n /** Loading state during initialization */\n isLoading: boolean\n\n /** Error during initialization */\n error: Error | null\n}\n\n/**\n * useQueryParams - Auto-generate URL state from GraphQL query\n *\n * This hook:\n * 1. Parses GraphQL query AST to extract variable definitions\n * 2. Fetches GraphQL schema via introspection (optional, cached)\n * 3. Flattens nested input types to simple URL parameters\n * 4. Syncs URL ↔ GraphQL variables bidirectionally\n * 5. Provides type-safe parameter updates\n *\n * @param query - GraphQL DocumentNode (from gql`` template tag)\n * @param options - Configuration options\n * @returns Hook API for managing URL state\n */\nexport function useQueryParams<TVariables = Record<string, any>>(\n query: DocumentNode,\n options: UseQueryParamsOptions = {}\n): UseQueryParamsReturn<TVariables> {\n const router = useRouter()\n const searchParams = useSearchParams()\n\n const [isLoading, setIsLoading] = useState(true)\n const [isReady, setIsReady] = useState(false)\n const [error, setError] = useState<Error | null>(null)\n const [schema, setSchema] = useState<Record<string, FlattenedParam>>({})\n\n // Extract default values\n const defaultValues = options.defaultValues || {}\n const skipIntrospection = options.skipIntrospection || false\n const debug = options.debug || false\n\n // Initialize: Parse query + fetch schema (once)\n useEffect(() => {\n async function initialize() {\n try {\n if (debug) console.log('[useQueryParams] Initializing...')\n\n // 1. Extract variables from query AST\n const queryVariables = extractVariablesFromQuery(query)\n\n if (debug) {\n console.log('[useQueryParams] Extracted variables:', queryVariables)\n }\n\n // 2. Fetch GraphQL schema via introspection (if needed and not skipped)\n if (!skipIntrospection && !introspector.isLoaded()) {\n const endpoint = options.introspectionEndpoint ||\n (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_API_URL\n ? `${process.env.NEXT_PUBLIC_API_URL}/graphql`\n : '')\n\n if (endpoint) {\n try {\n await introspector.fetchSchema(endpoint, options.introspectionHeaders)\n if (debug) console.log('[useQueryParams] Introspection complete')\n } catch (err) {\n console.warn('[useQueryParams] Introspection failed, continuing without it:', err)\n // Continue without introspection - nested types won't be flattened\n }\n }\n }\n\n // 3. Flatten schema (with or without introspection)\n let flattenedSchema = await flattenQueryVariables(queryVariables, introspector)\n\n // Apply custom param mapping if provided\n if (options.paramMapping) {\n flattenedSchema = applyParamMapping(flattenedSchema, options.paramMapping)\n }\n\n // Merge default values\n flattenedSchema = mergeDefaults(flattenedSchema, defaultValues)\n\n // Validate schema\n validateSchema(flattenedSchema)\n\n if (debug) {\n console.log('[useQueryParams] Flattened schema:', flattenedSchema)\n }\n\n setSchema(flattenedSchema)\n setIsReady(true)\n } catch (err) {\n const error = err instanceof Error ? err : new Error(String(err))\n console.error('[useQueryParams] Initialization failed:', error)\n setError(error)\n } finally {\n setIsLoading(false)\n }\n }\n\n initialize()\n }, [query, options.introspectionEndpoint, skipIntrospection, debug])\n\n // Convert URL params to GraphQL variables\n const variables = useMemo(() => {\n if (!isReady) {\n return defaultValues as TVariables\n }\n\n try {\n const varsFromUrl = urlParamsToVariables(searchParams, schema)\n const merged = { ...defaultValues, ...varsFromUrl }\n\n if (debug) {\n console.log('[useQueryParams] Variables from URL:', merged)\n }\n\n return merged as TVariables\n } catch (err) {\n console.error('[useQueryParams] Failed to convert URL to variables:', err)\n return defaultValues as TVariables\n }\n }, [searchParams, schema, isReady, defaultValues, debug])\n\n // Raw URL params (before conversion)\n const params = useMemo(() => {\n const result: Record<string, any> = {}\n searchParams.forEach((value, key) => {\n if (result[key]) {\n // Multiple values - convert to array\n if (!Array.isArray(result[key])) {\n result[key] = [result[key]]\n }\n result[key].push(value)\n } else {\n result[key] = value\n }\n })\n return result\n }, [searchParams])\n\n // Update URL with new parameters\n const updateUrl = useCallback((newParams: URLSearchParams) => {\n const url = newParams.toString() ? `?${newParams.toString()}` : window.location.pathname\n\n if (debug) {\n console.log('[useQueryParams] Updating URL:', url)\n }\n\n // Use replace for shallow routing (no page reload, no history spam)\n router.replace(url, { scroll: false })\n }, [router, debug])\n\n // Set a single parameter\n const setParam = useCallback((key: string, value: any) => {\n if (!isReady) {\n console.warn('[useQueryParams] Schema not ready, cannot set param')\n return\n }\n\n try {\n const currentVars = variables\n const updated = mergeVariables(currentVars as Record<string, any>, { [key]: value }, schema)\n const newParams = variablesToUrlParams(updated, schema)\n updateUrl(newParams)\n } catch (err) {\n console.error('[useQueryParams] Failed to set param:', err)\n }\n }, [variables, schema, isReady, updateUrl])\n\n // Set multiple parameters\n const setParams = useCallback((updates: Record<string, any>) => {\n if (!isReady) {\n console.warn('[useQueryParams] Schema not ready, cannot set params')\n return\n }\n\n try {\n const currentVars = variables\n const updated = mergeVariables(currentVars as Record<string, any>, updates, schema)\n const newParams = variablesToUrlParams(updated, schema)\n updateUrl(newParams)\n } catch (err) {\n console.error('[useQueryParams] Failed to set params:', err)\n }\n }, [variables, schema, isReady, updateUrl])\n\n // Clear specific parameters\n const clearParamsHandler = useCallback((keys: string[]) => {\n if (!isReady) {\n console.warn('[useQueryParams] Schema not ready, cannot clear params')\n return\n }\n\n try {\n const currentVars = variables\n const updated = clearParams(currentVars as Record<string, any>, keys, schema)\n const newParams = variablesToUrlParams(updated, schema)\n updateUrl(newParams)\n } catch (err) {\n console.error('[useQueryParams] Failed to clear params:', err)\n }\n }, [variables, schema, isReady, updateUrl])\n\n // Reset all parameters\n const resetParams = useCallback(() => {\n if (debug) {\n console.log('[useQueryParams] Resetting params')\n }\n\n router.replace(window.location.pathname, { scroll: false })\n }, [router, debug])\n\n return {\n variables,\n params,\n schema,\n setParam,\n setParams,\n clearParams: clearParamsHandler,\n resetParams,\n isReady,\n isLoading,\n error\n }\n}\n\n/**\n * Apply custom parameter name mapping to schema\n */\nfunction applyParamMapping(\n schema: Record<string, FlattenedParam>,\n mapping: Record<string, string>\n): Record<string, FlattenedParam> {\n const mapped: Record<string, FlattenedParam> = {}\n\n for (const [key, param] of Object.entries(schema)) {\n const newKey = mapping[key] || key\n mapped[newKey] = {\n ...param,\n urlParamName: mapping[key] || param.urlParamName\n }\n }\n\n return mapped\n}\n","/**\n * GraphQL AST Parser for URL State Management\n *\n * Extracts variable definitions from GraphQL DocumentNode at runtime\n * to automatically generate URL parameter handling.\n */\n\nimport {\n DocumentNode,\n VariableDefinitionNode,\n TypeNode,\n NamedTypeNode,\n ListTypeNode,\n NonNullTypeNode,\n visit\n} from 'graphql'\n\n/**\n * JavaScript type that can be represented in URL parameters\n */\nexport type JSType = 'string' | 'number' | 'boolean' | 'array' | 'object'\n\n/**\n * Variable definition extracted from GraphQL query\n */\nexport interface VariableDefinition {\n /** Variable name (e.g., \"search\", \"filter\") */\n name: string\n /** JavaScript type for URL parameter handling */\n type: JSType\n /** Whether the variable is required (non-null in GraphQL) */\n required: boolean\n /** Whether the variable is an array/list */\n isArray: boolean\n /** Original GraphQL type name (e.g., \"String\", \"LogFilterInput\") */\n graphqlTypeName: string\n}\n\n/**\n * Parsed type information from GraphQL TypeNode\n */\ninterface ParsedType {\n typeName: string\n isNonNull: boolean\n isList: boolean\n}\n\n/**\n * Extract all variable definitions from a GraphQL query\n *\n * @param query - GraphQL DocumentNode (from gql template tag)\n * @returns Record of variable definitions keyed by variable name\n *\n * @example\n * const LOGS_QUERY = gql`\n * query GetLogs($search: String, $filter: LogFilterInput) { ... }\n * `\n *\n * const variables = extractVariablesFromQuery(LOGS_QUERY)\n * // {\n * // search: { name: 'search', type: 'string', ... },\n * // filter: { name: 'filter', type: 'object', graphqlTypeName: 'LogFilterInput' }\n * // }\n */\nexport function extractVariablesFromQuery(\n query: DocumentNode\n): Record<string, VariableDefinition> {\n const variables: Record<string, VariableDefinition> = {}\n\n visit(query, {\n VariableDefinition(node: VariableDefinitionNode) {\n const name = node.variable.name.value\n const typeInfo = parseGraphQLType(node.type)\n\n variables[name] = {\n name,\n type: mapGraphQLTypeToJS(typeInfo.typeName),\n required: typeInfo.isNonNull,\n isArray: typeInfo.isList,\n graphqlTypeName: typeInfo.typeName\n }\n }\n })\n\n return variables\n}\n\n/**\n * Parse GraphQL TypeNode to extract type information\n * Handles NonNullType, ListType, and NamedType recursively\n *\n * @param typeNode - GraphQL type node from AST\n * @returns Parsed type information\n */\nfunction parseGraphQLType(typeNode: TypeNode): ParsedType {\n let typeName = ''\n let isNonNull = false\n let isList = false\n\n // Unwrap NonNullType wrapper\n if (typeNode.kind === 'NonNullType') {\n isNonNull = true\n typeNode = typeNode.type\n }\n\n // Handle ListType\n if (typeNode.kind === 'ListType') {\n isList = true\n typeNode = typeNode.type\n\n // ListType can also be wrapped in NonNullType\n if (typeNode.kind === 'NonNullType') {\n typeNode = typeNode.type\n }\n }\n\n // Extract the base type name\n if (typeNode.kind === 'NamedType') {\n typeName = typeNode.name.value\n }\n\n return {\n typeName,\n isNonNull,\n isList\n }\n}\n\n/**\n * Map GraphQL scalar/input types to JavaScript types\n *\n * @param graphqlType - GraphQL type name (e.g., \"String\", \"Int\", \"LogFilterInput\")\n * @returns JavaScript type for URL parameter handling\n */\nfunction mapGraphQLTypeToJS(graphqlType: string): JSType {\n // GraphQL scalar types\n const scalarTypeMap: Record<string, JSType> = {\n 'String': 'string',\n 'Int': 'number',\n 'Float': 'number',\n 'Boolean': 'boolean',\n 'ID': 'string'\n }\n\n // Check if it's a known scalar\n if (scalarTypeMap[graphqlType]) {\n return scalarTypeMap[graphqlType]\n }\n\n // Unknown types are assumed to be input objects\n // These will be flattened using introspection\n return 'object'\n}\n\n/**\n * Check if a GraphQL type is a scalar type\n */\nexport function isScalarType(graphqlTypeName: string): boolean {\n const scalars = ['String', 'Int', 'Float', 'Boolean', 'ID']\n return scalars.includes(graphqlTypeName)\n}\n\n/**\n * Check if a GraphQL type is an input object type\n */\nexport function isInputObjectType(graphqlTypeName: string): boolean {\n return !isScalarType(graphqlTypeName)\n}\n","/**\n * Schema Flattening Utilities for URL State Management\n *\n * Converts nested GraphQL input types to flat URL parameter mappings.\n * Example: { filter: { severity: [...] } } → URL params: ?severity=...\n */\n\nimport { VariableDefinition, JSType } from './graphql-parser'\nimport { GraphQLIntrospector } from './introspection'\n\n/**\n * Flattened URL parameter configuration\n *\n * Maps URL parameter names to their GraphQL variable paths\n */\nexport interface FlattenedParam {\n /** URL parameter name (e.g., \"severity\") */\n urlParamName: string\n /** Path in GraphQL variables (e.g., \"filter.severity\") */\n graphqlPath: string\n /** JavaScript type for URL handling */\n type: JSType\n /** Default value for this parameter */\n defaultValue?: any\n /** Whether this parameter is required */\n required?: boolean\n /** Whether this parameter is an array */\n isArray?: boolean\n}\n\n/**\n * Flatten GraphQL query variables to URL parameter schema\n *\n * Takes top-level query variables and flattens nested input objects\n * to create a simple URL parameter mapping.\n *\n * @param queryVariables - Variables extracted from GraphQL query\n * @param introspector - Introspector instance with loaded schema\n * @returns Flattened parameter schema for URL handling\n *\n * @example\n * Input variables:\n * {\n * search: { type: 'string', ... },\n * filter: { type: 'object', graphqlTypeName: 'LogFilterInput' },\n * cursor: { type: 'string', ... }\n * }\n *\n * Output schema:\n * {\n * search: { urlParamName: 'search', graphqlPath: 'search', type: 'string' },\n * severity: { urlParamName: 'severity', graphqlPath: 'filter.severity', type: 'array' },\n * toolType: { urlParamName: 'toolType', graphqlPath: 'filter.toolType', type: 'array' },\n * cursor: { urlParamName: 'cursor', graphqlPath: 'cursor', type: 'string' }\n * }\n *\n * URL: ?search=error&severity=critical&toolType=rmm&cursor=abc\n */\nexport async function flattenQueryVariables(\n queryVariables: Record<string, VariableDefinition>,\n introspector: GraphQLIntrospector\n): Promise<Record<string, FlattenedParam>> {\n const flattened: Record<string, FlattenedParam> = {}\n\n for (const [varName, varDef] of Object.entries(queryVariables)) {\n // Primitive types or arrays - keep at top level\n if (varDef.type !== 'object') {\n flattened[varName] = {\n urlParamName: varName,\n graphqlPath: varName,\n type: varDef.isArray ? 'array' : varDef.type,\n required: varDef.required,\n isArray: varDef.isArray\n }\n continue\n }\n\n // Input object types - flatten fields to top level\n // This requires introspection to know the input type's fields\n if (introspector.isLoaded() && introspector.hasType(varDef.graphqlTypeName)) {\n const fields = introspector.getInputTypeFields(varDef.graphqlTypeName)\n\n for (const [fieldName, fieldDef] of Object.entries(fields)) {\n flattened[fieldName] = {\n urlParamName: fieldName,\n graphqlPath: `${varName}.${fieldName}`,\n type: fieldDef.isArray ? 'array' : fieldDef.type,\n required: fieldDef.required,\n isArray: fieldDef.isArray\n }\n }\n } else {\n // Introspection not available or type not found\n // Keep as top-level object (will need manual handling)\n flattened[varName] = {\n urlParamName: varName,\n graphqlPath: varName,\n type: 'object',\n required: varDef.required,\n isArray: false\n }\n }\n }\n\n return flattened\n}\n\n/**\n * Merge default values into flattened schema\n *\n * @param schema - Flattened parameter schema\n * @param defaults - Default values keyed by URL param name\n * @returns Updated schema with default values\n */\nexport function mergeDefaults(\n schema: Record<string, FlattenedParam>,\n defaults: Record<string, any>\n): Record<string, FlattenedParam> {\n const merged: Record<string, FlattenedParam> = {}\n\n for (const [key, param] of Object.entries(schema)) {\n merged[key] = {\n ...param,\n defaultValue: defaults[key] !== undefined ? defaults[key] : param.defaultValue\n }\n }\n\n return merged\n}\n\n/**\n * Validate that flattened schema has no conflicts\n *\n * Ensures that no two parameters map to the same URL param name\n *\n * @param schema - Flattened parameter schema\n * @throws Error if conflicts are detected\n */\nexport function validateSchema(schema: Record<string, FlattenedParam>): void {\n const urlParamNames = new Set<string>()\n\n for (const [key, param] of Object.entries(schema)) {\n if (urlParamNames.has(param.urlParamName)) {\n throw new Error(\n `[FlattenSchema] Conflict: Multiple parameters map to URL param \"${param.urlParamName}\"`\n )\n }\n urlParamNames.add(param.urlParamName)\n }\n}\n\n/**\n * Get all array-type parameters from schema\n *\n * Useful for knowing which URL params should use repeated values\n * (e.g., ?severity=error&severity=warning)\n *\n * @param schema - Flattened parameter schema\n * @returns Array parameter names\n */\nexport function getArrayParams(schema: Record<string, FlattenedParam>): string[] {\n return Object.entries(schema)\n .filter(([_, param]) => param.type === 'array' || param.isArray)\n .map(([key]) => key)\n}\n\n/**\n * Get required parameters from schema\n *\n * @param schema - Flattened parameter schema\n * @returns Required parameter names\n */\nexport function getRequiredParams(schema: Record<string, FlattenedParam>): string[] {\n return Object.entries(schema)\n .filter(([_, param]) => param.required)\n .map(([key]) => key)\n}\n\n/**\n * Check if a parameter should be included in URL\n *\n * Excludes:\n * - null/undefined values\n * - Empty arrays\n * - Default values (to keep URLs clean)\n *\n * @param value - Parameter value\n * @param param - Parameter configuration\n * @returns Whether to include in URL\n */\nexport function shouldIncludeInUrl(\n value: any,\n param: FlattenedParam\n): boolean {\n // Null/undefined - exclude\n if (value === null || value === undefined) {\n return false\n }\n\n // Empty arrays - exclude\n if (Array.isArray(value) && value.length === 0) {\n return false\n }\n\n // Empty strings - exclude\n if (value === '') {\n return false\n }\n\n // Default values - exclude to keep URL clean\n if (param.defaultValue !== undefined && value === param.defaultValue) {\n return false\n }\n\n return true\n}\n","/**\n * URL ↔ Variables Converter for URL State Management\n *\n * Bidirectional conversion between URL parameters and GraphQL variables.\n * Handles type coercion, nested paths, and array parameters.\n */\n\nimport { FlattenedParam, shouldIncludeInUrl } from './flatten-schema'\nimport { JSType } from './graphql-parser'\n\n/**\n * Convert URL search params to GraphQL variables\n *\n * Reads URL parameters and reconstructs the nested GraphQL variables object\n * based on the flattened schema mapping.\n *\n * @param searchParams - URLSearchParams from window.location or Next.js\n * @param schema - Flattened parameter schema\n * @returns GraphQL variables object ready for Apollo Client\n *\n * @example\n * URL: ?search=error&severity=critical&severity=error&cursor=abc\n * Schema: {\n * search: { graphqlPath: 'search', type: 'string' },\n * severity: { graphqlPath: 'filter.severity', type: 'array' },\n * cursor: { graphqlPath: 'cursor', type: 'string' }\n * }\n * Result: {\n * search: 'error',\n * filter: { severity: ['critical', 'error'] },\n * cursor: 'abc'\n * }\n */\nexport function urlParamsToVariables(\n searchParams: URLSearchParams,\n schema: Record<string, FlattenedParam>\n): Record<string, any> {\n const variables: Record<string, any> = {}\n\n for (const [paramName, paramConfig] of Object.entries(schema)) {\n // Read value from URL\n const rawValue = paramConfig.type === 'array' || paramConfig.isArray\n ? searchParams.getAll(paramName)\n : searchParams.get(paramName)\n\n // Skip if no value in URL\n if (!rawValue || (Array.isArray(rawValue) && rawValue.length === 0)) {\n // Use default value if available\n if (paramConfig.defaultValue !== undefined) {\n setNestedValue(variables, paramConfig.graphqlPath, paramConfig.defaultValue)\n }\n continue\n }\n\n // Coerce value to correct type\n const value = coerceValue(rawValue, paramConfig.type)\n\n // Set value at nested path\n setNestedValue(variables, paramConfig.graphqlPath, value)\n }\n\n return variables\n}\n\n/**\n * Convert GraphQL variables to URL search params\n *\n * Flattens nested GraphQL variables to URL parameters based on schema mapping.\n * Excludes null/undefined/default values to keep URLs clean.\n *\n * @param variables - GraphQL variables object\n * @param schema - Flattened parameter schema\n * @returns URLSearchParams ready for router.push()\n *\n * @example\n * Variables: {\n * search: 'error',\n * filter: { severity: ['critical'], toolType: [] },\n * cursor: null\n * }\n * Result URL: ?search=error&severity=critical\n * (empty arrays and null values excluded)\n */\nexport function variablesToUrlParams(\n variables: Record<string, any>,\n schema: Record<string, FlattenedParam>\n): URLSearchParams {\n const params = new URLSearchParams()\n\n for (const [paramName, paramConfig] of Object.entries(schema)) {\n // Get value from nested path\n const value = getNestedValue(variables, paramConfig.graphqlPath)\n\n // Skip if should not include in URL\n if (!shouldIncludeInUrl(value, paramConfig)) {\n continue\n }\n\n // Add to URL params\n if (Array.isArray(value)) {\n // Array: Use repeated params (e.g., ?tag=foo&tag=bar)\n value.forEach(v => {\n if (v !== null && v !== undefined && v !== '') {\n params.append(paramName, String(v))\n }\n })\n } else {\n // Single value: Use set\n params.set(paramName, String(value))\n }\n }\n\n return params\n}\n\n/**\n * Coerce URL parameter value to correct JavaScript type\n *\n * @param value - Raw value from URLSearchParams (string or string[])\n * @param type - Target JavaScript type\n * @returns Coerced value\n */\nexport function coerceValue(value: string | string[], type: JSType): any {\n // Array handling\n if (Array.isArray(value)) {\n return value.map(v => coerceValue(v, type === 'array' ? 'string' : type))\n }\n\n // Type coercion for single values\n switch (type) {\n case 'number':\n const num = parseFloat(value)\n return isNaN(num) ? null : num\n\n case 'boolean':\n return value === 'true' || value === '1'\n\n case 'array':\n // Single value treated as array with one element\n return [value]\n\n case 'string':\n default:\n return value\n }\n}\n\n/**\n * Set value at nested path in object\n *\n * Creates intermediate objects as needed.\n *\n * @param obj - Target object to modify\n * @param path - Dot-separated path (e.g., \"filter.severity\")\n * @param value - Value to set\n *\n * @example\n * setNestedValue({}, 'filter.severity', ['error'])\n * // Result: { filter: { severity: ['error'] } }\n */\nexport function setNestedValue(obj: any, path: string, value: any): void {\n const parts = path.split('.')\n let current = obj\n\n // Navigate/create intermediate objects\n for (let i = 0; i < parts.length - 1; i++) {\n const part = parts[i]\n if (!(part in current) || typeof current[part] !== 'object') {\n current[part] = {}\n }\n current = current[part]\n }\n\n // Set final value\n const lastPart = parts[parts.length - 1]\n current[lastPart] = value\n}\n\n/**\n * Get value from nested path in object\n *\n * Returns undefined if path doesn't exist.\n *\n * @param obj - Source object\n * @param path - Dot-separated path (e.g., \"filter.severity\")\n * @returns Value at path or undefined\n *\n * @example\n * getNestedValue({ filter: { severity: ['error'] } }, 'filter.severity')\n * // Result: ['error']\n */\nexport function getNestedValue(obj: any, path: string): any {\n return path.split('.').reduce((current, key) => {\n return current?.[key]\n }, obj)\n}\n\n/**\n * Merge URL parameters into existing variables\n *\n * Useful for updating specific parameters without losing others.\n *\n * @param currentVariables - Current GraphQL variables\n * @param updates - Parameter updates (flat object)\n * @param schema - Flattened parameter schema\n * @returns Updated variables object\n */\nexport function mergeVariables(\n currentVariables: Record<string, any>,\n updates: Record<string, any>,\n schema: Record<string, FlattenedParam>\n): Record<string, any> {\n const merged = { ...currentVariables }\n\n for (const [paramName, value] of Object.entries(updates)) {\n const paramConfig = schema[paramName]\n if (!paramConfig) continue\n\n setNestedValue(merged, paramConfig.graphqlPath, value)\n }\n\n return merged\n}\n\n/**\n * Clear specific parameters from variables\n *\n * @param variables - Current GraphQL variables\n * @param paramNames - Parameter names to clear\n * @param schema - Flattened parameter schema\n * @returns Variables with specified params removed\n */\nexport function clearParams(\n variables: Record<string, any>,\n paramNames: string[],\n schema: Record<string, FlattenedParam>\n): Record<string, any> {\n const cleared = { ...variables }\n\n for (const paramName of paramNames) {\n const paramConfig = schema[paramName]\n if (!paramConfig) continue\n\n // Set to undefined (will be excluded from URL)\n setNestedValue(cleared, paramConfig.graphqlPath, undefined)\n }\n\n return cleared\n}\n\n/**\n * Validate GraphQL variables against schema\n *\n * Checks for required parameters and type consistency.\n *\n * @param variables - GraphQL variables to validate\n * @param schema - Flattened parameter schema\n * @returns Validation errors (empty array if valid)\n */\nexport function validateVariables(\n variables: Record<string, any>,\n schema: Record<string, FlattenedParam>\n): string[] {\n const errors: string[] = []\n\n for (const [paramName, paramConfig] of Object.entries(schema)) {\n const value = getNestedValue(variables, paramConfig.graphqlPath)\n\n // Check required parameters\n if (paramConfig.required && (value === null || value === undefined)) {\n errors.push(`Required parameter \"${paramName}\" is missing`)\n }\n\n // Check type consistency\n if (value !== null && value !== undefined) {\n const actualType = Array.isArray(value) ? 'array' : typeof value\n const expectedType = paramConfig.type === 'array' ? 'array' : paramConfig.type\n\n if (actualType !== expectedType && actualType !== 'object') {\n errors.push(\n `Parameter \"${paramName}\" has wrong type: expected ${expectedType}, got ${actualType}`\n )\n }\n }\n }\n\n return errors\n}\n","/**\n * GraphQL Schema Introspection for URL State Management\n *\n * Fetches GraphQL schema via introspection query at runtime (works behind auth).\n * Caches schema in localStorage + memory for performance.\n */\n\nimport {\n getIntrospectionQuery,\n buildClientSchema,\n IntrospectionQuery,\n GraphQLSchema,\n GraphQLInputObjectType,\n GraphQLInputType,\n isInputObjectType\n} from 'graphql'\nimport { VariableDefinition, JSType } from './graphql-parser'\n\n/**\n * Schema cache structure stored in localStorage\n */\ninterface SchemaCache {\n schema: IntrospectionQuery\n timestamp: number\n version: string\n}\n\n/**\n * GraphQL Introspection Manager\n *\n * Singleton class that handles schema introspection with caching.\n * Fetches schema once per session and caches for 24 hours.\n */\nexport class GraphQLIntrospector {\n private schema: GraphQLSchema | null = null\n private cacheKey = 'graphql-schema-cache-v1'\n private cacheDuration = 24 * 60 * 60 * 1000 // 24 hours\n private schemaVersion = '1.0.0'\n\n /**\n * Fetch GraphQL schema via introspection query\n *\n * @param endpoint - GraphQL endpoint URL\n * @param headers - HTTP headers (e.g., authentication)\n * @param skipCache - Force fresh fetch, ignore cache\n *\n * @example\n * await introspector.fetchSchema(\n * 'http://localhost/api/graphql',\n * { 'Authorization': 'Bearer token123' }\n * )\n */\n async fetchSchema(\n endpoint: string,\n headers: Record<string, string> = {},\n skipCache = false\n ): Promise<void> {\n // Check cache first (unless skipped)\n if (!skipCache) {\n const cached = this.loadFromCache()\n if (cached && Date.now() - cached.timestamp < this.cacheDuration) {\n try {\n this.schema = buildClientSchema(cached.schema)\n return\n } catch (error) {\n console.warn('[Introspector] Failed to build schema from cache:', error)\n // Continue to fetch fresh schema\n }\n }\n }\n\n // Fetch schema via introspection query\n try {\n const response = await fetch(endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n ...headers\n },\n body: JSON.stringify({\n query: getIntrospectionQuery()\n })\n })\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`)\n }\n\n const { data, errors } = await response.json()\n\n if (errors) {\n throw new Error(`GraphQL errors: ${JSON.stringify(errors)}`)\n }\n\n if (!data) {\n throw new Error('No data returned from introspection query')\n }\n\n // Build and cache schema\n this.schema = buildClientSchema(data)\n this.saveToCache(data)\n } catch (error) {\n console.error('[Introspector] Failed to fetch schema:', error)\n throw error\n }\n }\n\n /**\n * Get fields from a GraphQL input object type\n *\n * @param typeName - Name of the input type (e.g., \"LogFilterInput\")\n * @returns Record of field definitions\n *\n * @example\n * const fields = introspector.getInputTypeFields('LogFilterInput')\n * // {\n * // severity: { name: 'severity', type: 'array', ... },\n * // toolType: { name: 'toolType', type: 'array', ... }\n * // }\n */\n getInputTypeFields(typeName: string): Record<string, VariableDefinition> {\n if (!this.schema) {\n return {}\n }\n\n const type = this.schema.getType(typeName)\n\n if (!type || !isInputObjectType(type)) {\n return {}\n }\n\n const fields: Record<string, VariableDefinition> = {}\n const typeFields = type.getFields()\n\n for (const [fieldName, field] of Object.entries(typeFields)) {\n const fieldType = this.unwrapType(field.type)\n\n fields[fieldName] = {\n name: fieldName,\n type: this.mapGraphQLTypeToJS(fieldType.typeName),\n required: fieldType.isNonNull,\n isArray: fieldType.isList,\n graphqlTypeName: fieldType.typeName\n }\n }\n\n return fields\n }\n\n /**\n * Check if a type exists in the schema\n */\n hasType(typeName: string): boolean {\n if (!this.schema) return false\n return this.schema.getType(typeName) !== undefined\n }\n\n /**\n * Get the schema (if loaded)\n */\n getSchema(): GraphQLSchema | null {\n return this.schema\n }\n\n /**\n * Check if schema is loaded\n */\n isLoaded(): boolean {\n return this.schema !== null\n }\n\n /**\n * Clear cached schema\n */\n clearCache(): void {\n try {\n localStorage.removeItem(this.cacheKey)\n this.schema = null\n } catch {\n // Ignore storage errors\n }\n }\n\n /**\n * Unwrap GraphQL type to get base type info\n * Handles NonNull and List wrappers\n */\n private unwrapType(type: GraphQLInputType): {\n typeName: string\n isNonNull: boolean\n isList: boolean\n } {\n let typeName = ''\n let isNonNull = false\n let isList = false\n let current: any = type\n\n // Unwrap NonNull wrapper\n if (current.toString().endsWith('!')) {\n isNonNull = true\n current = 'ofType' in current ? current.ofType : current\n }\n\n // Check for List wrapper\n if (current.toString().startsWith('[')) {\n isList = true\n current = 'ofType' in current ? current.ofType : current\n }\n\n // Handle inner NonNull (e.g., [String!])\n if (current.toString().endsWith('!')) {\n current = 'ofType' in current ? current.ofType : current\n }\n\n // Get base type name\n typeName = current.name || current.toString().replace(/[[\\]!]/g, '')\n\n return { typeName, isNonNull, isList }\n }\n\n /**\n * Map GraphQL types to JavaScript types for URL handling\n */\n private mapGraphQLTypeToJS(graphqlType: string): JSType {\n const typeMap: Record<string, JSType> = {\n 'String': 'string',\n 'Int': 'number',\n 'Float': 'number',\n 'Boolean': 'boolean',\n 'ID': 'string'\n }\n\n return typeMap[graphqlType] || 'object'\n }\n\n /**\n * Load schema from localStorage cache\n */\n private loadFromCache(): SchemaCache | null {\n try {\n const cached = localStorage.getItem(this.cacheKey)\n if (!cached) return null\n\n const parsed = JSON.parse(cached) as SchemaCache\n\n // Validate cache version\n if (parsed.version !== this.schemaVersion) {\n return null\n }\n\n return parsed\n } catch (error) {\n console.warn('[Introspector] Failed to load cache:', error)\n return null\n }\n }\n\n /**\n * Save schema to localStorage cache\n */\n private saveToCache(schema: IntrospectionQuery): void {\n try {\n const cache: SchemaCache = {\n schema,\n timestamp: Date.now(),\n version: this.schemaVersion\n }\n\n localStorage.setItem(this.cacheKey, JSON.stringify(cache))\n } catch (error) {\n console.warn('[Introspector] Failed to save cache:', error)\n // Ignore storage errors (e.g., quota exceeded)\n }\n }\n}\n\n/**\n * Singleton introspector instance\n *\n * Use this instance throughout the application to share schema cache\n *\n * @example\n * import { introspector } from '@flamingo/ui-kit/hooks/state'\n *\n * // Initialize after auth\n * await introspector.fetchSchema(endpoint, { Authorization: token })\n *\n * // Get input type fields\n * const fields = introspector.getInputTypeFields('LogFilterInput')\n */\nexport const introspector = new GraphQLIntrospector()\n","/**\n * useApiParams Hook - REST API Integration for URL State Management\n *\n * Manual schema definition for REST APIs. Provides same URL sync functionality\n * as useQueryParams but without GraphQL dependency.\n *\n * @example\n * const { params, setParam } = useApiParams({\n * search: { type: 'string', default: '' },\n * page: { type: 'number', default: 1 },\n * tags: { type: 'array', default: [] }\n * })\n *\n * fetch(`/api/items?${new URLSearchParams(params)}`)\n *\n * // URL: /items?search=laptop&page=2&tags=electronics&tags=sale\n * // params: { search: 'laptop', page: 2, tags: ['electronics', 'sale'] }\n */\n\n'use client'\n\nimport { useRouter, useSearchParams } from '../../embed-shims/next-navigation'\nimport { useCallback, useMemo, useRef } from 'react'\nimport { FlattenedParam, shouldIncludeInUrl } from './flatten-schema'\nimport { JSType } from './graphql-parser'\nimport { coerceValue } from './url-converter'\n\n/**\n * Returns the previous reference if the JSON-serialized content of `value`\n * hasn't changed across renders. Internal helper used to shield consumers from\n * ref churn caused by:\n * - `useSearchParams()` returning a new `ReadonlyURLSearchParams` instance\n * on every render even when the URL is unchanged (Next.js behavior).\n * - Consumers passing the schema as a fresh object literal on every render.\n */\nfunction useContentStable<T>(value: T, key: string): T {\n const ref = useRef<{ value: T; key: string } | undefined>(undefined)\n if (ref.current && ref.current.key === key) return ref.current.value\n ref.current = { value, key }\n return value\n}\n\n/**\n * Reuses a previous array reference if its content (shallow string equality)\n * matches the freshly parsed array. Lets `params.tier` etc. stay\n * reference-stable across renders that don't actually change those values.\n */\nfunction reuseIfShallowEqual<T extends string | number | boolean>(\n prev: unknown,\n next: T[],\n): T[] {\n if (!Array.isArray(prev) || prev.length !== next.length) return next\n for (let i = 0; i < next.length; i++) {\n if (prev[i] !== next[i]) return next\n }\n return prev as T[]\n}\n\n/**\n * Type mapping from JSType to TypeScript types for OUTPUT (reading params)\n */\ntype OutputTypeMap = {\n string: string\n number: number\n boolean: boolean\n array: string[]\n object: Record<string, unknown>\n}\n\n/**\n * Type mapping from JSType to TypeScript types for INPUT (setting params)\n * More permissive to allow null/undefined in arrays which get filtered\n */\ntype InputTypeMap = {\n string: string | null | undefined\n number: number | null | undefined\n boolean: boolean | null | undefined\n array: (string | null | undefined)[]\n object: Record<string, unknown> | null | undefined\n}\n\n/**\n * Get the TypeScript type for OUTPUT (reading from params)\n */\ntype OutputTypeForJSType<T extends JSType> = OutputTypeMap[T]\n\n/**\n * Get the TypeScript type for INPUT (setting params)\n */\ntype InputTypeForJSType<T extends JSType> = InputTypeMap[T]\n\n/**\n * Get the default value type for a given JSType\n */\ntype DefaultValueForType<T extends JSType> =\n T extends 'array' ? string[] :\n T extends 'object' ? Record<string, unknown> :\n OutputTypeMap[T]\n\n/**\n * Parameter configuration for a single parameter\n */\nexport interface ParamConfig<T extends JSType = JSType> {\n /** JavaScript type for URL parameter */\n type: T\n /** Default value matching the type */\n default?: DefaultValueForType<T>\n /** Whether parameter is required */\n required?: boolean\n}\n\n/**\n * REST API parameter schema definition\n * Maps parameter names to their configuration\n */\nexport type ParamSchema = Record<string, ParamConfig>\n\n/**\n * Helper to create a typed param schema (preserves literal types)\n */\nexport function defineParamSchema<T extends ParamSchema>(schema: T): T {\n return schema\n}\n\n/**\n * Options for useApiParams hook\n */\nexport interface UseApiParamsOptions {\n /** Enable debug logging */\n debug?: boolean\n}\n\n/**\n * Infer the OUTPUT params type from a ParamSchema (for reading)\n * Maps each key in the schema to its corresponding TypeScript type\n *\n * @example\n * const schema = defineParamSchema({\n * search: { type: 'string', default: '' },\n * page: { type: 'number', default: 1 },\n * tags: { type: 'array', default: [] }\n * })\n * type Params = InferParamsFromSchema<typeof schema>\n * // { search: string; page: number; tags: string[] }\n */\nexport type InferParamsFromSchema<TSchema extends ParamSchema> = {\n [K in keyof TSchema]: TSchema[K]['type'] extends infer T\n ? T extends JSType\n ? OutputTypeForJSType<T>\n : never\n : never\n}\n\n/**\n * Infer the INPUT params type from a ParamSchema (for setting)\n * More permissive to allow null/undefined values\n */\nexport type InferInputParamsFromSchema<TSchema extends ParamSchema> = {\n [K in keyof TSchema]: TSchema[K]['type'] extends infer T\n ? T extends JSType\n ? InputTypeForJSType<T>\n : never\n : never\n}\n\n/**\n * Type for parameter values that can be set\n * Allows setting values that match the schema types or can be coerced to them\n */\nexport type ParamValue = \n | string \n | number \n | boolean \n | string[] \n | (string | null | undefined)[]\n | Record<string, unknown> \n | null \n | undefined\n\n/**\n * Return type for useApiParams hook with strict typing\n */\nexport interface UseApiParamsReturn<\n TSchema extends ParamSchema,\n TParams = InferParamsFromSchema<TSchema>\n> {\n /** Parsed parameters object with strict typing */\n params: TParams\n\n /** URLSearchParams for fetch/axios */\n urlSearchParams: URLSearchParams\n\n /** Set a single parameter with type-safe key and value */\n setParam: <K extends keyof TSchema & string>(\n key: K,\n value: InferInputParamsFromSchema<Pick<TSchema, K>>[K]\n ) => void\n\n /** Set multiple parameters at once */\n setParams: (updates: Partial<InferInputParamsFromSchema<TSchema>>) => void\n\n /** Clear specific parameters */\n clearParams: (keys: (keyof TSchema & string)[]) => void\n\n /** Reset all parameters (clear URL) */\n resetParams: () => void\n}\n\n/**\n * useApiParams - Manual URL state for REST APIs\n *\n * This hook:\n * 1. Reads URL search parameters\n * 2. Coerces to correct types based on schema\n * 3. Provides type-safe parameter updates\n * 4. Syncs changes to URL automatically\n *\n * @param schema - Parameter schema definition\n * @param options - Configuration options\n * @returns Hook API for managing URL state\n */\nexport function useApiParams<TSchema extends ParamSchema>(\n schema: TSchema,\n options: UseApiParamsOptions = {}\n): UseApiParamsReturn<TSchema> {\n const router = useRouter()\n const searchParamsLive = useSearchParams()\n const debug = options.debug || false\n\n // ───── Reference-stability layer ──────────────────────────────────────\n //\n // Goal: `params`, `params.<arrayField>`, and the setter callbacks must keep\n // the SAME reference across renders unless the URL or schema content\n // actually changes. Otherwise consumer `useMemo`/`useEffect` deps that\n // include `params.foo` invalidate on every parent re-render.\n //\n // Without this, every call site has to defensively `JSON.stringify` filter\n // arrays into a content-key — a known footgun. The stability is provided\n // here, once, instead of in 17 consumers.\n\n // 1. URL string is the canonical, value-stable representation of search params.\n const searchString = searchParamsLive.toString()\n\n // 2. Schema reference stabilized by content. Consumers commonly pass an\n // object literal each render, which would otherwise invalidate every memo.\n const schemaKey = useMemo(() => JSON.stringify(schema), [schema])\n const stableSchema = useContentStable(schema, schemaKey)\n\n // ──────────────────────────────────────────────────────────────────────\n\n // Convert schema to flattened format for reuse\n // biome-ignore lint/correctness/useExhaustiveDependencies: schemaKey is the content-stable key for `stableSchema`.\n const flattenedSchema = useMemo((): Record<string, FlattenedParam> => {\n const flattened: Record<string, FlattenedParam> = {}\n\n for (const [key, config] of Object.entries(stableSchema)) {\n flattened[key] = {\n urlParamName: key,\n graphqlPath: key,\n type: config.type,\n defaultValue: config.default,\n required: config.required,\n isArray: config.type === 'array'\n }\n }\n\n return flattened\n }, [schemaKey])\n\n // Parse URL parameters with type coercion. Reuse previous array refs when\n // their content is unchanged so `params.<arrayField>` stays stable across\n // renders that don't touch that specific field.\n const prevParamsRef = useRef<Record<string, unknown> | undefined>(undefined)\n // biome-ignore lint/correctness/useExhaustiveDependencies: `searchString` and `schemaKey` are content-stable representations of `searchParamsLive` and `stableSchema`.\n const params = useMemo((): InferParamsFromSchema<TSchema> => {\n const sp = new URLSearchParams(searchString)\n const result: Record<string, unknown> = {}\n const prev = prevParamsRef.current\n\n for (const [key, config] of Object.entries(stableSchema)) {\n // Read from URL\n const rawValue = config.type === 'array'\n ? sp.getAll(key)\n : sp.get(key)\n\n // Use value from URL or default\n let value: unknown\n if (rawValue && (Array.isArray(rawValue) ? rawValue.length > 0 : true)) {\n value = coerceValue(rawValue, config.type)\n } else {\n value = config.default\n }\n\n // Reuse previous reference when content matches — keeps array fields\n // reference-stable when an unrelated param changed.\n if (Array.isArray(value) && prev) {\n value = reuseIfShallowEqual(prev[key], value as (string | number | boolean)[])\n }\n\n result[key] = value\n }\n\n if (debug) {\n console.log('[useApiParams] Parsed params:', result)\n }\n\n prevParamsRef.current = result\n return result as InferParamsFromSchema<TSchema>\n }, [searchString, schemaKey, debug])\n\n // Helper: Add parameter value to URLSearchParams\n const addParamToSearchParams = useCallback((\n searchParams: URLSearchParams,\n key: string,\n value: ParamValue\n ): void => {\n if (value === undefined || value === '' || value === null) {\n return\n }\n\n if (Array.isArray(value)) {\n value.forEach(v => {\n if (v !== undefined && v !== '' && v !== null) {\n searchParams.append(key, String(v))\n }\n })\n } else if (typeof value === 'object') {\n // For objects, convert to JSON string\n searchParams.set(key, JSON.stringify(value))\n } else {\n searchParams.set(key, String(value))\n }\n }, [])\n\n // Get URLSearchParams for fetch/axios. Iterates `stableSchema` (not raw\n // `schema`) so consumers passing an inline schema literal don't invalidate\n // this memo on every render.\n // biome-ignore lint/correctness/useExhaustiveDependencies: `schemaKey` is the content-stable key for `stableSchema`.\n const urlSearchParams = useMemo((): URLSearchParams => {\n const newParams = new URLSearchParams()\n\n for (const key of Object.keys(stableSchema)) {\n const value = (params as Record<string, unknown>)[key]\n const paramConfig = flattenedSchema[key]\n\n // Skip if should not include\n if (!shouldIncludeInUrl(value, paramConfig)) {\n continue\n }\n\n addParamToSearchParams(newParams, key, value as ParamValue)\n }\n\n return newParams\n }, [params, schemaKey, flattenedSchema, addParamToSearchParams])\n\n // Update URL with new parameters (preserve other params not managed by this\n // hook). Depends only on value-stable inputs (`searchString`, `schemaKey`),\n // so the callback ref itself is stable across renders that don't change URL\n // or schema — important for consumers that put `setParam`/`setParams` in\n // `useEffect` deps.\n // biome-ignore lint/correctness/useExhaustiveDependencies: `searchString` and `schemaKey` are content-stable representations of `searchParamsLive` and `stableSchema`.\n const updateUrl = useCallback((newParams: URLSearchParams, keysToRemove: string[] = []) => {\n // Preserve all existing params, then override with new ones\n const finalParams = new URLSearchParams(searchString)\n\n // Remove keys that are explicitly marked for removal\n keysToRemove.forEach(key => {\n if (key in stableSchema) {\n finalParams.delete(key)\n }\n })\n\n // Remove keys that are being updated (from newParams)\n // This preserves other schema parameters that aren't being changed\n newParams.forEach((_, key) => {\n // Only remove keys that are in our schema\n if (key in stableSchema) {\n finalParams.delete(key)\n }\n })\n\n // Add all new values (including multiple values for array params)\n // Only add parameters that are in our schema to avoid duplicating external params\n newParams.forEach((value, key) => {\n // Only process keys that are in our schema\n if (key in stableSchema) {\n if (finalParams.has(key)) {\n // Key already exists (from array params), append\n finalParams.append(key, value)\n } else {\n // First value for this key, use set\n finalParams.set(key, value)\n }\n }\n })\n\n const url = finalParams.toString()\n ? `?${finalParams.toString()}`\n : window.location.pathname\n\n if (debug) {\n console.log('[useApiParams] Updating URL:', url)\n }\n\n // Use replace for shallow routing (no page reload, no history spam)\n router.replace(url, { scroll: false })\n }, [router, debug, searchString, schemaKey])\n\n // Helper to check if value is empty\n const isEmptyValue = (value: unknown): boolean => {\n if (value === undefined || value === null || value === '') {\n return true\n }\n if (Array.isArray(value)) {\n // Empty array or array with all empty/null/undefined values\n return value.length === 0 || value.every(v => v === undefined || v === null || v === '')\n }\n return false\n }\n\n // Set a single parameter\n // biome-ignore lint/correctness/useExhaustiveDependencies: `schemaKey` is the content-stable key for `stableSchema`.\n const setParam = useCallback(<K extends keyof TSchema & string>(\n key: K,\n value: InferInputParamsFromSchema<Pick<TSchema, K>>[K]\n ) => {\n const config = stableSchema[key]\n\n if (!config) {\n console.warn(`[useApiParams] Unknown parameter: ${key}`)\n return\n }\n\n const newParams = new URLSearchParams()\n\n if (isEmptyValue(value)) {\n updateUrl(newParams, [key])\n } else {\n addParamToSearchParams(newParams, key, value as ParamValue)\n updateUrl(newParams)\n }\n }, [schemaKey, updateUrl, addParamToSearchParams])\n\n // Set multiple parameters\n // biome-ignore lint/correctness/useExhaustiveDependencies: `schemaKey` is the content-stable key for `stableSchema`.\n const setParams = useCallback((\n updates: Partial<InferInputParamsFromSchema<TSchema>>\n ) => {\n const newParams = new URLSearchParams()\n const keysToRemove: string[] = []\n\n for (const [key, value] of Object.entries(updates)) {\n const config = stableSchema[key]\n\n if (!config) {\n console.warn(`[useApiParams] Unknown parameter: ${key}`)\n continue\n }\n\n if (isEmptyValue(value)) {\n keysToRemove.push(key)\n } else {\n addParamToSearchParams(newParams, key, value as ParamValue)\n }\n }\n\n updateUrl(newParams, keysToRemove)\n }, [schemaKey, updateUrl, addParamToSearchParams])\n\n // Clear specific parameters\n const clearParams = useCallback((keys: (keyof TSchema & string)[]) => {\n const newParams = new URLSearchParams()\n updateUrl(newParams, keys)\n }, [updateUrl])\n\n // Reset all parameters\n const resetParams = useCallback(() => {\n if (debug) {\n console.log('[useApiParams] Resetting params')\n }\n\n router.replace(window.location.pathname, { scroll: false })\n }, [router, debug])\n\n return {\n params,\n urlSearchParams,\n setParam,\n setParams,\n clearParams,\n resetParams\n }\n}\n\n/**\n * Helper: Create URLSearchParams from object\n *\n * Handles arrays as repeated parameters. Filters out undefined, and empty values.\n *\n * @param params - Parameters object\n * @returns URLSearchParams\n */\nexport function createSearchParams(params: Record<string, ParamValue>): URLSearchParams {\n const searchParams = new URLSearchParams()\n\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === '' || value === null) {\n continue\n }\n\n if (Array.isArray(value)) {\n value.forEach(v => {\n if (v !== undefined && v !== '' && v !== null) {\n searchParams.append(key, String(v))\n }\n })\n } else if (typeof value === 'object') {\n // For objects, convert to JSON string\n searchParams.set(key, JSON.stringify(value))\n } else {\n searchParams.set(key, String(value))\n }\n }\n\n return searchParams\n}","/**\n * Unified Cursor Pagination State Hook\n *\n * Manages all common cursor-based pagination state logic:\n * - URL state management with useApiParams\n * - Debounced search input\n * - hasLoadedBeyondFirst tracking\n * - Initial load detection\n * - Search change detection\n * - Pagination handlers (next, reset)\n *\n * This eliminates ~60-80 lines of boilerplate from each paginated component.\n *\n * @example\n * const {\n * searchInput, setSearchInput,\n * hasLoadedBeyondFirst,\n * handleNextPage, handleResetToFirstPage\n * } = useCursorPaginationState({\n * onInitialLoad: (search, cursor) => fetchDialogs(false, search, true, cursor),\n * onSearchChange: (search) => fetchDialogs(false, search)\n * })\n */\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { useApiParams, type UseApiParamsReturn } from './use-api-params';\n\nexport interface UseCursorPaginationStateOptions {\n /**\n * Debounce delay for search input (default: 300ms)\n */\n debounceMs?: number\n\n /**\n * Callback for initial page load\n * Called once on mount with current search and cursor from URL\n */\n onInitialLoad: (search: string, cursor: string | null) => void | Promise<unknown>\n\n /**\n * Callback when search term changes (after debounce)\n * Called after URL is updated, cursor is already reset\n */\n onSearchChange: (search: string) => void | Promise<unknown>\n}\n\nconst urlSchema = {\n search: { type: 'string' as const, default: '' },\n cursor: { type: 'string' as const, default: '' },\n}\n\ntype ApiParamsReturn = UseApiParamsReturn<typeof urlSchema>\n\n/** Pagination params managed by this hook */\nexport type PaginationParams = ApiParamsReturn['params']\n\nexport interface CursorPaginationStateReturn {\n // Search\n searchInput: string\n setSearchInput: (value: string) => void\n\n // Pagination tracking\n hasLoadedBeyondFirst: boolean\n setHasLoadedBeyondFirst: (value: boolean) => void\n\n // Handlers for useTablePagination\n handleNextPage: (endCursor: string, fetchFn: () => Promise<unknown>) => Promise<void>\n handleResetToFirstPage: (fetchFn: () => Promise<unknown>) => Promise<void>\n\n // URL params access (for advanced use cases)\n params: PaginationParams\n setParam: ApiParamsReturn['setParam']\n setParams: ApiParamsReturn['setParams']\n}\n\nexport function useCursorPaginationState(\n options: UseCursorPaginationStateOptions\n): CursorPaginationStateReturn {\n const {\n onInitialLoad,\n onSearchChange,\n } = options\n\n const { params, setParam, setParams } = useApiParams(urlSchema)\n\n // Local search input with debounce\n const [searchInput, setSearchInput] = useState(params.search || '')\n\n // Pagination tracking\n const [hasLoadedBeyondFirst, setHasLoadedBeyondFirst] = useState(false)\n // Use a counter instead of boolean to ensure effects see the latest state\n const [initialLoadCount, setInitialLoadCount] = useState(0)\n // Initialize to null to distinguish \"never set\" from \"set to empty string\"\n const lastSearchRef = useRef<string | null>(null)\n // Track if we're syncing from URL to prevent loops\n const isSyncingFromUrl = useRef(false)\n // Track if initial load is in progress to block ALL other effects\n const isInitialLoadInProgress = useRef(true)\n\n // Sync local input with URL param (for tab switches that clear params)\n // Only sync if the URL changed externally (not from our own debounce update)\n useEffect(() => {\n // Block during initial load\n if (isInitialLoadInProgress.current) return\n\n const urlSearch = params.search || ''\n // Only sync if URL differs from current input\n if (urlSearch !== searchInput) {\n isSyncingFromUrl.current = true\n setSearchInput(urlSearch)\n // Reset the flag after a tick to allow normal operation\n setTimeout(() => { isSyncingFromUrl.current = false }, 0)\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [params.search, initialLoadCount]) // Add initialLoadCount to re-run after initial load\n\n // Sync debounced search to URL, reset cursor when search changes\n useEffect(() => {\n // Block during initial load\n if (isInitialLoadInProgress.current) return\n // Skip if we're syncing from URL to prevent loops\n if (isSyncingFromUrl.current) return\n\n if (searchInput !== params.search) {\n setParams({\n search: searchInput,\n cursor: '' // Reset cursor when search changes\n })\n }\n }, [searchInput, params.search, setParams, initialLoadCount])\n\n // Initial load effect - runs once and blocks all other effects until complete\n useEffect(() => {\n if (initialLoadCount === 0) {\n const cursor = params.cursor || null\n const search = params.search || ''\n\n // Set all refs BEFORE calling onInitialLoad\n lastSearchRef.current = search\n\n // If we have a cursor in URL (page refresh), we're beyond first page\n if (cursor) {\n setHasLoadedBeyondFirst(true)\n }\n\n // Call the initial load and wait for it to complete before\n // marking initial load as done (handles async onInitialLoad)\n Promise.resolve(onInitialLoad(search, cursor)).finally(() => {\n isInitialLoadInProgress.current = false\n setInitialLoadCount(1)\n })\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n // Search change detection - only after initial load is fully complete\n useEffect(() => {\n // Block during initial load\n if (isInitialLoadInProgress.current) return\n if (initialLoadCount === 0) return\n\n const currentSearch = params.search || ''\n // Only trigger search change if:\n // 1. lastSearchRef has been set (not null - means initial load happened)\n // 2. The search actually changed\n if (lastSearchRef.current !== null && currentSearch !== lastSearchRef.current) {\n lastSearchRef.current = currentSearch\n setHasLoadedBeyondFirst(false)\n onSearchChange(currentSearch)\n }\n }, [params.search, onSearchChange, initialLoadCount])\n\n // Pagination handlers\n const handleNextPage = useCallback(\n async (endCursor: string, fetchFn: () => Promise<unknown>) => {\n setParam('cursor', endCursor)\n await fetchFn()\n setHasLoadedBeyondFirst(true)\n },\n [setParam]\n )\n\n const handleResetToFirstPage = useCallback(\n async (fetchFn: () => Promise<unknown>) => {\n setParam('cursor', '')\n await fetchFn()\n setHasLoadedBeyondFirst(false)\n },\n [setParam]\n )\n\n return {\n searchInput,\n setSearchInput,\n hasLoadedBeyondFirst,\n setHasLoadedBeyondFirst,\n handleNextPage,\n handleResetToFirstPage,\n params,\n setParam,\n setParams,\n }\n}\n","import { useEffect, useMemo, useState } from 'react'\n\nimport type { NatsClient, NatsClientOptions, NatsStatus } from '../../nats'\nimport { createNatsClient } from '../../nats'\n\nexport interface UseNatsClientOptions {\n /**\n * When true, connect on mount and close on unmount.\n */\n autoConnect?: boolean\n}\n\nexport interface UseNatsClientResult {\n client: NatsClient | null\n status: NatsStatus\n isConnected: boolean\n lastError: unknown\n}\n\n/**\n * React hook for managing a shared NATS WebSocket connection lifecycle.\n *\n * Important: pass a memoized `clientOptions` (e.g. via `useMemo`) to avoid\n * reconnecting on every render.\n */\nexport function useNatsClient(\n clientOptions: NatsClientOptions | null,\n options: UseNatsClientOptions = {},\n): UseNatsClientResult {\n const { autoConnect = true } = options\n\n const client = useMemo(() => {\n if (!clientOptions) return null\n return createNatsClient(clientOptions)\n }, [clientOptions])\n\n const [status, setStatus] = useState<NatsStatus>('disconnected')\n const [lastError, setLastError] = useState<unknown>(null)\n\n useEffect(() => {\n if (!client) {\n setStatus('disconnected')\n setLastError(null)\n return\n }\n\n const off = client.onStatus((event) => {\n setStatus(event.status)\n if (event.status === 'error') setLastError(event.data)\n })\n\n if (autoConnect) {\n client.connect().catch((e) => {\n setLastError(e)\n setStatus('error')\n })\n }\n\n return () => {\n off()\n client.close().catch(() => {\n // ignore\n })\n }\n }, [client, autoConnect])\n\n return {\n client,\n status,\n isConnected: status === 'connected',\n lastError,\n }\n}\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","'use client'\n\n/**\n * Access Code Integration Hook\n *\n * React-side wrapper around the pure `access-code-client` utilities.\n * Lives in `hooks/` (client bundle) so the `createContext()` call in\n * `endpoints-runtime-context` doesn't get pulled into the server-safe\n * `utils/index` bundle.\n *\n * The pure standalone functions (`validateAccessCode`,\n * `consumeAccessCode`, `validateAndConsumeAccessCode`) remain importable\n * from `@flamingo-stack/openframe-frontend-core/utils` — they take the\n * endpoints object as an argument. This hook binds those endpoints from\n * `EndpointsRuntimeContext` so React callers don't have to plumb URLs.\n */\n\nimport React from 'react'\n\nimport { useRequiredEndpointsRuntime } from '../contexts/endpoints-runtime-context'\nimport {\n validateAccessCode,\n consumeAccessCode,\n validateAndConsumeAccessCode,\n} from '../utils/access-code-client'\n\n/**\n * Resolves access-code endpoints from `EndpointsRuntimeContext` (throws\n * if no provider is mounted) and exposes loading-state-aware wrappers\n * around the standalone helpers in `utils/access-code-client`.\n *\n * @returns the following fields. The `validate` / `consume` /\n * `validateAndConsume` functions and the returned object identity are\n * NOT memoized — they're re-created each render. Wrap with\n * `useCallback` / `useMemo` at the call site if downstream effect\n * dep arrays depend on stable identities.\n * - `validate(email, code)`: validates only.\n * - `consume(email, code)`: consumes only.\n * - `validateAndConsume(email, code)`: one-step validate-then-consume.\n * - `isValidating: boolean`: a validate call is in flight.\n * - `isConsuming: boolean`: a consume call is in flight.\n * - `isProcessing: boolean`: convenience — `isValidating || isConsuming`.\n * Use this for a single \"in-flight\" indicator on UI affordances that\n * should disable during both phases.\n *\n * @example\n * const { validate, consume, isProcessing } = useAccessCodeIntegration();\n *\n * const handleRegistration = async (formData) => {\n * const validation = await validate(formData.email, formData.accessCode);\n * if (!validation.valid) {\n * setError(validation.message);\n * return;\n * }\n *\n * // Process registration...\n * const registrationResult = await registerUser(formData);\n *\n * if (registrationResult.success) {\n * await consume(formData.email, formData.accessCode);\n * }\n * };\n */\nexport function useAccessCodeIntegration() {\n const runtime = useRequiredEndpointsRuntime()\n const endpoints = runtime.accessCode\n const [isValidating, setIsValidating] = React.useState(false)\n const [isConsuming, setIsConsuming] = React.useState(false)\n\n const validate = async (email: string, code: string) => {\n setIsValidating(true)\n try {\n return await validateAccessCode(email, code, endpoints)\n } finally {\n setIsValidating(false)\n }\n }\n\n const consume = async (email: string, code: string) => {\n setIsConsuming(true)\n try {\n return await consumeAccessCode(email, code, endpoints)\n } finally {\n setIsConsuming(false)\n }\n }\n\n const validateAndConsume = async (email: string, code: string) => {\n setIsValidating(true)\n setIsConsuming(true)\n try {\n return await validateAndConsumeAccessCode(email, code, endpoints)\n } finally {\n setIsValidating(false)\n setIsConsuming(false)\n }\n }\n\n return {\n validate,\n consume,\n validateAndConsume,\n isValidating,\n isConsuming,\n isProcessing: isValidating || isConsuming,\n }\n}\n","/**\n * Access Code Client Utilities — pure standalone functions.\n *\n * Endpoint paths are NOT hardcoded — every function takes an\n * `endpoints` argument. The React-side wrapper that binds them from\n * `EndpointsRuntimeContext` lives separately at\n * `hooks/use-access-code-integration.ts` (`useAccessCodeIntegration`).\n *\n * Keep this file **free of React imports** — it lives in the\n * server-safe `utils/index` tsup bundle. Any module-top-level call\n * into `createContext()` (which the runtime context file does) would\n * be pulled into the server bundle and crash SSR with\n * `createContext is not a function`.\n */\n\nimport {\n AccessCodeValidation,\n AccessCodeValidationResponse,\n AccessCodeConsumptionResponse\n} from '../types/access-code-cohorts';\n\n/** Endpoints required by the standalone client utilities. The\n * `useAccessCodeIntegration` hook (in `hooks/`) resolves these from\n * `EndpointsRuntimeContext.accessCode` automatically. */\nexport interface AccessCodeEndpoints {\n validateUrl: string\n consumeUrl: string\n}\n\n/**\n * Validate an access code for a given email\n *\n * @param email - User's email address\n * @param code - Access code to validate\n * @returns Promise with validation result\n *\n * @example\n * const result = await validateAccessCode('user@example.com', 'ABC123XY');\n * if (result.valid) {\n * // Allow user to proceed with registration\n * console.log(`Welcome to ${result.cohort_name}!`);\n * } else {\n * // Show error message\n * console.error(result.message);\n * }\n */\nexport async function validateAccessCode(\n email: string,\n code: string,\n endpoints: AccessCodeEndpoints,\n): Promise<AccessCodeValidationResponse> {\n try {\n const response = await fetch(endpoints.validateUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ email, code } as AccessCodeValidation),\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(error.error || 'Validation request failed');\n }\n\n return await response.json() as AccessCodeValidationResponse;\n } catch (error) {\n return {\n valid: false,\n message: error instanceof Error ? error.message : 'Validation failed',\n };\n }\n}\n\n/**\n * Consume an access code after successful registration\n *\n * Call this ONLY after the user has successfully completed registration.\n * This marks the code as used and prevents further usage.\n *\n * @param email - User's email address\n * @param code - Access code to consume\n * @returns Promise with consumption result\n *\n * @example\n * // After successful registration\n * const result = await consumeAccessCode('user@example.com', 'ABC123XY');\n * if (result.consumed) {\n * console.log('Access code consumed successfully');\n * } else {\n * console.warn('Failed to consume access code:', result.message);\n * }\n */\nexport async function consumeAccessCode(\n email: string,\n code: string,\n endpoints: AccessCodeEndpoints,\n): Promise<AccessCodeConsumptionResponse> {\n try {\n const response = await fetch(endpoints.consumeUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({ email, code } as AccessCodeValidation),\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(error.error || 'Consumption request failed');\n }\n\n return await response.json() as AccessCodeConsumptionResponse;\n } catch (error) {\n return {\n success: false,\n consumed: false,\n message: error instanceof Error ? error.message : 'Consumption failed',\n };\n }\n}\n\n/**\n * Complete access code flow: validate then consume\n *\n * This is a convenience function that validates an access code and,\n * if valid, immediately consumes it. Use this when you want to\n * validate and consume in one step during registration.\n *\n * @param email - User's email address\n * @param code - Access code to validate and consume\n * @returns Promise with validation and consumption results\n *\n * @example\n * const result = await validateAndConsumeAccessCode('user@example.com', 'ABC123XY');\n * if (result.valid && result.consumed) {\n * // Registration successful\n * console.log(`Welcome to ${result.cohort_name}!`);\n * } else {\n * console.error(result.message);\n * }\n */\nexport async function validateAndConsumeAccessCode(\n email: string,\n code: string,\n endpoints: AccessCodeEndpoints,\n): Promise<AccessCodeValidationResponse & { consumed?: boolean }> {\n // First validate\n const validation = await validateAccessCode(email, code, endpoints);\n\n if (!validation.valid) {\n return validation;\n }\n\n // If valid, consume the code\n const consumption = await consumeAccessCode(email, code, endpoints);\n\n return {\n ...validation,\n consumed: consumption.consumed,\n message: consumption.consumed\n ? `Access granted for ${validation.cohort_name}`\n : consumption.message || validation.message,\n };\n}\n\n// `useAccessCodeIntegration` (the React-side wrapper) lives in\n// `hooks/use-access-code-integration.ts`. It binds the endpoints from\n// `EndpointsRuntimeContext` so React callers don't have to plumb URLs.\n","'use client'\n\nimport { useMemo } from 'react'\n\nimport { useChatRuntime } from '../contexts/chat-runtime-context'\nimport { buildOgPlaceholderUrl } from '../utils/og-placeholder'\n\n/**\n * Resolve a branded og-placeholder image URL for a title, driven entirely by\n * the runtime `endpoints` (no injected builder).\n *\n * THE one og-placeholder hook. It reads the host's `endpoints` from\n * `ChatRuntime` and hands them to `buildOgPlaceholderUrl`, which resolves the\n * route base (explicit `ogPlaceholderUrl` → derived from `imageProxyUrlPrefix`\n * → same-origin `/api/og-placeholder`) and appends `?title=…`. Per-platform\n * brand colors are resolved SERVER-SIDE by the route — nothing is baked here.\n *\n * Replaces the old builder-injection `useOgPlaceholder(buildUrl, …)`: callers\n * no longer pass a URL builder. `useEntityCardPlaceholder` delegates here too,\n * so every surface shares one memo + one code path.\n */\nexport interface UseOgPlaceholderUrlArgs {\n /** Text to display on the placeholder. */\n title: string | undefined | null\n /** Site name shown below the title (optional). */\n siteName?: string\n /** `'wide'` (1200×630 social-card; default) or `'square'` (1024×1024 — for\n * compact chat-inline slots so `object-cover` doesn't crop the title off). */\n aspect?: 'wide' | 'square'\n /** When `false`, returns `null` instead of a URL. */\n enabled?: boolean\n}\n\nexport function useOgPlaceholderUrl({\n title,\n siteName = '',\n aspect = 'wide',\n enabled = true,\n}: UseOgPlaceholderUrlArgs): string | null {\n const endpoints = useChatRuntime()?.endpoints\n\n return useMemo(() => {\n if (!enabled || !title) return null\n return buildOgPlaceholderUrl(endpoints, title, { site: siteName || undefined, aspect })\n }, [endpoints, title, siteName, aspect, enabled])\n}\n","/**\n * Branded OG-placeholder URL construction — the DEFAULT cover-image fallback\n * for entity cards (onboarding guides, blog/case-study/release/etc.) that have\n * no image.\n *\n * ALL of the logic lives here, in the lib. A consumer hands over its runtime\n * `endpoints` object and NOTHING else — base resolution AND the `?title=…`\n * concatenation happen inside `buildOgPlaceholderUrl`. For the entity-card +\n * onboarding-detail surfaces this is the single entry point — no card builds an\n * og-placeholder URL itself (that was the bug this replaced: each embedder\n * wired a per-surface callback that concatenated the URL, and a host that\n * forgot it rendered a blank slot with no request). (Other server-side OG paths\n * such as the hub's blog og:image generator construct their own URLs and are\n * out of this module's scope.)\n *\n * The base API URL is taken from the endpoints the host already configures:\n * 1. explicit `endpoints.ogPlaceholderUrl`\n * 2. derived from the sibling `endpoints.imageProxyUrlPrefix` (same API base)\n * 3. same-origin relative `/api/og-placeholder`\n * The base may already carry pre-existing query params; they're preserved and\n * `title` (+ dimensions) are layered on top. Per-platform brand colors are NOT\n * baked into this URL — the `/api/og-placeholder` route resolves them\n * server-side from the platform. Most hosts leave `ogPlaceholderUrl` unset and\n * let the base derive from `imageProxyUrlPrefix`.\n */\n\n/** The slice of `ChatRuntime.endpoints` this module needs. */\nexport interface OgPlaceholderEndpoints {\n /** Explicit base URL for the og-placeholder route. May already carry query\n * params — they're preserved. Per-platform colors are NOT baked here; the\n * route resolves them server-side from the platform. */\n ogPlaceholderUrl?: string\n /** Sibling image route under the SAME API base. When `ogPlaceholderUrl` is\n * unset, the base is derived from this by swapping the trailing\n * `/image-proxy` segment for `/og-placeholder` — so a host that already\n * proxies images gets the placeholder for free, with zero extra wiring. */\n imageProxyUrlPrefix?: string\n}\n\nexport interface BuildOgPlaceholderOptions {\n /** Site name shown under the title. Skipped when empty. */\n site?: string\n /** `'wide'` (1200×630, the route default — no `w`/`h` emitted) or\n * `'square'` (1024×1024, for compact 56×56 chat-inline slots). */\n aspect?: 'wide' | 'square'\n /** Explicit pixel overrides (win over `aspect`). */\n width?: number\n height?: number\n}\n\n/** Same-origin default — for hosts that serve the route themselves (the hub).\n * Cross-origin embedders override via `ogPlaceholderUrl` or inherit it from\n * `imageProxyUrlPrefix`. */\nconst DEFAULT_OG_PLACEHOLDER_PATH = '/api/og-placeholder'\n\n/** Resolve the og-placeholder route base from the host's endpoints.\n * Internal — callers go through `buildOgPlaceholderUrl(endpoints, …)`. */\nfunction resolveOgPlaceholderBase(endpoints?: OgPlaceholderEndpoints | null): string {\n if (endpoints?.ogPlaceholderUrl) return endpoints.ogPlaceholderUrl\n const imageProxy = endpoints?.imageProxyUrlPrefix\n if (imageProxy) {\n // `/image-proxy` and `/og-placeholder` are sibling API routes under one\n // base. Anchor to a path-segment boundary so we only rewrite the route\n // name, never an incidental substring.\n const derived = imageProxy.replace(/\\/image-proxy(?=$|[?/])/, '/og-placeholder')\n if (derived !== imageProxy) return derived\n }\n return DEFAULT_OG_PLACEHOLDER_PATH\n}\n\n/**\n * Build the branded og-placeholder image URL from the host's `endpoints` + a\n * title. This is the single entry point for entity-card + onboarding-detail\n * cover fallbacks: it resolves the route base from `endpoints` AND concatenates\n * `title` (+ dimensions), so those consumers never construct a URL themselves.\n *\n * Pure string construction — SSR- and browser-safe. Always returns a usable\n * URL (relative default at worst), so a missing/unknown image degrades\n * gracefully via the `<img onError>` recovery in the card components.\n */\nexport function buildOgPlaceholderUrl(\n endpoints: OgPlaceholderEndpoints | null | undefined,\n title: string,\n options: BuildOgPlaceholderOptions = {},\n): string {\n const base = resolveOgPlaceholderBase(endpoints)\n const qIndex = base.indexOf('?')\n const path = qIndex === -1 ? base : base.slice(0, qIndex)\n const params = new URLSearchParams(qIndex === -1 ? '' : base.slice(qIndex + 1))\n\n params.set('title', title)\n if (options.site) params.set('site', options.site)\n\n // Square aspect → request a 1024×1024 image so `object-cover` doesn't crop\n // the title off in compact slots. Wide leaves dimensions to the route\n // default (1200×630). Explicit width/height always win.\n const width = options.width ?? (options.aspect === 'square' ? 1024 : undefined)\n const height = options.height ?? (options.aspect === 'square' ? 1024 : undefined)\n // `Number.isFinite` does not coerce, so it already rejects `undefined` — no\n // separate `typeof === 'number'` guard needed.\n if (Number.isFinite(width)) params.set('w', String(width))\n if (Number.isFinite(height)) params.set('h', String(height))\n\n return `${path}?${params.toString()}`\n}\n","'use client'\n\nimport { useEffect, useState } from 'react'\nimport { useEndpointsRuntime } from '../contexts/endpoints-runtime-context'\nimport { toClaudeMirrorPath } from '../utils/embed-url-converters'\n\nexport type ClaudeMirrorStatus =\n /** No mirror candidate at all (no proxy configured, or a non-mirrorable url). */\n | 'absent'\n /** Probing the host for a self-hosted mirror (or minting one on first view). */\n | 'probing'\n /** A mirror exists and `src` points at it. */\n | 'found'\n\nexport interface ClaudeMirrorState {\n /** The mirror src when found (with a cache-bust once a re-publish is\n * detected), else `null`. */\n src: string | null\n status: ClaudeMirrorStatus\n}\n\n/**\n * Ask the host's storage-view proxy whether the cached mirror is still the\n * artifact's CURRENT version, re-minting it silently if a re-publish moved\n * it. Returns a cache-bust token (the new version) when the mirror changed,\n * else null. Best-effort: any failure just leaves the shown mirror in place.\n */\nasync function revalidateMirror(mirrorPath: string): Promise<string | null> {\n try {\n const res = await fetch(`${mirrorPath}?revalidate=1`)\n if (!res.ok) return null\n const body = (await res.json()) as { updated?: boolean; version?: string | null }\n return body?.updated ? String(body.version ?? Date.now()) : null\n } catch {\n return null\n }\n}\n\n/**\n * The host's SELF-HOSTED MIRROR src for a claude artifact url, plus a\n * `status` so the viewer can show a loading state while it resolves — THE\n * hook behind `ClaudeEmbed`'s transparent mirror leg, extracted so the\n * component stays presentational.\n *\n * Fully under the hood, three moves:\n * 1. Derive the candidate mirror path from the url's artifact id under the\n * host's configured storage-view proxy base\n * (`EndpointsRuntime.storageViewBaseUrl`) and probe it with a 1-byte\n * ranged GET. On a cold first view that probe IS the server-side\n * self-heal (~1-2s), which is exactly why `status: 'probing'` exists —\n * the viewer shows a spinner instead of the empty \"no embeddable view\"\n * flash. Exists → `found`; missing / no proxy / blip → `absent`, and the\n * caller's claude.ai fallback stands (the pre-mirror behavior).\n * 2. Once found, fire ONE background revalidation. If the artifact was\n * re-published since the mirror was cached, the server re-mints it and\n * reports the new version; the hook bumps a cache-bust on `src` so the\n * iframe silently reloads the fresh bytes. Unchanged → nothing happens.\n *\n * A host that mounts no EndpointsRuntime provider (or omits the entry) skips\n * the mirror leg entirely (`absent`).\n */\nexport function useClaudeMirrorSrc(url: string): ClaudeMirrorState {\n const endpoints = useEndpointsRuntime()\n const mirrorPath = toClaudeMirrorPath(url, endpoints?.storageViewBaseUrl)\n const [state, setState] = useState<ClaudeMirrorState>(() => ({\n src: null,\n status: mirrorPath ? 'probing' : 'absent',\n }))\n\n useEffect(() => {\n if (!mirrorPath) {\n setState({ src: null, status: 'absent' })\n return\n }\n setState({ src: null, status: 'probing' })\n let cancelled = false\n fetch(mirrorPath, { headers: { Range: 'bytes=0-0' } })\n .then((res) => {\n if (cancelled) return\n if (!(res.ok || res.status === 206)) {\n setState({ src: null, status: 'absent' })\n return\n }\n setState({ src: mirrorPath, status: 'found' })\n // Background freshness check — reload in place only if it changed.\n revalidateMirror(mirrorPath).then((bust) => {\n if (!cancelled && bust) {\n setState({ src: `${mirrorPath}?v=${encodeURIComponent(bust)}`, status: 'found' })\n }\n })\n })\n .catch(() => {\n // No proxy on this host / network blip — the caller's claude.ai\n // fallback stands, same as before mirrors existed.\n if (!cancelled) setState({ src: null, status: 'absent' })\n })\n return () => {\n cancelled = true\n }\n }, [mirrorPath])\n\n return state\n}\n","/**\n * Shared utilities for converting external service URLs to embeddable formats.\n * Used by lib's embed components and the hub's admin document editor.\n */\n\nexport function toGoogleSheetsEmbedUrl(url: string): string {\n if (url.includes('/htmlembed')) return url\n\n const match = url.match(/\\/spreadsheets\\/d\\/([a-zA-Z0-9-_]+)/)\n if (!match) return url\n\n const gidMatch = url.match(/[#?&]gid=(\\d+)/)\n const gid = gidMatch ? gidMatch[1] : '0'\n\n return `https://docs.google.com/spreadsheets/d/${match[1]}/htmlembed?widget=true&chrome=false&headers=false&gid=${gid}`\n}\n\nexport function toGoogleSheetsOriginalUrl(url: string): string {\n const match = url.match(/\\/spreadsheets\\/d\\/([a-zA-Z0-9-_]+)/)\n if (!match) return url\n\n const gidMatch = url.match(/[#?&]gid=(\\d+)/)\n const gid = gidMatch ? gidMatch[1] : '0'\n\n return `https://docs.google.com/spreadsheets/d/${match[1]}/edit#gid=${gid}`\n}\n\n/**\n * Convert a Figma URL to an embeddable URL.\n * Slides/deck URLs map to `deck` (present) by default; `slidesView: 'browse'` switches to `slides` (browse).\n */\nexport function toFigmaEmbedUrl(\n url: string,\n opts?: { slidesView?: 'present' | 'browse' }\n): string {\n if (url.includes('embed.figma.com')) return url\n if (url.includes('figma.com/embed')) return url\n\n const match = url.match(\n /figma\\.com\\/(design|file|proto|board|slides|deck)\\/([a-zA-Z0-9]+)(?:\\/([^?]*))?(\\?.*)?$/\n )\n\n if (match) {\n const [, urlType, fileKey, titleSlug, queryString] = match\n const isSlides = urlType === 'slides' || urlType === 'deck'\n const embedType =\n urlType === 'proto' ? 'proto'\n : isSlides ? (opts?.slidesView === 'browse' ? 'slides' : 'deck')\n : 'design'\n const pathSuffix = titleSlug ? `/${titleSlug}` : ''\n\n const params = new URLSearchParams(queryString?.replace(/^\\?/, '') || '')\n if (!params.has('embed-host')) {\n params.set('embed-host', 'flamingo')\n }\n const clientId = process.env.NEXT_PUBLIC_FIGMA_CLIENT_ID\n if (clientId && !params.has('client-id')) {\n params.set('client-id', clientId)\n }\n\n return `https://embed.figma.com/${embedType}/${fileKey}${pathSuffix}?${params.toString()}`\n }\n\n return `https://www.figma.com/embed?embed-host=flamingo&url=${encodeURIComponent(url)}`\n}\n\nexport function isFigmaSlidesUrl(url: string): boolean {\n if (!url) return false\n return /(?:www\\.|embed\\.)?figma\\.com\\/(?:slides|deck)\\/[a-zA-Z0-9]+/.test(url)\n}\n\nexport function toFigmaOriginalUrl(url: string): string {\n if (url.includes('embed.figma.com')) {\n return url.replace('embed.figma.com', 'www.figma.com').replace(/\\?.*$/, '')\n }\n return url.replace(/\\?.*$/, '')\n}\n\n/**\n * The self-hosted MIRROR url for a claude artifact url — the whole\n * mirror mechanism is UNDER THE HOOD: the viewer detects a claude url,\n * DERIVES the mirror location from the artifact's own id under the\n * host's configured storage-view proxy base, and frames the downloaded\n * copy from there. No per-link data, no props, no shortcode changes —\n * 100% transparent to every consumer.\n *\n * `storageViewBaseUrl` comes from `EndpointsRuntime` — the SAME\n * host-overridable proxy-path mechanism every other lib→API call uses\n * (a reverse-proxied embedder remaps it; a host without the proxy omits\n * it and gets `null` here, i.e. no mirror leg at all).\n *\n * `null` when the base is absent or the url carries no artifact id. The\n * derived url is the configured base + a validated uuid — safe as a\n * frame source without further checks. KEEP THE OBJECT NAMING IN SYNC\n * with the hub's `lib/data/design-brief-mirror.cjs` (bucket +\n * `<uuid>.html`) — that module owns the ingest/audit half.\n */\nexport function toClaudeMirrorPath(\n url: string | null | undefined,\n storageViewBaseUrl: string | null | undefined,\n): string | null {\n if (!url || !storageViewBaseUrl) return null\n try {\n const u = new URL(url)\n const host = u.hostname.toLowerCase()\n const id =\n (host === 'claude.ai' &&\n /^\\/(?:code\\/artifact|public\\/artifacts)\\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[/?#]|$)/i.exec(\n u.pathname,\n )?.[1]) ||\n (host === 'claude.site' &&\n /^\\/artifacts\\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[/?#]|$)/i.exec(\n u.pathname,\n )?.[1]) ||\n null\n return id\n ? `${storageViewBaseUrl.replace(/\\/$/, '')}/design-briefs/${id.toLowerCase()}.html`\n : null\n } catch {\n return null\n }\n}\n\n/**\n * The EMBED url for a published Claude artifact, or `null` when the link is not\n * one we can frame — the Claude counterpart of `toFigmaEmbedUrl`.\n *\n * claude.ai serves `frame-ancestors 'self'` on the artifact page itself, but a\n * PUBLISHED artifact also has an `/embed` route whose `frame-ancestors` carries\n * the domains its author allow-listed under \"Get embed code → Allowed domains\".\n * That is Anthropic's supported way to put an artifact on another site, so that\n * is what the iframe points at.\n *\n * Returns `null` for everything else, including a Claude CODE url\n * (`claude.ai/code/artifact/…`), whose Share dialog exposes no Embed settings\n * and whose `/embed` path answers `frame-ancestors 'self'` (verified against\n * real artifacts, 2026-08). A trailing `/embed` is accepted because that is the\n * url \"Get embed code\" hands the author.\n */\nexport function toClaudeEmbedUrl(url: string | null | undefined): string | null {\n if (!url) return null\n try {\n const u = new URL(url)\n const host = u.hostname.toLowerCase()\n // `claude.site/artifacts/<id>` 308-redirects to the claude.ai public url.\n const publicId =\n (host === 'claude.ai' && /^\\/public\\/artifacts\\/([\\w-]+)(?:\\/embed)?\\/?$/.exec(u.pathname)?.[1]) ||\n (host === 'claude.site' && /^\\/artifacts\\/([\\w-]+)(?:\\/embed)?\\/?$/.exec(u.pathname)?.[1]) ||\n null\n return publicId ? `https://claude.ai/public/artifacts/${publicId}/embed` : null\n } catch {\n return null\n }\n}\n","'use client'\n\nimport { useEffect } from 'react'\nimport { scrollElementIntoView } from '../utils/scroll-into-view'\nimport { getHashTargetElement, normalizeHashFragment } from '../utils/same-page-hash-nav'\n\n/** ~1s at 60fps — long enough to outlast Radix accordion expand + SWR\n * mount, short enough that a missed anchor doesn't hang the page. */\nconst MAX_POLL_FRAMES = 60\n\nexport interface UseScrollToHashOptions {\n /** Pixels to subtract for sticky chrome. */\n headerOffset?: number\n}\n\n/**\n * Scroll the page to `window.location.hash` once `readyDep` resolves\n * to a truthy value. Polls via rAF for ~1s so lazy-mounted rows (Radix\n * accordion, SWR fetch) have time to render. Re-runs on `readyDep`\n * reference change AND on `hashchange` (browser back/forward + the\n * synthetic event `navigateSamePageHash` dispatches).\n *\n * Skipped when `readyDep == null || readyDep === false`. Default\n * `true` makes the hook run on mount for pages whose target is in the\n * initial SSR render.\n */\nexport function useScrollToHash(\n readyDep: unknown = true,\n options?: UseScrollToHashOptions,\n): void {\n const headerOffset = options?.headerOffset ?? 0\n useEffect(() => {\n if (typeof window === 'undefined') return\n if (readyDep === null || readyDep === false) return\n let rafId: number | null = null\n const cancelPoll = () => {\n if (rafId !== null) {\n cancelAnimationFrame(rafId)\n rafId = null\n }\n }\n const tryScrollToHash = () => {\n // `normalizeHashFragment` heals a malformed multi-fragment hash\n // so `getElementById` resolves on deep-link entries that bypass\n // `navigateSamePageHash`'s own normalize.\n const hash = normalizeHashFragment(window.location.hash).slice(1)\n if (!hash) return\n // Cancel any in-flight poll from a prior invocation so two\n // concurrent ticks can't both call scrollElementIntoView.\n cancelPoll()\n let frames = 0\n const tick = () => {\n // Raw-then-decoded lookup — encoded fragments from href\n // composers must match the unencoded ids rows render.\n const el = getHashTargetElement(hash)\n if (el) {\n rafId = null\n scrollElementIntoView(el, { headerOffset })\n return\n }\n if (frames++ < MAX_POLL_FRAMES) {\n rafId = requestAnimationFrame(tick)\n } else {\n rafId = null\n }\n }\n tick()\n }\n tryScrollToHash()\n window.addEventListener('hashchange', tryScrollToHash)\n return () => {\n window.removeEventListener('hashchange', tryScrollToHash)\n cancelPoll()\n }\n }, [readyDep, headerOffset])\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' &&\n 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","/**\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 \\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 =\n /[\\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 {\n createFenceTracker,\n isBlankLine,\n type FenceTracker,\n type FenceLineRole,\n} 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(\n content: string,\n options: ScanHeadingsOptions = {},\n): 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).replace(/-+/g, '-').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(\n rawId: string,\n doc: Document,\n): 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","import { scrollElementIntoView } from './scroll-into-view'\nimport { findAnchorElementByNormalizedId } from './anchor-id'\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(\n target: string,\n options: NavigateSamePageHashOptions = {},\n): boolean {\n if (typeof window === 'undefined') return false\n const { headerOffset = 0, history: historyMode = 'push' } = options\n const normalizedTarget =\n target.startsWith('#')\n ? window.location.pathname + window.location.search + target\n : 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 // eslint-disable-next-line no-console\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(new HashChangeEvent('hashchange', {\n oldURL,\n newURL: window.location.href,\n }))\n }\n const el = id ? getHashTargetElement(id) : null\n if (id && !el && process.env.NODE_ENV === 'development') {\n // eslint-disable-next-line no-console\n console.warn(\n `[navigateSamePageHash] anchor \"#${id}\" not found — scrolling to top.`,\n )\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","'use client'\n\nimport { useCallback, useRef } from 'react'\nimport {\n HONEYPOT_FIELD,\n ELAPSED_MS_FIELD,\n type HumanitySignals,\n} from '../utils/humanity-signals'\n\n/**\n * useHumanitySignals — client primitive backing the invisible bot-protection\n * layer. Owns the honeypot <input> ref + a mount timestamp, and exposes the\n * props to render the field plus a getter that produces the wire object to\n * merge into a form's POST body. Reused by every public form (contact,\n * waitlist, ticket, review) so the signal shape lives in exactly one place.\n *\n * Usage:\n * const { honeypotInputProps, getSignals, resetSignals } = useHumanitySignals()\n * // render: <HoneypotField {...honeypotInputProps} />\n * // submit: fetch(url, { body: JSON.stringify({ ...formData, ...getSignals() }) })\n * // after a successful submit: resetSignals()\n *\n * The two signals are origin-independent (they ride in the body), so they keep\n * working when the form is embedded behind a reverse-proxy / prefixed URL.\n */\nexport function useHumanitySignals() {\n const ref = useRef<HTMLInputElement>(null)\n // performance.now() is monotonic (immune to wall-clock skew). SSR-guarded\n // even though the hook only executes on the client.\n const mountedAt = useRef<number>(typeof performance !== 'undefined' ? performance.now() : 0)\n\n const getSignals = useCallback(\n (): HumanitySignals => ({\n [HONEYPOT_FIELD]: ref.current?.value ?? '',\n [ELAPSED_MS_FIELD]:\n typeof performance !== 'undefined' ? Math.round(performance.now() - mountedAt.current) : 0,\n }),\n [],\n )\n\n const resetSignals = useCallback(() => {\n if (ref.current) ref.current.value = ''\n if (typeof performance !== 'undefined') mountedAt.current = performance.now()\n }, [])\n\n return { honeypotInputProps: { ref, name: HONEYPOT_FIELD }, getSignals, resetSignals } as const\n}\n","/**\n * Humanity signals — invisible bot-protection primitives shared by the lib's\n * public forms (client) and the hub's per-route `verifyHuman` gate (server).\n *\n * PURE + React-free on purpose: this module is a tsup SERVER entry (no\n * \"use client\" banner) so the hub can import it server-side without pulling a\n * client-reference boundary — same pattern as `schemas/contact-schema` and\n * `components/features/mux-origins`.\n *\n * Two origin-independent signals travel in the POST body: a honeypot (a hidden\n * field real users never fill) and timing (ms from form mount to submit).\n * `evaluateHumanitySignals` is the SINGLE source of truth for the block/allow\n * decision — the hub imports + calls it rather than re-implementing the rules.\n */\n\n/** Hidden honeypot field name. Innocuous + autofill-resistant (deliberately NOT name/email). */\nexport const HONEYPOT_FIELD = 'contact_url_confirm'\n/** Client-measured ms between form mount and submit. */\nexport const ELAPSED_MS_FIELD = 'form_elapsed_ms'\n/** Default minimum fill time (ms). A submit faster than this is treated as a bot. */\nexport const DEFAULT_MIN_FILL_MS = 700\n\n/**\n * Every humanity-signal key that rides in a public form's POST body.\n * Server-side handlers that forward form payloads upstream (HubSpot booking,\n * CRM pushes, …) MUST strip by THIS array — never hand-typed strings — so a\n * field rename here propagates everywhere and the honeypot value can never\n * silently leak into an upstream record.\n */\nexport const HUMANITY_SIGNAL_KEYS = [HONEYPOT_FIELD, ELAPSED_MS_FIELD] as const\n\n/** Keyed wire object produced by `useHumanitySignals().getSignals()` and spread into the POST body. */\nexport type HumanitySignals = Record<string, string | number>\n\n/** Result of {@link evaluateHumanitySignals}. */\nexport type HumanityVerdict = { ok: true } | { ok: false; reason: 'honeypot' | 'too_fast' }\n\n/** Tolerant reader — never throws; missing/garbage timing → null. */\nexport function extractHumanitySignals(body: unknown): { honeypot: string; elapsedMs: number | null } {\n const b = (body ?? {}) as Record<string, unknown>\n const rawHp = b[HONEYPOT_FIELD]\n // A legit client always sends a STRING here (getSignals → ref.value ?? ''),\n // so ANY present non-string value is a bot filling the decoy with a non-string\n // to dodge the empty-check — coerce to a (non-empty) string so it still trips.\n // null/undefined → '' = the correct \"field absent / unfilled\" allow case.\n const honeypot = rawHp == null ? '' : String(rawHp)\n const rawMs = b[ELAPSED_MS_FIELD]\n const elapsedMs = typeof rawMs === 'number' && Number.isFinite(rawMs) ? rawMs : null\n return { honeypot, elapsedMs }\n}\n\n/**\n * SINGLE decision fn for honeypot + timing (the hub's `verifyHuman` imports + calls this):\n * - honeypot non-empty → bot (real users never fill the off-screen field)\n * - elapsed below `minFillMs` → bot (humans take time; a MISSING timing value never blocks)\n */\nexport function evaluateHumanitySignals(body: unknown, opts: { minFillMs: number }): HumanityVerdict {\n const { honeypot, elapsedMs } = extractHumanitySignals(body)\n if (honeypot.trim() !== '') return { ok: false, reason: 'honeypot' }\n if (elapsedMs !== null && elapsedMs < opts.minFillMs) return { ok: false, reason: 'too_fast' }\n return { ok: true }\n}\n\n/** Parse a comma-separated env string → trimmed, non-empty entries (undefined → []). */\nexport const splitCsvEnv = (s?: string): string[] =>\n s?.split(',').map((t) => t.trim()).filter(Boolean) ?? []\n"]}
|