{"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-X6G7JIX6.cjs","../src/components/shared/product-release/product-releases-view.tsx","../src/components/shared/media-gallery-strip.tsx","../src/components/shared/product-release/release-detail-page.tsx","../src/components/shared/product-release/release-detail-skeleton.tsx","../src/components/shared/roadmap/use-roadmap-voting.ts","../src/components/shared/roadmap/roadmap-grid.tsx","../src/components/shared/roadmap/roadmap-grid-skeleton.tsx","../src/components/shared/roadmap/roadmap-view.tsx","../src/components/shared/delivery/delivery-table.tsx","../src/components/shared/delivery/delivery-lists.tsx","../src/components/shared/legal-document/use-legal-docs.ts","../src/components/shared/legal-document/legal-document-page.tsx"],"names":["DEFAULT_ENDPOINT","useEffect","DEFAULT_SEARCH_PARAM_KEY","DEFAULT_STATUS_PARAM_KEY"],"mappings":"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","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-X6G7JIX6.cjs","sourcesContent":[null,"'use client';\n\n/**\n * `<ProductReleasesView />` — the shared, SELF-CONTAINED releases LIST surface.\n *\n * The host configures only two things: the **api route** (`endpoint`, default\n * `/api/releases`) and the **internal page route** for a release's detail page\n * (via `runtime.composeContentUrl` / `basePath`). Everything else — reading the\n * search / status / page URL params the section chrome writes, fetching the\n * page, pagination, empty / error / loading states, card composition + nav —\n * happens INSIDE the lib. Mirrors the `DeliveryLists` internal-fetch pattern\n * (plain `fetch` + `useEffect`/`useState`, no react-query dependency).\n *\n * SSR: pass `initialData` to hydrate the first page without a client round-trip\n * (the hub can server-fetch); embedders omit it and the view fetches on mount.\n *\n * Card props: `buildCardProps` defaults to the lib's RICH `buildProductReleaseCardProps`\n * (full lg metadata — Type / Status / author / changelog counts); pass your own to customize.\n */\n\nimport type { MouseEvent } from 'react';\n\nimport { useChatRuntime } from '../../../contexts/chat-runtime-context';\nimport { useRouter, useSearchParams, usePathname } from '../../../embed-shims';\nimport { useSelfFetch } from '../../../hooks/use-self-fetch';\nimport type { ProductRelease, ProductReleaseListResponse } from '../../../types/product-release';\nimport { cn } from '../../../utils/cn';\nimport { resolveContentHref } from '../../../utils/content-href';\nimport { DEV_SECTION_PARAM_KEYS } from '../../../utils/dev-sections/dev-section-param-keys';\nimport { pageCount, positiveInt } from '../../../utils/search-params';\nimport { buildProductReleaseCardProps } from '../../chat/entity-cards/product-release-card-defaults';\nimport { useEntityCardLink } from '../../chat/entity-cards/use-entity-card-link';\nimport { isModifierClick } from '../../chat/utils/chat-nav-resolution';\nimport { executeNavigationImperative } from '../../chat/utils/execute-navigation';\nimport { EmptyState } from '../../empty-state';\nimport { PersistentPaginationWrapper } from '../../persistent-pagination';\nimport { LoadError } from '../../ui/error-state';\nimport { ProductReleaseCard, type ProductReleaseCardProps } from './product-release-card';\nimport { ProductReleaseCardSkeleton } from './product-release-card-skeleton';\n\nconst DEFAULT_ENDPOINT = '/api/releases';\n// Param keys sourced from the shared registry (see RoadmapView) — single source for the\n// chrome's written `?key=` and this view's read.\nconst DEFAULT_SEARCH_PARAM_KEY = DEV_SECTION_PARAM_KEYS.search;\nconst DEFAULT_STATUS_PARAM_KEY = DEV_SECTION_PARAM_KEYS.releaseStatus;\nconst DEFAULT_PAGE_PARAM_KEY = 'page';\n\n/** The `<ProductReleaseCard>` props the caller derives per release — the card's\n *  own data fields minus the ones this view supplies (title/summary/version/\n *  size/nav). `formattedDate` stays required so the spread satisfies the card. */\nexport type ProductReleaseCardExtras = Omit<\n  ProductReleaseCardProps,\n  'title' | 'summary' | 'version' | 'size' | 'anchorProps' | 'onClick' | 'className'\n>;\n\nexport interface ProductReleasesViewProps {\n  /** GET endpoint for the releases list (the api route). The view appends\n   *  `?limit&offset&<search>&<release_status>`. Default `/api/releases`. */\n  endpoint?: string;\n  /** Optional SSR hydrate for the first page — skips the initial client fetch. */\n  initialData?: ProductReleaseListResponse;\n  /** Page size → fixed slot count + offset math. Default 5. */\n  itemsPerPage?: number;\n  /** Fallback detail-href prefix when `runtime.composeContentUrl` is not wired\n   *  (single-platform embedders). Default `/releases`. */\n  basePath?: string;\n  /** Derive the per-card prop bundle. Defaults to the lib's RICH lg builder\n   *  (`buildProductReleaseCardProps` — full Type/Status/author/changelog metadata)\n   *  so embedders get the complete card with zero config; pass your own to customize. */\n  buildCardProps?: (release: ProductRelease) => ProductReleaseCardExtras;\n  /** URL param key for the search input. MUST match the chrome that writes it.\n   *  Default `'search'` (also the outbound query-param name). */\n  searchParamKey?: string;\n  /** URL param key for the status filter. Default `'release_status'`. */\n  statusParamKey?: string;\n  /** URL param key for the page number. Default `'page'`. */\n  pageParamKey?: string;\n  className?: string;\n}\n\n/**\n * Per-row child — owns the per-release hooks (`useChatRuntime` +\n * `useEntityCardLink` + `useRouter`) so they run at a component top level, NOT\n * inside the parent `.map()` (Rules-of-Hooks).\n */\nfunction ReleaseRow({\n  release,\n  basePath,\n  buildCardProps,\n}: {\n  release: ProductRelease;\n  basePath: string;\n  buildCardProps: (release: ProductRelease) => ProductReleaseCardExtras;\n}) {\n  const runtime = useChatRuntime();\n  const router = useRouter();\n  // Pass the HYDRATED junction (`product_release_platforms` — the release DAL\n  // flattens each platform's `name` onto it), like the blog / case-study /\n  // interview grids. The composer reads `name` → resolves to the CURRENT\n  // platform when the release belongs to it → relative same-tab href.\n  const cta = resolveContentHref(runtime?.composeContentUrl, {\n    type: 'product_release',\n    slug: release.slug,\n    basePath,\n    platforms: release.product_release_platforms,\n  });\n  const { target, rel } = useEntityCardLink({ href: cta.href, targetPlatform: cta.targetPlatform });\n\n  const onClick = (e: MouseEvent<HTMLAnchorElement>) => {\n    // Let the browser handle modifier / middle / new-tab clicks (shared\n    // `isModifierClick` rule). Otherwise soft same-origin nav: the host's\n    // `navigate` (hub docNav) if wired, else the registered embed-shims router.\n    if (e.defaultPrevented || isModifierClick(e) || target === '_blank') return;\n    e.preventDefault();\n    executeNavigationImperative({\n      runtime,\n      href: cta.href,\n      targetPlatform: cta.targetPlatform,\n      fallbackNavigate: router.push,\n    });\n  };\n\n  return (\n    <ProductReleaseCard\n      size=\"lg\"\n      title={release.title}\n      summary={release.summary}\n      version={release.version}\n      {...buildCardProps(release)}\n      anchorProps={{ href: cta.href, target, rel, onClick }}\n    />\n  );\n}\n\nexport function ProductReleasesView({\n  endpoint = DEFAULT_ENDPOINT,\n  initialData,\n  itemsPerPage = 5,\n  basePath = '/releases',\n  buildCardProps = buildProductReleaseCardProps,\n  searchParamKey = DEFAULT_SEARCH_PARAM_KEY,\n  statusParamKey = DEFAULT_STATUS_PARAM_KEY,\n  pageParamKey = DEFAULT_PAGE_PARAM_KEY,\n  className,\n}: ProductReleasesViewProps = {}) {\n  const searchParams = useSearchParams();\n  const router = useRouter();\n  const pathname = usePathname();\n\n  // Filter / page state from the URL (written by the section chrome above).\n  const search = searchParams.get(searchParamKey) || '';\n  const status = searchParams.get(statusParamKey) || 'all';\n  const currentPage = positiveInt(searchParams.get(pageParamKey), 1);\n  const offset = (currentPage - 1) * itemsPerPage;\n\n  // Fold every query param into the url so it IS the fetch key.\n  const listParams = new URLSearchParams({ limit: String(itemsPerPage), offset: String(offset) });\n  if (search) listParams.set(searchParamKey, search);\n  if (status && status !== 'all') listParams.set(statusParamKey, status);\n  const { data, isLoading, error, reload } = useSelfFetch<ProductReleaseListResponse>(\n    `${endpoint}?${listParams.toString()}`,\n    { initialData },\n  );\n\n  const releases = data?.data ?? [];\n  const totalCount = data?.count ?? 0;\n  const totalPages = pageCount(totalCount, itemsPerPage);\n  const hasActiveFilters = search !== '' || status !== 'all';\n  const showEmpty = !isLoading && !error && releases.length === 0;\n\n  const goToPage = (page: number) => {\n    const params = new URLSearchParams(searchParams.toString());\n    params.set(pageParamKey, String(page));\n    router.replace(`${pathname}?${params.toString()}`, { scroll: false });\n  };\n  const resetFilters = () => {\n    const params = new URLSearchParams(searchParams.toString());\n    params.delete(searchParamKey);\n    params.delete(statusParamKey);\n    params.delete(pageParamKey);\n    router.replace(`${pathname}?${params.toString()}`, { scroll: false });\n  };\n\n  if (error) {\n    return (\n      <div className={cn('w-full', className)}>\n        <LoadError message=\"Failed to load releases.\" onRetry={reload} />\n      </div>\n    );\n  }\n\n  return (\n    <div className={cn('flex w-full flex-col gap-[40px]', className)}>\n      <div className=\"min-h-[600px]\">\n        {showEmpty ? (\n          <div className=\"flex h-[600px] items-center justify-center\">\n            {hasActiveFilters ? (\n              <EmptyState\n                type=\"search\"\n                title=\"No releases found\"\n                description=\"No releases match your current filters. Try adjusting your search or status filter.\"\n                showCTA\n                ctaText=\"Reset Filters\"\n                onCtaClick={resetFilters}\n              />\n            ) : (\n              <EmptyState\n                type=\"generic\"\n                title=\"No releases available\"\n                description=\"Check back soon for product updates!\"\n                showCTA={false}\n              />\n            )}\n          </div>\n        ) : (\n          <>\n            {/* ALWAYS render `itemsPerPage` slots — visibility toggles so the\n                list height is stable across loading ↔ loaded. */}\n            <div className=\"flex flex-col gap-6\">\n              {Array.from({ length: itemsPerPage }).map((_, i) => {\n                const release = releases[i];\n                const hasData = !!release;\n                return (\n                  <div\n                    key={release?.id ?? `slot-${i}`}\n                    style={{ visibility: isLoading || hasData ? 'visible' : 'hidden' }}\n                  >\n                    {isLoading ? (\n                      <ProductReleaseCardSkeleton size=\"lg\" />\n                    ) : release ? (\n                      <ReleaseRow release={release} basePath={basePath} buildCardProps={buildCardProps} />\n                    ) : (\n                      <ProductReleaseCardSkeleton size=\"lg\" />\n                    )}\n                  </div>\n                );\n              })}\n            </div>\n\n            {/* Pagination — always present at the bottom for consistent spacing. */}\n            <div className=\"mt-6 flex justify-center md:mt-8\">\n              {isLoading ? (\n                <div className=\"m-3 h-12 w-64\" />\n              ) : releases.length > 0 && totalPages > 1 ? (\n                <PersistentPaginationWrapper\n                  isLoading={false}\n                  currentPage={currentPage}\n                  totalPages={totalPages}\n                  onPageChange={goToPage}\n                  variant=\"blog\"\n                />\n              ) : (\n                <div className=\"m-3 h-12 w-64\" style={{ visibility: 'hidden' }} />\n              )}\n            </div>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n","'use client';\n\nimport { useState } from 'react';\nimport Image from '../../embed-shims/next-image';\nimport { Video } from '../features/video';\nimport { ImageGalleryModal } from '../ui/image-gallery-modal';\n\nexport interface MediaGalleryStripItem {\n  media_type: string;\n  media_url: string;\n  title?: string | null;\n  /** Stable key when present (e.g. release_media rows); falls back to index. */\n  id?: string;\n}\n\n/** Still-images open the lightbox; clips ('video'/'demo') play inline. */\nfunction isGalleryImage(mediaType: string): boolean {\n  return mediaType !== 'video' && mediaType !== 'demo';\n}\n\nexport interface MediaGalleryStripProps {\n  items: MediaGalleryStripItem[];\n  className?: string;\n}\n\n/**\n * Read-only media gallery — a horizontal-scroll strip of 240×200 tiles where\n * still-images open an {@link ImageGalleryModal} lightbox and video clips play\n * inline via {@link Video}. The lightbox index is the tile's rank among the\n * IMAGE-only items (clips are skipped), so the modal opens on the correct image.\n *\n * Single source of truth for the detail-page media strip — replaces the markup\n * that was hand-rolled inline in the product-release and \"What I Shipped\" detail\n * pages (the release copy also had a raw-index lightbox bug this component fixes).\n */\nexport function MediaGalleryStrip({ items, className }: MediaGalleryStripProps) {\n  const [galleryOpen, setGalleryOpen] = useState(false);\n  const [galleryIndex, setGalleryIndex] = useState(0);\n\n  if (!items || items.length === 0) return null;\n\n  const galleryImages = items.filter(m => isGalleryImage(m.media_type)).map(m => m.media_url);\n\n  const tileClass =\n    'shrink-0 w-[240px] h-[200px] rounded-md overflow-hidden border border-ods-border bg-black transition-opacity';\n\n  return (\n    <>\n      <div className={`flex w-full gap-6 overflow-x-auto ${className ?? ''}`}>\n        {items.map((mediaItem, index) => {\n          // Image tiles open the lightbox, so they're real <button>s — keyboard\n          // focusable + Enter/Space activatable. Clips render in <Video>, which\n          // owns its own controls, so their tile stays a plain container.\n          if (isGalleryImage(mediaItem.media_type)) {\n            return (\n              <button\n                key={mediaItem.id || index}\n                type=\"button\"\n                className={`${tileClass} cursor-pointer hover:opacity-80`}\n                aria-label={`Open ${mediaItem.title || `media ${index + 1}`} in gallery`}\n                onClick={() => {\n                  // Lightbox position = rank among image-only items (clips skipped).\n                  setGalleryIndex(items.slice(0, index).filter(m => isGalleryImage(m.media_type)).length);\n                  setGalleryOpen(true);\n                }}\n              >\n                <Image\n                  src={mediaItem.media_url}\n                  alt={mediaItem.title || `Media ${index + 1}`}\n                  className=\"h-full w-full object-cover\"\n                  width={240}\n                  height={200}\n                  unoptimized\n                />\n              </button>\n            );\n          }\n          return (\n            <div key={mediaItem.id || index} className={tileClass}>\n              <Video url={mediaItem.media_url} layout=\"native\" />\n            </div>\n          );\n        })}\n      </div>\n\n      {galleryImages.length > 0 && (\n        <ImageGalleryModal\n          images={galleryImages}\n          isOpen={galleryOpen}\n          onClose={() => setGalleryOpen(false)}\n          initialIndex={galleryIndex}\n        />\n      )}\n    </>\n  );\n}\n","'use client';\n\nimport { AlertTriangle, ExternalLink, BookMarked, Sparkles, TrendingUp, Wrench } from 'lucide-react';\nimport { useState, useEffect, Fragment, type ComponentType, type ReactNode } from 'react';\nimport Link from '../../../embed-shims/next-link';\nimport { useRouter } from '../../../embed-shims/next-navigation';\n// PageShell (wide) — match the related-content/FAQ rail container the hub\n// renders below this view (was ArticleDetailLayout, 1280px — narrower than\n// the rail, see hub detail-container alignment decision 2026-06-10).\nimport { useSelfFetch } from '../../../hooks/use-self-fetch';\nimport type { TagAssoc } from '../../../types/blog';\nimport type { EntityAuthor } from '../../../types/entity-author';\nimport type { ChangelogEntry } from '../../../types/product-release';\nimport type { VideoTeaser } from '../../../types/video-processing';\nimport { formatReleaseDate } from '../../../utils/date-formatters';\nimport { contentFetch } from '../../../utils/embed-content-fetch';\nimport { EntityMetadataAuthorCell } from '../../chat/entity-cards/entity-author-card';\nimport type { CaptionSrtFields } from '../../features/captions-url';\nimport { EntityTagBadges } from '../../features/entity-tag-badges';\nimport { useCaptions } from '../../features/use-captions';\nimport { Video } from '../../features/video';\nimport {\n  DEFAULT_VIDEO_BITES_TITLE,\n  toStripProfile,\n  type VideoBiteStripProfile,\n} from '../../features/video-bites-shared';\nimport { GitHubIcon } from '../../icons/github-icon';\nimport { PageShell } from '../../layout/article-detail-layout';\nimport { PageLayout } from '../../layout/page-layout';\nimport { Card, CardContent } from '../../ui/card';\nimport { FadePreview } from '../../ui/fade-preview';\nimport { RichMarkdownRenderer } from '../../ui/markdown';\nimport { ReleaseChangelogSection } from '../../ui/release-changelog-section';\nimport { DetailPageSkeleton } from '../detail-page-skeleton';\nimport { MediaGalleryStrip } from '../media-gallery-strip';\n\n// Types for injectable components\nexport interface MarkdownRendererProps {\n  content: string;\n}\n\nimport type { DeliveryResponse } from '../../../types/delivery';\n// Canonical RoadmapItem shape lives in chat entity types — see\n// `src/components/chat/types/entities/roadmap-item.ts`. The product-release\n// detail page previously declared a structural placeholder\n// (`{ id; [k: string]: unknown }`) that conflicted with the canonical\n// shape once the entities barrel was added; re-exporting the canonical\n// type fixes the collision while keeping the same import path for\n// downstream consumers of `./release-detail-page`.\nimport type { RoadmapItem } from '../../chat/types/entities/roadmap-item';\n// Re-export both types for source-compat with consumers importing\n// through this module. Canonical sources:\n//   - RoadmapItem  → `../../chat/types/entities/roadmap-item`\n//   - DeliveryResponse → `../../../types/delivery` (single source of\n//     truth, shared with the lib `<DeliveryLists>` / `<DeliveryTable>`\n//     components and the new types barrel).\nexport type { RoadmapItem, DeliveryResponse };\n\n/** Wire shape of `${roadmapApiEndpoint}?task_ids=…` — `{ items, count }`. */\ninterface RoadmapTasksResponse {\n  items?: RoadmapItem[];\n}\n\nexport interface RoadmapSectionProps {\n  items: RoadmapItem[];\n  isLoading: boolean;\n  onItemUpdate?: (item: RoadmapItem) => void;\n}\n\nexport interface DeliverySectionProps {\n  data: DeliveryResponse | null;\n  isLoading: boolean;\n}\n\nexport interface VideoDisplaySectionProps {\n  mainVideoUrl?: string | null;\n  youtubeUrl?: string | null;\n  highlightVideoUrl?: string | null;\n  highlightVideoThumbnail?: string | null;\n  mainVideoPoster?: string | null;\n  title?: string;\n  videoSummary?: string | null;\n  videoBites?: VideoTeaser[];\n  bitesTitle?: string;\n  filterPublishedBites?: boolean;\n  /** Profile shown in the bites strip's hover overlay (kit sources it from release.author). */\n  bitesProfile?: VideoBiteStripProfile | null;\n  /** Overlay-footer navigation target (the release the bites originated from). */\n  bitesHref?: string;\n  srtContent?: string | null;\n  captionsUrl?: string | null;\n  /** VTT captions URL for the highlight reel (variant=highlight). */\n  highlightCaptionsUrl?: string | null;\n}\n\nexport interface ReleaseDetailPageProps {\n  /** Release to fetch. Not needed when `initialData` is supplied. */\n  slug?: string;\n  /** Pre-fetched release (SSR / RSC / admin preview) — skips the client fetch. */\n  initialData?: unknown;\n  /**\n   * Base GET endpoint for one release; the slug is appended. Default\n   * `/api/releases` — the same knob, and the same default, as\n   * `<ProductReleasesView>` takes for the list.\n   *\n   * This page fetches its own release (via `useSelfFetch`, like every other\n   * self-fetching content surface here) rather than taking a host-supplied\n   * `useRelease` hook, which is what it used to do. A hook passed as a prop is\n   * called from inside this component, where nothing — not `rules-of-hooks`,\n   * not the compiler, not the host — can guarantee it runs unconditionally,\n   * exactly once, per render; and every host was writing the same hook anyway.\n   * Nothing about the read was host-specific: `contentFetch` already carries\n   * the host's embed auth (registered `EmbedAuthAdapter` → bearer +\n   * 401-refresh), so an authed embedder configures a base URL here, not a\n   * fetcher.\n   */\n  endpoint?: string;\n  // Injectable components for app-specific rendering\n  MarkdownRenderer?: ComponentType<MarkdownRendererProps>;\n  RoadmapSection?: ComponentType<RoadmapSectionProps>;\n  DeliverySection?: ComponentType<DeliverySectionProps>;\n  /** Injectable video display section with tabs for full/highlight video + summary + bites */\n  VideoDisplaySection?: ComponentType<VideoDisplaySectionProps>;\n  // API endpoints for fetching linked tasks\n  roadmapApiEndpoint?: string;\n  deliveryApiEndpoint?: string;\n  /** Back-button config — same pattern as `DevSectionPage` /\n   *  `LegalDocumentPage`. Pass `false` to hide. Default\n   *  `{ label: 'Back to home', href: '/' }`. */\n  backButton?: { label?: string; href?: string } | false;\n  /** Link target for the author name in the metadata grid — the host\n   *  computes it (public author page; absent ⇒ plain text). */\n  authorHref?: string;\n  /** Optional slot rendered inside the page chrome, BELOW the article body —\n   *  e.g. the hub's end-of-article author byline + related-content / FAQ rail.\n   *  Lets the hub mount this page directly (no local wrapper component) while\n   *  embedders that don't have those extras simply omit it. */\n  relatedContent?: ReactNode;\n  /** Render the standalone `<PageShell>`. Default true. Pass false when the host\n   *  layout already provides the page container — only the padding box renders,\n   *  avoiding a nested `<main>`. */\n  shell?: boolean;\n}\n\n// Default renderer = the lib's `RichMarkdownRenderer` so out-of-the-box\n// release pages get full rich-link previews, embedded media, social cards,\n// etc. Hosts that want a different rendering (or a Supabase-aware preset)\n// override via the `MarkdownRenderer` prop.\nconst DefaultMarkdownRenderer = RichMarkdownRenderer;\n\n/** Single-release route — the hub's public API shape, mirroring `<ProductReleasesView>`. */\nconst DEFAULT_ENDPOINT = '/api/releases';\n\nexport function ReleaseDetailPage({\n  authorHref,\n  slug,\n  initialData,\n  endpoint = DEFAULT_ENDPOINT,\n  MarkdownRenderer = DefaultMarkdownRenderer,\n  RoadmapSection,\n  DeliverySection,\n  VideoDisplaySection,\n  roadmapApiEndpoint = '/api/roadmap',\n  deliveryApiEndpoint = '/api/delivery',\n  backButton,\n  relatedContent,\n  shell = true,\n}: ReleaseDetailPageProps) {\n  const router = useRouter();\n  const captions = useCaptions();\n  // `shell` true → standalone `<PageShell>`; false → padding-only box (no nested\n  // <main>) for hosts whose layout already provides the container.\n  const renderShell = (node: ReactNode) =>\n    shell ? <PageShell>{node}</PageShell> : <div className=\"page-shell-content\">{node}</div>;\n  // The release read. `initialData` (SSR / RSC / admin preview) seeds it and\n  // skips the first fetch; a slug-less, seed-less mount fetches nothing and\n  // falls through to the not-found state below.\n  // A host that passes `null` for \"no server data\" must not seed the read with\n  // it — a seeded read never fetches, and would render not-found forever.\n  const seed = initialData == null ? undefined : (initialData as Record<string, unknown>);\n  const {\n    data: release,\n    isLoading,\n    error,\n  } = useSelfFetch<Record<string, unknown>>(slug ? `${endpoint}/${encodeURIComponent(slug)}` : null, {\n    initialData: seed,\n  });\n\n  // Back-button config — mirrors DevSectionPage / LegalDocumentPage.\n  // Default: { label: 'Back to home', href: '/' }. Pass `false` to hide\n  // (e.g. embed-mode where the host owns navigation chrome).\n  // Narrowing note: `backButton &&` already eliminates the `false` branch,\n  // so the inner expressions are typed as `{ label?, href? } | undefined`.\n  // Don't re-compare to `false` here — tsc TS2367s on the dead branch.\n  const showBackButton = backButton !== false;\n  const backLabel = (backButton ? backButton.label : undefined) ?? 'Back to home';\n  const backHref = (backButton ? backButton.href : undefined) ?? '/';\n\n  // Fetch roadmap and delivery tasks if linked to this release\n  const [roadmapTasks, setRoadmapTasks] = useState<RoadmapItem[]>([]);\n  const [deliveryData, setDeliveryData] = useState<DeliveryResponse | null>(null);\n  const [roadmapLoading, setRoadmapLoading] = useState(false);\n  const [deliveryLoading, setDeliveryLoading] = useState(false);\n\n  useEffect(() => {\n    // `release` is a fresh object identity whenever the release read resolves\n    // or refetches, so this effect can re-run with two request pairs open at once.\n    // Gate every write so a superseded run cannot paint the previous release's\n    // roadmap/delivery rows, or drop a loading flag the live run still needs.\n    let cancelled = false;\n\n    async function fetchLinkedTasks() {\n      if (!release) return;\n\n      try {\n        // Fetch roadmap tasks if linked\n        const roadmapTasksData = release.clickup_roadmap_tasks as Array<{ clickup_task_id: string }> | undefined;\n        if (roadmapTasksData && roadmapTasksData.length > 0 && RoadmapSection) {\n          setRoadmapLoading(true);\n          const roadmapIds = roadmapTasksData.map(t => t.clickup_task_id).join(',');\n          const roadmapResponse = await contentFetch(`${roadmapApiEndpoint}?task_ids=${roadmapIds}`);\n          // A failed request answers with the `{ error, code }` envelope, not\n          // with `items` — read it only on 2xx so an outage renders an empty\n          // section instead of feeding the error body to the table.\n          const roadmapData = roadmapResponse.ok ? ((await roadmapResponse.json()) as RoadmapTasksResponse) : null;\n          if (cancelled) return;\n          setRoadmapTasks(roadmapData?.items ?? []);\n          setRoadmapLoading(false);\n        }\n\n        // Fetch delivery tasks if linked\n        const deliveryTasksData = release.clickup_delivery_tasks as Array<{ clickup_task_id: string }> | undefined;\n        if (deliveryTasksData && deliveryTasksData.length > 0 && DeliverySection) {\n          setDeliveryLoading(true);\n          const deliveryIds = deliveryTasksData.map(t => t.clickup_task_id).join(',');\n          const deliveryResponse = await contentFetch(`${deliveryApiEndpoint}?task_ids=${deliveryIds}`);\n          // Same guard as the roadmap fetch — plus both lists are normalized to\n          // arrays, because the render reads `.completed.length` unconditionally\n          // and an envelope missing either key used to throw during render.\n          const deliveryPayload = deliveryResponse.ok\n            ? ((await deliveryResponse.json()) as Partial<DeliveryResponse>)\n            : null;\n          if (cancelled) return;\n          setDeliveryData(\n            deliveryPayload\n              ? { completed: deliveryPayload.completed ?? [], inProgress: deliveryPayload.inProgress ?? [] }\n              : null,\n          );\n          setDeliveryLoading(false);\n        }\n      } catch (err) {\n        if (cancelled) return;\n        console.error('Error fetching linked tasks:', err);\n        setRoadmapLoading(false);\n        setDeliveryLoading(false);\n      }\n    }\n\n    // Never rejects — try/catch, every write gated on `cancelled`.\n    void fetchLinkedTasks();\n\n    return () => {\n      cancelled = true;\n    };\n  }, [release, RoadmapSection, DeliverySection, roadmapApiEndpoint, deliveryApiEndpoint]);\n\n  // `isLoading` is false whenever `initialData` seeded the read, so a\n  // pre-fetched (SSR / admin-preview) release never flashes the skeleton.\n  if (isLoading && !release) {\n    // `bare` + `PageShell` so the loading state matches the loaded page's full\n    // width / padding / min-height (the wrapper supplies the page chrome).\n    return renderShell(\n      // Match the loaded page's top offset (TitleBlock's\n      // `pt-[var(--spacing-system-l)]`) so content doesn't jump on load.\n      <div className=\"pt-[var(--spacing-system-l)]\">\n        <DetailPageSkeleton bare metadataColumns={4} showImageGallery={true} />\n      </div>,\n    );\n  }\n\n  if (error || !release) {\n    return renderShell(\n      <div className=\"py-16 text-center\">\n        <h1 className=\"mb-4 text-ods-text-primary text-h1\">Release Not Found</h1>\n        <p className=\"text-ods-text-secondary text-h4\">The release you&apos;re looking for doesn&apos;t exist.</p>\n      </div>,\n    );\n  }\n\n  const hasBreakingChanges = Array.isArray(release.breaking_changes) && release.breaking_changes.length > 0;\n\n  // Type assertions for release data\n  const releaseTitle = release.title as string;\n  const releaseVersion = release.version as string;\n  const releaseSummary = release.summary as string | null;\n  const releaseContent = release.content as string | null;\n  const releaseDate = release.release_date as string;\n  const releaseType = release.release_type as string;\n  const releaseStatus = release.release_status as string;\n  const releaseMedia = release.release_media as\n    Array<{ id?: string; media_type: string; media_url: string; title?: string }> | undefined;\n  // Field-cast per this file's loose-release idiom (release_type etc. above)\n  // — but to the SHARED EntityAuthor, never an inline shadow author shape.\n  const author = release.author as EntityAuthor | undefined;\n  const githubReleases = release.github_releases as Array<{ id: string; github_release_url: string }> | undefined;\n  const knowledgeBaseLinks = release.knowledge_base_links as\n    Array<{ id?: string; kb_article_path: string }> | string[] | undefined;\n  const migrationGuideUrl = release.migration_guide_url as string | undefined;\n  const documentationUrl = release.documentation_url as string | undefined;\n  const youtubeUrl = release.youtube_url as string | undefined;\n  const mainVideoUrl = release.main_video_url as string | undefined;\n  const videoBites = release.video_bites as VideoTeaser[] | undefined;\n  const highlightVideoUrl = release.highlight_video_url as string | undefined;\n  const highlightVideoThumbnail = release.highlight_video_thumbnail as string | undefined;\n  // Caption tracks — host-provided values win (the hub's wrapper enriches\n  // them); otherwise built HERE from the release's SRT columns, based on\n  // `runtime.endpoints.captionsUrlPrefix` (standard runtime-endpoint wiring),\n  // so embedders get correctly proxied `<track>` URLs with zero per-host code.\n  const entityCaptions = captions.forEntity('product_release', release as unknown as CaptionSrtFields);\n  const captionsUrl = (release.captionsUrl as string | undefined) ?? entityCaptions.captionsUrl;\n  const highlightCaptionsUrl =\n    (release.highlightCaptionsUrl as string | undefined) ?? entityCaptions.highlightCaptionsUrl;\n  const breakingChanges = release.breaking_changes as ChangelogEntry[] | undefined;\n  const featuresAdded = release.features_added as ChangelogEntry[] | undefined;\n  const bugFixed = release.bugs_fixed as ChangelogEntry[] | undefined;\n  const improvements = release.improvements as ChangelogEntry[] | undefined;\n\n  return renderShell(\n    <PageLayout\n      title={releaseTitle}\n      subtitle={`Version: ${releaseVersion}`}\n      titleSize=\"h1\"\n      titleWrap\n      backButton={showBackButton ? { label: backLabel, onClick: () => router.push(backHref) } : undefined}\n    >\n      <div className=\"space-y-6 md:space-y-8\">\n        {/* Tags — flat product_release_tags[] from entity_tags */}\n        <EntityTagBadges tags={release.product_release_tags as TagAssoc[] | undefined} />\n\n        {/* Metadata Grid */}\n        <div className=\"grid w-full grid-cols-1 overflow-hidden rounded-md border border-ods-border md:grid-cols-4\">\n          {/* Release Type */}\n          <div className=\"flex flex-col gap-3 border-b border-ods-border bg-ods-card p-4 md:border-b-0 md:border-r\">\n            <div className=\"flex flex-col gap-0\">\n              <p className=\"text-ods-text-primary text-h4\">{releaseType.toLocaleUpperCase()}</p>\n              <p className=\"text-ods-text-secondary text-h6\">Release Type</p>\n            </div>\n          </div>\n\n          {/* Release Status */}\n          <div className=\"flex flex-col gap-3 border-b border-ods-border bg-ods-card p-4 md:border-b-0 md:border-r\">\n            <div className=\"flex flex-col gap-0\">\n              <p className=\"text-ods-text-primary text-h4\">{releaseStatus.toLocaleUpperCase()}</p>\n              <p className=\"text-ods-text-secondary text-h6\">Release Status</p>\n            </div>\n          </div>\n\n          {/* Release Date */}\n          <div className=\"flex flex-col gap-3 border-b border-ods-border bg-ods-card p-4 md:border-b-0 md:border-r\">\n            <div className=\"flex flex-col gap-0\">\n              <p className=\"text-ods-text-primary text-h4\">{formatReleaseDate(releaseDate)}</p>\n              <p className=\"text-ods-text-secondary text-h6\">Release Date</p>\n            </div>\n          </div>\n\n          {/* Author — the shared metadata-grid author cell (it was extracted\n              FROM this page; rendering the export instead of an inline copy\n              keeps the two in lockstep). The stub author preserves the legacy\n              \"Unknown Author\" rendering for author-less releases; the\n              job_title-over-roleLabel rule matches the guide detail page. */}\n          <EntityMetadataAuthorCell\n            author={author ?? { full_name: null, avatar_url: null }}\n            authorHref={author?.full_name ? authorHref : undefined}\n          />\n        </div>\n\n        {/* Image gallery — shared strip + lightbox (images) / inline clips. */}\n        <MediaGalleryStrip items={releaseMedia ?? []} />\n\n        {/* Summary */}\n        {releaseSummary && (\n          <div className=\"text-ods-text-primary text-h4\">\n            <p>{releaseSummary}</p>\n          </div>\n        )}\n\n        {/* Video Display Section - Injectable or fallback */}\n        {VideoDisplaySection ? (\n          <VideoDisplaySection\n            mainVideoUrl={mainVideoUrl}\n            youtubeUrl={youtubeUrl}\n            highlightVideoUrl={highlightVideoUrl}\n            highlightVideoThumbnail={highlightVideoThumbnail}\n            title={releaseTitle}\n            videoBites={videoBites}\n            bitesTitle={DEFAULT_VIDEO_BITES_TITLE}\n            filterPublishedBites={true}\n            bitesProfile={toStripProfile(author)}\n            bitesHref={typeof release.slug === 'string' ? `/releases/${release.slug}` : undefined}\n            srtContent={release?.srt_content as string | null | undefined}\n            captionsUrl={captionsUrl}\n            highlightCaptionsUrl={highlightCaptionsUrl}\n          />\n        ) : (\n          <>\n            {/*\n              Fallback when no `VideoDisplaySection` is injected. `<Video>` is the\n              SSoT for every video surface — single source of truth across YouTube,\n              HLS, and MP4 paths.\n            */}\n            {youtubeUrl && <Video kind=\"youtube\" url={youtubeUrl} title={`${releaseTitle} - Video`} layout=\"native\" />}\n            {!youtubeUrl && mainVideoUrl && (\n              <Video\n                url={mainVideoUrl}\n                srtContent={release?.srt_content as string | undefined}\n                captionsUrl={captionsUrl}\n                layout=\"centered\"\n              />\n            )}\n            {highlightVideoUrl && (\n              <Video\n                url={highlightVideoUrl}\n                poster={highlightVideoThumbnail}\n                captionsUrl={highlightCaptionsUrl}\n                layout=\"centered\"\n              />\n            )}\n          </>\n        )}\n\n        {/* Content */}\n        {releaseContent && (\n          <div className=\"text-ods-text-primary text-h4\">\n            <MarkdownRenderer content={releaseContent} />\n          </div>\n        )}\n\n        {/* Breaking Changes Warning */}\n        {hasBreakingChanges && (\n          <Card className=\"border-ods-error bg-ods-error/10\">\n            <CardContent className=\"p-6\">\n              <div className=\"flex items-center gap-3\">\n                <AlertTriangle className=\"h-6 w-6 text-ods-error\" />\n                <div>\n                  <h3 className=\"text-ods-error text-h3\">Breaking Changes</h3>\n                  <p className=\"text-ods-text-secondary\">\n                    This release contains breaking changes. Review carefully before upgrading.\n                  </p>\n                </div>\n              </div>\n            </CardContent>\n          </Card>\n        )}\n\n        {/* Changelog Sections — icons match the catalog card's changelog\n            strip taxonomy (Sparkles/Wrench/TrendingUp/AlertTriangle) so the\n            user sees a consistent visual signature across catalog → detail. */}\n        <ReleaseChangelogSection\n          title=\"Breaking Changes\"\n          entries={breakingChanges || []}\n          isBreaking\n          hideTitle\n          icon={<AlertTriangle className=\"h-6 w-6\" />}\n          MarkdownRenderer={MarkdownRenderer}\n        />\n        {/* Features / Bugs / Improvements use `previewFirst` — same\n            progressive-disclosure pattern as the investor-update detail\n            page's Key Highlights / Financial Notes sections. Shows the\n            first entry in full + fade-masks the rest, with a \"Show N\n            more / Show less\" toggle. Breaking Changes (above) stays\n            fully expanded — it's critical info, not skim-friendly. */}\n        <ReleaseChangelogSection\n          title=\"Features Added\"\n          entries={featuresAdded || []}\n          icon={<Sparkles className=\"h-6 w-6\" />}\n          previewFirst\n          MarkdownRenderer={MarkdownRenderer}\n        />\n        <ReleaseChangelogSection\n          title=\"Bugs Fixed\"\n          entries={bugFixed || []}\n          icon={<Wrench className=\"h-6 w-6\" />}\n          previewFirst\n          MarkdownRenderer={MarkdownRenderer}\n        />\n        <ReleaseChangelogSection\n          title=\"Improvements\"\n          entries={improvements || []}\n          icon={<TrendingUp className=\"h-6 w-6\" />}\n          previewFirst\n          MarkdownRenderer={MarkdownRenderer}\n        />\n\n        {/* Related Roadmap Items */}\n        {RoadmapSection && (roadmapLoading || roadmapTasks.length > 0) && (\n          <div className=\"w-full space-y-4\">\n            <p className=\"tracking-[-0.28px] text-ods-text-secondary text-h5\">Related Roadmap Items</p>\n            <RoadmapSection\n              items={roadmapTasks}\n              isLoading={roadmapLoading}\n              onItemUpdate={updatedItem => {\n                setRoadmapTasks(prevTasks => prevTasks.map(task => (task.id === updatedItem.id ? updatedItem : task)));\n              }}\n            />\n          </div>\n        )}\n\n        {/* Bug-fixes & Enhancements Section — releases can link dozens of\n            delivery tasks, so the list gets the same `FadePreview`\n            progressive disclosure as the changelog sections above: first\n            row visible, rest fade-masked behind \"Show N more\". */}\n        {DeliverySection &&\n          (deliveryLoading ||\n            (deliveryData && (deliveryData.completed.length > 0 || deliveryData.inProgress.length > 0))) && (\n            <div className=\"w-full space-y-4\">\n              <p className=\"tracking-[-0.28px] text-ods-text-secondary text-h5\">Related Enhancements and Bug-fixes</p>\n              {(() => {\n                const deliveryCount = deliveryData ? deliveryData.completed.length + deliveryData.inProgress.length : 0;\n                return deliveryLoading ? (\n                  <DeliverySection data={deliveryData} isLoading={deliveryLoading} />\n                ) : (\n                  <FadePreview hiddenCount={deliveryCount - 1} collapsedHeight={300} resetKey={deliveryCount}>\n                    {/* space-y-4 restores the completed/in-progress table gap the\n                      parent's space-y used to provide before the fade wrapper. */}\n                    <div className=\"space-y-4\">\n                      <DeliverySection data={deliveryData} isLoading={false} />\n                    </div>\n                  </FadePreview>\n                );\n              })()}\n            </div>\n          )}\n\n        {/* Related Links */}\n        {(githubReleases?.length || knowledgeBaseLinks?.length || migrationGuideUrl || documentationUrl) && (\n          <div className=\"w-full space-y-1\">\n            <p className=\"tracking-[-0.28px] text-ods-text-secondary text-h5\">Related Links</p>\n            <Card className=\"border-ods-border bg-ods-card p-6\">\n              <div className=\"space-y-4\">\n                {/* GitHub Releases */}\n                {githubReleases && githubReleases.length > 0 && (\n                  <>\n                    {githubReleases.map(ghRelease => (\n                      <div key={ghRelease.id} className=\"flex items-start gap-1\">\n                        <GitHubIcon className=\"shrink-0\" width={24} height={24} color=\"var(--color-text-secondary)\" />\n                        <span className=\"text-ods-text-primary text-h4\">Github Release</span>\n                        <a\n                          href={ghRelease.github_release_url}\n                          target=\"_blank\"\n                          rel=\"noopener noreferrer\"\n                          className=\"text-ods-accent text-h4 hover:underline\"\n                        >\n                          {ghRelease.github_release_url.split('/').pop()}\n                        </a>\n                        <ExternalLink className=\"h-6 w-6 shrink-0 text-ods-accent\" />\n                      </div>\n                    ))}\n                  </>\n                )}\n\n                {/* Knowledge Base Links */}\n                {knowledgeBaseLinks && knowledgeBaseLinks.length > 0 && (\n                  <>\n                    {knowledgeBaseLinks.map(linkObj => {\n                      const path = typeof linkObj === 'string' ? linkObj : linkObj.kb_article_path;\n                      const linkId = typeof linkObj === 'string' ? path : linkObj.id || path;\n                      return (\n                        <div key={linkId} className=\"flex items-start gap-1\">\n                          <BookMarked className=\"h-6 w-6 shrink-0 text-ods-text-secondary\" />\n                          <span className=\"text-ods-text-primary text-h4\">Knowledge Base</span>\n                          <Link\n                            href={\n                              path.startsWith('http')\n                                ? path\n                                : `/knowledge-base${path.startsWith('/') ? '' : '/'}${path}`\n                            }\n                            className=\"text-ods-accent text-h4 hover:underline\"\n                          >\n                            {path.replace(/^\\//, '').split('/').pop()?.replace(/-/g, ' ') || 'View Article'}\n                          </Link>\n                          <ExternalLink className=\"h-6 w-6 shrink-0 text-ods-accent\" />\n                        </div>\n                      );\n                    })}\n                  </>\n                )}\n\n                {/* Migration Guide */}\n                {migrationGuideUrl && (\n                  <div className=\"flex items-start gap-1\">\n                    <BookMarked className=\"h-6 w-6 shrink-0 text-ods-text-secondary\" />\n                    <a\n                      href={migrationGuideUrl}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      className=\"text-ods-accent text-h4 hover:underline\"\n                    >\n                      📖 Migration Guide\n                    </a>\n                    <ExternalLink className=\"h-6 w-6 shrink-0 text-ods-accent\" />\n                  </div>\n                )}\n\n                {/* Documentation */}\n                {documentationUrl && (\n                  <div className=\"flex items-start gap-1\">\n                    <BookMarked className=\"h-6 w-6 shrink-0 text-ods-text-secondary\" />\n                    <a\n                      href={documentationUrl}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      className=\"text-ods-accent text-h4 hover:underline\"\n                    >\n                      📚 Documentation\n                    </a>\n                    <ExternalLink className=\"h-6 w-6 shrink-0 text-ods-accent\" />\n                  </div>\n                )}\n              </div>\n            </Card>\n          </div>\n        )}\n      </div>\n\n      {/*\n       * Host slot — end-of-article byline + related-content / FAQ rail.\n       *\n       * KEYED for the same reason as `onboarding-guide-detail-view.tsx`: this is a\n       * member of PageLayout's static children array, and a slot element built in a\n       * SERVER component crosses the RSC boundary as a lazy chunk wrapper, so\n       * `validateChildKeys` marks the wrapper and the fulfilled path never copies\n       * `_store.validated` onto the revived element — it reaches the reconciler\n       * unvalidated and warns.\n       *\n       * Today the hub's only consumer of this view is a `'use client'` wrapper that\n       * builds the slot locally, so no element actually crosses the boundary and\n       * this is pre-emptive. It is kept because the trap is invisible from here: a\n       * future server-built slot would warn with no clue pointing at this line.\n       * A keyed Fragment emits no DOM, so output is byte-identical either way.\n       */}\n      <Fragment key=\"related-content\">{relatedContent}</Fragment>\n    </PageLayout>,\n  );\n}\n","'use client';\n\nimport { DetailPageSkeleton } from '../detail-page-skeleton';\n\nexport function ReleaseDetailSkeleton() {\n  return <DetailPageSkeleton metadataColumns={4} showImageGallery={true} />;\n}\n","'use client';\n\n/**\n * useRoadmapVoting — localStorage-backed optimistic voting for roadmap cards.\n *\n * One vote per task per user (storage key scoped per `storageKey` option,\n * default `'roadmap_votes_v1'`). Toggling the same vote removes it;\n * switching directions sends a remove + add pair so the server's running\n * totals stay correct.\n *\n * Endpoint configuration — `voteApiEndpoint`:\n *   The hook posts to ONE endpoint (default `/api/roadmap/vote`) for\n *   BOTH the optimistic add AND the opposite-vote remove. Reverse-proxy\n *   embedders override this with their proxied path; lib otherwise\n *   matches the hub's pre-migration call shape.\n */\n\nimport { useState, useEffect, useCallback } from 'react';\nimport { useIsHydrated } from '../../../hooks/ui/use-is-hydrated';\nimport { useHumanitySignals } from '../../../hooks/use-humanity-signals';\nimport { contentFetch } from '../../../utils/embed-content-fetch';\n\nexport type VoteType = 'up' | 'down' | null;\n\nexport interface VoteState {\n  [taskId: string]: VoteType;\n}\n\nexport interface UseRoadmapVotingOptions {\n  /** Vote endpoint URL. Default `/api/roadmap/vote`. */\n  voteApiEndpoint?: string;\n  /** localStorage key. Default `'roadmap_votes_v1'`. Embedders mounting\n   *  multiple roadmap surfaces in the same origin can scope per-surface\n   *  (e.g. `'roadmap_votes_v1_main'` vs `'roadmap_votes_v1_admin'`) so\n   *  votes don't cross-contaminate. */\n  storageKey?: string;\n}\n\nconst DEFAULT_VOTE_ENDPOINT = '/api/roadmap/vote';\nconst DEFAULT_STORAGE_KEY = 'roadmap_votes_v1';\n\n/** One shared empty map so \"no votes\" is a stable identity across renders. */\nconst NO_VOTES: VoteState = {};\n\n/** Client-only: callers must gate on `useIsHydrated()` before reaching here. */\nfunction readStoredVotes(storageKey: string): VoteState {\n  try {\n    const stored = localStorage.getItem(storageKey);\n    return stored ? (JSON.parse(stored) as VoteState) : NO_VOTES;\n  } catch (error) {\n    console.error('[Voting] Error loading votes from localStorage:', error);\n    return NO_VOTES;\n  }\n}\n\nexport function useRoadmapVoting(options: UseRoadmapVotingOptions = {}) {\n  const voteApiEndpoint = options.voteApiEndpoint ?? DEFAULT_VOTE_ENDPOINT;\n  const storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;\n\n  // Humanity signals ride every vote POST: no decoy field is rendered (a vote\n  // is not a form), so the honeypot key is always '' — but the mount-to-click\n  // elapsed-ms lets the server POSITIVELY affirm human timing and downgrade a\n  // BotID false positive (ad-blocker users block the BotID script and would\n  // otherwise 403 on votes). The server never BLOCKS a vote on these signals.\n  const { getSignals } = useHumanitySignals();\n\n  const [votes, setVotes] = useState<VoteState>(NO_VOTES);\n  // Which key `votes` was loaded from. `null` until the client has read\n  // storage — that IS the loading flag, so nothing has to be written into a\n  // separate `isLoading` state.\n  const [loadedKey, setLoadedKey] = useState<string | null>(null);\n\n  // Load votes from localStorage. Happens on the first render after hydration,\n  // AND whenever `storageKey` changes — when the key changes mid-lifecycle\n  // (e.g. an embedder remounts with a new namespace) the previous key's votes\n  // MUST NOT survive, or the save below writes the old key's data into the new\n  // key. Re-entering the loading phase is implicit: `loadedKey` stops matching,\n  // so `isLoading` is true again until this reload lands.\n  //\n  // Read and applied while rendering, not from an effect. `localStorage` cannot\n  // be touched during SSR or the hydration render, which is what `useIsHydrated`\n  // gates; past that point `getItem` is just a read, and doing it here means the\n  // stored votes are present in the FIRST render that can show them instead of\n  // being published by a second render pass.\n  const hydrated = useIsHydrated();\n  if (hydrated && loadedKey !== storageKey) {\n    setLoadedKey(storageKey);\n    setVotes(readStoredVotes(storageKey));\n  }\n  const isLoading = loadedKey !== storageKey;\n\n  // Save votes to localStorage whenever they change\n  useEffect(() => {\n    if (!isLoading) {\n      try {\n        localStorage.setItem(storageKey, JSON.stringify(votes));\n      } catch (error) {\n        console.error('[Voting] Error saving votes to localStorage:', error);\n      }\n    }\n  }, [votes, isLoading, storageKey]);\n\n  const getVote = useCallback(\n    (taskId: string): VoteType => {\n      return votes[taskId] || null;\n    },\n    [votes],\n  );\n\n  const toggleVote = useCallback(\n    async (\n      taskId: string,\n      voteType: 'up' | 'down',\n    ): Promise<{ success: boolean; newVote: VoteType; action: 'add' | 'remove' }> => {\n      const currentVote = votes[taskId];\n\n      let newVote: VoteType = null;\n      let action: 'add' | 'remove' = 'add';\n\n      if (currentVote === voteType) {\n        // User clicked same vote - remove it\n        newVote = null;\n        action = 'remove';\n      } else {\n        // User clicked different vote - set it. If they had an opposite\n        // vote, remove that first so the server totals stay consistent.\n        if (currentVote) {\n          await contentFetch(voteApiEndpoint, {\n            method: 'POST',\n            headers: { 'Content-Type': 'application/json' },\n            body: JSON.stringify({\n              taskId,\n              voteType: currentVote,\n              action: 'remove',\n              ...getSignals(),\n            }),\n          }).catch((err: unknown) => console.error('[Voting] Error removing opposite vote:', err));\n        }\n\n        newVote = voteType;\n        action = 'add';\n      }\n\n      // Optimistic update\n      setVotes(prev => ({\n        ...prev,\n        [taskId]: newVote,\n      }));\n\n      try {\n        const response = await contentFetch(voteApiEndpoint, {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({ taskId, voteType, action, ...getSignals() }),\n        });\n\n        if (!response.ok) {\n          throw new Error('Vote API request failed');\n        }\n\n        return { success: true, newVote, action };\n      } catch (error) {\n        console.error('[Voting] API error:', error);\n\n        // Revert optimistic update on error\n        setVotes(prev => ({\n          ...prev,\n          [taskId]: currentVote,\n        }));\n\n        return { success: false, newVote: currentVote, action };\n      }\n    },\n    [votes, voteApiEndpoint, getSignals],\n  );\n\n  const clearVotes = useCallback(() => {\n    setVotes(NO_VOTES);\n    localStorage.removeItem(storageKey);\n  }, [storageKey]);\n\n  return {\n    votes,\n    isLoading,\n    getVote,\n    toggleVote,\n    clearVotes,\n  };\n}\n","'use client';\n\n/**\n * RoadmapGrid — the shared roadmap LIST surface.\n *\n * Two modes (one component, one voting state):\n *   - `groupByQuarter` (DEFAULT): buckets items by `item.quarter`, sorts the\n *     quarters chronologically, and renders each in a collapsible `<Accordion>`\n *     — the same quarter grouping the hub's roadmap page has, now shared so\n *     every embedder gets it for free. Quarters at/after the current quarter\n *     (and within `quartersToKeepClosed` of it) open by default; older ones\n *     collapse. When `hasActiveFilters`, all quarters expand.\n *   - `groupByQuarter={false}`: a flat 2-col grid (related-content rails that\n *     pass a small pre-filtered `items` slice).\n *\n * Voting state (`useRoadmapVoting` + the in-flight set) lives ONCE at this\n * level and is shared across every quarter's grid, so a vote in Q3 and a vote\n * in Q4 can't race separate states. A successful vote triggers a single-task\n * refresh (`buildRefreshUrl`) and patches the parent list via `onItemUpdate`.\n *\n * Hydration: `expandedQuarters` starts `[]` and is populated in a client-only\n * effect (mirrors the hub) so SSR markup matches first client paint.\n */\n\nimport { useEffect, useRef, useState } from 'react';\nimport { cn } from '../../../utils/cn';\nimport { devSectionAnchorId } from '../../../utils/dev-sections/dev-section-param-keys';\nimport { contentFetch } from '../../../utils/embed-content-fetch';\nimport { RoadmapCard } from '../../chat/entity-cards/roadmap-card';\nimport type { RoadmapItem } from '../../chat/types/entities/roadmap-item';\nimport { EmptyState } from '../../empty-state';\nimport { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '../../ui';\nimport { useRoadmapVoting, type UseRoadmapVotingOptions } from './use-roadmap-voting';\n\nconst DEFAULT_BUILD_REFRESH_URL = (taskId: string) => `/api/roadmap/${taskId}`;\nconst BACKLOG = 'Backlog';\n\n/** Wire shape of the post-vote single-task refresh — `{ item }`. */\ninterface RoadmapItemResponse {\n  item?: RoadmapItem;\n}\n\n// ── Quarter helpers (pure; lifted from the hub roadmap section) ──────────────\n\n/** Status → sort priority (Complete → Working → Review → To-Do → other). */\nfunction getStatusPriority(status: string): number {\n  const s = (status || '').toLowerCase();\n  if (s.includes('complete') || s.includes('done')) return 1;\n  if (s.includes('working') || s.includes('progress')) return 2;\n  if (s.includes('review')) return 3;\n  if (s.includes('to do') || s.includes('plan')) return 4;\n  return 5;\n}\n\n/** Parse a `\"Q<n> <year>\"` label → {quarter, year}; `null` when unparseable. */\nfunction parseQuarterString(q: string): { quarter: number; year: number } | null {\n  const match = q.match(/Q(\\d+)\\s+(\\d+)/);\n  if (!match) return null;\n  return { quarter: parseInt(match[1], 10), year: parseInt(match[2], 10) };\n}\n\nfunction compareQuarters(a: { quarter: number; year: number }, b: { quarter: number; year: number }): number {\n  if (a.year !== b.year) return a.year - b.year;\n  return a.quarter - b.quarter;\n}\n\n/** Today's quarter — client-only (called from an effect, never during SSR). */\nfunction getCurrentQuarter(): { quarter: number; year: number } {\n  const now = new Date();\n  return { quarter: Math.floor(now.getMonth() / 3) + 1, year: now.getFullYear() };\n}\n\n/** Quarters open by default: current + future, plus the recent past within\n *  `quartersToKeepClosed` of the current quarter; Backlog always open. */\nfunction computeDefaultExpandedQuarters(quarters: string[], quartersToKeepClosed: number): string[] {\n  const currentQ = getCurrentQuarter();\n  const out: string[] = [];\n  for (const q of quarters) {\n    if (q === BACKLOG) continue;\n    const parsed = parseQuarterString(q);\n    if (!parsed) continue;\n    const diff = compareQuarters(parsed, currentQ);\n    if (diff >= 0) {\n      out.push(q);\n    } else {\n      const quartersAgo = currentQ.year * 4 + currentQ.quarter - (parsed.year * 4 + parsed.quarter);\n      if (quartersAgo < quartersToKeepClosed) out.push(q);\n    }\n  }\n  if (quarters.includes(BACKLOG)) out.push(BACKLOG);\n  return out;\n}\n\nexport interface RoadmapGridProps {\n  items: RoadmapItem[];\n  onItemUpdate?: (updatedItem: RoadmapItem) => void;\n  /** Show the desktop left margin (~120px) that aligns the grid with the page\n   *  hero. Default `true`. Related-content rails pass `false`. */\n  showLeftMargin?: boolean;\n  /**\n   * Columns at `md` and up. Default `2` (the roadmap page).\n   *\n   * A card in this grid needs real width — its title, status badge, target\n   * version and vote row sit on one line each. Two columns inside a NARROW\n   * host (a modal rail, a sidebar) halve an already-small container and crush\n   * every one of them, so such hosts pass `1`.\n   */\n  columns?: 1 | 2;\n  /** URL builder for the per-task refresh call after a successful vote. */\n  buildRefreshUrl?: (taskId: string) => string;\n  /** Voting hook options (vote endpoint + storage key). */\n  votingOptions?: UseRoadmapVotingOptions;\n  /** Group items into collapsible per-quarter accordions. Default `false`\n   *  (flat grid) so EXISTING callers — the hub's per-quarter RoadmapGrid calls\n   *  and related-content rails — stay unchanged. `RoadmapView` / full-page\n   *  callers pass `true` to get the shared quarter grouping. */\n  groupByQuarter?: boolean;\n  /** When true (search/filter active), every quarter expands so results aren't\n   *  hidden in collapsed sections. Threaded from `RoadmapView`. */\n  hasActiveFilters?: boolean;\n  /** Past quarters within this window of the current quarter stay open by\n   *  default; older ones collapse. Default `2`. */\n  quartersToKeepClosed?: number;\n}\n\n/** Internal flat 2-col grid for ONE set of items. Voting comes from the parent\n *  so the state is shared across quarters. */\nfunction RoadmapGridSingle({\n  items,\n  showLeftMargin,\n  columns = 2,\n  getVote,\n  onVote,\n  votingTasks,\n}: {\n  items: RoadmapItem[];\n  showLeftMargin: boolean;\n  columns?: 1 | 2;\n  getVote: (taskId: string) => 'up' | 'down' | null;\n  onVote: (taskId: string, voteType: 'up' | 'down') => void;\n  votingTasks: Set<string>;\n}) {\n  return (\n    // LITERAL class strings: an interpolated `md:grid-cols-${n}` is invisible\n    // to Tailwind's scanner and is never generated.\n    <div\n      className={`grid grid-cols-1 gap-6 ${columns === 2 ? 'md:grid-cols-2' : ''} ${\n        showLeftMargin ? 'md:ml-[120px]' : ''\n      }`}\n    >\n      {items.map(item => (\n        // DOM id + sticky-header scroll offset live ON RoadmapCard's own\n        // outer element (no wrapper div). Anchor mirrors\n        // `buildDevSectionUrl('roadmap', <id>)` → `#roadmap-<external_id>`;\n        // `useScrollToHash` in `roadmap-view.tsx` finds the card by id\n        // and scrolls.\n        <RoadmapCard\n          key={item.id}\n          item={item}\n          id={devSectionAnchorId('roadmap', item.id)}\n          userVote={getVote(item.id)}\n          onVote={voteType => onVote(item.id, voteType)}\n          isVoting={votingTasks.has(item.id)}\n        />\n      ))}\n    </div>\n  );\n}\n\nexport function RoadmapGrid({\n  items,\n  onItemUpdate,\n  showLeftMargin = true,\n  columns = 2,\n  buildRefreshUrl = DEFAULT_BUILD_REFRESH_URL,\n  votingOptions,\n  groupByQuarter = false,\n  hasActiveFilters = false,\n  quartersToKeepClosed = 2,\n}: RoadmapGridProps) {\n  // ── Voting (shared across all quarters) ──\n  const { getVote, toggleVote } = useRoadmapVoting(votingOptions);\n  const [votingTasks, setVotingTasks] = useState<Set<string>>(new Set());\n\n  const handleVote = async (taskId: string, voteType: 'up' | 'down') => {\n    if (votingTasks.has(taskId)) return;\n    setVotingTasks(prev => new Set(prev).add(taskId));\n    try {\n      const result = await toggleVote(taskId, voteType);\n      if (result.success) {\n        const response = await contentFetch(buildRefreshUrl(taskId));\n        if (response.ok) {\n          // `buildRefreshUrl` points at the single-task read, which answers\n          // `{ item }` (404 → `{ error }`, already excluded by `response.ok`).\n          const data = (await response.json()) as RoadmapItemResponse;\n          if (data.item && onItemUpdate) onItemUpdate(data.item);\n        }\n      }\n    } finally {\n      setVotingTasks(prev => {\n        const next = new Set(prev);\n        next.delete(taskId);\n        return next;\n      });\n    }\n  };\n\n  // ── Quarter bucketing + chronological sort (recomputed each render; cheap) ──\n  const itemsByQuarter: Record<string, RoadmapItem[]> = {};\n  for (const item of items) {\n    const q = item.quarter || BACKLOG;\n    (itemsByQuarter[q] ||= []).push(item);\n  }\n  for (const q of Object.keys(itemsByQuarter)) {\n    itemsByQuarter[q].sort((a, b) => getStatusPriority(a.status) - getStatusPriority(b.status));\n  }\n  const sortedQuarters = Object.keys(itemsByQuarter).sort((a, b) => {\n    if (a === BACKLOG) return 1;\n    if (b === BACKLOG) return -1;\n    const aD = parseQuarterString(a) ?? { quarter: 0, year: 0 };\n    const bD = parseQuarterString(b) ?? { quarter: 0, year: 0 };\n    return compareQuarters(aD, bD);\n  });\n  const sortedQuartersKey = sortedQuarters.join(',');\n\n  // ── Accordion expand state (hydration-safe: start [], populate in effects) ──\n  const [expandedQuarters, setExpandedQuarters] = useState<string[]>([]);\n  const [isInitialized, setIsInitialized] = useState(false);\n  const hasSetInitialState = useRef(false);\n  const prevItemsLength = useRef(0);\n\n  // Latest values for the two effects below. Neither can take `sortedQuarters`\n  // as a dependency: it is rebuilt on every render, and the effects are keyed\n  // on its LENGTH / content key on purpose. Declared before them so the sync\n  // has already run by the time either fires in the same commit.\n  const sortedQuartersRef = useRef(sortedQuarters);\n  const hasActiveFiltersRef = useRef(hasActiveFilters);\n  const quartersToKeepClosedRef = useRef(quartersToKeepClosed);\n  useEffect(() => {\n    sortedQuartersRef.current = sortedQuarters;\n    hasActiveFiltersRef.current = hasActiveFilters;\n    quartersToKeepClosedRef.current = quartersToKeepClosed;\n  });\n\n  // Initial expand state once data loads (runs once, or when data first arrives).\n  useEffect(() => {\n    const itemsJustLoaded = prevItemsLength.current === 0 && items.length > 0;\n    prevItemsLength.current = items.length;\n    const quarters = sortedQuartersRef.current;\n    if (quarters.length > 0 && (!hasSetInitialState.current || itemsJustLoaded)) {\n      hasSetInitialState.current = true;\n      setExpandedQuarters(\n        hasActiveFiltersRef.current\n          ? [...quarters]\n          : computeDefaultExpandedQuarters(quarters, quartersToKeepClosedRef.current),\n      );\n      setIsInitialized(true);\n    }\n  }, [sortedQuarters.length, items.length]);\n\n  // React to filter toggles AFTER init: filters on → expand all; off → defaults.\n  useEffect(() => {\n    const quarters = sortedQuartersRef.current;\n    if (!isInitialized || quarters.length === 0) return;\n    setExpandedQuarters(\n      hasActiveFilters ? [...quarters] : computeDefaultExpandedQuarters(quarters, quartersToKeepClosed),\n    );\n  }, [hasActiveFilters, sortedQuartersKey, isInitialized, quartersToKeepClosed]);\n\n  if (items.length === 0) {\n    return (\n      <EmptyState\n        type=\"generic\"\n        title=\"No roadmap items\"\n        description=\"Check back soon for upcoming features and improvements!\"\n      />\n    );\n  }\n\n  // Flat mode (related-content rails) — no accordion.\n  if (!groupByQuarter) {\n    return (\n      <RoadmapGridSingle\n        items={items}\n        showLeftMargin={showLeftMargin}\n        columns={columns}\n        getVote={getVote}\n        onVote={handleVote}\n        votingTasks={votingTasks}\n      />\n    );\n  }\n\n  return (\n    <Accordion\n      type=\"multiple\"\n      value={expandedQuarters}\n      onValueChange={setExpandedQuarters}\n      className=\"flex flex-col gap-10\"\n    >\n      {sortedQuarters.map(quarter => {\n        const itemCount = itemsByQuarter[quarter]?.length || 0;\n        const isExpanded = expandedQuarters.includes(quarter);\n        return (\n          <AccordionItem\n            key={quarter}\n            value={quarter}\n            id={`quarter-${quarter.replace(/\\s+/g, '-').toLowerCase()}`}\n            className=\"border-0\"\n          >\n            <AccordionTrigger className=\"w-full p-0 hover:no-underline [&>svg]:ml-auto [&>svg]:h-5 [&>svg]:w-5 [&>svg]:shrink-0 [&>svg]:text-ods-text-secondary\">\n              <div className=\"flex items-center gap-3\">\n                <h3\n                  className={cn(\n                    'text-ods-text-primary transition-opacity text-h2',\n                    isExpanded ? 'opacity-100' : 'opacity-60',\n                  )}\n                >\n                  {quarter}\n                  <span className=\"text-ods-accent\">:</span>\n                </h3>\n                <span\n                  className={cn(\n                    'transition-opacity text-h6',\n                    isExpanded ? 'text-ods-text-secondary opacity-100' : 'text-ods-text-tertiary opacity-60',\n                  )}\n                >\n                  {itemCount} {itemCount === 1 ? 'item' : 'items'}\n                  {isInitialized && !isExpanded && <span className=\"ml-2 text-ods-accent\">Click to expand</span>}\n                </span>\n              </div>\n            </AccordionTrigger>\n            <AccordionContent className=\"overflow-hidden pb-0 pt-4 data-[state=closed]:animate-none data-[state=open]:animate-none\">\n              <RoadmapGridSingle\n                items={itemsByQuarter[quarter]}\n                showLeftMargin={showLeftMargin}\n                columns={columns}\n                getVote={getVote}\n                onVote={handleVote}\n                votingTasks={votingTasks}\n              />\n            </AccordionContent>\n          </AccordionItem>\n        );\n      })}\n    </Accordion>\n  );\n}\n","/**\n * RoadmapGridSkeleton — loading state for the `/roadmap` grid view.\n *\n * Pure JSX (no hooks, no events) — `'use client'` not strictly required\n * here; tsup's client-entry banner injects it automatically when this\n * file is bundled into the client output. We match the playbook's\n * skeleton-file convention (no directive when no hooks).\n *\n * NOTE: lib's `chat/entity-cards/roadmap-card.tsx` also exports a\n * `RoadmapCardSkeleton` — that one is the COMPACT 56px chat-card\n * variant. This file's internal card-skeleton (340px grid card)\n * intentionally stays file-internal to avoid the naming collision;\n * only `RoadmapGridSkeleton` is exported.\n */\n\nfunction RoadmapCardSkeleton() {\n  return (\n    <div className=\"relative flex min-h-[340px] flex-col gap-[16px] rounded-[6px] border border-ods-border bg-ods-card p-[24px]\">\n      {/* Status Badge Skeleton - Top Right */}\n      <div className=\"absolute right-[24px] top-[24px]\">\n        <div className=\"h-[20px] w-[80px] animate-pulse rounded bg-ods-border\"></div>\n      </div>\n\n      {/* Icon and title skeleton */}\n      <div className=\"flex items-center gap-[16px] pr-[120px]\">\n        <div className=\"h-[80px] w-[80px] flex-shrink-0 animate-pulse rounded-lg bg-ods-border\"></div>\n        <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n          <div className=\"flex min-h-[48px] items-center\">\n            <div className=\"h-[24px] w-full animate-pulse rounded bg-ods-border\"></div>\n          </div>\n          <div className=\"flex min-h-[20px] items-center\">\n            <div className=\"h-[14px] w-1/2 animate-pulse rounded bg-ods-border\"></div>\n          </div>\n        </div>\n      </div>\n\n      {/* Description skeleton - exactly 3 lines */}\n      <div className=\"flex min-h-[72px] items-center\">\n        <div className=\"w-full space-y-2\">\n          <div className=\"h-[24px] animate-pulse rounded bg-ods-border\"></div>\n          <div className=\"h-[24px] animate-pulse rounded bg-ods-border\"></div>\n          <div className=\"h-[24px] w-4/5 animate-pulse rounded bg-ods-border\"></div>\n        </div>\n      </div>\n\n      <div className=\"flex-1\"></div>\n\n      {/* Bottom skeleton */}\n      <div className=\"flex items-center justify-between\">\n        <div className=\"h-[48px] w-[120px] animate-pulse rounded bg-ods-border\"></div>\n        <div className=\"h-[32px] w-[100px] animate-pulse rounded bg-ods-border\"></div>\n      </div>\n    </div>\n  );\n}\n\nexport interface RoadmapGridSkeletonProps {\n  /** Number of skeleton cards to show. Default 4. */\n  count?: number;\n  /** Show the desktop left margin (~120px) that aligns the grid with\n   *  the page hero's title block. Default `true`. Related-content rails\n   *  inside narrower surfaces (e.g. the release detail page) pass `false`. */\n  showLeftMargin?: boolean;\n}\n\nexport function RoadmapGridSkeleton({ count = 4, showLeftMargin = true }: RoadmapGridSkeletonProps) {\n  return (\n    <div className={`grid grid-cols-1 gap-6 md:grid-cols-2 ${showLeftMargin ? 'md:ml-[120px]' : ''}`}>\n      {Array.from({ length: count }).map((_, i) => (\n        <RoadmapCardSkeleton key={i} />\n      ))}\n    </div>\n  );\n}\n","'use client';\n\n/**\n * `<RoadmapView />` — the SELF-CONTAINED roadmap LIST surface.\n *\n * Fetches the roadmap list via the shared `useSelfFetch` hook and renders the\n * pure controlled `<RoadmapGrid>` (kept controlled so related-content rails can\n * still pass `items`). The host configures only **api routes**: the list\n * `endpoint` (default `/api/roadmap`), the per-task `buildRefreshUrl`, and the\n * vote endpoint via `votingOptions`. Optional `initialItems` hydrates SSR.\n */\n\nimport { useMemo } from 'react';\n\nimport { useSearchParams } from '../../../embed-shims';\nimport { useScrollToHash } from '../../../hooks/use-scroll-to-hash';\nimport { useSelfFetch } from '../../../hooks/use-self-fetch';\nimport { DEV_SECTION_PARAM_KEYS } from '../../../utils/dev-sections/dev-section-param-keys';\nimport { STICKY_HEADER_OFFSET_PX } from '../../../utils/same-page-hash-nav';\nimport type { RoadmapItem } from '../../chat/types/entities/roadmap-item';\nimport { LoadError } from '../../ui/error-state';\nimport { RoadmapGrid } from './roadmap-grid';\nimport { RoadmapGridSkeleton } from './roadmap-grid-skeleton';\nimport type { UseRoadmapVotingOptions } from './use-roadmap-voting';\n\nconst DEFAULT_ENDPOINT = '/api/roadmap';\n// Defaults sourced from the ONE param-key registry the chrome (OPENFRAME_DEV_SECTIONS) also\n// reads, so the chrome's written `?key=` and this view's read can't silently diverge.\nconst DEFAULT_SEARCH_PARAM_KEY = DEV_SECTION_PARAM_KEYS.search;\nconst DEFAULT_STATUS_PARAM_KEY = DEV_SECTION_PARAM_KEYS.status;\n\nexport interface RoadmapViewProps {\n  /** GET list endpoint (the api route). Returns `{ items }`. Default\n   *  `/api/roadmap`. */\n  endpoint?: string;\n  /** Optional SSR hydrate — skips the initial client fetch. */\n  initialItems?: RoadmapItem[];\n  showLeftMargin?: boolean;\n  /** Per-task refresh URL builder (after a vote). Default `/api/roadmap/<id>`. */\n  buildRefreshUrl?: (taskId: string) => string;\n  /** Voting hook options (vote endpoint + storage key). */\n  votingOptions?: UseRoadmapVotingOptions;\n  /** URL param key for the search input — MUST match the section chrome\n   *  (`DevSectionView`) that writes it. Default `'search'`. */\n  searchParamKey?: string;\n  /** URL param key for the status filter. Default `'status'` (the roadmap\n   *  section's `filter.paramKey`). `'all'` means no filter. */\n  statusParamKey?: string;\n}\n\nexport function RoadmapView({\n  endpoint = DEFAULT_ENDPOINT,\n  initialItems,\n  showLeftMargin,\n  buildRefreshUrl,\n  votingOptions,\n  searchParamKey = DEFAULT_SEARCH_PARAM_KEY,\n  statusParamKey = DEFAULT_STATUS_PARAM_KEY,\n}: RoadmapViewProps = {}) {\n  // Read the search + status params the section chrome (`DevSectionView`) writes\n  // and fold them INTO the fetch url so the url IS the cache key — the list\n  // refetches filtered whenever the controls change. Mirrors `ProductReleasesView`.\n  const searchParams = useSearchParams();\n  const search = searchParams.get(searchParamKey) || '';\n  const status = searchParams.get(statusParamKey) || 'all';\n  const listParams = new URLSearchParams();\n  if (search) listParams.set(searchParamKey, search);\n  if (status && status !== 'all') listParams.set(statusParamKey, status);\n  const qs = listParams.toString();\n  const url = qs ? `${endpoint}?${qs}` : endpoint;\n\n  // Memoize so the SSR `initialItems` wrapper keeps a STABLE identity — else the\n  // hook's initialData re-sync effect fires every render and clobbers the\n  // optimistic vote patch below.\n  const initialData = useMemo(() => (initialItems ? { items: initialItems } : undefined), [initialItems]);\n  const { data, setData, isLoading, error, reload } = useSelfFetch<{ items?: RoadmapItem[] }>(url, { initialData });\n  const items = data?.items ?? [];\n\n  // Deep-link hash dispatch — `?search=<id>#roadmap-<id>` from a chat card.\n  // Shared hook owns the poll-until-mount + hashchange-listener wiring\n  // (same instance used by DeliveryLists). The rAF poll inside the hook\n  // handles the Radix `<AccordionItem>` lazy-unmount: on first paint\n  // every quarter is collapsed; an effect in `roadmap-grid.tsx` expands\n  // them when `hasActiveFilters` is true (chat URL carries `?search=<id>`).\n  // The card mounts one tick after `data` lands; the hook waits.\n  useScrollToHash(data, { headerOffset: STICKY_HEADER_OFFSET_PX });\n\n  if (error) {\n    return <LoadError message=\"Failed to load roadmap.\" onRetry={reload} />;\n  }\n  // Skeleton only while the FIRST fetch is in flight (no data yet) — a malformed\n  // body lacking `items` renders the grid (empty), never a stuck skeleton.\n  if (isLoading && !data) {\n    return <RoadmapGridSkeleton showLeftMargin={showLeftMargin} />;\n  }\n\n  return (\n    <RoadmapGrid\n      items={items}\n      showLeftMargin={showLeftMargin}\n      buildRefreshUrl={buildRefreshUrl}\n      votingOptions={votingOptions}\n      // Full-page roadmap → collapsible quarter grouping (the shared RoadmapGrid\n      // capability; flat grids stay the RoadmapGrid default). When the chrome's\n      // search / status filter is active, expand every quarter so matches aren't hidden.\n      groupByQuarter\n      hasActiveFilters={search !== '' || status !== 'all'}\n      // After a vote refreshes a single task, patch it into the fetched list so\n      // the displayed counts stay live.\n      onItemUpdate={updated =>\n        setData(prev =>\n          prev ? { ...prev, items: (prev.items ?? []).map(it => (it.id === updated.id ? updated : it)) } : prev,\n        )\n      }\n    />\n  );\n}\n","'use client';\n\n/**\n * DeliveryTable — bordered card containing one `<DeliveryRow />` per\n * item. Visual rendering of each row lives in `delivery-row.tsx` so the\n * exact same primitive can be composed elsewhere (notably the linked-\n * delivery surface inside `<TicketDetailDrawer>`).\n *\n * Props:\n *   - `items` — flat list of `DeliveryItem`. Two buckets (completed +\n *     in-progress) are rendered as two separate `DeliveryTable`s by\n *     the parent `DeliveryLists`.\n *   - `isLoading` — skeleton rows.\n *   - `focusId` — `?focus=<id>` URL param. Marks the matching row\n *     `id=\"delivery-<id>\"` and applies the highlight ring so the\n *     deep-link from a ticket's linked-card scrolls + flashes the\n *     right row.\n */\n\nimport type { DeliveryItem } from '../../../types/delivery';\nimport { devSectionAnchorId } from '../../../utils/dev-sections/dev-section-param-keys';\nimport { DeliveryRow } from './delivery-row';\n\ninterface DeliveryTableProps {\n  items: DeliveryItem[];\n  isLoading?: boolean;\n}\n\n/**\n * Skeleton loader for rows - matching responsive structure\n */\nfunction SkeletonRow() {\n  return (\n    <div className=\"border-b border-ods-border p-[12px] last:border-b-0 md:p-[16px]\">\n      <div className=\"flex w-full flex-col items-start justify-between gap-[12px] md:flex-row md:gap-[16px]\">\n        {/* Left: Title, subtitle, and description skeleton */}\n        <div className=\"flex w-full min-w-0 flex-1 flex-col gap-[12px] md:w-auto md:gap-[16px]\">\n          {/* Title skeleton - responsive */}\n          <div className=\"flex min-h-[24px] items-center\">\n            <div className=\"h-[20px] w-full animate-pulse rounded bg-ods-border\"></div>\n          </div>\n          {/* Subtitle skeleton - 1 line */}\n          <div className=\"flex min-h-[20px] items-center\">\n            <div className=\"h-[20px] w-1/2 animate-pulse rounded bg-ods-border\"></div>\n          </div>\n          {/* Description skeleton - 3 lines */}\n          <div className=\"flex min-h-[72px] items-center\">\n            <div className=\"flex-1 space-y-1\">\n              <div className=\"h-[20px] w-full animate-pulse rounded bg-ods-border\"></div>\n              <div className=\"h-[20px] w-full animate-pulse rounded bg-ods-border\"></div>\n              <div className=\"h-[20px] w-2/3 animate-pulse rounded bg-ods-border\"></div>\n            </div>\n          </div>\n        </div>\n\n        {/* Right: Badge skeleton - two stacked badges */}\n        <div className=\"flex flex-shrink-0 flex-col gap-2 self-start\">\n          <div className=\"h-[32px] w-[100px] animate-pulse rounded bg-ods-border\"></div>\n          <div className=\"h-[32px] w-[120px] animate-pulse rounded bg-ods-border\"></div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\n/**\n * DeliveryTable Component\n * Displays bug fixes and enhancements with fixed-height rows\n */\nexport function DeliveryTable({ items, isLoading = false }: DeliveryTableProps) {\n  // Show skeletons while loading\n  if (isLoading) {\n    return (\n      <div className=\"w-full overflow-hidden rounded-[6px] border border-ods-border bg-ods-card\">\n        <div className=\"w-full\">\n          {[1, 2, 3, 4, 5].map(i => (\n            <SkeletonRow key={i} />\n          ))}\n        </div>\n      </div>\n    );\n  }\n\n  // Empty state\n  if (items.length === 0) {\n    return (\n      <div className=\"w-full rounded-[6px] border border-ods-border bg-ods-card p-[40px] text-center\">\n        <p className=\"text-ods-text-secondary text-h6\">No tasks available</p>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"w-full overflow-hidden rounded-[6px] border border-ods-border bg-ods-card\">\n      <div className=\"w-full\">\n        {items.map(item => (\n          // DOM id lives on DeliveryRow's own outer element (no wrapper\n          // div). Anchor mirrors `buildDevSectionUrl('delivery', <id>)`\n          // → `#delivery-<external_id>`; `useScrollToHash` in\n          // `delivery-lists.tsx` finds the row by id and scrolls. The\n          // outer wrapper here ONLY exists for the row separators.\n          <div key={item.id} className=\"border-b border-ods-border last:border-b-0\">\n            <DeliveryRow item={item} id={devSectionAnchorId('delivery', item.id)} />\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n","'use client';\n\n/**\n * DeliveryLists — the delivery section body (two tables: recently\n * completed + active). Reads `search` and `task_type` URL params\n * written by the shared `<DevSectionView>` chrome and refetches on\n * change.\n *\n * Endpoint configuration:\n *   - `completedApiEndpoint` / `inProgressApiEndpoint` are the two\n *     per-bucket GET endpoints. Defaults match the hub's pre-migration\n *     routes (`/api/delivery/completed`, `/api/delivery/in-progress`).\n *\n * Coupling constraint — `searchParamKey` / `taskTypeParamKey`:\n *   These props serve TWO purposes:\n *     1. URL READS — keys this component reads via `useSearchParams()`.\n *        MUST match the consuming chrome's `section.search.paramKey` /\n *        `section.filter.paramKey` (the chrome WRITES the URL params).\n *     2. API WRITES — keys this component sends as query params on the\n *        outbound fetch to `{completedApiEndpoint,inProgressApiEndpoint}`.\n *        The hub API contract uses `'search'` / `'task_type'`; embedders\n *        reverse-proxying those routes must preserve the same names OR\n *        rewrite the inbound query string on the proxy side.\n *\n *   Defaults align with `OPENFRAME_DEV_SECTIONS.delivery.{search.paramKey,filter.paramKey}`\n *   AND the hub API contract, so the OpenFrame zero-config case \"just\n *   works\". Custom chrome overriding the param keys must override BOTH\n *   ends consistently AND ensure the backend reads the same names.\n */\n\nimport { useEffect, useState } from 'react';\nimport { useSearchParams, useRouter, usePathname } from '../../../embed-shims';\nimport { useScrollToHash } from '../../../hooks/use-scroll-to-hash';\nimport type { DeliveryItem, DeliveryResponse } from '../../../types/delivery';\nimport { DEV_SECTION_PARAM_KEYS } from '../../../utils/dev-sections/dev-section-param-keys';\nimport { contentFetch } from '../../../utils/embed-content-fetch';\nimport { STICKY_HEADER_OFFSET_PX } from '../../../utils/same-page-hash-nav';\nimport { EmptyState } from '../../empty-state';\nimport { LoadError } from '../../ui/error-state';\nimport { DeliveryTable } from './delivery-table';\n\nconst DEFAULT_COMPLETED_ENDPOINT = '/api/delivery/completed';\nconst DEFAULT_IN_PROGRESS_ENDPOINT = '/api/delivery/in-progress';\n// Param keys sourced from the shared registry (see RoadmapView) — single source for the\n// chrome's written `?key=` and this view's read.\nconst DEFAULT_SEARCH_PARAM_KEY = DEV_SECTION_PARAM_KEYS.search;\nconst DEFAULT_TASK_TYPE_PARAM_KEY = DEV_SECTION_PARAM_KEYS.deliveryTaskType;\n\n/**\n * Wire shape of ONE bucket endpoint — `{ items, count, pagination }`. Only\n * `items` is read here; `items` stays optional because an embedder's proxy is\n * free to answer with an empty envelope, and the render treats \"no items\" and\n * \"empty items\" identically.\n */\ninterface DeliveryBucketResponse {\n  items?: DeliveryItem[];\n}\n\nexport interface DeliveryListsProps {\n  /** GET endpoint for the \"Recently Completed\" bucket. Default\n   *  `/api/delivery/completed`. */\n  completedApiEndpoint?: string;\n  /** GET endpoint for the \"Active Tasks\" bucket. Default\n   *  `/api/delivery/in-progress`. */\n  inProgressApiEndpoint?: string;\n  /** URL param key for the search input. MUST match the consuming\n   *  chrome's `section.search.paramKey`. Default `'search'`. */\n  searchParamKey?: string;\n  /** URL param key for the task-type filter. MUST match the consuming\n   *  chrome's `section.filter.paramKey`. Default `'task_type'`. */\n  taskTypeParamKey?: string;\n}\n\nexport function DeliveryLists({\n  completedApiEndpoint = DEFAULT_COMPLETED_ENDPOINT,\n  inProgressApiEndpoint = DEFAULT_IN_PROGRESS_ENDPOINT,\n  searchParamKey = DEFAULT_SEARCH_PARAM_KEY,\n  taskTypeParamKey = DEFAULT_TASK_TYPE_PARAM_KEY,\n}: DeliveryListsProps = {}) {\n  const searchParams = useSearchParams();\n  const router = useRouter();\n  const pathname = usePathname();\n\n  const [data, setData] = useState<DeliveryResponse | null>(null);\n  const [isLoading, setIsLoading] = useState(true);\n  const [error, setError] = useState<string | null>(null);\n\n  // Get filter state from URL\n  const searchQuery = searchParams.get(searchParamKey) || '';\n  const taskTypeFilter = searchParams.get(taskTypeParamKey) || 'all';\n\n  useEffect(() => {\n    // `searchQuery`/`taskTypeFilter` come from the URL, so every keystroke that\n    // rewrites the query string re-runs this effect while the previous pair of\n    // requests is still open. Without this guard a slower EARLIER response can\n    // resolve last and paint results for a query the user already moved past.\n    let cancelled = false;\n\n    async function fetchDeliveryData() {\n      try {\n        setIsLoading(true);\n        setError(null);\n\n        // Build query parameters for filtering. The outbound key names\n        // mirror the inbound URL-param keys — see \"Coupling constraint\"\n        // in the file docblock for why.\n        const params = new URLSearchParams();\n        if (searchQuery) {\n          params.set(searchParamKey, searchQuery);\n        }\n        if (taskTypeFilter && taskTypeFilter !== 'all') {\n          params.set(taskTypeParamKey, taskTypeFilter);\n        }\n        const queryString = params.toString();\n        const queryParam = queryString ? `?${queryString}` : '';\n\n        // Fetch completed and in-progress tasks separately with filters\n        const [completedResponse, inProgressResponse] = await Promise.all([\n          contentFetch(`${completedApiEndpoint}${queryParam}`),\n          contentFetch(`${inProgressApiEndpoint}${queryParam}`),\n        ]);\n\n        if (!completedResponse.ok || !inProgressResponse.ok) {\n          throw new Error('Failed to fetch delivery items');\n        }\n\n        const [completedResult, inProgressResult] = await Promise.all([\n          completedResponse.json() as Promise<DeliveryBucketResponse>,\n          inProgressResponse.json() as Promise<DeliveryBucketResponse>,\n        ]);\n\n        if (cancelled) return;\n        setData({\n          completed: completedResult.items ?? [],\n          inProgress: inProgressResult.items ?? [],\n        });\n      } catch (err) {\n        if (cancelled) return;\n        console.error('Error fetching delivery items:', err);\n        setError('Failed to load delivery items. Please try again later.');\n      } finally {\n        if (!cancelled) setIsLoading(false);\n      }\n    }\n\n    // Never rejects — try/catch/finally, every write gated on `cancelled`.\n    void fetchDeliveryData();\n\n    return () => {\n      cancelled = true;\n    };\n  }, [searchQuery, taskTypeFilter, completedApiEndpoint, inProgressApiEndpoint, searchParamKey, taskTypeParamKey]);\n\n  const filteredCompleted = data?.completed || [];\n  const filteredInProgress = data?.inProgress || [];\n\n  // Deep-link hash dispatch — `?search=<id>#delivery-<id>` from a chat\n  // card or a linked-delivery card on a ticket. Shared hook owns the\n  // poll-until-mount + hashchange-listener wiring (same instance used\n  // by RoadmapView). 96 matches the sticky-header offset every\n  // hash-scroll surface in the app uses.\n  useScrollToHash(data, { headerOffset: STICKY_HEADER_OFFSET_PX });\n\n  const showCompleted = true;\n  const showInProgress = true;\n\n  const hasActiveFilters = searchQuery !== '' || taskTypeFilter !== 'all';\n  const hasResults =\n    (showCompleted && filteredCompleted.length > 0) || (showInProgress && filteredInProgress.length > 0);\n\n  // Error state — consume lib's canonical LoadError so ODS tokens +\n  // retry affordance stay in lockstep with every other surface.\n  if (error) {\n    return (\n      <div className=\"w-full\">\n        <LoadError message={error} onRetry={() => window.location.reload()} />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex w-full flex-col gap-[40px]\">\n      {/* Empty state if no results after filtering */}\n      {!isLoading &&\n        !hasResults &&\n        (hasActiveFilters ? (\n          <EmptyState\n            type=\"search\"\n            title=\"No tasks found\"\n            description=\"No tasks match your current filters. Try adjusting your search or status filter.\"\n            showCTA={true}\n            ctaText=\"Reset Filters\"\n            onCtaClick={() => {\n              const params = new URLSearchParams(searchParams.toString());\n              params.delete(searchParamKey);\n              params.delete(taskTypeParamKey);\n              router.replace(`${pathname}?${params.toString()}`, { scroll: false });\n            }}\n          />\n        ) : (\n          <EmptyState\n            type=\"generic\"\n            title=\"No tasks available\"\n            description=\"Check back soon for upcoming tasks!\"\n            showCTA={false}\n          />\n        ))}\n\n      {/* Completed Tasks Table */}\n      {showCompleted && (hasResults || isLoading) && (\n        <div className=\"w-full\">\n          <h3 className=\"mb-4 tracking-[-0.48px] text-ods-text-primary text-h2 md:tracking-[-0.56px] lg:tracking-[-0.64px]\">\n            Recently Completed<span className=\"text-ods-accent\">:</span>\n          </h3>\n          <DeliveryTable items={filteredCompleted} isLoading={isLoading} />\n        </div>\n      )}\n\n      {/* In Progress Tasks Table */}\n      {showInProgress && (hasResults || isLoading) && (\n        <div className=\"w-full\">\n          <h3 className=\"mb-4 tracking-[-0.48px] text-ods-text-primary text-h2 md:tracking-[-0.56px] lg:tracking-[-0.64px]\">\n            Active Tasks<span className=\"text-ods-accent\">:</span>\n          </h3>\n          <DeliveryTable items={filteredInProgress} isLoading={isLoading} />\n        </div>\n      )}\n    </div>\n  );\n}\n","'use client';\n\n/**\n * useLegalDocs — fetches a legal document (privacy policy, terms of\n * service, or any other markdown-backed legal page) from a hub API.\n *\n * Endpoint configuration — `apiEndpoint`:\n *   Default `/api/legal/<docType>`. Reverse-proxy embedders override\n *   with their proxied path (e.g. `/proxy/legal/privacy`).\n *\n * Data shape mirrors the hub's `lib/data/legal-utils.ts:LegalDocument`\n * server type. The hook intentionally re-declares the type here so\n * lib consumers don't need to import a server-side type.\n */\n\nimport { useState, useEffect, useRef } from 'react';\nimport { contentFetch } from '../../../utils/embed-content-fetch';\n\nexport interface LegalDocument {\n  title: string;\n  content: string;\n  sourceFile: string;\n  lastSynced: string | null;\n  githubSha: string | null;\n  sections: Array<{ id: string; title: string; level: number }>;\n  docType: string;\n  meta: {\n    sectionsCount: number;\n    contentLength: number;\n    lastSyncedAgo: string;\n  };\n}\n\nexport interface UseLegalDocsReturn {\n  data: LegalDocument | null;\n  isLoading: boolean;\n  error: string | null;\n  refetch: () => void;\n}\n\nexport interface UseLegalDocsOptions {\n  /** Optional pre-fetched payload from server (SSR / RSC). When set,\n   *  the hook skips the initial client fetch. */\n  initialData?: LegalDocument | null;\n  /** Full GET endpoint URL. Default `/api/legal/<docType>`. */\n  apiEndpoint?: string;\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 toSections(value: unknown): LegalDocument['sections'] {\n  if (!isUnknownArray(value)) return [];\n  const sections: LegalDocument['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\n/**\n * Decode the endpoint's answer into a `LegalDocument`.\n *\n * `Response.json()` is typed `any`, so the body used to be stored as a\n * `LegalDocument` after a single `!result.content` check — every other field of\n * the interface was a promise the wire had never been asked to keep. Missing\n * ones surfaced downstream as `undefined` (`sections` is indexed by the\n * markdown renderer) or as `NaN` (`meta.sectionsCount` / `meta.contentLength`\n * are declared `number` and are counts, so any arithmetic on an absent one\n * poisons the result rather than reading as zero).\n *\n * `content` stays the one hard requirement — null here reproduces the previous\n * \"document content is empty\" failure exactly. The remaining fields fall back\n * to values derived from what did arrive, so a sparse-but-usable document\n * still renders.\n */\nfunction toLegalDocument(payload: unknown, docType: string): LegalDocument | null {\n  if (!isRecord(payload)) return null;\n\n  const content = readString(payload, 'content');\n  if (!content) return null;\n\n  const lastSynced = payload.lastSynced;\n  const githubSha = payload.githubSha;\n  const sections = toSections(payload.sections);\n  const meta = isRecord(payload.meta) ? payload.meta : {};\n  const sectionsCount = meta.sectionsCount;\n  const contentLength = meta.contentLength;\n\n  return {\n    title: readString(payload, 'title') ?? '',\n    content,\n    sourceFile: readString(payload, 'sourceFile') ?? '',\n    lastSynced: typeof lastSynced === 'string' ? lastSynced : null,\n    githubSha: typeof githubSha === 'string' ? githubSha : null,\n    sections,\n    docType: readString(payload, 'docType') ?? docType,\n    meta: {\n      sectionsCount: typeof sectionsCount === 'number' ? sectionsCount : sections.length,\n      contentLength: typeof contentLength === 'number' ? contentLength : content.length,\n      lastSyncedAgo: readString(meta, 'lastSyncedAgo') ?? '',\n    },\n  };\n}\n\n/**\n * Hook to fetch a legal document.\n * @param docType — short identifier for the document (drives the\n *   default endpoint path AND the error-log prefix). Common values:\n *   `'privacy'` (SECURITY.md), `'terms'` (LICENSE). Embedders may use\n *   any string — the hook treats it as opaque.\n */\nexport function useLegalDocs(docType: string, options: UseLegalDocsOptions = {}): UseLegalDocsReturn {\n  const { initialData = null, apiEndpoint } = options;\n  const effectiveEndpoint = apiEndpoint ?? `/api/legal/${docType}`;\n\n  const [data, setData] = useState<LegalDocument | null>(initialData ?? null);\n  const [isLoading, setIsLoading] = useState(!initialData);\n  const [error, setError] = useState<string | null>(null);\n\n  // Monotonic request id. The fetch re-runs on every `docType` / endpoint\n  // change AND on every `refetch()`, so two runs can overlap; only the newest\n  // may write. The reset below clears the state, but on its own it cannot stop\n  // the OLD doc's in-flight response from landing afterwards and pinning the\n  // PREVIOUS document's content permanently.\n  const requestIdRef = useRef(0);\n  // Bumped by `refetch()`. The effect is the single owner of the request, the\n  // way `useSelfFetch` is built — a component-scope `fetchDocument` that the\n  // effect called synchronously read as a setState in an effect body.\n  const [reloadCount, setReloadCount] = useState(0);\n\n  // Reset cached data when docType changes — otherwise an embedder using\n  // the same hook instance for sequential docTypes (privacy → terms)\n  // would briefly render the OLD doc's content while the new fetch is\n  // in-flight. Not currently triggered by hub's per-route SSR (each\n  // docType mounts in a fresh component), but enforces the contract.\n  //\n  // Adjusted while rendering — React's documented pattern for a prop-driven\n  // reset — rather than from an effect, which is precisely one commit too late:\n  // the render that switched docType still painted the previous document. The\n  // endpoint joins the key because a changed `apiEndpoint` also means a\n  // different source for the same docType, and it is what re-fires the fetch\n  // below. A fresh source also drops any pending `refetch()` count: the skip\n  // for server-provided data applies again to the NEW data.\n  const [syncedFor, setSyncedFor] = useState({ docType, initialData, effectiveEndpoint });\n  if (\n    syncedFor.docType !== docType ||\n    syncedFor.initialData !== initialData ||\n    syncedFor.effectiveEndpoint !== effectiveEndpoint\n  ) {\n    setSyncedFor({ docType, initialData, effectiveEndpoint });\n    setData(initialData ?? null);\n    setError(null);\n    setIsLoading(!initialData);\n    setReloadCount(0);\n  }\n\n  // Fetch on mount (only if we don't already have server-provided initialData),\n  // and on every explicit `refetch()`.\n  useEffect(() => {\n    if (initialData && reloadCount === 0) return;\n\n    const requestId = ++requestIdRef.current;\n    const isCurrent = () => requestId === requestIdRef.current;\n\n    const fetchDocument = async () => {\n      try {\n        const response = await contentFetch(effectiveEndpoint);\n\n        if (!response.ok) {\n          throw new Error(`Failed to fetch ${docType} document: ${response.status} ${response.statusText}`);\n        }\n\n        const payload: unknown = await response.json();\n\n        // Validate the response has required fields\n        const document = toLegalDocument(payload, docType);\n        if (!document) {\n          throw new Error(`${docType} document content is empty`);\n        }\n\n        if (!isCurrent()) return;\n        setData(document);\n      } catch (err) {\n        if (!isCurrent()) return;\n        const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';\n        // `docType` is externally controlled (URL path segment in embedders), so it must NOT sit in\n        // console.error's FIRST (format-string) argument — Node interprets %s/%j/%o there\n        // (CodeQL js/tainted-format-string). Keep the format string constant; pass docType + err as\n        // plain trailing args.\n        console.error('Error fetching legal document:', docType, err);\n        setError(errorMessage);\n      } finally {\n        if (isCurrent()) setIsLoading(false);\n      }\n    };\n\n    // Never rejects — settles every failure into `error` state.\n    void fetchDocument();\n  }, [docType, effectiveEndpoint, initialData, reloadCount]);\n\n  const refetch = () => {\n    // An explicit retry starts from a settled (usually errored) state, so it\n    // enters the loading phase itself — on mount and after a reset the pair\n    // already says \"loading, no error\" before the effect above runs.\n    setIsLoading(true);\n    setError(null);\n    setReloadCount(c => c + 1);\n  };\n\n  return {\n    data,\n    isLoading,\n    error,\n    refetch,\n  };\n}\n","'use client';\n\n/**\n * LegalDocumentPage — unified UI for privacy-policy, terms-of-service,\n * and any other markdown-backed legal document.\n *\n * Replaces two near-identical hub components (`PrivacyPolicyPage` +\n * `TermsOfServicePage`) that differed only in title, contact email,\n * and copy strings. Caller passes those as props.\n *\n * Markdown rendering: defaults to lib's `SimpleMarkdownRenderer`\n * (sufficient for plain-markdown legal docs). Embedders that need\n * richer markdown (embeds, video, OG previews) pass their own via\n * the `MarkdownRenderer` prop — same injection pattern as\n * `ReleaseDetailPage`.\n *\n * Endpoint configuration: forwarded to `useLegalDocs(docType, { apiEndpoint })`.\n */\n\nimport type { ComponentType } from 'react';\nimport { useRouter } from '../../../embed-shims/next-navigation';\nimport { formatLegalDate } from '../../../utils/format';\nimport { PageShell, PageLayout } from '../../ui';\nimport { RichMarkdownRenderer } from '../../ui/markdown';\nimport { useLegalDocs, type LegalDocument } from './use-legal-docs';\n\nexport interface LegalDocumentMarkdownRendererProps {\n  content: string;\n  sectionIds?: Array<{ id: string; title: string; level: number }>;\n  demoteMarkdownH1ToH2?: boolean;\n}\n\nexport interface LegalDocumentPageProps {\n  /** Document type identifier — drives the default API endpoint\n   *  `/api/legal/<docType>` AND the error-log prefix. Common values:\n   *  `'privacy'`, `'terms'`. Embedders may use any string. */\n  docType: string;\n  /** Heading text (e.g. \"Privacy Policy\", \"Terms of Service\"). */\n  title: string;\n  /** Fallback subtitle shown when no `lastUpdated` date is available\n   *  (e.g. \"Our privacy policy and data protection practices\"). */\n  fallbackDescription: string;\n  /** Email shown in the error + empty-state copy\n   *  (e.g. `'privacy@openframe.io'`, `'legal@openframe.io'`). */\n  contactEmail: string;\n  /** Prompt shown above the contact link in the error state\n   *  (e.g. \"For privacy-related questions, please contact:\"). */\n  errorContactPrompt: string;\n  /** Title for the error block (e.g. \"Unable to load privacy policy\"). */\n  errorTitle: string;\n  /** Sentence shown when the API returns no document\n   *  (e.g. \"Privacy policy content is not available at this time.\"). */\n  emptyStateMessage: string;\n  /** SSR-prepared document, if available. */\n  initialData?: LegalDocument | null;\n  /** SSR-prepared formatted \"Last Updated\" label. Stable across hydration. */\n  initialLastUpdatedLabel?: string | null;\n  /** Override the default `/api/legal/<docType>` endpoint\n   *  (reverse-proxy embedders, alternate API paths). */\n  apiEndpoint?: string;\n  /** Override the default markdown renderer. */\n  MarkdownRenderer?: ComponentType<LegalDocumentMarkdownRendererProps>;\n  /** Back-button config — same pattern as `DevSectionPage`. Pass `false`\n   *  to hide. Default `{ label: 'Back to home', href: '/' }`. */\n  backButton?: { label?: string; href?: string } | false;\n  /** Render the standalone `<PageShell>`. Default true. Pass false when the host\n   *  layout already provides the page container — only the padding box renders,\n   *  avoiding a nested `<main>`. */\n  shell?: boolean;\n}\n\nexport function LegalDocumentPage({\n  docType,\n  title,\n  fallbackDescription,\n  contactEmail,\n  errorContactPrompt,\n  errorTitle,\n  emptyStateMessage,\n  initialData = null,\n  initialLastUpdatedLabel = null,\n  apiEndpoint,\n  MarkdownRenderer = RichMarkdownRenderer,\n  backButton,\n  shell = true,\n}: LegalDocumentPageProps) {\n  const router = useRouter();\n  const { data, isLoading, error } = useLegalDocs(docType, { initialData, apiEndpoint });\n\n  // Back-button config — mirrors DevSectionPage's `{ label: 'Back to home',\n  // onClick: () => router.push('/') }`. Hide entirely when caller passes\n  // `false` (e.g. embed-mode where the host owns navigation chrome).\n  const backCfg =\n    backButton === false\n      ? undefined\n      : {\n          label: backButton?.label ?? 'Back to home',\n          onClick: () => router.push(backButton?.href ?? '/'),\n        };\n\n  const fallbackLastUpdatedLabel = data?.lastSynced != null ? formatLegalDate(data.lastSynced) : null;\n  const effectiveLastUpdatedLabel = initialLastUpdatedLabel ?? fallbackLastUpdatedLabel;\n\n  // Subtitle routes through the frozen `PageLayout` `TitleBlock` (text-h2 title\n  // + subtitle) — unified header across all help-center pages. Shows the\n  // last-updated date when known, else the fallback description.\n  const subtitle = effectiveLastUpdatedLabel ? `Last Updated: ${effectiveLastUpdatedLabel}` : fallbackDescription;\n\n  const inner = (\n    <PageLayout title={title} subtitle={subtitle} backButton={backCfg} titleSize=\"h1\" titleWrap>\n      {data?.sourceFile && <p className=\"text-ods-text-secondary opacity-75 text-h6\">Source: {data.sourceFile}</p>}\n\n      <div className=\"flex flex-1 flex-col items-start gap-6 lg:flex-row lg:gap-10\">\n        <div className=\"flex-1\">\n          <div className=\"w-full\">\n            <article className=\"space-y-2\">\n              {isLoading ? (\n                // Loading skeleton matching Knowledge Hub pattern\n                <div className=\"space-y-6\">\n                  <div className=\"h-10 w-3/4 animate-pulse rounded-lg bg-ods-skeleton\"></div>\n                  <div className=\"space-y-4\">\n                    <div className=\"h-4 w-full animate-pulse rounded bg-ods-skeleton\"></div>\n                    <div className=\"h-4 w-full animate-pulse rounded bg-ods-skeleton\"></div>\n                    <div className=\"h-4 w-5/6 animate-pulse rounded bg-ods-skeleton\"></div>\n                  </div>\n                  <div className=\"h-32 animate-pulse rounded-lg border border-ods-border bg-ods-card\"></div>\n                  <div className=\"space-y-4\">\n                    <div className=\"h-4 w-full animate-pulse rounded bg-ods-skeleton\"></div>\n                    <div className=\"h-4 w-4/5 animate-pulse rounded bg-ods-skeleton\"></div>\n                  </div>\n                </div>\n              ) : error ? (\n                <div className=\"space-y-4 text-center\">\n                  <div className=\"rounded-lg border border-ods-error bg-ods-error-secondary p-6\">\n                    <p className=\"mb-2 text-ods-error\">{errorTitle}</p>\n                    <p className=\"text-ods-error text-h6\">{error}</p>\n                  </div>\n                  <div className=\"text-ods-text-secondary\">\n                    <p>{errorContactPrompt}</p>\n                    <a href={`mailto:${contactEmail}`} className=\"text-ods-accent hover:underline\">\n                      {contactEmail}\n                    </a>\n                  </div>\n                </div>\n              ) : data ? (\n                <MarkdownRenderer content={data.content} sectionIds={data.sections || []} demoteMarkdownH1ToH2 />\n              ) : (\n                <div className=\"py-16 text-center text-ods-text-secondary\">\n                  <p className=\"text-h4\">{emptyStateMessage}</p>\n                  <p className=\"mt-2\">\n                    Please contact{' '}\n                    <a href={`mailto:${contactEmail}`} className=\"text-ods-accent hover:underline\">\n                      {contactEmail}\n                    </a>{' '}\n                    for more information.\n                  </p>\n                </div>\n              )}\n            </article>\n          </div>\n        </div>\n      </div>\n    </PageLayout>\n  );\n\n  return shell ? <PageShell>{inner}</PageShell> : <div className=\"page-shell-content\">{inner}</div>;\n}\n"]}