@flamingo-stack/openframe-frontend-core 0.0.296 → 0.0.297

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 (276) hide show
  1. package/README.md +9 -0
  2. package/dist/{chunk-7RIYT7ZH.js → chunk-2QG57XOJ.js} +1067 -205
  3. package/dist/chunk-2QG57XOJ.js.map +1 -0
  4. package/dist/{chunk-WHMATDVP.js → chunk-3JIQVE7T.js} +9 -15
  5. package/dist/{chunk-WHMATDVP.js.map → chunk-3JIQVE7T.js.map} +1 -1
  6. package/dist/{chunk-GLLDTKZK.cjs → chunk-4PSQS3SW.cjs} +7 -9
  7. package/dist/chunk-4PSQS3SW.cjs.map +1 -0
  8. package/dist/{chunk-OY7OF7E7.js → chunk-4TLE6VLU.js} +30 -24
  9. package/dist/chunk-4TLE6VLU.js.map +1 -0
  10. package/dist/{chunk-W6M2FLLT.cjs → chunk-53FUMSZ5.cjs} +40 -46
  11. package/dist/chunk-53FUMSZ5.cjs.map +1 -0
  12. package/dist/{chunk-D3LEFMOA.cjs → chunk-54KNMC2R.cjs} +3 -3
  13. package/dist/{chunk-D3LEFMOA.cjs.map → chunk-54KNMC2R.cjs.map} +1 -1
  14. package/dist/{chunk-EYEW6PTA.cjs → chunk-6C526VNN.cjs} +358 -118
  15. package/dist/chunk-6C526VNN.cjs.map +1 -0
  16. package/dist/{chunk-XREEV72C.cjs → chunk-7OVGB2DQ.cjs} +19 -25
  17. package/dist/chunk-7OVGB2DQ.cjs.map +1 -0
  18. package/dist/{chunk-6GCI7JOE.js → chunk-AD6C23QY.js} +8 -7
  19. package/dist/{chunk-6GCI7JOE.js.map → chunk-AD6C23QY.js.map} +1 -1
  20. package/dist/chunk-F5OB2YAL.cjs +144 -0
  21. package/dist/chunk-F5OB2YAL.cjs.map +1 -0
  22. package/dist/chunk-FBWXMMRB.cjs +2 -0
  23. package/dist/chunk-FBWXMMRB.cjs.map +1 -0
  24. package/dist/{chunk-YIGPRLQY.cjs → chunk-FCDQNTDG.cjs} +21 -20
  25. package/dist/chunk-FCDQNTDG.cjs.map +1 -0
  26. package/dist/{chunk-IE6OU3WQ.cjs → chunk-FQOTC3UU.cjs} +318 -16
  27. package/dist/chunk-FQOTC3UU.cjs.map +1 -0
  28. package/dist/{chunk-QHIXS3W2.cjs → chunk-GUTS7HGA.cjs} +11590 -2105
  29. package/dist/chunk-GUTS7HGA.cjs.map +1 -0
  30. package/dist/chunk-GZ4C3XW6.js +2 -0
  31. package/dist/chunk-GZ4C3XW6.js.map +1 -0
  32. package/dist/{chunk-5P3B2LZW.js → chunk-IL47XWV5.js} +8 -14
  33. package/dist/{chunk-5P3B2LZW.js.map → chunk-IL47XWV5.js.map} +1 -1
  34. package/dist/{chunk-LCNMR277.js → chunk-IZ7JSBFP.js} +1 -1
  35. package/dist/chunk-IZ7JSBFP.js.map +1 -0
  36. package/dist/{chunk-EL6QLAWX.js → chunk-JALO4TAZ.js} +357 -55
  37. package/dist/chunk-JALO4TAZ.js.map +1 -0
  38. package/dist/{chunk-AQOWFSMB.cjs → chunk-L6PSSIUQ.cjs} +1 -1
  39. package/dist/chunk-L6PSSIUQ.cjs.map +1 -0
  40. package/dist/{chunk-MBFWU2EM.js → chunk-L7ULJKG7.js} +6 -10
  41. package/dist/{chunk-MBFWU2EM.js.map → chunk-L7ULJKG7.js.map} +1 -1
  42. package/dist/{chunk-K2PFPBMF.js → chunk-PC746XCO.js} +15050 -5565
  43. package/dist/chunk-PC746XCO.js.map +1 -0
  44. package/dist/{chunk-3ZXUQQL4.js → chunk-PI4WSYQV.js} +2 -2
  45. package/dist/{chunk-E4XABBSU.js → chunk-PWQUAVA3.js} +338 -98
  46. package/dist/chunk-PWQUAVA3.js.map +1 -0
  47. package/dist/chunk-SA2WPJVO.js +144 -0
  48. package/dist/chunk-SA2WPJVO.js.map +1 -0
  49. package/dist/{chunk-X6BV7MB7.cjs → chunk-UNVE2SDJ.cjs} +37 -31
  50. package/dist/chunk-UNVE2SDJ.cjs.map +1 -0
  51. package/dist/{chunk-5E2HOSSH.cjs → chunk-WMSTJAZT.cjs} +913 -51
  52. package/dist/chunk-WMSTJAZT.cjs.map +1 -0
  53. package/dist/{chunk-ZP4AVIZP.js → chunk-X4DOXQRT.js} +4 -6
  54. package/dist/{chunk-ZP4AVIZP.js.map → chunk-X4DOXQRT.js.map} +1 -1
  55. package/dist/{chunk-X647HY3F.cjs → chunk-YBYI62OE.cjs} +33 -37
  56. package/dist/chunk-YBYI62OE.cjs.map +1 -0
  57. package/dist/components/case-studies/index.cjs +126 -0
  58. package/dist/components/case-studies/index.cjs.map +1 -0
  59. package/dist/components/case-studies/index.d.ts +2 -0
  60. package/dist/components/case-studies/index.d.ts.map +1 -0
  61. package/dist/components/case-studies/index.js +126 -0
  62. package/dist/components/case-studies/index.js.map +1 -0
  63. package/dist/components/case-studies/share-experience-section.d.ts +48 -0
  64. package/dist/components/case-studies/share-experience-section.d.ts.map +1 -0
  65. package/dist/components/chat/index.cjs +8 -18
  66. package/dist/components/chat/index.cjs.map +1 -1
  67. package/dist/components/chat/index.js +75 -85
  68. package/dist/components/contact/index.cjs +8 -15
  69. package/dist/components/contact/index.cjs.map +1 -1
  70. package/dist/components/contact/index.js +7 -14
  71. package/dist/components/docs/doc-viewer.d.ts +39 -2
  72. package/dist/components/docs/doc-viewer.d.ts.map +1 -1
  73. package/dist/components/docs/docs-hub-page.d.ts +46 -0
  74. package/dist/components/docs/docs-hub-page.d.ts.map +1 -0
  75. package/dist/components/docs/index.cjs +17 -9
  76. package/dist/components/docs/index.cjs.map +1 -1
  77. package/dist/components/docs/index.d.ts +4 -0
  78. package/dist/components/docs/index.d.ts.map +1 -1
  79. package/dist/components/docs/index.js +16 -8
  80. package/dist/components/docs/skeletons.d.ts +32 -0
  81. package/dist/components/docs/skeletons.d.ts.map +1 -0
  82. package/dist/components/docs/use-docs-resolve-link.d.ts +20 -0
  83. package/dist/components/docs/use-docs-resolve-link.d.ts.map +1 -0
  84. package/dist/components/docs/use-document-tree.d.ts.map +1 -1
  85. package/dist/components/embeds/embed-container.d.ts +37 -0
  86. package/dist/components/embeds/embed-container.d.ts.map +1 -0
  87. package/dist/components/embeds/embed-iframe.d.ts.map +1 -1
  88. package/dist/components/embeds/file-download-card.d.ts +18 -0
  89. package/dist/components/embeds/file-download-card.d.ts.map +1 -0
  90. package/dist/components/embeds/index.cjs +38 -15
  91. package/dist/components/embeds/index.cjs.map +1 -1
  92. package/dist/components/embeds/index.d.ts +8 -0
  93. package/dist/components/embeds/index.d.ts.map +1 -1
  94. package/dist/components/embeds/index.js +40 -17
  95. package/dist/components/embeds/linkedin-embed-client.d.ts +8 -0
  96. package/dist/components/embeds/linkedin-embed-client.d.ts.map +1 -0
  97. package/dist/components/embeds/markdown-image.d.ts +5 -0
  98. package/dist/components/embeds/markdown-image.d.ts.map +1 -0
  99. package/dist/components/embeds/reddit-embed-client.d.ts +7 -0
  100. package/dist/components/embeds/reddit-embed-client.d.ts.map +1 -0
  101. package/dist/components/embeds/rich-markdown-runtime.d.ts +46 -0
  102. package/dist/components/embeds/rich-markdown-runtime.d.ts.map +1 -0
  103. package/dist/components/embeds/twitter-embed-client.d.ts +8 -0
  104. package/dist/components/embeds/twitter-embed-client.d.ts.map +1 -0
  105. package/dist/components/faq/index.cjs +9 -16
  106. package/dist/components/faq/index.cjs.map +1 -1
  107. package/dist/components/faq/index.js +8 -15
  108. package/dist/components/features/index.cjs +8 -16
  109. package/dist/components/features/index.cjs.map +1 -1
  110. package/dist/components/features/index.js +24 -32
  111. package/dist/components/index.cjs +257 -452
  112. package/dist/components/index.cjs.map +1 -1
  113. package/dist/components/index.js +781 -976
  114. package/dist/components/index.js.map +1 -1
  115. package/dist/components/layout/page-header.d.ts +78 -0
  116. package/dist/components/layout/page-header.d.ts.map +1 -0
  117. package/dist/components/layout/page-layout.d.ts +10 -1
  118. package/dist/components/layout/page-layout.d.ts.map +1 -1
  119. package/dist/components/layout/page-with-header.d.ts +67 -0
  120. package/dist/components/layout/page-with-header.d.ts.map +1 -0
  121. package/dist/components/layout/title-block.d.ts +17 -1
  122. package/dist/components/layout/title-block.d.ts.map +1 -1
  123. package/dist/components/navigation/index.cjs +7 -15
  124. package/dist/components/navigation/index.cjs.map +1 -1
  125. package/dist/components/navigation/index.js +9 -17
  126. package/dist/components/onboarding-guides/index.cjs +35 -36
  127. package/dist/components/onboarding-guides/index.cjs.map +1 -1
  128. package/dist/components/onboarding-guides/index.js +13 -14
  129. package/dist/components/onboarding-guides/index.js.map +1 -1
  130. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts +1 -1
  131. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  132. package/dist/components/related-content/index.cjs +9 -16
  133. package/dist/components/related-content/index.cjs.map +1 -1
  134. package/dist/components/related-content/index.js +8 -15
  135. package/dist/components/shared/dev-section/dev-section-page.d.ts +9 -0
  136. package/dist/components/shared/dev-section/dev-section-page.d.ts.map +1 -1
  137. package/dist/components/shared/dev-section/dev-section-view.d.ts.map +1 -1
  138. package/dist/components/shared/dev-section/index.d.ts +1 -1
  139. package/dist/components/shared/dev-section/index.d.ts.map +1 -1
  140. package/dist/components/shared/doc-search/use-doc-search.d.ts.map +1 -1
  141. package/dist/components/shared/legal-document/legal-document-page.d.ts.map +1 -1
  142. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  143. package/dist/components/tickets/index.cjs +100 -112
  144. package/dist/components/tickets/index.cjs.map +1 -1
  145. package/dist/components/tickets/index.js +20 -32
  146. package/dist/components/tickets/index.js.map +1 -1
  147. package/dist/components/ui/file-manager/index.cjs +50 -52
  148. package/dist/components/ui/file-manager/index.cjs.map +1 -1
  149. package/dist/components/ui/file-manager/index.js +4 -6
  150. package/dist/components/ui/file-manager/index.js.map +1 -1
  151. package/dist/components/ui/index.cjs +13 -19
  152. package/dist/components/ui/index.cjs.map +1 -1
  153. package/dist/components/ui/index.d.ts +2 -0
  154. package/dist/components/ui/index.d.ts.map +1 -1
  155. package/dist/components/ui/index.js +133 -139
  156. package/dist/components/ui/release-changelog-section.d.ts +6 -2
  157. package/dist/components/ui/release-changelog-section.d.ts.map +1 -1
  158. package/dist/components/ui/rich-markdown-renderer.d.ts +34 -0
  159. package/dist/components/ui/rich-markdown-renderer.d.ts.map +1 -0
  160. package/dist/components/ui/simple-markdown-renderer.d.ts +2 -8
  161. package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -1
  162. package/dist/contexts/chat-runtime-context.d.ts +14 -0
  163. package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
  164. package/dist/contexts/index.cjs +3 -3
  165. package/dist/contexts/index.js +5 -5
  166. package/dist/embed-shims/index.cjs +3 -3
  167. package/dist/embed-shims/index.cjs.map +1 -1
  168. package/dist/embed-shims/index.js +4 -4
  169. package/dist/hooks/index.cjs +4 -9
  170. package/dist/hooks/index.cjs.map +1 -1
  171. package/dist/hooks/index.js +6 -11
  172. package/dist/index.cjs +14 -20
  173. package/dist/index.cjs.map +1 -1
  174. package/dist/index.js +362 -368
  175. package/dist/types/doc-source.d.ts +31 -1
  176. package/dist/types/doc-source.d.ts.map +1 -1
  177. package/dist/utils/index.cjs +4 -0
  178. package/dist/utils/index.cjs.map +1 -1
  179. package/dist/utils/index.d.ts +1 -0
  180. package/dist/utils/index.d.ts.map +1 -1
  181. package/dist/utils/index.js +4 -1
  182. package/dist/utils/index.js.map +1 -1
  183. package/dist/utils/page-header-constants.d.ts +15 -0
  184. package/dist/utils/page-header-constants.d.ts.map +1 -0
  185. package/dist/utils/social-embed-cache.d.ts +29 -0
  186. package/dist/utils/social-embed-cache.d.ts.map +1 -0
  187. package/package.json +7 -1
  188. package/src/components/case-studies/index.ts +4 -0
  189. package/src/components/case-studies/share-experience-section.tsx +185 -0
  190. package/src/components/chat/embeddable-chat.tsx +1 -1
  191. package/src/components/docs/doc-viewer.tsx +111 -19
  192. package/src/components/docs/docs-hub-page.tsx +149 -0
  193. package/src/components/docs/index.ts +17 -0
  194. package/src/components/docs/skeletons.tsx +138 -0
  195. package/src/components/docs/use-docs-resolve-link.ts +52 -0
  196. package/src/components/docs/use-document-tree.ts +21 -0
  197. package/src/components/embeds/embed-container.tsx +80 -0
  198. package/src/components/embeds/embed-iframe.tsx +7 -9
  199. package/src/components/embeds/file-download-card.tsx +54 -0
  200. package/src/components/embeds/index.ts +30 -0
  201. package/src/components/embeds/linkedin-embed-client.tsx +100 -0
  202. package/src/components/embeds/markdown-image.tsx +88 -0
  203. package/src/components/embeds/og-link-preview.tsx +13 -13
  204. package/src/components/embeds/reddit-embed-client.tsx +550 -0
  205. package/src/components/embeds/rich-markdown-runtime.tsx +79 -0
  206. package/src/components/embeds/twitter-embed-client.tsx +308 -0
  207. package/src/components/layout/page-header.tsx +182 -0
  208. package/src/components/layout/page-layout.tsx +14 -1
  209. package/src/components/layout/page-with-header.tsx +110 -0
  210. package/src/components/layout/title-block.tsx +40 -62
  211. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +3 -3
  212. package/src/components/shared/dev-section/dev-section-page.tsx +9 -1
  213. package/src/components/shared/dev-section/dev-section-view.tsx +14 -9
  214. package/src/components/shared/dev-section/index.ts +1 -1
  215. package/src/components/shared/doc-search/use-doc-search.ts +7 -3
  216. package/src/components/shared/legal-document/legal-document-page.tsx +2 -2
  217. package/src/components/shared/product-release/release-detail-page.tsx +6 -4
  218. package/src/components/ui/index.ts +2 -0
  219. package/src/components/ui/release-changelog-section.tsx +7 -2
  220. package/src/components/ui/rich-markdown-renderer.tsx +1203 -0
  221. package/src/components/ui/simple-markdown-renderer.tsx +7 -11
  222. package/src/contexts/chat-runtime-context.tsx +14 -0
  223. package/src/types/doc-source.ts +33 -1
  224. package/src/utils/index.ts +1 -0
  225. package/src/utils/page-header-constants.ts +15 -0
  226. package/src/utils/social-embed-cache.ts +391 -0
  227. package/dist/chunk-26PKDALD.js +0 -2379
  228. package/dist/chunk-26PKDALD.js.map +0 -1
  229. package/dist/chunk-3MCHAFHB.js +0 -89
  230. package/dist/chunk-3MCHAFHB.js.map +0 -1
  231. package/dist/chunk-5E2HOSSH.cjs.map +0 -1
  232. package/dist/chunk-66AANIOC.cjs +0 -619
  233. package/dist/chunk-66AANIOC.cjs.map +0 -1
  234. package/dist/chunk-6JINAOI7.cjs +0 -311
  235. package/dist/chunk-6JINAOI7.cjs.map +0 -1
  236. package/dist/chunk-7RIYT7ZH.js.map +0 -1
  237. package/dist/chunk-AQOWFSMB.cjs.map +0 -1
  238. package/dist/chunk-BOCFIKYS.cjs +0 -3009
  239. package/dist/chunk-BOCFIKYS.cjs.map +0 -1
  240. package/dist/chunk-D652TJBQ.js +0 -3009
  241. package/dist/chunk-D652TJBQ.js.map +0 -1
  242. package/dist/chunk-E4XABBSU.js.map +0 -1
  243. package/dist/chunk-EL6QLAWX.js.map +0 -1
  244. package/dist/chunk-EYEW6PTA.cjs.map +0 -1
  245. package/dist/chunk-FQJK446R.js +0 -1606
  246. package/dist/chunk-FQJK446R.js.map +0 -1
  247. package/dist/chunk-GLLDTKZK.cjs.map +0 -1
  248. package/dist/chunk-IE6OU3WQ.cjs.map +0 -1
  249. package/dist/chunk-J54Z3OCR.cjs +0 -1606
  250. package/dist/chunk-J54Z3OCR.cjs.map +0 -1
  251. package/dist/chunk-K2PFPBMF.js.map +0 -1
  252. package/dist/chunk-KXCRGTRN.cjs +0 -2379
  253. package/dist/chunk-KXCRGTRN.cjs.map +0 -1
  254. package/dist/chunk-LCNMR277.js.map +0 -1
  255. package/dist/chunk-LFGGF7OT.cjs +0 -449
  256. package/dist/chunk-LFGGF7OT.cjs.map +0 -1
  257. package/dist/chunk-M2OCXTNT.js +0 -311
  258. package/dist/chunk-M2OCXTNT.js.map +0 -1
  259. package/dist/chunk-ME4EVDFP.js +0 -619
  260. package/dist/chunk-ME4EVDFP.js.map +0 -1
  261. package/dist/chunk-OQ6X7ZOC.js +0 -449
  262. package/dist/chunk-OQ6X7ZOC.js.map +0 -1
  263. package/dist/chunk-OY7OF7E7.js.map +0 -1
  264. package/dist/chunk-POKKCWKF.js +0 -354
  265. package/dist/chunk-POKKCWKF.js.map +0 -1
  266. package/dist/chunk-QHIXS3W2.cjs.map +0 -1
  267. package/dist/chunk-TFSYSWPS.cjs +0 -89
  268. package/dist/chunk-TFSYSWPS.cjs.map +0 -1
  269. package/dist/chunk-W6M2FLLT.cjs.map +0 -1
  270. package/dist/chunk-X647HY3F.cjs.map +0 -1
  271. package/dist/chunk-X6BV7MB7.cjs.map +0 -1
  272. package/dist/chunk-XREEV72C.cjs.map +0 -1
  273. package/dist/chunk-YETA25JW.cjs +0 -354
  274. package/dist/chunk-YETA25JW.cjs.map +0 -1
  275. package/dist/chunk-YIGPRLQY.cjs.map +0 -1
  276. /package/dist/{chunk-3ZXUQQL4.js.map → chunk-PI4WSYQV.js.map} +0 -0
