{"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-YSG26SQ7.cjs","../src/components/docs/doc-viewer.tsx","../src/components/persistent-filter-controls.tsx","../src/components/docs/doc-navigation-context.tsx","../src/components/docs/use-docs-resolve-link.ts","../src/components/docs/use-document-tree.ts","../src/components/docs/use-scroll-spy.ts","../src/components/docs/skeletons.tsx","../src/components/docs/docs-hub-page.tsx"],"names":["jsx","useCallback","readString","useRef","pathParts","currentPath","useState","useMemo","jsxs"],"mappings":"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","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-YSG26SQ7.cjs","sourcesContent":[null,"'use client';\n\nimport type React from 'react';\nimport { useMemo } from 'react';\nimport { useRouter } from '../../embed-shims/next-navigation';\nimport type { DocContent, DocNode, DocRenderHandlers, DocSourceId } from '../../types/doc-source';\nimport { findDocNodeByPath } from '../../utils/doc-tree-nav';\nimport { PageShell } from '../layout/article-detail-layout';\nimport { PageLayout } from '../layout/page-layout';\nimport { CategorySidebarSkeleton } from '../loading/page-layout-skeleton';\nimport { MultiLevelNavigation, MobileNavigationDropdown } from '../navigation/multi-level-navigation';\nimport { StickySectionNav } from '../navigation/sticky-section-nav';\nimport { PersistentSidebar, PersistentMobileDropdown } from '../persistent-filter-controls';\nimport { DocSearchBar, useDocSearch } from '../shared/doc-search';\nimport { useDocNavigation } from './doc-navigation-context';\nimport { useDocsResolveLink } from './use-docs-resolve-link';\nimport { useDocumentTree } from './use-document-tree';\nimport { useScrollSpy } from './use-scroll-spy';\n\n/** Color tokens for the doc-viewer chrome. Hub-side `DocViewer` callers share\n *  this constant; no need to override per source — the palette is intentionally\n *  uniform across knowledge-base + data-room (both use ODS dark tokens). */\nexport const DEFAULT_DOC_VIEWER_PALETTE = {\n  background: 'bg-ods-bg',\n  containerBackground: 'transparent',\n  headerText: 'text-ods-text-primary',\n  primaryText: 'text-ods-text-primary',\n  secondaryText: 'text-ods-text-secondary',\n  accent: 'var(--ods-accent)',\n  border: 'border-ods-border',\n  cardBackground: 'bg-ods-card',\n} as const;\n\nexport interface DocViewerProps {\n  /**\n   * Registry source id (`'openframe-docs'`, `'data-room-docs'`, …). Flowed through\n   * `renderContent`'s handlers for `/api/docs/resolve-link` POSTs.\n   */\n  sourceId: DocSourceId;\n\n  /**\n   * Render the content body. The page shell owns this — it picks the markdown\n   * renderer, dispatches PDF/Figma/Sheets/file branches, etc. No renderer\n   * interface in between.\n   */\n  renderContent: (content: DocContent, handlers: DocRenderHandlers) => React.ReactNode;\n\n  /**\n   * Render the loading skeleton. Receives the selected node's `documentType`\n   * (undefined while structure is still loading) so the caller can return a\n   * markdown-shaped skeleton vs an embed-shaped skeleton.\n   */\n  renderSkeleton: (documentType: DocNode['documentType']) => React.ReactNode;\n\n  /**\n   * Chat-source identifier — passed in by the page shell from server-side\n   * `currentPlatform()`. Lib has no platform context; the page shell is the\n   * trusted boundary that wires this. NEVER pass user input here.\n   */\n  chatSource: string;\n\n  /** Page title — rendered as the inline hero `<h1>` (same DOM\n   *  `<DevSectionView>`'s hero uses) so the doc-viewer chrome matches the\n   *  dev-section pages. ReactNode is intentionally not supported here —\n   *  every consumer renders the same typography. */\n  title?: string;\n  /** Optional icon rendered inline before the title text — same slot\n   *  `<DevSectionView>`'s hero uses (Map for Roadmap, Rocket for Releases,\n   *  etc.). Pass a pre-rendered React element styled with\n   *  `SECTION_HERO_ICON_CLASS` (`h-10 w-10 text-ods-accent`) for visual\n   *  parity with other lib pages. */\n  titleIcon?: React.ReactNode;\n  /** Subtitle (h6, secondary text) rendered beneath the title. */\n  subtitle?: string;\n  /** Render a yellow accent dot (`.`) after the title — same flag as\n   *  the hub's legacy `<AdminPageHeader accentDot>` so the docs-hub\n   *  surface keeps its existing accent styling after the migration. */\n  accentDot?: boolean;\n  /** Override the default ODS palette. Optional — most callers should omit. */\n  colorPalette?: typeof DEFAULT_DOC_VIEWER_PALETTE;\n  className?: string;\n  /** Render the standalone `<PageShell>` (own `<main>` + bg + max-width). Default\n   *  true. Pass false when the host layout already provides the page container —\n   *  only the padding box renders, avoiding a nested `<main>`. */\n  shell?: boolean;\n\n  /** Initial doc path (URL `[...path]`). */\n  docPath?: string;\n\n  /** Sidebar header copy (`'DOCUMENTATION'`, `'DATA ROOM'`). */\n  sidebarLabel?: string;\n\n  /**\n   * API endpoint for fetching the document tree structure. Defaults to the\n   * dispatcher path `/api/docs/sources/${sourceId}/structure`. Override only\n   * if hosting the viewer behind a different route.\n   */\n  structureEndpoint?: string;\n  /** Same shape as `structureEndpoint`. Defaults to `/api/docs/sources/${sourceId}/content`. */\n  contentEndpoint?: string;\n  /** RAG-search endpoint that backs the in-source search bar (when `showAIChat`\n   *  is on). Defaults to `/api/docs/search`. Override for proxy-prefix embeds —\n   *  same injectability pattern as `structureEndpoint` / `contentEndpoint`. */\n  searchEndpoint?: string;\n  /** POST internal-link resolver. The viewer threads an async `onResolveLink`\n   *  into `renderContent`'s `handlers` that posts `{ link, currentPath, source }`\n   *  here. Defaults to `/api/docs/resolve-link`. Override for proxy-prefix embeds —\n   *  same injectability pattern as `structureEndpoint` / `contentEndpoint` /\n   *  `searchEndpoint`, with `ChatRuntime.endpoints.docsResolveLinkUrl` as a\n   *  runtime fallback (prop → runtime → default). */\n  resolveLinkEndpoint?: string;\n  /** Base route path for URL navigation. */\n  baseRoute: string;\n\n  /** Empty state copy when no doc is selected. */\n  emptyStateText?: string;\n\n  /** Whether to render the doc-search bar (bound to chat). */\n  showAIChat?: boolean;\n\n  /** Folder-index filename (default `'README.md'`). */\n  folderIndexFile?: string;\n\n  /** Back-button shown above the title. Mirrors `<DevSectionPage>` /\n   *  `<HelpCenterList>` / `<LegalDocumentPage>` so every embeddable surface\n   *  shares the same chrome. Defaults to `{ label: 'Back to home', href: '/' }`.\n   *  Pass `false` to hide; pass `{ href: '/docs' }` etc. when the embed's\n   *  home isn't `/`. */\n  backButton?: { label?: string; href?: string } | false;\n}\n\nexport function DocViewer(props: DocViewerProps) {\n  return <DocViewerContent {...props} />;\n}\n\nfunction DocViewerContent({\n  sourceId,\n  renderContent,\n  renderSkeleton,\n  chatSource,\n  title,\n  subtitle,\n  colorPalette = DEFAULT_DOC_VIEWER_PALETTE,\n  className = '',\n  shell = true,\n  docPath,\n  sidebarLabel = 'DOCUMENTATION',\n  structureEndpoint,\n  contentEndpoint,\n  searchEndpoint,\n  resolveLinkEndpoint,\n  baseRoute,\n  emptyStateText,\n  showAIChat = false,\n  folderIndexFile,\n  backButton,\n}: DocViewerProps) {\n  // Default endpoints derived from sourceId. Hub callers omit the props in 99%\n  // of cases; the override is for embed contexts where the doc-viewer sits\n  // behind a non-standard route.\n  const resolvedStructureEndpoint = structureEndpoint ?? `/api/docs/sources/${sourceId}/structure`;\n  const resolvedContentEndpoint = contentEndpoint ?? `/api/docs/sources/${sourceId}/content`;\n  // Resolve-link endpoint chain (prop → ChatRuntime.endpoints → hub default)\n  // + the full fetch + JSON-parse pipeline live in `useDocsResolveLink`.\n  // Keeping it factored out as a proper hook makes the contract reusable\n  // by any embedder rendering doc content outside `<DocViewer>` (custom\n  // markdown renderers, link-resolver previews, etc.) and keeps this\n  // component focused on layout + state.\n  const resolveLink = useDocsResolveLink(sourceId, resolveLinkEndpoint);\n  const {\n    structure,\n    selectedPath,\n    content,\n    isLoadingStructure,\n    isLoadingContent,\n    error,\n    expandedNodes,\n    selectNode,\n    toggleNode,\n    navigateToDoc,\n  } = useDocumentTree(\n    {\n      structureEndpoint: resolvedStructureEndpoint,\n      contentEndpoint: resolvedContentEndpoint,\n      baseRoute,\n      folderIndexFile,\n    },\n    docPath,\n  );\n\n  const { activeSection, handleSectionClick } = useScrollSpy(content?.sections);\n\n  const docNav = useDocNavigation();\n\n  // Back-button config — mirrors `<DevSectionPage>` so the docs surface\n  // matches every other embeddable page's chrome. Default target is `/`\n  // (the embed's home); pass `backButton: false` to hide entirely, or\n  // override the href when the embed's home isn't `/`.\n  const router = useRouter();\n  const backCfg =\n    backButton === false\n      ? null\n      : {\n          label: backButton?.label ?? 'Back to home',\n          onClick: () => router.push(backButton?.href ?? '/'),\n        };\n  const docSearch = useDocSearch({\n    source: chatSource,\n    baseRoute,\n    searchEndpoint,\n    onNavigate: path => navigateToDoc(path, { fromInternalLink: true }),\n    onInPageSwap: path => docNav.navigate(path),\n  });\n\n  const renderedContent = useMemo(() => {\n    if (!content) return null;\n    return renderContent(content, {\n      onInternalLinkClick: navigateToDoc,\n      // Relative-link base = the RENDERED document's path, NOT `selectedPath`.\n      // They diverge for a no-README folder: selection stays on the folder\n      // (e.g. `repo/diagrams`) while the body is its first descendant doc\n      // (e.g. `repo/diagrams/architecture/README.md`, via `findFirstDocPath`).\n      // Resolving `./sibling.mmd` against the folder would 404; resolving it\n      // against `content.path` lands in the descendant's directory. The DAL\n      // sets `content.path` to the served doc in all cases (file / README\n      // folder / first-child fallback), so this is correct everywhere.\n      currentPath: content.path,\n      sourceId,\n      onResolveLink: resolveLink,\n    });\n  }, [content, renderContent, navigateToDoc, sourceId, resolveLink]);\n\n  // Selected node's documentType drives:\n  //   - which skeleton the caller renders during fetch (markdown vs embed)\n  //   - the article max-width + sticky-nav visibility (markdown only)\n  // `undefined` documentType is treated as `'markdown'` (per the DocNode\n  // discriminator's documented default).\n  const selectedNodeDocType =\n    selectedPath && structure.length > 0 ? findDocNodeByPath(selectedPath, structure)?.documentType : undefined;\n  // During loading, the in-flight content's type isn't known yet — fall back\n  // to the selected node's type (or markdown if neither is set).\n  const activeDocType = content?.documentType ?? selectedNodeDocType;\n  const isMarkdownContent = !activeDocType || activeDocType === 'markdown';\n  const showStickyNav = isMarkdownContent;\n\n  const stickyNavSections = content?.sections?.map(s => ({ id: s.id, label: s.title })) ?? [];\n\n  const isColorValue =\n    colorPalette.background.startsWith('#') ||\n    colorPalette.background.startsWith('rgb') ||\n    colorPalette.background.startsWith('var(');\n\n  const bgStyle = isColorValue ? { backgroundColor: colorPalette.background } : {};\n  const bgClass = !isColorValue ? colorPalette.background : '';\n  const containerBgStyle =\n    colorPalette.containerBackground !== 'transparent' ? { backgroundColor: colorPalette.containerBackground } : {};\n\n  const defaultEmptyText =\n    structure.length > 0\n      ? 'Select a document from the sidebar to view'\n      : 'No documents yet. Add content from the admin panel.';\n  const resolvedEmptyText = emptyStateText || defaultEmptyText;\n\n  // Unified header: title/subtitle route through the canonical (frozen)\n  // `PageLayout` `TitleBlock` (text-h2) — same as every other help-center page —\n  // so the docs hub shares one header. The `gap-10` column then holds the search\n  // bar + content grid. `colorPalette` / `className` / `bgStyle` flow through the\n  // shell's contentClassName + an inner style-passthrough wrapper.\n  const inner = (\n    <div style={{ ...bgStyle, ...containerBgStyle }}>\n      <PageLayout title={title} subtitle={subtitle} titleSize=\"h1\" titleWrap backButton={backCfg ?? undefined}>\n        <div className=\"flex w-full flex-col gap-10\">\n          {showAIChat && (\n            <DocSearchBar\n              placeholder={`Search ${sidebarLabel?.toLowerCase() || 'documents'}...`}\n              query={docSearch.query}\n              onQueryChange={docSearch.setQuery}\n              results={docSearch.results}\n              isLoading={docSearch.isLoading}\n              onResultSelect={docSearch.handleResultSelect}\n              showDropdown={docSearch.keepDropdownOpen}\n            />\n          )}\n\n          {error && (\n            <div className=\"flex justify-center\">\n              <div className=\"max-w-md rounded-lg border border-ods-border bg-ods-card p-8 text-center\">\n                <h2 className=\"text-ods-text-primary text-h3\">Error Loading Documents</h2>\n                <p className=\"mt-2 text-ods-text-secondary\">{error}. Please try again later.</p>\n              </div>\n            </div>\n          )}\n\n          {!error && (\n            <div className=\"flex flex-1 flex-col items-start gap-6 lg:flex-row lg:gap-10\">\n              <div className=\"w-full lg:w-[320px] lg:shrink-0\">\n                <div className=\"lg:sticky lg:top-20\">\n                  {isLoadingStructure ? (\n                    <CategorySidebarSkeleton />\n                  ) : (\n                    <>\n                      <PersistentMobileDropdown isLoading={false}>\n                        <MobileNavigationDropdown\n                          nodes={structure}\n                          selectedPath={selectedPath}\n                          expandedNodes={expandedNodes}\n                          onNodeClick={selectNode}\n                          onToggleExpand={toggleNode}\n                          isLoading={false}\n                          folderIndexFile={folderIndexFile}\n                        />\n                      </PersistentMobileDropdown>\n\n                      <PersistentSidebar isLoading={false}>\n                        <div className=\"hidden lg:block\">\n                          <div className=\"space-y-4\">\n                            <h3 className=\"font-semibold text-ods-text-secondary text-h5\">{sidebarLabel}</h3>\n                            <MultiLevelNavigation\n                              nodes={structure}\n                              selectedPath={selectedPath}\n                              expandedNodes={expandedNodes}\n                              onNodeClick={selectNode}\n                              onToggleExpand={toggleNode}\n                              isLoading={false}\n                              folderIndexFile={folderIndexFile}\n                            />\n                          </div>\n                        </div>\n                      </PersistentSidebar>\n                    </>\n                  )}\n                </div>\n              </div>\n\n              <div className=\"w-full min-w-0 flex-1\">\n                <div\n                  className={`grid grid-cols-1 ${\n                    // \"On this page\" right column only makes sense for\n                    // MARKDOWN content (PDFs / Sheets / Figma / file have no\n                    // sections to navigate to). Gating the grid template on\n                    // `isMarkdownContent` also suppresses the section-skeleton\n                    // bars during embed loads — the user-reported \"skeleton\n                    // shouldn't be on file pages\" bug.\n                    isMarkdownContent &&\n                    ((showStickyNav && stickyNavSections.length > 0) || isLoadingContent || isLoadingStructure)\n                      ? 'lg:grid-cols-[1fr_280px]'\n                      : ''\n                  } gap-8`}\n                >\n                  {/* min-w-0: grid items default to min-width:auto, which would\n                      let a long unbreakable token push this column past the\n                      track width. Pair with the inherited overflow-wrap:anywhere\n                      (app-globals.css) so content wraps instead of overflowing. */}\n                  <div className={`w-full min-w-0 ${isMarkdownContent ? 'mx-auto max-w-4xl' : ''}`}>\n                    <article className=\"space-y-2\">\n                      {isLoadingContent || isLoadingStructure ? (\n                        renderSkeleton(selectedNodeDocType)\n                      ) : !content ? (\n                        <div className=\"py-16 text-center\">\n                          <p className=\"text-ods-text-secondary text-h4\">{resolvedEmptyText}</p>\n                        </div>\n                      ) : (\n                        renderedContent\n                      )}\n                    </article>\n                  </div>\n\n                  {isMarkdownContent && (isLoadingContent || isLoadingStructure) && (\n                    <div className=\"hidden lg:block\">\n                      <div className=\"sticky top-24\">\n                        <div className=\"mb-5 h-[14px] w-28 animate-pulse rounded bg-ods-border\" />\n                        <div className=\"space-y-0\">\n                          {[130, 170, 190, 220, 110, 200, 80, 100, 120, 140, 90].map((w, i) => (\n                            <div\n                              key={i}\n                              className={`border-l-2 py-[13px] pl-3 ${\n                                i === 0 ? 'border-ods-accent' : 'border-transparent'\n                              }`}\n                            >\n                              <div className=\"h-[13px] animate-pulse rounded bg-ods-border\" style={{ width: w }} />\n                            </div>\n                          ))}\n                        </div>\n                      </div>\n                    </div>\n                  )}\n\n                  {showStickyNav && content && stickyNavSections.length > 0 && !isLoadingContent && (\n                    <div className=\"hidden lg:block\">\n                      <div className=\"sticky top-24\">\n                        <h3 className=\"mb-4 font-semibold text-ods-text-secondary text-h5\">ON THIS PAGE</h3>\n                        <StickySectionNav\n                          sections={stickyNavSections}\n                          activeSection={activeSection}\n                          onSectionClick={handleSectionClick}\n                          ribbonPosition=\"left\"\n                          ribbonColor=\"var(--ods-accent)\"\n                        />\n                      </div>\n                    </div>\n                  )}\n                </div>\n              </div>\n            </div>\n          )}\n        </div>\n      </PageLayout>\n    </div>\n  );\n\n  // `shell` true → standalone `<PageShell>`; false → padding-only box (no nested\n  // <main>) for hosts whose layout already provides the container. Both carry the\n  // palette/className via the same `page-shell-content` styling hook.\n  return shell ? (\n    <PageShell contentClassName={`${bgClass} ${className}`}>{inner}</PageShell>\n  ) : (\n    <div className={`page-shell-content ${bgClass} ${className}`.trim()}>{inner}</div>\n  );\n}\n","'use client';\n\nimport type { ReactNode } from 'react';\nimport { cn } from '../utils/cn';\n\ninterface PersistentFilterControlsProps {\n  /**\n   * Whether the filters are currently in a loading state\n   */\n  isLoading: boolean;\n  /**\n   * The filter control components (search, sidebar, mobile dropdown)\n   */\n  children: ReactNode;\n  /**\n   * Additional CSS classes\n   */\n  className?: string;\n  /**\n   * Disabled opacity (0-1)\n   */\n  disabledOpacity?: number;\n  /**\n   * Transition duration in milliseconds\n   */\n  transitionDuration?: number;\n  /**\n   * Whether to prevent pointer events during loading\n   */\n  preventInteraction?: boolean;\n}\n\n/**\n * PersistentFilterControls\n *\n * A wrapper component that keeps filter controls visible during loading states\n * but provides visual feedback that they are temporarily disabled.\n *\n * Features:\n * - Reduces opacity and disables pointer events during loading\n * - Maintains layout and accessibility during loading states\n * - Provides smooth transitions between enabled/disabled states\n * - Preserves keyboard navigation and screen reader functionality\n * - Applies consistent disabled styling across all filter types\n *\n * Usage:\n * ```tsx\n * <PersistentFilterControls isLoading={isLoadingVendors}>\n *   <SearchContainer />\n *   <CategoryFilterSidebar />\n *   <MobileDropdown />\n * </PersistentFilterControls>\n * ```\n */\nexport function PersistentFilterControls({\n  isLoading,\n  children,\n  className,\n  disabledOpacity = 0.6,\n  transitionDuration = 300,\n  preventInteraction = true,\n}: PersistentFilterControlsProps) {\n  return (\n    <div\n      className={cn(\n        'relative transition-all ease-in-out',\n        isLoading && preventInteraction && 'pointer-events-none',\n        className,\n      )}\n      style={{\n        opacity: isLoading ? disabledOpacity : 1,\n        transitionDuration: `${transitionDuration}ms`,\n      }}\n      role=\"region\"\n      aria-label=\"Filter controls\"\n      aria-busy={isLoading}\n      data-loading={isLoading}\n    >\n      {/* Loading indicator overlay for screen readers */}\n      {isLoading && (\n        <div className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n          Filters temporarily disabled while loading content\n        </div>\n      )}\n\n      {/* Filter controls with disabled state styling */}\n      <div\n        className={cn('transition-all ease-in-out', isLoading && 'cursor-not-allowed')}\n        style={{\n          transitionDuration: `${transitionDuration}ms`,\n        }}\n      >\n        {children}\n      </div>\n\n      {/* Visual loading indicator */}\n      {isLoading && (\n        <div className=\"absolute right-2 top-2 z-10\" role=\"status\" aria-label=\"Loading filters\">\n          <div className=\"h-4 w-4 animate-spin rounded-full border-2 border-ods-border border-t-ods-accent\" />\n        </div>\n      )}\n    </div>\n  );\n}\n\n/**\n * Enhanced SearchContainer wrapper that integrates with persistent loading\n */\ninterface PersistentSearchContainerProps {\n  isLoading: boolean;\n  children: ReactNode;\n  className?: string;\n}\n\nexport function PersistentSearchContainer({ isLoading, children, className }: PersistentSearchContainerProps) {\n  return (\n    <PersistentFilterControls\n      isLoading={isLoading}\n      preventInteraction={false} // Keep search interactive during loading\n      className={className}\n    >\n      {children}\n    </PersistentFilterControls>\n  );\n}\n\n/**\n * Enhanced sidebar wrapper for desktop filter controls\n */\ninterface PersistentSidebarProps {\n  isLoading: boolean;\n  children: ReactNode;\n  className?: string;\n}\n\nexport function PersistentSidebar({ isLoading, children, className }: PersistentSidebarProps) {\n  return (\n    <PersistentFilterControls\n      isLoading={isLoading}\n      disabledOpacity={0.5}\n      className={cn('lg:sticky lg:top-20', className)}\n    >\n      {children}\n    </PersistentFilterControls>\n  );\n}\n\n/**\n * Enhanced mobile dropdown wrapper\n */\ninterface PersistentMobileDropdownProps {\n  isLoading: boolean;\n  children: ReactNode;\n  className?: string;\n}\n\nexport function PersistentMobileDropdown({ isLoading, children, className }: PersistentMobileDropdownProps) {\n  return (\n    <PersistentFilterControls isLoading={isLoading} disabledOpacity={0.7} className={cn('lg:hidden', className)}>\n      {children}\n    </PersistentFilterControls>\n  );\n}\n","'use client';\n\nimport React, { createContext, useCallback, useContext, useRef, type ReactNode } from 'react';\nimport type { DocNode } from '../../types/doc-source';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport interface DocNavigator {\n  /** Base route this navigator owns (e.g., '/knowledge-base', '/data-room'). */\n  baseRoute: string;\n  /** Look up a node in the tree by its storage path (e.g., 'openframe-cli/README.md'). */\n  findNodeByPath: (path: string) => DocNode | null;\n  /** Navigate to a node using the same flow as a sidebar menu click. */\n  selectNode: (node: DocNode) => void;\n}\n\ninterface DocNavigationContextValue {\n  /**\n   * Register a navigator for a `baseRoute`. Returns a cleanup function that\n   * removes the navigator from the map IF the slot still owns it (StrictMode-safe).\n   * Duplicate registration for the same baseRoute logs a console.warn and replaces.\n   */\n  register: (nav: DocNavigator) => () => void;\n  /**\n   * Navigate to a path inside whichever registered navigator owns its baseRoute prefix.\n   * Returns true if handled, false otherwise (caller falls back to opening in a new tab).\n   */\n  navigate: (path: string) => boolean;\n  /** Whether ANY navigator is currently mounted. */\n  isAvailable: () => boolean;\n}\n\n// =============================================================================\n// Context\n// =============================================================================\n\nconst DocNavigationContext = createContext<DocNavigationContextValue | null>(null);\n\n/**\n * Bridges `useDocumentTree` instances (deep in the page tree) to `GlobalAskAI`\n * (high up near the root) without global events or URL parsing.\n *\n * Multi-navigator design: a `Map<baseRoute, DocNavigator>` lets multiple\n * viewers coexist (nested viewers, dual-pane scenarios). Today only one\n * viewer mounts at a time, so behavior is unchanged.\n */\nexport function DocNavigationProvider({ children }: { children: ReactNode }) {\n  const navMap = useRef<Map<string, DocNavigator>>(new Map());\n\n  const register = useCallback((nav: DocNavigator) => {\n    const existing = navMap.current.get(nav.baseRoute);\n    if (existing && existing !== nav) {\n      console.warn(`[DocNavigationContext] duplicate registration for ${nav.baseRoute}; replacing`);\n    }\n    navMap.current.set(nav.baseRoute, nav);\n    return () => {\n      // StrictMode-safe identity check: only delete if THIS nav still owns the slot.\n      if (navMap.current.get(nav.baseRoute) === nav) {\n        navMap.current.delete(nav.baseRoute);\n      }\n    };\n  }, []);\n\n  const findOwningNavigator = useCallback((path: string): DocNavigator | null => {\n    // First try: a navigator whose baseRoute is a prefix of `path`. Walks the\n    // longest match first so nested routes win over a parent.\n    const baseRoutes = Array.from(navMap.current.keys()).sort((a, b) => b.length - a.length);\n    for (const baseRoute of baseRoutes) {\n      if (path === baseRoute || path.startsWith(`${baseRoute}/`)) {\n        return navMap.current.get(baseRoute) ?? null;\n      }\n    }\n    // Fallback: a single navigator handles any path (existing behavior — chat\n    // chips emit storage paths, not absolute URLs, so prefix matching misses).\n    if (navMap.current.size === 1) {\n      return navMap.current.values().next().value ?? null;\n    }\n    return null;\n  }, []);\n\n  const navigate = useCallback(\n    (path: string): boolean => {\n      const nav = findOwningNavigator(path);\n      if (!nav) return false;\n      const node = nav.findNodeByPath(path);\n      if (!node) return false;\n      nav.selectNode(node);\n      return true;\n    },\n    [findOwningNavigator],\n  );\n\n  const isAvailable = useCallback(() => navMap.current.size > 0, []);\n\n  const value = React.useMemo(() => ({ register, navigate, isAvailable }), [register, navigate, isAvailable]);\n\n  return <DocNavigationContext.Provider value={value}>{children}</DocNavigationContext.Provider>;\n}\n\n// =============================================================================\n// Hooks\n// =============================================================================\n\nexport function useDocNavigation(): DocNavigationContextValue {\n  const ctx = useContext(DocNavigationContext);\n  if (!ctx) {\n    // Safe no-op fallback when used outside the provider.\n    return {\n      register: () => () => {},\n      navigate: () => false,\n      isAvailable: () => false,\n    };\n  }\n  return ctx;\n}\n","import { useCallback } from 'react';\nimport { useChatRuntime } from '../../contexts/chat-runtime-context';\nimport type { ResolveLinkResult } from '../../types/doc-source';\nimport { contentFetch } from '../../utils/embed-content-fetch';\n\n/**\n * `useDocsResolveLink(sourceId, override?)` — POST `/api/docs/resolve-link`\n * (or the override / `ChatRuntime.endpoints.docsResolveLinkUrl`) for a\n * relative href inside a doc body, returning a `ResolveLinkResult`\n * envelope.\n *\n * The endpoint chain (`override ?? runtime.endpoints.docsResolveLinkUrl\n * ?? '/api/docs/resolve-link'`) mirrors `searchEndpoint` resolution in\n * `<DocViewer>` so embedders configure both the same way: per-instance\n * prop OR ambient `ChatRuntimeProvider`.\n *\n * The full fetch + JSON-parse pipeline is wrapped in try/catch so a\n * network throw (DNS / CORS / offline) or a non-JSON response surfaces\n * as `{ success: false, error }` — the markdown renderer's broken-link\n * badge handles that branch instead of swallowing an unhandled rejection\n * past the click handler.\n */\n/**\n * Narrow the endpoint's answer to a `ResolveLinkResult`.\n *\n * `Response.json()` hands back `any`, and the route answers either the bare\n * result or a `{ data: … }` envelope — so the shape is checked instead of\n * trusted: a non-object body, or a field of the wrong primitive type, degrades\n * to `{ success: false }`, which is the \"leave the link alone\" branch the\n * markdown renderer already handles.\n *\n * Mirrors `toResolveLinkResult` in `ui/markdown/rich/rich-markdown-renderer`,\n * which narrows the same wire shape for the renderer's own fetch (that copy\n * cannot be imported here — it would pull the whole rich renderer chunk into\n * this hook).\n */\nfunction toResolveLinkResult(body: unknown): ResolveLinkResult {\n  if (typeof body !== 'object' || body === null) {\n    return { success: false };\n  }\n  const envelope: Record<string, unknown> = { ...body };\n  const inner = envelope.data;\n  const fields: Record<string, unknown> = typeof inner === 'object' && inner !== null ? { ...inner } : envelope;\n  const readString = (key: string): string | undefined => (typeof fields[key] === 'string' ? fields[key] : undefined);\n\n  return {\n    success: fields.success === true,\n    resolvedPath: readString('resolvedPath'),\n    type: readString('type'),\n    action: readString('action'),\n    error: readString('error'),\n    message: readString('message'),\n  };\n}\n\nexport function useDocsResolveLink(sourceId: string, resolveLinkEndpoint?: string | null) {\n  const chatRuntime = useChatRuntime();\n  const resolvedResolveLinkEndpoint =\n    resolveLinkEndpoint ?? chatRuntime?.endpoints.docsResolveLinkUrl ?? '/api/docs/resolve-link';\n\n  return useCallback(\n    async (href: string, currentPath: string): Promise<ResolveLinkResult> => {\n      try {\n        const response = await contentFetch(resolvedResolveLinkEndpoint, {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({ link: href, currentPath, source: sourceId }),\n        });\n        if (!response.ok) {\n          return { success: false, error: `Resolve failed: ${response.status}` };\n        }\n        const json: unknown = await response.json();\n        return toResolveLinkResult(json);\n      } catch (error) {\n        return {\n          success: false,\n          error: error instanceof Error ? error.message : 'Resolve failed',\n        };\n      }\n    },\n    [resolvedResolveLinkEndpoint, sourceId],\n  );\n}\n","'use client';\n\nimport { useState, useEffect, useCallback, useMemo, useRef } from 'react';\nimport type { DocNode, DocContent, DocumentType } from '../../types/doc-source';\nimport { apiErrorMessage } from '../../utils/common';\nimport {\n  stripFolderIndexFromPath,\n  findDocNodeByPath,\n  getDocAncestorNodeIds,\n  DEFAULT_FOLDER_INDEX_FILE,\n} from '../../utils/doc-tree-nav';\nimport { contentFetch } from '../../utils/embed-content-fetch';\nimport { navigateSamePageHash, HUB_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav';\nimport { scrollElementIntoView } from '../../utils/scroll-into-view';\nimport { useDocNavigation } from './doc-navigation-context';\n\nfunction scrollToContent() {\n  const article = document.querySelector('article');\n  if (article) {\n    scrollElementIntoView(article, { headerOffset: HUB_HEADER_OFFSET_PX });\n  } else {\n    // Same anchoring-proof tween for the no-article fallback — native smooth\n    // scrollTo is cancelled by scroll anchoring while the new doc renders in.\n    scrollElementIntoView(document.documentElement);\n  }\n}\n\n/**\n * First displayable document inside a folder (depth-first): a direct non-Mermaid\n * file, else the first doc found in a subfolder. Used so a folder WITHOUT a\n * README shows its first child's content (mirroring how a README folder shows\n * its README) instead of a blank panel or a redundant in-page listing — the\n * sidebar tree is the directory browser. Returns null for a folder with no docs.\n */\nfunction findFirstDocPath(folder: DocNode): string | null {\n  const children = folder.children ?? [];\n  for (const child of children) {\n    if (child.type === 'file' && !child.path.toLowerCase().endsWith('.mmd')) {\n      return child.path;\n    }\n  }\n  for (const child of children) {\n    if (child.type === 'folder') {\n      const nested = findFirstDocPath(child);\n      if (nested) return nested;\n    }\n  }\n  return null;\n}\n\n// ---------------------------------------------------------------------------\n// Wire decoding for the two doc-source endpoints\n//\n// `Response.json()` is typed `any`, so every field of both envelopes used to be\n// read off an `any` and the whole tree walk below inherited it. Both endpoints\n// are untrusted input — an embedder's reverse proxy answers them — so the shape\n// is validated once here instead of being trusted at ~30 read sites.\n//\n// Mirrors the narrowing `toResolveLinkResult` does for the resolve-link\n// envelope in `use-docs-resolve-link.ts`; that decoder covers a different\n// endpoint and is not reusable here.\n// ---------------------------------------------------------------------------\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return typeof value === 'object' && value !== null;\n}\n\n/** `Array.isArray` narrows `unknown` to `any[]`; this keeps the elements `unknown`. */\nfunction isUnknownArray(value: unknown): value is readonly unknown[] {\n  return Array.isArray(value);\n}\n\nfunction readString(source: Record<string, unknown>, key: string): string | undefined {\n  const value = source[key];\n  return typeof value === 'string' ? value : undefined;\n}\n\nfunction isDocumentType(value: unknown): value is DocumentType {\n  return value === 'markdown' || value === 'pdf' || value === 'google_sheet' || value === 'figma' || value === 'file';\n}\n\n/**\n * One tree node, or null when the row cannot be rendered.\n *\n * `id`, `name`, `path` and `type` are load-bearing: the sidebar calls\n * `node.name.endsWith('.md')` and every nav helper keys off `id`/`path`, so a\n * row missing one of them throws while rendering the tree rather than merely\n * looking wrong. Dropping the row keeps the rest of the tree browsable.\n *\n * `slug` is declared on `DocNode` but read nowhere in the lib, and the shared\n * `TreeNodeBase` producer contract does not carry it — so it is derived from\n * the path (the derivation the doc-source DALs use) when the payload omits it.\n */\nfunction toDocNode(value: unknown): DocNode | null {\n  if (!isRecord(value)) return null;\n\n  const id = readString(value, 'id');\n  const name = readString(value, 'name');\n  const path = readString(value, 'path');\n  const type = value.type;\n  if (id === undefined || name === undefined || path === undefined) return null;\n  if (type !== 'file' && type !== 'folder') return null;\n\n  const sortOrder = value.sortOrder;\n  const hasReadme = value.hasReadme;\n\n  return {\n    id,\n    name,\n    slug: readString(value, 'slug') ?? path.split('/').pop() ?? '',\n    path,\n    type,\n    hasReadme: typeof hasReadme === 'boolean' ? hasReadme : undefined,\n    sortOrder: typeof sortOrder === 'number' ? sortOrder : undefined,\n    documentType: isDocumentType(value.documentType) ? value.documentType : undefined,\n    children: toDocNodes(value.children) ?? undefined,\n  };\n}\n\n/** The tree, or null when the payload is not an array at all (a failed load). */\nfunction toDocNodes(value: unknown): DocNode[] | null {\n  if (!isUnknownArray(value)) return null;\n  const nodes: DocNode[] = [];\n  for (const entry of value) {\n    const node = toDocNode(entry);\n    if (node) nodes.push(node);\n  }\n  return nodes;\n}\n\nfunction toDocSections(value: unknown): DocContent['sections'] {\n  if (!isUnknownArray(value)) return [];\n  const sections: DocContent['sections'] = [];\n  for (const entry of value) {\n    if (!isRecord(entry)) continue;\n    const id = readString(entry, 'id');\n    const title = readString(entry, 'title');\n    const level = entry.level;\n    if (id === undefined || title === undefined || typeof level !== 'number') continue;\n    sections.push({ id, title, level });\n  }\n  return sections;\n}\n\nfunction toStringArray(value: unknown): string[] | undefined {\n  if (!isUnknownArray(value)) return undefined;\n  return value.filter((entry): entry is string => typeof entry === 'string');\n}\n\n/**\n * One document body, or null when the payload is not an object.\n *\n * `content` is declared required but is empty by nature for the rich document\n * types — a `pdf` / `google_sheet` / `figma` doc renders purely from `fileUrl`\n * / `externalUrl` (see `docs-hub-page.tsx`) — so a missing `content` is\n * normalized to `''` rather than treated as a failed load.\n *\n * `sections` and `path` are also declared required, and both leak when absent:\n * `path` feeds `DocRenderHandlers.currentPath`, which is what every relative\n * link inside the body is resolved against. `requestedPath` is the path this\n * fetch asked for, which is the correct value whenever the server omits it.\n */\nfunction toDocContent(value: unknown, requestedPath: string): DocContent | null {\n  if (!isRecord(value)) return null;\n\n  const fileSize = value.fileSize;\n\n  return {\n    content: readString(value, 'content') ?? '',\n    sections: toDocSections(value.sections),\n    path: readString(value, 'path') ?? requestedPath,\n    documentType: isDocumentType(value.documentType) ? value.documentType : undefined,\n    brokenLinks: toStringArray(value.brokenLinks),\n    fileUrl: readString(value, 'fileUrl'),\n    externalUrl: readString(value, 'externalUrl'),\n    mimeType: readString(value, 'mimeType'),\n    fileName: readString(value, 'fileName'),\n    fileSize: typeof fileSize === 'number' ? fileSize : undefined,\n    publishedAt: readString(value, 'publishedAt'),\n    updatedAt: readString(value, 'updatedAt'),\n  };\n}\n\nexport interface UseDocumentTreeConfig {\n  /** API endpoint for fetching the document tree structure */\n  structureEndpoint: string;\n  /** API endpoint for fetching document content */\n  contentEndpoint: string;\n  /** Base route path for URL navigation (e.g., '/knowledge-base', '/data-room') */\n  baseRoute: string;\n  /** Folder-index filename (defaults to 'README.md'). */\n  folderIndexFile?: string;\n}\n\n/**\n * Generic hook for document tree navigation and content fetching.\n * Drives DocViewer across all doc-source consumers.\n *\n * Client-only: structure + content fetches run in parallel on first mount.\n * No SSR pre-population — the previous SSR path required a Supabase admin\n * client (service role key) and silently fell back to client fetches on\n * envs where the key wasn't set; the parallel client fetches keep behavior\n * uniform across local + prod (latency ~= max(structure, content), not sum).\n */\nexport function useDocumentTree(config: UseDocumentTreeConfig, initialPath?: string) {\n  const { structureEndpoint, contentEndpoint, baseRoute } = config;\n  const folderIndexFile = config.folderIndexFile ?? DEFAULT_FOLDER_INDEX_FILE;\n\n  const cleanInitialPath = stripFolderIndexFromPath(initialPath?.replace(/\\/$/, '') || '', folderIndexFile);\n\n  const [structure, setStructure] = useState<DocNode[]>([]);\n  const [selectedPath, setSelectedPath] = useState<string>(cleanInitialPath);\n  const [content, setContent] = useState<DocContent | null>(null);\n  const [isLoadingStructure, setIsLoadingStructure] = useState(true);\n  const [isLoadingContent, setIsLoadingContent] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());\n  const lastFetchedPath = useRef<string | null>(null);\n\n  const normalizedBaseRoute = baseRoute.replace(/\\/$/, '');\n\n  // Refreshed after every commit rather than in the render body: the reader is\n  // a popstate listener, which cannot fire before a commit, and a discarded\n  // render attempt must not leave a path behind that was never navigated to.\n  const selectedPathRef = useRef(selectedPath);\n  useEffect(() => {\n    selectedPathRef.current = selectedPath;\n  });\n\n  const docNavigation = useDocNavigation();\n\n  useEffect(() => {\n    const handlePopState = () => {\n      const pathname = window.location.pathname;\n      let pathFromUrl = '';\n\n      if (pathname === normalizedBaseRoute || pathname === `${normalizedBaseRoute}/`) {\n        pathFromUrl = '';\n      } else if (pathname.startsWith(`${normalizedBaseRoute}/`)) {\n        pathFromUrl = pathname.substring(`${normalizedBaseRoute}/`.length);\n      }\n\n      pathFromUrl = stripFolderIndexFromPath(pathFromUrl, folderIndexFile);\n\n      if (pathFromUrl !== selectedPathRef.current) {\n        setSelectedPath(pathFromUrl);\n        if (pathFromUrl) {\n          const parentPath = pathFromUrl.includes('/')\n            ? pathFromUrl.substring(0, pathFromUrl.lastIndexOf('/'))\n            : pathFromUrl;\n          setExpandedNodes(new Set(getDocAncestorNodeIds(parentPath)));\n        }\n        setTimeout(() => {\n          scrollToContent();\n        }, 150);\n      }\n    };\n\n    window.addEventListener('popstate', handlePopState);\n    return () => window.removeEventListener('popstate', handlePopState);\n  }, [normalizedBaseRoute, folderIndexFile]);\n\n  // External-URL → state sync. The popstate listener above catches browser\n  // back/forward, but client-side routers (react-router, Next App Router…)\n  // change the URL via `history.pushState` which does NOT fire popstate.\n  // The host re-renders the viewer with a new `initialPath` prop instead, so\n  // we mirror the popstate logic here against the (memoized) `cleanInitialPath`.\n  // Without this, a chat-card click that soft-navigates via react-router\n  // updates the URL but the viewer stays on the previously-selected doc.\n  //\n  // Adjusted while rendering — React's documented pattern for a prop-driven\n  // sync — rather than from an effect: the viewer renders `selectedPath` and\n  // `expandedNodes` in THIS render, so an effect painted a full frame of the\n  // previous document (and the previous expanded branch) before swapping. The\n  // guard compares against the `selectedPath` STATE rather than the popstate\n  // listener's ref, both because a ref must not be read during render and\n  // because at this point the two hold the same value.\n  const [syncedInitialPath, setSyncedInitialPath] = useState(cleanInitialPath);\n  const [externalNavCount, setExternalNavCount] = useState(0);\n  if (syncedInitialPath !== cleanInitialPath) {\n    setSyncedInitialPath(cleanInitialPath);\n    if (cleanInitialPath !== selectedPath) {\n      setSelectedPath(cleanInitialPath);\n      if (cleanInitialPath) {\n        const parentPath = cleanInitialPath.includes('/')\n          ? cleanInitialPath.substring(0, cleanInitialPath.lastIndexOf('/'))\n          : cleanInitialPath;\n        setExpandedNodes(new Set(getDocAncestorNodeIds(parentPath)));\n      }\n      // Counter rather than a flag: it makes the scroll below fire once per\n      // ACTUAL external navigation, which is exactly when the old effect\n      // reached its `setTimeout` — a bare `cleanInitialPath` dep would also\n      // fire for the host mirroring a sidebar click back into the URL.\n      setExternalNavCount(n => n + 1);\n    }\n  }\n\n  // Match popstate's scroll-to-content delay; the targeted content fetch\n  // dispatched by the selectedPath effect lands before this fires.\n  useEffect(() => {\n    if (externalNavCount === 0) return undefined;\n    const timer = setTimeout(scrollToContent, 150);\n    return () => clearTimeout(timer);\n  }, [externalNavCount]);\n\n  const fetchStructure = async () => {\n    try {\n      setIsLoadingStructure(true);\n      setError(null);\n\n      const response = await contentFetch(structureEndpoint);\n\n      if (!response.ok) {\n        throw new Error('Failed to load documentation structure');\n      }\n\n      const payload: unknown = await response.json();\n      // A non-array `data` used to be handed straight to `setStructure`, and\n      // every later `findDocNodeByPath` then tried to iterate it — a hard\n      // \"nodes is not iterable\" throw instead of the error banner below.\n      const nodes = isRecord(payload) && payload.success ? toDocNodes(payload.data) : null;\n\n      if (nodes) {\n        setStructure(nodes);\n\n        if (cleanInitialPath) {\n          const pathForExpansion = cleanInitialPath.includes('.')\n            ? cleanInitialPath.substring(0, cleanInitialPath.lastIndexOf('/'))\n            : cleanInitialPath;\n          if (pathForExpansion) {\n            setExpandedNodes(new Set(getDocAncestorNodeIds(pathForExpansion)));\n          }\n        } else if (nodes.length > 0) {\n          const hasRootReadme = nodes.some(node => node.type === 'file' && node.path === folderIndexFile);\n\n          if (!hasRootReadme) {\n            const firstNode = nodes[0];\n            if (firstNode.type === 'folder') {\n              setExpandedNodes(new Set([firstNode.id]));\n              if (firstNode.hasReadme) {\n                setSelectedPath(firstNode.path);\n                window.history.replaceState({}, '', `${normalizedBaseRoute}/${firstNode.path}`);\n              }\n            }\n          }\n        }\n      } else {\n        setError(apiErrorMessage(payload, 'Failed to load documentation structure'));\n      }\n    } catch (err) {\n      console.error('Error fetching documentation structure:', err);\n      setError(err instanceof Error ? err.message : 'Failed to load documentation');\n    } finally {\n      setIsLoadingStructure(false);\n    }\n  };\n\n  const fetchContent = async (path: string) => {\n    try {\n      setIsLoadingContent(true);\n      // Don't clear `error` here — if a previous fetch set an error and this\n      // is a stale/speculative call that gets superseded, the guard below\n      // returns early without writing to state. Clearing error here would\n      // briefly flicker the user-visible error message.\n\n      const response = await contentFetch(`${contentEndpoint}?path=${encodeURIComponent(path)}`);\n\n      // Request-id guard: between awaits, `lastFetchedPath.current` may have\n      // been bumped by a newer fetch (the structure-arrives auto-select issues\n      // a more-targeted fetch while the speculative one is in flight). Bail\n      // BEFORE writing to state — otherwise the late 404 of the speculative\n      // fetch overwrites the targeted fetch's good content with null.\n      if (path !== lastFetchedPath.current) return;\n\n      if (!response.ok) {\n        if (response.status === 404) {\n          const errorPayload: unknown = await response.json().catch(() => null);\n          if (path !== lastFetchedPath.current) return;\n          // Landing-page silent fallback: when the user lands on the source's\n          // root URL and there's no root `README.md` (knowledge-base case),\n          // the speculative fetch 404s — surface an empty state instead of\n          // an error banner. The structure-arrives auto-select will fire\n          // a targeted fetch for the first-folder README on the next render.\n          if (path === folderIndexFile && selectedPath === '') {\n            // Superseded by the auto-select fetch the structure effect fires.\n            // Null the request id so the `finally` does NOT drop the spinner —\n            // otherwise there's a 1-frame gap (isLoadingContent false, content\n            // null) where the empty state flashes before the real fetch starts.\n            lastFetchedPath.current = null;\n            setError(null);\n            setContent(null);\n            return;\n          }\n          // No-README FOLDER → stay silent: the content effect resolves it to\n          // the folder's first child, so the folder-path 404 from the direct-\n          // load speculative fetch is expected, not an error to flash.\n          const probe = findDocNodeByPath(stripFolderIndexFromPath(path, folderIndexFile), structure);\n          const probeIsNoReadmeFolder = !!probe && probe.type === 'folder' && !probe.hasReadme;\n          // Before the structure has loaded to classify the path (`structure`\n          // is [] in the speculative call's closure), only silence FOLDER-LIKE\n          // paths (no `.md`) — a genuinely missing `*.md` leaf must still error.\n          const preStructureFolderLike = structure.length === 0 && !path.endsWith('.md');\n          if (probeIsNoReadmeFolder || preStructureFolderLike) {\n            // Superseded by the targeted fetch (first-child / reclassified path)\n            // the structure effect fires. Null the request id so the `finally`\n            // keeps the spinner up instead of flashing an empty state for a\n            // frame before that fetch starts.\n            lastFetchedPath.current = null;\n            setError(null);\n            setContent(null);\n            return;\n          }\n          setError(apiErrorMessage(errorPayload, 'Documentation file not found'));\n          setContent(null);\n          return;\n        }\n        throw new Error('Failed to load documentation content');\n      }\n\n      const payload: unknown = await response.json();\n      if (path !== lastFetchedPath.current) return;\n      setError(null);\n\n      const doc = isRecord(payload) && payload.success ? toDocContent(payload.data, path) : null;\n\n      if (doc) {\n        // `correctPath` is only honored when it is actually a string. The old\n        // `!== undefined` check also admitted `null`, which `setSelectedPath`\n        // then stored as the selection: the URL became `${baseRoute}/null` and\n        // the content effect's `selectedPath === null` guard bailed on every\n        // subsequent run, freezing the viewer on that document.\n        const correctPath = isRecord(payload) && payload.redirect ? readString(payload, 'correctPath') : undefined;\n        if (correctPath !== undefined) {\n          setSelectedPath(correctPath);\n          window.history.replaceState({}, '', `${normalizedBaseRoute}/${correctPath}`);\n        }\n        setContent(doc);\n      } else {\n        setError(apiErrorMessage(payload, 'Failed to load content'));\n        setContent(null);\n      }\n    } catch (err) {\n      if (path !== lastFetchedPath.current) return;\n      console.error('Error fetching documentation content:', err);\n      setError(err instanceof Error ? err.message : 'Failed to load content');\n      setContent(null);\n    } finally {\n      // Only clear loading state if THIS fetch is still the active one — a\n      // superseded speculative shouldn't flip the spinner off while the\n      // targeted fetch is still in flight.\n      if (path === lastFetchedPath.current) setIsLoadingContent(false);\n    }\n  };\n  // `fetchStructure` / `fetchContent` are plain async functions rebuilt on\n  // every render, so neither effect below can take them as dependencies: the\n  // init effect would re-fire its speculative fetch and the path effect would\n  // refetch the same document on every parent render. Read through a ref that\n  // is refreshed after every commit, so both always call the current closures.\n  const latestRef = useRef({ fetchContent, fetchStructure, cleanInitialPath, folderIndexFile });\n  useEffect(() => {\n    latestRef.current = { fetchContent, fetchStructure, cleanInitialPath, folderIndexFile };\n  });\n\n  // Mount-only. The `isInitialized` state this used to latch on was read\n  // NOWHERE else: it was a run-once guard that cost a setState in an effect\n  // body (and a second, no-op run of this effect) to say what empty deps say\n  // directly.\n  useEffect(() => {\n    // Kick off the speculative content fetch IN PARALLEL with the structure\n    // fetch — the two endpoints are independent and most landing pages have\n    // a root README (the default folder-index). If the structure ends up\n    // pointing at a different path (e.g. knowledge-base falls back to the\n    // first-folder README because there's no root README), the content\n    // useEffect issues the correct fetch after structure arrives — the\n    // speculative result silently no-ops (the content state update gets\n    // overwritten by the targeted fetch).\n    const latest = latestRef.current;\n    const speculativeContentPath = latest.cleanInitialPath || latest.folderIndexFile;\n    lastFetchedPath.current = speculativeContentPath;\n    // Both settle their own failures into `error` state via try/catch/finally,\n    // so neither can reject; they are deliberately NOT awaited so the two\n    // independent endpoints stay in parallel.\n    void latest.fetchContent(speculativeContentPath);\n    void latest.fetchStructure();\n  }, []);\n\n  useEffect(() => {\n    if (selectedPath === null || selectedPath === undefined) return;\n    if (!structure || structure.length === 0) return;\n\n    let pathToFetch: string | null = null;\n\n    if (selectedPath === '') {\n      pathToFetch = folderIndexFile;\n    } else {\n      const node = findDocNodeByPath(selectedPath, structure);\n\n      if (node && node.type === 'folder' && !node.hasReadme) {\n        // No-README folder has no body of its own — show its FIRST child doc\n        // (mirrors a README folder showing its README). selectedPath stays the\n        // folder, so the sidebar keeps it highlighted/expanded; the sidebar is\n        // the directory browser, so we render NO separate in-page listing.\n        const firstDocPath = findFirstDocPath(node);\n        if (!firstDocPath) {\n          // Genuinely empty folder — nothing to fetch. The empty state itself\n          // is DERIVED (see `isEmptyFolder` below); clearing content/error/\n          // loading from here would be a second render pass to publish\n          // something `structure` + `selectedPath` already say.\n          lastFetchedPath.current = null;\n          return;\n        }\n        pathToFetch = firstDocPath;\n      } else if (node && node.type === 'folder' && node.hasReadme) {\n        // `getContent(folder)` already resolves a README folder to its README,\n        // so the initial speculative fetch (which uses the bare folder path)\n        // ALREADY loaded this content. Re-fetching the `${folder}/README.md`\n        // variant is a redundant 2nd request whose in-flight `isLoadingContent`\n        // flashes the skeleton — content → skeleton → content — on first load.\n        // Skip it when the folder path was already the (speculatively) fetched\n        // path; the result (or its in-flight request) covers the README.\n        if (lastFetchedPath.current === selectedPath) {\n          return;\n        }\n        pathToFetch = `${selectedPath}/${folderIndexFile}`;\n      } else {\n        pathToFetch = selectedPath;\n      }\n    }\n\n    if (pathToFetch === lastFetchedPath.current) {\n      return;\n    }\n\n    if (pathToFetch) {\n      lastFetchedPath.current = pathToFetch;\n      // Never rejects — settles its own failures into `error` state.\n      void latestRef.current.fetchContent(pathToFetch);\n    }\n  }, [selectedPath, structure, folderIndexFile]);\n\n  const toggleNode = useCallback((nodeId: string) => {\n    setExpandedNodes(prev => {\n      const next = new Set(prev);\n      if (next.has(nodeId)) {\n        next.delete(nodeId);\n      } else {\n        next.add(nodeId);\n      }\n      return next;\n    });\n  }, []);\n\n  // Structural minimum the body uses — `id`, `path`, `type`, `hasReadme`.\n  // Widening from `DocNode` lets the navigation components (which carry the\n  // narrower `NavigationNode` row shape) pass their own node back without the\n  // cross-type `as` cast. Both DocNode and NavigationNode satisfy this.\n  const selectNode = useCallback(\n    (node: Pick<DocNode, 'id' | 'path' | 'type' | 'hasReadme'>) => {\n      // Expansion only: clicking a folder toggles its own subtree; clicking a file\n      // reveals its ancestor chain.\n      if (node.type === 'folder') {\n        setExpandedNodes(prev => {\n          if (prev.has(node.id)) {\n            const ancestorIds = getDocAncestorNodeIds(node.path);\n            ancestorIds.pop(); // collapse self, keep ancestors open\n            return new Set(ancestorIds);\n          }\n          return new Set(getDocAncestorNodeIds(node.path));\n        });\n      } else {\n        const lastSlash = node.path.lastIndexOf('/');\n        if (lastSlash > 0) {\n          setExpandedNodes(new Set(getDocAncestorNodeIds(node.path.substring(0, lastSlash))));\n        }\n      }\n\n      // Every node is a navigable destination — a file shows its body, a README\n      // folder its README, a no-README folder its first child doc (the content\n      // effect resolves which). So selection + URL + scroll are identical for all\n      // node types; no per-type special-casing.\n      lastFetchedPath.current = null;\n      setSelectedPath(node.path);\n      window.history.pushState({}, '', `${normalizedBaseRoute}/${node.path}`);\n      setTimeout(scrollToContent, 150);\n    },\n    [normalizedBaseRoute],\n  );\n\n  const navigateToDoc = useCallback(\n    (path: string, options?: { expandFolder?: boolean; fromInternalLink?: boolean }) => {\n      const hashIndex = path.indexOf('#');\n      const anchor = hashIndex !== -1 ? path.substring(hashIndex) : '';\n      const cleanPath = path.replace(/\\/$/, '').split('#')[0];\n\n      // Same-doc-different-anchor shortcut. Content is already mounted, so we\n      // don't need the 300ms \"wait-for-fetch\" bandaid — the canonical helper\n      // owns pushState + synthetic `hashchange` (so any in-doc TOC / accordion\n      // bound to the URL hash re-renders) + the anchoring-proof tween in one\n      // sync call. `headerOffset: HUB_HEADER_OFFSET_PX` matches the cross-doc path below so\n      // anchors land BELOW the docs sticky header on every same-doc internal\n      // link click. Cross-doc nav (different cleanPath) falls through to the\n      // existing fetch-then-scroll path below.\n      //\n      // We pass the BARE-hash form to the helper rather than reconstructing\n      // a full `${normalizedBaseRoute}/${cleanPath}${anchor}` path: the\n      // helper's pathname check compares against `window.location.pathname`,\n      // which carries the FOLDER-INDEX-STRIPPED form (`/docs/foo` for\n      // `foo/README.md`, `/docs` for the root index). Handing it `cleanPath`\n      // — the raw resolved path — produces e.g. `/docs/foo/README.md` and\n      // the compare fails → helper returns false → silent dead-click. The\n      // bare-hash form sidesteps that entirely: the helper reconstructs\n      // `pathname + search + hash` from `window.location`, so the compare\n      // is trivially equal. Covers bare `#anchor` links (resolve to\n      // `cleanPath=''`) AND folder-index links (`foo/README.md` resolving\n      // to the current `/docs/foo`).\n      // Bare-hash internal links (`[Click](#section)`) come in as\n      // `path === '#section'`, so `cleanPath` becomes `''` and the naive\n      // strip-then-compare misses the same-doc shortcut on every NON-root\n      // doc (selectedPath is e.g. `'foo/bar'`, not `''`). For that case the\n      // current doc IS the same-doc target by definition — short-circuit\n      // pathForSelection to the current selection so the shortcut fires.\n      const pathForSelection =\n        anchor && options?.fromInternalLink && cleanPath === ''\n          ? selectedPathRef.current\n          : stripFolderIndexFromPath(cleanPath, folderIndexFile);\n      if (anchor && options?.fromInternalLink && pathForSelection === selectedPathRef.current) {\n        navigateSamePageHash(anchor, { headerOffset: HUB_HEADER_OFFSET_PX });\n        return;\n      }\n\n      const scrollAfterNav = () => {\n        if (anchor) {\n          setTimeout(() => {\n            const el = document.getElementById(anchor.substring(1));\n            if (el) {\n              scrollElementIntoView(el, { headerOffset: HUB_HEADER_OFFSET_PX });\n            } else {\n              scrollToContent();\n            }\n          }, 300);\n        } else {\n          setTimeout(() => {\n            scrollToContent();\n          }, 150);\n        }\n      };\n\n      if (options?.expandFolder) {\n        lastFetchedPath.current = null;\n        setSelectedPath(cleanPath);\n        window.history.pushState({}, '', `${normalizedBaseRoute}/${cleanPath}${anchor}`);\n\n        const pathParts = cleanPath.split('/');\n        const nodeIdsToExpand: string[] = [];\n        let currentPath = '';\n\n        for (const part of pathParts) {\n          currentPath = currentPath ? `${currentPath}-${part}` : part;\n          nodeIdsToExpand.push(currentPath.toLowerCase());\n        }\n\n        setExpandedNodes(new Set(nodeIdsToExpand));\n\n        if (options?.fromInternalLink) {\n          scrollAfterNav();\n        }\n\n        setError(null);\n        return;\n      }\n\n      // `pathForSelection` was already computed above (inside the\n      // same-doc-anchor shortcut check); reuse it here for cross-doc nav.\n      const urlPath = pathForSelection;\n\n      lastFetchedPath.current = null;\n      setSelectedPath(pathForSelection);\n      window.history.pushState({}, '', `${normalizedBaseRoute}/${urlPath}${anchor}`);\n\n      if (options?.fromInternalLink) {\n        scrollAfterNav();\n      }\n\n      const pathParts = cleanPath.split('/');\n      const parentIds: string[] = [];\n      let currentPath = '';\n      const partsToProcess = cleanPath.includes('.') ? pathParts.slice(0, -1) : pathParts;\n\n      for (const part of partsToProcess) {\n        currentPath = currentPath ? `${currentPath}-${part}` : part;\n        parentIds.push(currentPath.toLowerCase());\n      }\n\n      if (parentIds.length > 0) {\n        setExpandedNodes(new Set(parentIds));\n      }\n    },\n    [normalizedBaseRoute, folderIndexFile],\n  );\n\n  useEffect(() => {\n    return docNavigation.register({\n      baseRoute: normalizedBaseRoute,\n      findNodeByPath: path => {\n        const clean = stripFolderIndexFromPath(path.replace(/\\/$/, '').split('#')[0], folderIndexFile);\n        return findDocNodeByPath(clean, structure) ?? null;\n      },\n      selectNode,\n    });\n  }, [docNavigation, normalizedBaseRoute, structure, selectNode, folderIndexFile]);\n\n  // A folder with no README and no documents anywhere under it has no body at\n  // all, and that is a pure fact about `structure` + `selectedPath` — both\n  // available while rendering. Derived here rather than written into state from\n  // the content effect, which reached the same conclusion one commit later and\n  // meanwhile left the PREVIOUS document's body on screen.\n  const isEmptyFolder = useMemo(() => {\n    if (!selectedPath || structure.length === 0) return false;\n    const node = findDocNodeByPath(selectedPath, structure);\n    return !!node && node.type === 'folder' && !node.hasReadme && !findFirstDocPath(node);\n  }, [selectedPath, structure]);\n\n  return {\n    structure,\n    selectedPath,\n    setSelectedPath,\n    content: isEmptyFolder ? null : content,\n    isLoadingStructure,\n    isLoadingContent: isEmptyFolder ? false : isLoadingContent,\n    error: isEmptyFolder ? null : error,\n    expandedNodes,\n    toggleNode,\n    selectNode,\n    navigateToDoc,\n    refetch: fetchStructure,\n    setExpandedNodes,\n  };\n}\n\nexport type { DocNode, DocContent };\n","'use client';\n\nimport { useState, useRef, useCallback, useEffect, useMemo } from 'react';\nimport { scrollElementIntoView } from '../../utils/scroll-into-view';\n\n// Single source for the sticky-chrome height. Used for BOTH the scroll target\n// offset (where a clicked section lands) AND the active-section detection\n// threshold (where the scroll listener flips highlight). They must match —\n// previously 100 vs 150 caused a 50px window where the indicator jumped to\n// the next section even though that section's top was still below the\n// clicked one's resting offset.\nconst SCROLL_OFFSET = 100;\n\n// Separator for the section-id key. A newline cannot occur inside an HTML id,\n// so the join is unambiguous and the key can be split back apart.\nconst ID_SEPARATOR = '\\n';\n\ninterface ScrollSpySection {\n  id: string;\n  title?: string;\n  level?: number;\n}\n\ninterface UseScrollSpyReturn {\n  activeSection: string;\n  handleSectionClick: (sectionId: string) => void;\n}\n\n/**\n * Shared scroll spy hook for tracking active section based on scroll position.\n * Used by DocViewer for sticky section navigation.\n */\nexport function useScrollSpy(sections: ScrollSpySection[] | undefined): UseScrollSpyReturn {\n  const [activeSection, setActiveSection] = useState('');\n  const isScrollingFromClick = useRef(false);\n\n  // The scroll listener only ever needs the section IDS, and callers rebuild\n  // the `sections` array on every render — so the value-stable joined key IS\n  // the input, and the effect unpacks it again. That replaces a ref written\n  // during render whose only job was to keep the array's churning identity out\n  // of the dependency array.\n  const sectionIdsKey = useMemo(() => sections?.map(s => s.id).join(ID_SEPARATOR) ?? '', [sections]);\n\n  const handleSectionClick = useCallback((sectionId: string) => {\n    const targetElement = document.getElementById(sectionId);\n    if (!targetElement) return;\n\n    isScrollingFromClick.current = true;\n    setActiveSection(sectionId);\n\n    scrollElementIntoView(targetElement, { headerOffset: SCROLL_OFFSET });\n\n    setTimeout(() => {\n      isScrollingFromClick.current = false;\n    }, 800);\n  }, []);\n\n  useEffect(() => {\n    const sectionIds = sectionIdsKey === '' ? [] : sectionIdsKey.split(ID_SEPARATOR);\n    if (sectionIds.length === 0) return undefined;\n\n    const handleScroll = () => {\n      if (isScrollingFromClick.current) return;\n\n      const scrollPosition = window.scrollY + SCROLL_OFFSET;\n      let currentSection = sectionIds[0] ?? '';\n\n      for (let i = sectionIds.length - 1; i >= 0; i--) {\n        const element = document.getElementById(sectionIds[i]);\n        if (element && scrollPosition >= element.offsetTop) {\n          currentSection = sectionIds[i];\n          break;\n        }\n      }\n\n      setActiveSection(prev => (prev !== currentSection ? currentSection : prev));\n    };\n\n    let scrollTimer: ReturnType<typeof setTimeout>;\n    const throttledScroll = () => {\n      clearTimeout(scrollTimer);\n      scrollTimer = setTimeout(handleScroll, 100);\n    };\n\n    window.addEventListener('scroll', throttledScroll);\n    handleScroll();\n\n    return () => {\n      window.removeEventListener('scroll', throttledScroll);\n      clearTimeout(scrollTimer);\n    };\n  }, [sectionIdsKey]);\n\n  return { activeSection, handleSectionClick };\n}\n","/**\n * Text-style skeleton — matches a rendered markdown article layout.\n * Used by `<DocsHubPage>` as the default for `markdown` (and unknown\n * document types). Embedders can override via `renderSkeleton`.\n */\nexport function MarkdownSkeleton() {\n  return (\n    <div className=\"mt-6 space-y-7\">\n      <div className=\"space-y-[14px]\">\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-3/4 animate-pulse rounded bg-ods-border\" />\n      </div>\n      <div className=\"space-y-[14px]\">\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-5/6 animate-pulse rounded bg-ods-border\" />\n      </div>\n      <div className=\"space-y-[14px]\">\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-2/3 animate-pulse rounded bg-ods-border\" />\n      </div>\n      <div className=\"h-[88px] animate-pulse rounded-lg border border-ods-border bg-ods-card\" />\n      <div className=\"h-7 w-1/3 animate-pulse rounded bg-ods-border\" />\n      <div className=\"space-y-[14px]\">\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-[72%] animate-pulse rounded bg-ods-border\" />\n      </div>\n      <div className=\"space-y-[14px]\">\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-[58%] animate-pulse rounded bg-ods-border\" />\n      </div>\n      <div className=\"h-7 w-2/5 animate-pulse rounded bg-ods-border\" />\n      <div className=\"space-y-[14px]\">\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-[90%] animate-pulse rounded bg-ods-border\" />\n      </div>\n      <div className=\"space-y-[14px]\">\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-full animate-pulse rounded bg-ods-border\" />\n        <div className=\"h-[16px] w-[70%] animate-pulse rounded bg-ods-border\" />\n      </div>\n      <div className=\"h-[88px] animate-pulse rounded-lg border border-ods-border bg-ods-card\" />\n    </div>\n  );\n}\n\n/**\n * Embed-style skeleton — matches the iframe loading state for `pdf`,\n * `google_sheet`, `figma`, and `file` document types. Used by\n * `<DocsHubPage>` as the default for non-markdown documentTypes.\n *\n * The skeleton is documentType-aware so its layout matches the actual\n * viewer that will replace it:\n *   - `pdf`                  → header with title + TWO buttons (Preview, Download)\n *   - `google_sheet`/`figma` → header with title + ONE button/toggle\n *   - `file`                 → centered FileDownloadCard-style box\n *   - undefined / others     → generic (1-button header)\n *\n * IMPORTANT: bars use `bg-ods-border` (NOT `bg-ods-skeleton`). The\n * `--ods-skeleton` token resolves to TRANSPARENT in this build, leaving\n * the skeleton box visually empty — the embed skeleton was the loudest\n * surface affected (a full-height iframe area showing nothing). Same fix\n * the chat-message-row skeleton already documents in its inline comment.\n */\nexport interface EmbedSkeletonProps {\n  /** When provided, the header layout matches the eventual viewer's\n   *  button count + arrangement, so the layout doesn't shift on load. */\n  documentType?: 'pdf' | 'google_sheet' | 'figma' | 'file' | string;\n}\n\nexport function EmbedSkeleton({ documentType }: EmbedSkeletonProps = {}) {\n  // Centered card shape for the `file` documentType — matches\n  // `<FileDownloadCard>`'s `flex flex-col items-center justify-center py-16`\n  // + bordered card with icon, name, type/size row, Download button.\n  if (documentType === 'file') {\n    return (\n      <div className=\"flex flex-col items-center justify-center py-16\">\n        <div className=\"w-full max-w-md space-y-4 rounded-xl border border-ods-border bg-ods-card p-8 text-center\">\n          <div className=\"mx-auto h-16 w-16 animate-pulse rounded bg-ods-border\" />\n          <div className=\"space-y-2\">\n            <div className=\"mx-auto h-5 w-2/3 animate-pulse rounded bg-ods-border\" />\n            <div className=\"mx-auto h-4 w-1/2 animate-pulse rounded bg-ods-border\" />\n          </div>\n          <div className=\"h-10 w-full animate-pulse rounded bg-ods-border\" />\n        </div>\n      </div>\n    );\n  }\n\n  // PDF viewer has TWO buttons (Preview + Download); Sheets / Figma\n  // render ONE (Open / view-toggle). Default to one for unknown types.\n  const buttonCount = documentType === 'pdf' ? 2 : 1;\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Header — matches the actual viewer's\n       *  `flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between`\n       *  (mobile-stacked, desktop-row). */}\n      <div className=\"flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between\">\n        {/* Left: icon + title */}\n        <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n          <div className=\"h-5 w-5 shrink-0 animate-pulse rounded bg-ods-border\" />\n          <div className=\"h-6 w-2/3 animate-pulse rounded bg-ods-border\" />\n        </div>\n        {/* Right: 1 or 2 buttons. Mobile = full-width; desktop = auto. */}\n        <div className=\"flex w-full items-center gap-2 sm:w-auto\">\n          {Array.from({ length: buttonCount }).map((_, i) => (\n            <div key={i} className=\"h-10 w-full flex-1 animate-pulse rounded bg-ods-border sm:w-32 sm:flex-initial\" />\n          ))}\n        </div>\n      </div>\n      {/* Body — clean iframe-sized rectangle, no fake inner placeholder\n       *  cruft. Matches the viewer's default `calc(100vh - 250px)` height. */}\n      <div\n        className=\"w-full animate-pulse rounded-lg border border-ods-border bg-ods-card\"\n        style={{ height: 'calc(100vh - 250px)' }}\n      />\n    </div>\n  );\n}\n","import type React from 'react';\nimport type { DocContent, DocRenderHandlers, DocumentType } from '../../types/doc-source';\nimport { FigmaEmbed } from '../embeds/figma-embed';\nimport { FileDownloadCard } from '../embeds/file-download-card';\nimport { GoogleSheetsViewer } from '../embeds/google-sheets-viewer';\nimport { PdfViewer } from '../embeds/pdf-viewer';\nimport { DocViewer, type DocViewerProps } from './doc-viewer';\nimport { MarkdownSkeleton, EmbedSkeleton } from './skeletons';\n\ntype DocRenderer = (content: DocContent, handlers: DocRenderHandlers) => React.ReactNode;\n\n/**\n * Per-document-type renderer map. `markdown` is required (the lib does NOT\n * ship a default markdown renderer — embedders pick their own library +\n * sanitization to avoid an XSS surface in the lib).\n *\n * `pdf` / `google_sheet` / `figma` / `file` are optional — the lib provides\n * defaults from `components/embeds`. Override only when you want different\n * props than the default (e.g. a custom PDF toolbar, embedded credentials).\n */\nexport type DocumentTypeRenderers = { markdown: DocRenderer } & Partial<Record<DocumentType, DocRenderer>>;\n\nexport interface DocsHubPageProps extends Omit<DocViewerProps, 'renderContent' | 'renderSkeleton' | 'showAIChat'> {\n  /** Per-document-type renderer map. `markdown` is REQUIRED. */\n  documentTypeRenderers: DocumentTypeRenderers;\n\n  /** Renderer for unknown / future document types. Defaults to a lib-styled\n   *  \"Unsupported document type\" message. */\n  fallbackRenderer?: DocRenderer;\n\n  /** Loading skeleton picker. Defaults: `markdown` / `undefined` →\n   *  `<MarkdownSkeleton>`, everything else → `<EmbedSkeleton>`. */\n  renderSkeleton?: (documentType: DocumentType | undefined) => React.ReactNode;\n\n  /** Defaults to `true` (the embeddable wrapper favors the chat-enabled\n   *  experience). Only mounts the in-source RAG search bar\n   *  (`<DocSearchBar>`) — does NOT require `ChatRuntimeContext`. */\n  showAIChat?: boolean;\n}\n\nconst DEFAULT_TITLE = 'Documents';\n\nconst defaultFallbackRenderer: DocRenderer = () => (\n  <div className=\"py-16 text-center\">\n    <p className=\"text-ods-text-secondary\">Unsupported document type</p>\n  </div>\n);\n\n// When the DAL hasn't populated the URL field for a rich-content type, the\n// embed-viewer components render a broken iframe (empty src). Fall back to\n// the same lib-styled \"Unsupported document type\" panel the explicit\n// fallback uses — the surface is honest about the missing data instead of\n// pretending to load. `FileDownloadCard` handles its own missing-URL state\n// (hides the Download button), so it doesn't need this guard.\nconst defaultPdfRenderer: DocRenderer = (content, handlers) =>\n  content.fileUrl ? (\n    <PdfViewer src={content.fileUrl} fileName={content.fileName} />\n  ) : (\n    defaultFallbackRenderer(content, handlers)\n  );\n\nconst defaultGoogleSheetRenderer: DocRenderer = (content, handlers) =>\n  content.externalUrl ? (\n    <GoogleSheetsViewer externalUrl={content.externalUrl} fileName={content.fileName} />\n  ) : (\n    defaultFallbackRenderer(content, handlers)\n  );\n\nconst defaultFigmaRenderer: DocRenderer = (content, handlers) =>\n  content.externalUrl ? (\n    <FigmaEmbed url={content.externalUrl} title={content.fileName} loading=\"eager\" />\n  ) : (\n    defaultFallbackRenderer(content, handlers)\n  );\n\nconst defaultFileRenderer: DocRenderer = content => (\n  <FileDownloadCard\n    fileName={content.fileName}\n    mimeType={content.mimeType}\n    fileSize={content.fileSize}\n    fileUrl={content.fileUrl}\n  />\n);\n\nconst defaultRenderSkeleton = (documentType: DocumentType | undefined) =>\n  !documentType || documentType === 'markdown' ? (\n    <MarkdownSkeleton />\n  ) : (\n    // Forward the documentType so the embed skeleton renders the right\n    // shape (PDF=2 buttons, sheets/figma=1 button, file=centered card).\n    <EmbedSkeleton documentType={documentType} />\n  );\n\n/**\n * Embeddable docs-hub page. Bundles `<DocViewer>` with safe defaults so the\n * minimum embed is a one-line mount (consumer only has to supply\n * `documentTypeRenderers.markdown`).\n *\n * Used by the hub at `/knowledge-base` and `/data-room`, and by third-party\n * React apps that embed the docs experience behind their own proxy. See\n * `docs/EMBEDDING_DOCS_HUB.md` for the embedder setup.\n *\n * SEO note: this component is `'use client'` (via the docs barrel) — server-\n * side SEO is the host's responsibility. The hub's `<DocSeoContent>` is the\n * canonical implementation embedders can reference.\n */\nexport function DocsHubPage({\n  title = DEFAULT_TITLE,\n  documentTypeRenderers,\n  fallbackRenderer = defaultFallbackRenderer,\n  renderSkeleton = defaultRenderSkeleton,\n  showAIChat = true,\n  className = 'min-h-screen',\n  sidebarLabel = 'DOCUMENTATION',\n  ...docViewerProps\n}: DocsHubPageProps) {\n  const resolvedRenderers: DocumentTypeRenderers = {\n    markdown: documentTypeRenderers.markdown,\n    pdf: documentTypeRenderers.pdf ?? defaultPdfRenderer,\n    google_sheet: documentTypeRenderers.google_sheet ?? defaultGoogleSheetRenderer,\n    figma: documentTypeRenderers.figma ?? defaultFigmaRenderer,\n    file: documentTypeRenderers.file ?? defaultFileRenderer,\n  };\n\n  const renderContent: DocViewerProps['renderContent'] = (content, handlers) => {\n    const type = content.documentType ?? 'markdown';\n    const renderer = resolvedRenderers[type] ?? fallbackRenderer;\n    return renderer(content, handlers);\n  };\n\n  return (\n    <DocViewer\n      {...docViewerProps}\n      title={title}\n      showAIChat={showAIChat}\n      className={className}\n      sidebarLabel={sidebarLabel}\n      renderContent={renderContent}\n      renderSkeleton={renderSkeleton}\n    />\n  );\n}\n"]}