{"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-FGBMUQTA.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":["useEffect","DEFAULT_ENDPOINT","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-FGBMUQTA.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 * as React from 'react'\n\nimport { useRouter, useSearchParams, usePathname } from '../../../embed-shims'\nimport { useSelfFetch } from '../../../hooks/use-self-fetch'\nimport { useChatRuntime } from '../../../contexts/chat-runtime-context'\nimport type { ProductRelease, ProductReleaseListResponse } from '../../../types/product-release'\nimport { cn } from '../../../utils/cn'\nimport { resolveContentHref } from '../../../utils/content-href'\nimport { isModifierClick } from '../../chat/utils/chat-nav-resolution'\nimport { executeNavigationImperative } from '../../chat/utils/execute-navigation'\nimport { useEntityCardLink } from '../../chat/entity-cards/use-entity-card-link'\nimport { buildProductReleaseCardProps } from '../../chat/entity-cards/product-release-card-defaults'\nimport { EmptyState } from '../../empty-state'\nimport { LoadError } from '../../ui/error-state'\nimport { PersistentPaginationWrapper } from '../../persistent-pagination'\nimport { ProductReleaseCard, type ProductReleaseCardProps } from './product-release-card'\nimport { ProductReleaseCardSkeleton } from './product-release-card-skeleton'\nimport { DEV_SECTION_PARAM_KEYS } from '../../../utils/dev-sections/dev-section-param-keys'\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: React.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 = Math.max(1, parseInt(searchParams.get(pageParamKey) || '1', 10) || 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 = Math.ceil(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('w-full flex flex-col gap-[40px]', className)}>\n      <div className=\"min-h-[600px]\">\n        {showEmpty ? (\n          <div className=\"h-[600px] flex 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 md:mt-8 flex justify-center\">\n              {isLoading ? (\n                <div className=\"h-12 m-3 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=\"h-12 m-3 w-64\" style={{ visibility: 'hidden' }} />\n              )}\n            </div>\n          </>\n        )}\n      </div>\n    </div>\n  )\n}\n","\"use client\";\n\nimport React, { useState } from 'react';\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 gap-6 overflow-x-auto w-full ${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                <img src={mediaItem.media_url} alt={mediaItem.title || `Media ${index + 1}`} className=\"w-full h-full object-cover\" />\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 { useState, useEffect, Fragment, ComponentType, type ReactNode } from 'react';\nimport Link from '../../../embed-shims/next-link';\nimport { useRouter } from '../../../embed-shims/next-navigation';\nimport { Card, CardContent } from '../../ui/card';\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 { PageShell } from '../../layout/article-detail-layout';\nimport { PageLayout } from '../../layout/page-layout';\nimport { FadePreview } from '../../ui/fade-preview';\nimport { ReleaseChangelogSection } from '../../ui/release-changelog-section';\nimport { RichMarkdownRenderer } from '../../ui/markdown';\nimport { EntityTagBadges } from '../../features/entity-tag-badges';\nimport { EntityMetadataAuthorCell } from '../../chat/entity-cards/entity-author-card';\nimport type { EntityAuthor } from '../../../types/entity-author';\nimport { MediaGalleryStrip } from '../media-gallery-strip';\nimport { GitHubIcon } from '../../icons/github-icon';\nimport { AlertTriangle, ExternalLink, BookMarked, Sparkles, TrendingUp, Wrench } from 'lucide-react';\nimport { formatReleaseDate } from '../../../utils/date-formatters';\nimport { contentFetch } from '../../../utils/embed-content-fetch';\nimport { Video } from '../../features/video';\nimport { type CaptionSrtFields } from '../../features/captions-url';\nimport { useCaptions } from '../../features/use-captions';\nimport { DetailPageSkeleton } from '../detail-page-skeleton';\nimport type { ChangelogEntry } from '../../../types/product-release';\nimport type { TagAssoc } from '../../../types/blog';\nimport type { VideoTeaser } from '../../../types/video-processing';\nimport {\n  DEFAULT_VIDEO_BITES_TITLE,\n  toStripProfile,\n  type VideoBiteStripProfile,\n} from '../../features/video-bites-shared';\n\n// Types for injectable components\nexport interface MarkdownRendererProps {\n  content: string;\n}\n\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';\nimport type { DeliveryResponse } from '../../../types/delivery';\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\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\n// Type for the useRelease hook result\nexport interface UseReleaseResult {\n  data: unknown;\n  error: Error | 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  slug: string;\n  initialData?: unknown; // Optional pre-fetched data for admin preview\n  // Required: Hook for fetching release data (must be from app-level to use correct QueryClient)\n  useRelease: (slug: string | undefined) => UseReleaseResult;\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\nexport function ReleaseDetailPage({\n  authorHref,\n  slug,\n  initialData,\n  useRelease,\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  // Use pre-fetched data if provided (admin preview), otherwise fetch via hook (public)\n  const { data: fetchedRelease, error, isLoading } = useRelease(initialData ? undefined : slug);\n  const release = (initialData || fetchedRelease) as Record<string, unknown> | undefined;\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    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          const roadmapData = await roadmapResponse.json();\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          const deliveryResponseData = await deliveryResponse.json();\n          setDeliveryData(deliveryResponseData);\n          setDeliveryLoading(false);\n        }\n      } catch (err) {\n        console.error('Error fetching linked tasks:', err);\n        setRoadmapLoading(false);\n        setDeliveryLoading(false);\n      }\n    }\n\n    fetchLinkedTasks();\n  }, [release, RoadmapSection, DeliverySection, roadmapApiEndpoint, deliveryApiEndpoint]);\n\n  // Don't show loading skeleton if we have initialData\n  if (!initialData && isLoading) {\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=\"text-center py-16\">\n        <h1 className=\"text-h1 text-ods-text-primary mb-4\">Release Not Found</h1>\n        <p className=\"text-h4 text-ods-text-secondary\">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 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 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 = (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={\n          showBackButton ? { label: backLabel, onClick: () => router.push(backHref) } : undefined\n        }\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 grid-cols-1 md:grid-cols-4 border border-ods-border rounded-md overflow-hidden w-full\">\n          {/* Release Type */}\n          <div className=\"bg-ods-card border-b md:border-b-0 md:border-r border-ods-border p-4 flex flex-col gap-3\">\n            <div className=\"flex flex-col gap-0\">\n              <p className=\"text-h4 text-ods-text-primary\">\n                {releaseType.toLocaleUpperCase()}\n              </p>\n              <p className=\"text-h6 text-ods-text-secondary\">\n                Release Type\n              </p>\n            </div>\n          </div>\n\n          {/* Release Status */}\n          <div className=\"bg-ods-card border-b md:border-b-0 md:border-r border-ods-border p-4 flex flex-col gap-3\">\n            <div className=\"flex flex-col gap-0\">\n              <p className=\"text-h4 text-ods-text-primary\">\n                {releaseStatus.toLocaleUpperCase()}\n              </p>\n              <p className=\"text-h6 text-ods-text-secondary\">\n                Release Status\n              </p>\n            </div>\n          </div>\n\n          {/* Release Date */}\n          <div className=\"bg-ods-card border-b md:border-b-0 md:border-r border-ods-border p-4 flex flex-col gap-3\">\n            <div className=\"flex flex-col gap-0\">\n              <p className=\"text-h4 text-ods-text-primary\">\n                {formatReleaseDate(releaseDate)}\n              </p>\n              <p className=\"text-h6 text-ods-text-secondary\">\n                Release Date\n              </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-h4 text-ods-text-primary\">\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 && (\n              <Video\n                kind=\"youtube\"\n                url={youtubeUrl}\n                title={`${releaseTitle} - Video`}\n                layout=\"native\"\n              />\n            )}\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-h4 text-ods-text-primary\">\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-h3 text-ods-error\">Breaking Changes</h3>\n                  <p className=\"text-ods-text-secondary\">This release contains breaking changes. Review carefully before upgrading.</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=\"space-y-4 w-full\">\n            <p className=\"text-h5 tracking-[-0.28px] text-ods-text-secondary\">\n              Related Roadmap Items\n            </p>\n            <RoadmapSection\n              items={roadmapTasks}\n              isLoading={roadmapLoading}\n              onItemUpdate={(updatedItem) => {\n                setRoadmapTasks(prevTasks =>\n                  prevTasks.map(task =>\n                    task.id === updatedItem.id ? updatedItem : task\n                  )\n                );\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 && (deliveryLoading || (deliveryData && (deliveryData.completed.length > 0 || deliveryData.inProgress.length > 0))) && (\n          <div className=\"w-full space-y-4\">\n            <p className=\"text-h5 tracking-[-0.28px] text-ods-text-secondary\">\n              Related Enhancements and Bug-fixes\n            </p>\n            {(() => {\n              const deliveryCount = deliveryData\n                ? deliveryData.completed.length + deliveryData.inProgress.length\n                : 0;\n              return deliveryLoading ? (\n                <DeliverySection data={deliveryData} isLoading={deliveryLoading} />\n              ) : (\n                <FadePreview\n                  hiddenCount={deliveryCount - 1}\n                  collapsedHeight={300}\n                  resetKey={deliveryCount}\n                >\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=\"space-y-1 w-full\">\n            <p className=\"text-h5 tracking-[-0.28px] text-ods-text-secondary\">\n              Related Links\n            </p>\n            <Card className=\"bg-ods-card border-ods-border 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-h4 text-ods-text-primary\">\n                          Github Release\n                        </span>\n                        <a\n                          href={ghRelease.github_release_url}\n                          target=\"_blank\"\n                          rel=\"noopener noreferrer\"\n                          className=\"text-h4 text-ods-accent hover:underline\"\n                        >\n                          {ghRelease.github_release_url.split('/').pop()}\n                        </a>\n                        <ExternalLink className=\"h-6 w-6 text-ods-accent shrink-0\" />\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 text-ods-text-secondary shrink-0\" />\n                          <span className=\"text-h4 text-ods-text-primary\">\n                            Knowledge Base\n                          </span>\n                          <Link\n                            href={path.startsWith('http') ? path : `/knowledge-base${path.startsWith('/') ? '' : '/'}${path}`}\n                            className=\"text-h4 text-ods-accent hover:underline\"\n                          >\n                            {path.replace(/^\\//, '').split('/').pop()?.replace(/-/g, ' ') || 'View Article'}\n                          </Link>\n                          <ExternalLink className=\"h-6 w-6 text-ods-accent shrink-0\" />\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 text-ods-text-secondary shrink-0\" />\n                    <a\n                      href={migrationGuideUrl}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      className=\"text-h4 text-ods-accent hover:underline\"\n                    >\n                      📖 Migration Guide\n                    </a>\n                    <ExternalLink className=\"h-6 w-6 text-ods-accent shrink-0\" />\n                  </div>\n                )}\n\n                {/* Documentation */}\n                {documentationUrl && (\n                  <div className=\"flex items-start gap-1\">\n                    <BookMarked className=\"h-6 w-6 text-ods-text-secondary shrink-0\" />\n                    <a\n                      href={documentationUrl}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      className=\"text-h4 text-ods-accent hover:underline\"\n                    >\n                      📚 Documentation\n                    </a>\n                    <ExternalLink className=\"h-6 w-6 text-ods-accent shrink-0\" />\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 { 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\nexport function useRoadmapVoting(options: UseRoadmapVotingOptions = {}) {\n  const voteApiEndpoint = options.voteApiEndpoint ?? DEFAULT_VOTE_ENDPOINT;\n  const storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;\n\n  const [votes, setVotes] = useState<VoteState>({});\n  const [isLoading, setIsLoading] = useState(true);\n\n  // Load votes from localStorage. Runs on mount AND whenever `storageKey`\n  // changes — when the key changes mid-lifecycle (e.g. an embedder\n  // remounts with a new namespace), we MUST reset state first so the\n  // save-effect below doesn't write the old key's data into the new\n  // key. We also re-enter the loading phase so the load completes\n  // before any save runs.\n  useEffect(() => {\n    setIsLoading(true);\n    setVotes({});\n    try {\n      const stored = localStorage.getItem(storageKey);\n      if (stored) {\n        setVotes(JSON.parse(stored));\n      }\n    } catch (error) {\n      console.error('[Voting] Error loading votes from localStorage:', error);\n    } finally {\n      setIsLoading(false);\n    }\n  }, [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            }),\n          }).catch(err => 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 }),\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]\n  );\n\n  const clearVotes = useCallback(() => {\n    setVotes({});\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 { RoadmapCard } from '../../chat/entity-cards/roadmap-card';\nimport { useRoadmapVoting, type UseRoadmapVotingOptions } from './use-roadmap-voting';\nimport { EmptyState } from '../../empty-state';\nimport {\n  Accordion,\n  AccordionItem,\n  AccordionTrigger,\n  AccordionContent,\n} from '../../ui';\nimport { cn } from '../../../utils/cn';\nimport { devSectionAnchorId } from '../../../utils/dev-sections/dev-section-param-keys';\nimport { contentFetch } from '../../../utils/embed-content-fetch';\nimport type { RoadmapItem } from '../../chat/types/entities/roadmap-item';\n\nconst DEFAULT_BUILD_REFRESH_URL = (taskId: string) => `/api/roadmap/${taskId}`;\nconst BACKLOG = 'Backlog';\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(\n  a: { quarter: number; year: number },\n  b: { quarter: number; year: number },\n): 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          const data = await response.json();\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 = items.reduce<Record<string, RoadmapItem[]>>((acc, item) => {\n    const q = item.quarter || BACKLOG;\n    (acc[q] ||= []).push(item);\n    return acc;\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  // 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    if (sortedQuarters.length > 0 && (!hasSetInitialState.current || itemsJustLoaded)) {\n      hasSetInitialState.current = true;\n      setExpandedQuarters(\n        hasActiveFilters\n          ? [...sortedQuarters]\n          : computeDefaultExpandedQuarters(sortedQuarters, quartersToKeepClosed),\n      );\n      setIsInitialized(true);\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [sortedQuarters.length, items.length]);\n\n  // React to filter toggles AFTER init: filters on → expand all; off → defaults.\n  useEffect(() => {\n    if (!isInitialized || sortedQuarters.length === 0) return;\n    setExpandedQuarters(\n      hasActiveFilters\n        ? [...sortedQuarters]\n        : computeDefaultExpandedQuarters(sortedQuarters, quartersToKeepClosed),\n    );\n    // eslint-disable-next-line react-hooks/exhaustive-deps\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]:h-5 [&>svg]:w-5 [&>svg]:text-ods-text-secondary [&>svg]:ml-auto [&>svg]:shrink-0\">\n              <div className=\"flex items-center gap-3\">\n                <h3\n                  className={cn(\n                    'text-h2 text-ods-text-primary transition-opacity',\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                    'text-h6 transition-opacity',\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=\"pt-4 pb-0 overflow-hidden 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=\"bg-ods-card border border-ods-border rounded-[6px] p-[24px] flex flex-col gap-[16px] min-h-[340px] relative\">\n      {/* Status Badge Skeleton - Top Right */}\n      <div className=\"absolute top-[24px] right-[24px]\">\n        <div className=\"h-[20px] w-[80px] bg-ods-border rounded animate-pulse\"></div>\n      </div>\n\n      {/* Icon and title skeleton */}\n      <div className=\"flex items-center gap-[16px] pr-[120px]\">\n        <div className=\"w-[80px] h-[80px] bg-ods-border rounded-lg flex-shrink-0 animate-pulse\"></div>\n        <div className=\"flex-1 min-w-0 flex flex-col gap-1\">\n          <div className=\"min-h-[48px] flex items-center\">\n            <div className=\"h-[24px] w-full bg-ods-border rounded animate-pulse\"></div>\n          </div>\n          <div className=\"min-h-[20px] flex items-center\">\n            <div className=\"h-[14px] w-1/2 bg-ods-border rounded animate-pulse\"></div>\n          </div>\n        </div>\n      </div>\n\n      {/* Description skeleton - exactly 3 lines */}\n      <div className=\"min-h-[72px] flex items-center\">\n        <div className=\"w-full space-y-2\">\n          <div className=\"h-[24px] bg-ods-border rounded animate-pulse\"></div>\n          <div className=\"h-[24px] bg-ods-border rounded animate-pulse\"></div>\n          <div className=\"h-[24px] w-4/5 bg-ods-border rounded animate-pulse\"></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] bg-ods-border rounded animate-pulse\"></div>\n        <div className=\"h-[32px] w-[100px] bg-ods-border rounded animate-pulse\"></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 md:grid-cols-2 gap-6 ${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 { LoadError } from '../../ui/error-state'\nimport { useSelfFetch } from '../../../hooks/use-self-fetch'\nimport { useScrollToHash } from '../../../hooks/use-scroll-to-hash'\nimport type { RoadmapItem } from '../../chat/types/entities/roadmap-item'\nimport { RoadmapGrid } from './roadmap-grid'\nimport { RoadmapGridSkeleton } from './roadmap-grid-skeleton'\nimport type { UseRoadmapVotingOptions } from './use-roadmap-voting'\nimport { DEV_SECTION_PARAM_KEYS } from '../../../utils/dev-sections/dev-section-param-keys'\nimport { STICKY_HEADER_OFFSET_PX } from '../../../utils/same-page-hash-nav'\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[] }>(\n    url,\n    { initialData },\n  )\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\n            ? { ...prev, items: (prev.items ?? []).map((it) => (it.id === updated.id ? updated : it)) }\n            : 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 { DeliveryRow } from './delivery-row';\nimport type { DeliveryItem } from '../../../types/delivery';\nimport { devSectionAnchorId } from '../../../utils/dev-sections/dev-section-param-keys';\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 last:border-b-0 p-[12px] md:p-[16px]\">\n      <div className=\"flex flex-col md:flex-row items-start justify-between gap-[12px] md:gap-[16px] w-full\">\n        {/* Left: Title, subtitle, and description skeleton */}\n        <div className=\"flex-1 min-w-0 w-full md:w-auto flex flex-col gap-[12px] md:gap-[16px]\">\n          {/* Title skeleton - responsive */}\n          <div className=\"min-h-[24px] flex items-center\">\n            <div className=\"h-[20px] bg-ods-border rounded animate-pulse w-full\"></div>\n          </div>\n          {/* Subtitle skeleton - 1 line */}\n          <div className=\"min-h-[20px] flex items-center\">\n            <div className=\"h-[20px] bg-ods-border rounded animate-pulse w-1/2\"></div>\n          </div>\n          {/* Description skeleton - 3 lines */}\n          <div className=\"min-h-[72px] flex items-center\">\n            <div className=\"flex-1 space-y-1\">\n              <div className=\"h-[20px] bg-ods-border rounded animate-pulse w-full\"></div>\n              <div className=\"h-[20px] bg-ods-border rounded animate-pulse w-full\"></div>\n              <div className=\"h-[20px] bg-ods-border rounded animate-pulse w-2/3\"></div>\n            </div>\n          </div>\n        </div>\n\n        {/* Right: Badge skeleton - two stacked badges */}\n        <div className=\"flex-shrink-0 self-start flex flex-col gap-2\">\n          <div className=\"h-[32px] w-[100px] bg-ods-border rounded animate-pulse\"></div>\n          <div className=\"h-[32px] w-[120px] bg-ods-border rounded animate-pulse\"></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=\"bg-ods-card border border-ods-border rounded-[6px] overflow-hidden w-full\">\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=\"bg-ods-card border border-ods-border rounded-[6px] p-[40px] text-center w-full\">\n        <p className=\"text-h6 text-ods-text-secondary\">\n          No tasks available\n        </p>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"bg-ods-card border border-ods-border rounded-[6px] overflow-hidden w-full\">\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\n            key={item.id}\n            className=\"border-b border-ods-border last:border-b-0\"\n          >\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 type { DeliveryResponse } from '../../../types/delivery';\nimport { DeliveryTable } from './delivery-table';\nimport { EmptyState } from '../../empty-state';\nimport { LoadError } from '../../ui/error-state';\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 { contentFetch } from '../../../utils/embed-content-fetch';\nimport { useScrollToHash } from '../../../hooks/use-scroll-to-hash';\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\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    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(),\n          inProgressResponse.json(),\n        ]);\n\n        setData({\n          completed: completedResult.items || [],\n          inProgress: inProgressResult.items || [],\n        });\n      } catch (err) {\n        console.error('Error fetching delivery items:', err);\n        setError('Failed to load delivery items. Please try again later.');\n      } finally {\n        setIsLoading(false);\n      }\n    }\n\n    fetchDeliveryData();\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 = (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=\"w-full flex flex-col gap-[40px]\">\n      {/* Empty state if no results after filtering */}\n      {!isLoading && !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\n      {/* Completed Tasks Table */}\n      {showCompleted && (hasResults || isLoading) && (\n        <div className=\"w-full\">\n          <h3 className=\"text-h2 text-ods-text-primary tracking-[-0.48px] md:tracking-[-0.56px] lg:tracking-[-0.64px] mb-4\">\n            Recently Completed<span className=\"text-ods-accent\">:</span>\n          </h3>\n          <DeliveryTable\n            items={filteredCompleted}\n            isLoading={isLoading}\n          />\n        </div>\n      )}\n\n      {/* In Progress Tasks Table */}\n      {showInProgress && (hasResults || isLoading) && (\n        <div className=\"w-full\">\n          <h3 className=\"text-h2 text-ods-text-primary tracking-[-0.48px] md:tracking-[-0.56px] lg:tracking-[-0.64px] mb-4\">\n            Active Tasks<span className=\"text-ods-accent\">:</span>\n          </h3>\n          <DeliveryTable\n            items={filteredInProgress}\n            isLoading={isLoading}\n          />\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, useCallback } 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\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(\n  docType: string,\n  options: UseLegalDocsOptions = {}\n): 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  const fetchDocument = useCallback(async () => {\n    try {\n      setIsLoading(true);\n      setError(null);\n\n      const response = await contentFetch(effectiveEndpoint);\n\n      if (!response.ok) {\n        throw new Error(\n          `Failed to fetch ${docType} document: ${response.status} ${response.statusText}`\n        );\n      }\n\n      const result = await response.json();\n\n      // Validate the response has required fields\n      if (!result.content) {\n        throw new Error(`${docType} document content is empty`);\n      }\n\n      setData(result);\n    } catch (err) {\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      setIsLoading(false);\n    }\n  }, [docType, effectiveEndpoint]);\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  useEffect(() => {\n    setData(initialData ?? null);\n    setError(null);\n    setIsLoading(!initialData);\n  }, [docType, initialData]);\n\n  // Fetch on mount (only if we don't already have server-provided initialData)\n  useEffect(() => {\n    if (initialData) return;\n    fetchDocument();\n  }, [fetchDocument, initialData]);\n\n  const refetch = () => {\n    fetchDocument();\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 { PageShell, PageLayout } from '../../ui';\nimport { RichMarkdownRenderer } from '../../ui/markdown';\nimport { useRouter } from '../../../embed-shims/next-navigation';\nimport { useLegalDocs, type LegalDocument } from './use-legal-docs';\nimport { formatLegalDate } from '../../../utils/format';\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 =\n    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 && (\n        <p className=\"text-h6 text-ods-text-secondary opacity-75\">Source: {data.sourceFile}</p>\n      )}\n\n      <div className=\"flex flex-col lg:flex-row gap-6 lg:gap-10 items-start flex-1\">\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 bg-ods-skeleton rounded-lg w-3/4 animate-pulse\"></div>\n                    <div className=\"space-y-4\">\n                      <div className=\"h-4 bg-ods-skeleton rounded w-full animate-pulse\"></div>\n                      <div className=\"h-4 bg-ods-skeleton rounded w-full animate-pulse\"></div>\n                      <div className=\"h-4 bg-ods-skeleton rounded w-5/6 animate-pulse\"></div>\n                    </div>\n                    <div className=\"h-32 bg-ods-card border border-ods-border rounded-lg animate-pulse\"></div>\n                    <div className=\"space-y-4\">\n                      <div className=\"h-4 bg-ods-skeleton rounded w-full animate-pulse\"></div>\n                      <div className=\"h-4 bg-ods-skeleton rounded w-4/5 animate-pulse\"></div>\n                    </div>\n                  </div>\n                ) : error ? (\n                  <div className=\"text-center space-y-4\">\n                    <div className=\"bg-ods-error-secondary border border-ods-error rounded-lg p-6\">\n                      <p className=\"text-ods-error mb-2\">{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\n                    content={data.content}\n                    sectionIds={data.sections || []}\n                    demoteMarkdownH1ToH2\n                  />\n                ) : (\n                  <div className=\"text-center text-ods-text-secondary py-16\">\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"]}