@@ -3,6 +3,11 @@
3
3
  export { DocViewer } from './doc-viewer'
4
4
  export type { DocViewerProps } from './doc-viewer'
5
5
 
6
+ export { DocsHubPage } from './docs-hub-page'
7
+ export type { DocsHubPageProps, DocumentTypeRenderers } from './docs-hub-page'
8
+
9
+ export { MarkdownSkeleton, EmbedSkeleton } from './skeletons'
10
+
6
11
  export { useDocumentTree } from './use-document-tree'
7
12
  export type { UseDocumentTreeConfig } from './use-document-tree'
8
13
 
@@ -10,3 +15,15 @@ export { useScrollSpy } from './use-scroll-spy'
10
15
 
11
16
  export { DocNavigationProvider, useDocNavigation } from './doc-navigation-context'
12
17
  export type { DocNavigator } from './doc-navigation-context'
18
+
19
+ // Re-export the doc-source types embedders need to implement the
20
+ // `/api/docs/sources/[sourceId]/{structure,content}` + `/api/docs/resolve-link`
21
+ // API contract.
22
+ export type {
23
+ DocNode,
24
+ DocContent,
25
+ DocRenderHandlers,
26
+ DocSourceId,
27
+ DocumentType,
28
+ ResolveLinkResult,
29
+ } from '../../types/doc-source'
@@ -0,0 +1,138 @@
1
+ import React from 'react'
2
+
3
+ /**
4
+ * Text-style skeleton — matches a rendered markdown article layout.
5
+ * Used by `<DocsHubPage>` as the default for `markdown` (and unknown
6
+ * document types). Embedders can override via `renderSkeleton`.
7
+ */
8
+ export function MarkdownSkeleton() {
9
+ return (
10
+ <div className="space-y-7 mt-6">
11
+ <div className="space-y-[14px]">
12
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
13
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
14
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
15
+ <div className="h-[16px] bg-ods-border rounded w-3/4 animate-pulse" />
16
+ </div>
17
+ <div className="space-y-[14px]">
18
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
19
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
20
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
21
+ <div className="h-[16px] bg-ods-border rounded w-5/6 animate-pulse" />
22
+ </div>
23
+ <div className="space-y-[14px]">
24
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
25
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
26
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
27
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
28
+ <div className="h-[16px] bg-ods-border rounded w-2/3 animate-pulse" />
29
+ </div>
30
+ <div className="h-[88px] bg-ods-card border border-ods-border rounded-lg animate-pulse" />
31
+ <div className="h-7 bg-ods-border rounded w-1/3 animate-pulse" />
32
+ <div className="space-y-[14px]">
33
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
34
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
35
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
36
+ <div className="h-[16px] bg-ods-border rounded w-[72%] animate-pulse" />
37
+ </div>
38
+ <div className="space-y-[14px]">
39
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
40
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
41
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
42
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
43
+ <div className="h-[16px] bg-ods-border rounded w-[58%] animate-pulse" />
44
+ </div>
45
+ <div className="h-7 bg-ods-border rounded w-2/5 animate-pulse" />
46
+ <div className="space-y-[14px]">
47
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
48
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
49
+ <div className="h-[16px] bg-ods-border rounded w-[90%] animate-pulse" />
50
+ </div>
51
+ <div className="space-y-[14px]">
52
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
53
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
54
+ <div className="h-[16px] bg-ods-border rounded w-full animate-pulse" />
55
+ <div className="h-[16px] bg-ods-border rounded w-[70%] animate-pulse" />
56
+ </div>
57
+ <div className="h-[88px] bg-ods-card border border-ods-border rounded-lg animate-pulse" />
58
+ </div>
59
+ )
60
+ }
61
+
62
+ /**
63
+ * Embed-style skeleton — matches the iframe loading state for `pdf`,
64
+ * `google_sheet`, `figma`, and `file` document types. Used by
65
+ * `<DocsHubPage>` as the default for non-markdown documentTypes.
66
+ *
67
+ * The skeleton is documentType-aware so its layout matches the actual
68
+ * viewer that will replace it:
69
+ * - `pdf` → header with title + TWO buttons (Preview, Download)
70
+ * - `google_sheet`/`figma` → header with title + ONE button/toggle
71
+ * - `file` → centered FileDownloadCard-style box
72
+ * - undefined / others → generic (1-button header)
73
+ *
74
+ * IMPORTANT: bars use `bg-ods-border` (NOT `bg-ods-skeleton`). The
75
+ * `--ods-skeleton` token resolves to TRANSPARENT in this build, leaving
76
+ * the skeleton box visually empty — the embed skeleton was the loudest
77
+ * surface affected (a full-height iframe area showing nothing). Same fix
78
+ * the chat-message-row skeleton already documents in its inline comment.
79
+ */
80
+ export interface EmbedSkeletonProps {
81
+ /** When provided, the header layout matches the eventual viewer's
82
+ * button count + arrangement, so the layout doesn't shift on load. */
83
+ documentType?: 'pdf' | 'google_sheet' | 'figma' | 'file' | string
84
+ }
85
+
86
+ export function EmbedSkeleton({ documentType }: EmbedSkeletonProps = {}) {
87
+ // Centered card shape for the `file` documentType — matches
88
+ // `<FileDownloadCard>`'s `flex flex-col items-center justify-center py-16`
89
+ // + bordered card with icon, name, type/size row, Download button.
90
+ if (documentType === 'file') {
91
+ return (
92
+ <div className="flex flex-col items-center justify-center py-16">
93
+ <div className="bg-ods-card border border-ods-border rounded-xl p-8 max-w-md w-full text-center space-y-4">
94
+ <div className="w-16 h-16 rounded mx-auto bg-ods-border animate-pulse" />
95
+ <div className="space-y-2">
96
+ <div className="h-5 w-2/3 mx-auto rounded bg-ods-border animate-pulse" />
97
+ <div className="h-4 w-1/2 mx-auto rounded bg-ods-border animate-pulse" />
98
+ </div>
99
+ <div className="h-10 w-full rounded bg-ods-border animate-pulse" />
100
+ </div>
101
+ </div>
102
+ )
103
+ }
104
+
105
+ // PDF viewer has TWO buttons (Preview + Download); Sheets / Figma
106
+ // render ONE (Open / view-toggle). Default to one for unknown types.
107
+ const buttonCount = documentType === 'pdf' ? 2 : 1
108
+
109
+ return (
110
+ <div className="space-y-4">
111
+ {/* Header — matches the actual viewer's
112
+ * `flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between`
113
+ * (mobile-stacked, desktop-row). */}
114
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
115
+ {/* Left: icon + title */}
116
+ <div className="flex items-center gap-2 min-w-0 flex-1">
117
+ <div className="w-5 h-5 shrink-0 rounded bg-ods-border animate-pulse" />
118
+ <div className="h-6 w-2/3 rounded bg-ods-border animate-pulse" />
119
+ </div>
120
+ {/* Right: 1 or 2 buttons. Mobile = full-width; desktop = auto. */}
121
+ <div className="flex items-center gap-2 w-full sm:w-auto">
122
+ {Array.from({ length: buttonCount }).map((_, i) => (
123
+ <div
124
+ key={i}
125
+ className="h-10 w-full sm:w-32 rounded bg-ods-border animate-pulse flex-1 sm:flex-initial"
126
+ />
127
+ ))}
128
+ </div>
129
+ </div>
130
+ {/* Body — clean iframe-sized rectangle, no fake inner placeholder
131
+ * cruft. Matches the viewer's default `calc(100vh - 250px)` height. */}
132
+ <div
133
+ className="w-full rounded-lg border border-ods-border bg-ods-card animate-pulse"
134
+ style={{ height: 'calc(100vh - 250px)' }}
135
+ />
136
+ </div>
137
+ )
138
+ }
@@ -0,0 +1,52 @@
1
+ import { useCallback } from 'react'
2
+ import { useChatRuntime } from '../../contexts/chat-runtime-context'
3
+ import type { ResolveLinkResult } from '../../types/doc-source'
4
+
5
+ /**
6
+ * `useDocsResolveLink(sourceId, override?)` — POST `/api/docs/resolve-link`
7
+ * (or the override / `ChatRuntime.endpoints.docsResolveLinkUrl`) for a
8
+ * relative href inside a doc body, returning a `ResolveLinkResult`
9
+ * envelope.
10
+ *
11
+ * The endpoint chain (`override ?? runtime.endpoints.docsResolveLinkUrl
12
+ * ?? '/api/docs/resolve-link'`) mirrors `searchEndpoint` resolution in
13
+ * `<DocViewer>` so embedders configure both the same way: per-instance
14
+ * prop OR ambient `ChatRuntimeProvider`.
15
+ *
16
+ * The full fetch + JSON-parse pipeline is wrapped in try/catch so a
17
+ * network throw (DNS / CORS / offline) or a non-JSON response surfaces
18
+ * as `{ success: false, error }` — the markdown renderer's broken-link
19
+ * badge handles that branch instead of swallowing an unhandled rejection
20
+ * past the click handler.
21
+ */
22
+ export function useDocsResolveLink(
23
+ sourceId: string,
24
+ resolveLinkEndpoint?: string | null,
25
+ ) {
26
+ const chatRuntime = useChatRuntime()
27
+ const resolvedResolveLinkEndpoint =
28
+ resolveLinkEndpoint ?? chatRuntime?.endpoints.docsResolveLinkUrl ?? '/api/docs/resolve-link'
29
+
30
+ return useCallback(
31
+ async (href: string, currentPath: string): Promise<ResolveLinkResult> => {
32
+ try {
33
+ const response = await fetch(resolvedResolveLinkEndpoint, {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({ link: href, currentPath, source: sourceId }),
37
+ })
38
+ if (!response.ok) {
39
+ return { success: false, error: `Resolve failed: ${response.status}` }
40
+ }
41
+ const json = await response.json()
42
+ return (json.data ?? json) as ResolveLinkResult
43
+ } catch (error) {
44
+ return {
45
+ success: false,
46
+ error: error instanceof Error ? error.message : 'Resolve failed',
47
+ }
48
+ }
49
+ },
50
+ [resolvedResolveLinkEndpoint, sourceId],
51
+ )
52
+ }
@@ -102,6 +102,27 @@ export function useDocumentTree(
102
102
  return () => window.removeEventListener('popstate', handlePopState)
103
103
  }, [normalizedBaseRoute, folderIndexFile])
