@flamingo-stack/openframe-frontend-core 0.0.685 → 0.0.686-2463.7461.1

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