104
104
 
105
+ // External-URL → state sync. The popstate listener above catches browser
106
+ // back/forward, but client-side routers (react-router, Next App Router…)
107
+ // change the URL via `history.pushState` which does NOT fire popstate.
108
+ // The host re-renders the viewer with a new `initialPath` prop instead, so
109
+ // we mirror the popstate logic here against the (memoized) `cleanInitialPath`.
110
+ // Without this, a chat-card click that soft-navigates via react-router
111
+ // updates the URL but the viewer stays on the previously-selected doc.
112
+ useEffect(() => {
113
+ if (cleanInitialPath === selectedPathRef.current) return
114
+ setSelectedPath(cleanInitialPath)
115
+ if (cleanInitialPath) {
116
+ const parentPath = cleanInitialPath.includes('/')
117
+ ? cleanInitialPath.substring(0, cleanInitialPath.lastIndexOf('/'))
118
+ : cleanInitialPath
119
+ setExpandedNodes(new Set(getDocAncestorNodeIds(parentPath)))
120
+ }
121
+ // Match popstate's scroll-to-content delay; the targeted content fetch
122
+ // dispatched by the selectedPath effect lands before this fires.
123
+ setTimeout(scrollToContent, 150)
124
+ }, [cleanInitialPath])
125
+
105
126
  useEffect(() => {
106
127
  if (!isInitialized) {
107
128
  // Kick off the speculative content fetch IN PARALLEL with the structure
@@ -0,0 +1,80 @@
1
+ import React from 'react';
2
+ import { cn } from '../../utils/cn';
3
+
4
+ // Base container sizes for different embed types
5
+ export const EMBED_SIZES = {
6
+ youtube: 'max-w-3xl', // 768px - Video content needs more space
7
+ twitter: 'max-w-md', // 448px - Narrow tweets, mobile-first
8
+ reddit: 'max-w-xl', // 576px - Medium width for discussion threads
9
+ linkedin: 'max-w-lg', // 512px - LinkedIn post embed, mobile-first
10
+ linkPreview: 'max-w-lg' // 512px - Balanced width for cards
11
+ } as const;
12
+
13
+ export type EmbedSize = keyof typeof EMBED_SIZES;
14
+
15
+ interface EmbedContainerProps {
16
+ size: EmbedSize;
17
+ children: React.ReactNode;
18
+ className?: string;
19
+ }
20
+
21
+ // Base container for all embeds
22
+ export function EmbedContainer({
23
+ size,
24
+ children,
25
+ className = ""
26
+ }: EmbedContainerProps) {
27
+ return (
28
+ <div className={cn(
29
+ "mx-auto rounded-lg overflow-hidden",
30
+ "bg-ods-card border border-ods-border",
31
+ "transition-all duration-200 ease-in-out",
32
+ "hover:border-ods-accent/30 hover:shadow-lg hover:shadow-ods-accent/10",
33
+ EMBED_SIZES[size],
34
+ className
35
+ )}>
36
+ {children}
37
+ </div>
38
+ );
39
+ }
40
+
41
+ // Specific containers for each platform
42
+ export function YouTubeContainer({ children, className }: { children: React.ReactNode; className?: string }) {
43
+ return (
44
+ <EmbedContainer size="youtube" className={cn("my-6", className)}>
45
+ {children}
46
+ </EmbedContainer>
47
+ );
48
+ }
49
+
50
+ export function TwitterContainer({ children, className }: { children: React.ReactNode; className?: string }) {
51
+ return (
52
+ <EmbedContainer size="twitter" className={cn("my-6", className)}>
53
+ {children}
54
+ </EmbedContainer>
55
+ );
56
+ }
57
+
58
+ export function RedditContainer({ children, className }: { children: React.ReactNode; className?: string }) {
59
+ return (
60
+ <EmbedContainer size="reddit" className={cn("my-6", className)}>
61
+ {children}
62
+ </EmbedContainer>
63
+ );
64
+ }
65
+
66
+ export function LinkPreviewContainer({ children, className }: { children: React.ReactNode; className?: string }) {
67
+ return (
68
+ <EmbedContainer size="linkPreview" className={cn("my-6", className)}>
69
+ {children}
70
+ </EmbedContainer>
71
+ );
72
+ }
73
+
74
+ export function LinkedInContainer({ children, className }: { children: React.ReactNode; className?: string }) {
75
+ return (
76
+ <EmbedContainer size="linkedin" className={cn("my-6", className)}>
77
+ {children}
78
+ </EmbedContainer>
79
+ );
80
+ }
@@ -2,19 +2,17 @@
2
2
 
3
3
  import React, { useState, useCallback, useRef, useEffect } from 'react'
4
4
 
5
- /** Loading skeleton for iframe embeds — matches project skeleton pattern */
5
+ /** Loading skeleton for iframe embeds — clean iframe-sized rectangle.
6
+ * Uses `bg-ods-card` (visible token) rather than `bg-ods-skeleton`
7
+ * which resolves to transparent in this build (the same gotcha
8
+ * documented in `chat-message-row.tsx`'s skeleton). No fake inner
9
+ * placeholder cruft — a real loading iframe shows a blank rectangle. */
6
10
  function EmbedLoadingSkeleton({ height }: { height?: string }) {
7
11
  return (
8
12
  <div
9
- className="w-full rounded-lg border border-ods-border overflow-hidden bg-ods-skeleton animate-pulse"
13
+ className="w-full rounded-lg border border-ods-border overflow-hidden bg-ods-card animate-pulse"
10
14
  style={{ height: height || 'calc(100vh - 250px)' }}
11
- >
12
- <div className="flex flex-col items-center justify-center h-full gap-4">
13
- <div className="w-12 h-12 rounded-lg bg-ods-card" />
14
- <div className="h-4 w-48 rounded bg-ods-card" />
15
- <div className="h-3 w-32 rounded bg-ods-card" />
16
- </div>
17
- </div>
15
+ />
18
16
  )
19
17
  }
20
18
 
@@ -0,0 +1,54 @@
1
+ import React from 'react'
2
+ import { Button } from '../ui'
3
+ import { FileText, Download } from 'lucide-react'
4
+ import { formatFileSize } from '../../utils'
5
+
6
+ export interface FileDownloadCardProps {
7
+ fileName?: string
8
+ mimeType?: string
9
+ fileSize?: number
10
+ fileUrl?: string
11
+ }
12
+
13
+ /**
14
+ * Generic downloadable-file card for the `file` document type. Used by
15
+ * `<DocsHubPage>`'s default `documentTypeRenderers.file`. Embedders can
16
+ * override the default by passing their own `file` renderer.
17
+ *
18
+ * When `fileUrl` is missing, the Download button is omitted (the card still
19
+ * renders the filename + type + size so the user knows what they were
20
+ * about to download).
21
+ */
22
+ export function FileDownloadCard({
23
+ fileName,
24
+ mimeType,
25
+ fileSize,
26
+ fileUrl,
27
+ }: FileDownloadCardProps) {
28
+ return (
29
+ <div className="flex flex-col items-center justify-center py-16">
30
+ <div className="bg-ods-card border border-ods-border rounded-xl p-8 max-w-md w-full text-center space-y-4">
31
+ <FileText className="w-16 h-16 text-ods-text-secondary mx-auto" />
32
+ <div>
33
+ <h3 className="text-lg font-semibold text-ods-text-primary">
34
+ {fileName || 'File'}
35
+ </h3>
36
+ <div className="flex items-center justify-center gap-3 mt-2 text-sm text-ods-text-secondary">
37
+ {mimeType && <span>{mimeType}</span>}
38
+ {typeof fileSize === 'number' && <span>{formatFileSize(fileSize)}</span>}
39
+ </div>
40
+ </div>
41
+ {fileUrl && (
42
+ <Button
43
+ variant="accent"
44
+ href={fileUrl}
45
+ openInNewTab
46
+ leftIcon={<Download className="w-4 h-4" />}
47
+ >
48
+ Download File
49
+ </Button>
50
+ )}
51
+ </div>
52
+ </div>
53
+ )
54
+ }
@@ -16,3 +16,33 @@ export type {
16
16
  OGData,
17
17
  BuildPlaceholderUrl,
18
18
  } from './og-link-preview'
19
+
20
+ export { FileDownloadCard } from './file-download-card'
21
+ export type { FileDownloadCardProps } from './file-download-card'
22
+
23
+ // Satellite embeds wired into `<RichMarkdownRenderer>` (`components/ui`).
24
+ // Exported individually so embedders can compose them outside the renderer
25
+ // (e.g. a release page that wants a single reddit card without parsing
26
+ // markdown). The runtime knobs (proxy endpoints, image transformer) are
27
+ // shared via `RichMarkdownRuntimeProvider` — call sites that mount a
28
+ // satellite directly should wrap with the provider when they need
29
+ // overrides; otherwise the defaults match the hub's existing endpoints.
30
+ export { RedditEmbedClient } from './reddit-embed-client'
31
+ export { TwitterEmbedClient } from './twitter-embed-client'
32
+ export { LinkedInEmbedClient } from './linkedin-embed-client'
33
+ export { MarkdownImage } from './markdown-image'
34
+ export {
35
+ EmbedContainer,
36
+ YouTubeContainer,
37
+ TwitterContainer,
38
+ RedditContainer,
39
+ LinkPreviewContainer,
40
+ LinkedInContainer,
41
+ EMBED_SIZES,
42
+ type EmbedSize,
43
+ } from './embed-container'
44
+ export {
45
+ RichMarkdownRuntimeProvider,
46
+ useRichMarkdownRuntime,
47
+ type RichMarkdownRuntime,
48
+ } from './rich-markdown-runtime'
@@ -0,0 +1,100 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from 'react';
4
+ import { LinkedInContainer } from './embed-container';
5
+ import { LinkedinIcon } from '../icons-v2-generated/brand-logos/linkedin-icon';
6
+ import { ExternalLink } from 'lucide-react';
7
+
8
+ /**
9
+ * Derive LinkedIn's official embed URL from any post URL or URN.
10
+ * LinkedIn renders public posts at /embed/feed/update/<urn>. Returns '' when no
11
+ * URN can be derived, so the component falls back to a link instead of a broken
12
+ * (X-Frame-blocked) iframe.
13
+ */
14
+ function toLinkedInEmbedUrl(url: string): string {
15
+ if (!url) return '';
16
+ if (url.includes('linkedin.com/embed/')) return url.split('?')[0];
17
+ let m = url.match(/urn:li:(activity|share|ugcPost):(\d+)/i);
18
+ if (m) return `https://www.linkedin.com/embed/feed/update/urn:li:${m[1]}:${m[2]}`;
19
+ m = url.match(/activity[-:](\d{15,25})/i);
20
+ if (m) return `https://www.linkedin.com/embed/feed/update/urn:li:activity:${m[1]}`;
21
+ m = url.match(/-(\d{15,25})(?:-[A-Za-z0-9_-]+)?\/?(?:\?.*)?$/);
22
+ if (m) return `https://www.linkedin.com/embed/feed/update/urn:li:activity:${m[1]}`;
23
+ return '';
24
+ }
25
+
26
+ interface LinkedInEmbedProps {
27
+ url: string;
28
+ /** Fixed iframe height — LinkedIn embeds don't auto-resize. */
29
+ height?: number;
30
+ }
31
+
32
+ export function LinkedInEmbedClient({ url, height = 600 }: LinkedInEmbedProps) {
33
+ const embedUrl = useMemo(() => toLinkedInEmbedUrl(url), [url]);
34
+ const [loaded, setLoaded] = useState(false);
35
+
36
+ // No derivable URN → graceful fallback card with a link (mirrors reddit's error state)
37
+ if (!embedUrl) {
38
+ return (
39
+ <LinkedInContainer>
40
+ <div className="p-6">
41
+ <div className="flex items-center space-x-3 text-ods-text-secondary mb-4">
42
+ <LinkedinIcon className="w-5 h-5 shrink-0" />
43
+ <span>LinkedIn post</span>
44
+ </div>
45
+ <a
46
+ href={url}
47
+ target="_blank"
48
+ rel="noopener noreferrer"
49
+ className="inline-flex items-center space-x-2 px-4 py-2 bg-ods-card border border-ods-border text-ods-text-primary rounded-md text-sm font-medium hover:bg-ods-bg-secondary transition-colors"
50
+ >
51
+ <LinkedinIcon className="w-4 h-4" />
52
+ <span>View on LinkedIn</span>
53
+ </a>
54
+ </div>
55
+ </LinkedInContainer>
56
+ );
57
+ }
58
+
59
+ return (
60
+ <LinkedInContainer>
61
+ <div className="relative w-full" style={{ height }}>
62
+ {!loaded && (
63
+ <div className="absolute inset-0 p-6 animate-pulse">
64
+ <div className="flex items-center space-x-3 mb-4">
65
+ <div className="w-12 h-12 bg-ods-border rounded-full" />
66
+ <div>
67
+ <div className="h-4 bg-ods-border rounded w-32 mb-2" />
68
+ <div className="h-3 bg-ods-border rounded w-24" />
69
+ </div>
70
+ </div>
71
+ <div className="space-y-2">
72
+ <div className="h-4 bg-ods-border rounded w-full" />
73
+ <div className="h-4 bg-ods-border rounded w-3/4" />
74
+ </div>
75
+ </div>
76
+ )}
77
+ <iframe
78
+ src={embedUrl}
79
+ title="Embedded LinkedIn post"
80
+ className="w-full h-full"
81
+ style={{ border: 0 }}
82
+ loading="lazy"
83
+ allowFullScreen
84
+ onLoad={() => setLoaded(true)}
85
+ />
86
+ </div>
87
+ <div className="px-4 py-3 bg-ods-bg-secondary border-t border-ods-border">
88
+ <a
89
+ href={url}
90
+ target="_blank"
91
+ rel="noopener noreferrer"
92
+ className="inline-flex items-center space-x-2 text-ods-accent hover:text-ods-accent/80 transition-colors text-sm font-medium"
93
+ >
94
+ <ExternalLink className="w-4 h-4" />
95
+ <span>View on LinkedIn</span>
96
+ </a>
97
+ </div>
98
+ </LinkedInContainer>
99
+ );
100
+ }
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import Image from '../../embed-shims/next-image';
5
+ import { useRichMarkdownRuntime } from './rich-markdown-runtime';
6
+
7
+ /**
8
+ * In-article markdown image.
9
+ *
10
+ * Markdown images have unknown intrinsic dimensions — which is what broke the old
11
+ * approach: a fixed width/height made the Supabase loader `resize=cover` CROP tall
12
+ * screenshots, and `w-full` then blew them up to the column width. This renders
13
+ * through the lib's `next/image` shim (full Next.js Image Optimization on the hub,
14
+ * plain `<img>` everywhere else) and fixes both problems at the source:
15
+ *
16
+ * - `object-contain` flips the hub's Supabase loader (injected via
17
+ * `transformImageSrc` from the {@link RichMarkdownRuntimeProvider}) to
18
+ * `resize=contain` — aspect preserved, never cropped.
19
+ * - We learn the real aspect ratio from a tiny probe (the same
20
+ * `transformImageSrc` at 48px when available) and pass matching width/height
21
+ * so `next/image`'s aspect-ratio box is correct.
22
+ * - CSS (`max-w-full` + `max-h` + auto) caps the on-page size so a tall
23
+ * screenshot can't dominate the article, while the browser keeps the true
24
+ * aspect ratio.
25
+ *
26
+ * Embedders that don't pass `transformImageSrc` get an identity fallback — the
27
+ * raw `src` is used both for the probe and the display copy.
28
+ */
29
+ const MAX_H_REM = 32; // ~512px on-page cap
30
+ const DISPLAY_W = 768; // logical width hint; the optimizer handles srcset + retina
31
+
32
+ export function MarkdownImage({ src, alt }: { src: string; alt?: string }) {
33
+ const { transformImageSrc } = useRichMarkdownRuntime();
34
+ const [ratio, setRatio] = useState<number | null>(null); // width / height
35
+
36
+ useEffect(() => {
37
+ let cancelled = false;
38
+ // Reset on src change so a reused instance doesn't briefly size the new image with
39
+ // the previous image's ratio.
40
+ setRatio(null);
41
+ // Probe a tiny aspect-preserving variant so we learn the real ratio without
42
+ // downloading the full image; the display copy is then fetched once, at the right size.
43
+ // When no transformer is wired (embedders), fall back to the raw src.
44
+ const probeSrc = transformImageSrc(src, { width: 48, resize: 'contain', quality: 20 }) ?? src;
45
+ const probe = new window.Image();
46
+ probe.onload = () => {
47
+ if (!cancelled && probe.naturalWidth && probe.naturalHeight) {
48
+ setRatio(probe.naturalWidth / probe.naturalHeight);
49
+ }
50
+ };
51
+ probe.onerror = () => {
52
+ if (!cancelled) setRatio(1.5); // neutral fallback so we still render something
53
+ };
54
+ probe.src = probeSrc;
55
+ return () => {
56
+ cancelled = true;
57
+ };
58
+ }, [src, transformImageSrc]);
59
+
60
+ // Reserve a neutral box while probing so the layout doesn't jump when the image appears.
61
+ if (!ratio) {
62
+ return (
63
+ <span
64
+ className="mx-auto my-2 block w-full max-w-full animate-pulse rounded-lg bg-ods-card"
65
+ style={{ aspectRatio: '3 / 2', maxHeight: `${MAX_H_REM}rem` }}
66
+ aria-hidden
67
+ />
68
+ );
69
+ }
70
+
71
+ return (
72
+ <Image
73
+ src={src}
74
+ alt={alt ?? 'No image available'}
75
+ width={DISPLAY_W}
76
+ height={Math.round(DISPLAY_W / ratio)}
77
+ sizes="(max-width: 768px) 100vw, 768px"
78
+ loading="lazy"
79
+ // `object-contain` → SupabaseOptimizedImage uses `resize=contain` (no crop).
80
+ // `w-full` (not `w-auto`) gives the img a definite width so it lays out + lazy-loads
81
+ // even before decode. Height follows via the aspect-ratio box; `maxHeight` caps tall
82
+ // images and `maxWidth = maxHeight × ratio` shrinks the width in lockstep so the box
83
+ // stays snug to the image (no letterbox bars).
84
+ className="mx-auto my-2 block h-auto w-full rounded-lg object-contain"
85
+ style={{ maxWidth: `calc(${MAX_H_REM}rem * ${ratio})`, maxHeight: `${MAX_H_REM}rem` }}
86
+ />
87
+ );
88
+ }