@flamingo-stack/openframe-frontend-core 0.0.492-1506.3956.1 → 0.0.492

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 (296) hide show
  1. package/dist/{chunk-KX3K2EH4.cjs → chunk-2XAAFAQT.cjs} +11 -11
  2. package/dist/{chunk-KX3K2EH4.cjs.map → chunk-2XAAFAQT.cjs.map} +1 -1
  3. package/dist/{chunk-JHEIOXXZ.js → chunk-4AWRPTKE.js} +5 -6
  4. package/dist/chunk-4AWRPTKE.js.map +1 -0
  5. package/dist/{chunk-PTXZB2KW.js → chunk-4J3CB4KO.js} +2 -2
  6. package/dist/{chunk-7N7YZCHR.cjs → chunk-4MLOMMDX.cjs} +5519 -7555
  7. package/dist/chunk-4MLOMMDX.cjs.map +1 -0
  8. package/dist/{chunk-MEMRQTOI.js → chunk-56J7RLMM.js} +10434 -12470
  9. package/dist/chunk-56J7RLMM.js.map +1 -0
  10. package/dist/{chunk-MEIGDK7M.cjs → chunk-5ICPQAUI.cjs} +60 -60
  11. package/dist/chunk-5ICPQAUI.cjs.map +1 -0
  12. package/dist/{chunk-BEEPU44B.js → chunk-5RHJVPL7.js} +10 -10
  13. package/dist/chunk-5RHJVPL7.js.map +1 -0
  14. package/dist/{chunk-XOFE2RT4.js → chunk-BTIRYGDT.js} +4 -4
  15. package/dist/{chunk-JSEJ5MH5.cjs → chunk-DY6AJNVK.cjs} +5 -5
  16. package/dist/{chunk-JSEJ5MH5.cjs.map → chunk-DY6AJNVK.cjs.map} +1 -1
  17. package/dist/{chunk-PLB6CQSO.js → chunk-FG5ALORQ.js} +2 -2
  18. package/dist/{chunk-QG2EBIH5.cjs → chunk-J2J36PEN.cjs} +31 -31
  19. package/dist/{chunk-QG2EBIH5.cjs.map → chunk-J2J36PEN.cjs.map} +1 -1
  20. package/dist/{chunk-A6FPYAUD.cjs → chunk-LIRXKEOE.cjs} +15 -15
  21. package/dist/{chunk-A6FPYAUD.cjs.map → chunk-LIRXKEOE.cjs.map} +1 -1
  22. package/dist/{chunk-MSL5NE3N.cjs → chunk-LVDI7R7T.cjs} +37 -37
  23. package/dist/{chunk-MSL5NE3N.cjs.map → chunk-LVDI7R7T.cjs.map} +1 -1
  24. package/dist/{chunk-MPUQFRJG.cjs → chunk-M4SCYPCR.cjs} +7 -7
  25. package/dist/{chunk-MPUQFRJG.cjs.map → chunk-M4SCYPCR.cjs.map} +1 -1
  26. package/dist/{chunk-QDGB2DKY.cjs → chunk-O6S6Q52W.cjs} +26 -26
  27. package/dist/{chunk-QDGB2DKY.cjs.map → chunk-O6S6Q52W.cjs.map} +1 -1
  28. package/dist/{chunk-W56JYDSC.cjs → chunk-OFFYASUY.cjs} +27 -28
  29. package/dist/chunk-OFFYASUY.cjs.map +1 -0
  30. package/dist/{chunk-NCAN6VUF.js → chunk-P3ITKTUE.js} +2 -2
  31. package/dist/{chunk-M6FEGPQJ.cjs → chunk-PI5BMKV3.cjs} +62 -62
  32. package/dist/{chunk-M6FEGPQJ.cjs.map → chunk-PI5BMKV3.cjs.map} +1 -1
  33. package/dist/{chunk-34TDA22K.js → chunk-QHRNQTW5.js} +2 -2
  34. package/dist/{chunk-OL3MJKUI.js → chunk-SZWMJIH3.js} +2 -2
  35. package/dist/{chunk-MZRIRQYV.js → chunk-U5LU56JE.js} +2 -2
  36. package/dist/{chunk-C65NCIMV.cjs → chunk-VML66FJQ.cjs} +9 -9
  37. package/dist/{chunk-C65NCIMV.cjs.map → chunk-VML66FJQ.cjs.map} +1 -1
  38. package/dist/{chunk-3LKB722U.js → chunk-WKWZ7LVC.js} +2 -2
  39. package/dist/{chunk-J3VBVR4W.js → chunk-WPFLV4QJ.js} +4 -4
  40. package/dist/components/case-studies/index.cjs +8 -8
  41. package/dist/components/case-studies/index.js +2 -2
  42. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  43. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  44. package/dist/components/chat/hooks/index.d.ts +1 -0
  45. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  46. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  47. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +48 -15
  48. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  49. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +6 -0
  50. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -0
  51. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +57 -10
  52. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  53. package/dist/components/chat/index.cjs +2 -10
  54. package/dist/components/chat/index.cjs.map +1 -1
  55. package/dist/components/chat/index.d.ts +0 -1
  56. package/dist/components/chat/index.d.ts.map +1 -1
  57. package/dist/components/chat/index.js +13 -21
  58. package/dist/components/chat/types/api.types.d.ts +81 -1
  59. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  60. package/dist/components/chat/types/processing.types.d.ts +93 -2
  61. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  62. package/dist/components/chat/utils/chunk-parser.d.ts +25 -0
  63. package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -0
  64. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +6 -34
  65. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  66. package/dist/components/chat/utils/index.d.ts +3 -2
  67. package/dist/components/chat/utils/index.d.ts.map +1 -1
  68. package/dist/components/chat/utils/process-historical-messages.d.ts +11 -59
  69. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  70. package/dist/components/contact/index.cjs +3 -3
  71. package/dist/components/contact/index.js +2 -2
  72. package/dist/components/docs/index.cjs +5 -5
  73. package/dist/components/docs/index.js +4 -4
  74. package/dist/components/embeds/index.cjs +3 -3
  75. package/dist/components/embeds/index.js +2 -2
  76. package/dist/components/faq/index.cjs +3 -3
  77. package/dist/components/faq/index.js +2 -2
  78. package/dist/components/features/floating-walkthrough-video.d.ts.map +1 -1
  79. package/dist/components/features/index.cjs +2 -2
  80. package/dist/components/features/index.js +1 -1
  81. package/dist/components/help-center-pages/index.cjs +20 -20
  82. package/dist/components/help-center-pages/index.js +11 -11
  83. package/dist/components/index.cjs +130 -172
  84. package/dist/components/index.cjs.map +1 -1
  85. package/dist/components/index.js +21 -63
  86. package/dist/components/index.js.map +1 -1
  87. package/dist/components/navigation/index.cjs +2 -2
  88. package/dist/components/navigation/index.js +1 -1
  89. package/dist/components/navigation/navigation-sidebar-item.d.ts +23 -1
  90. package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
  91. package/dist/components/navigation/navigation-sidebar.d.ts.map +1 -1
  92. package/dist/components/onboarding-guides/index.cjs +5 -5
  93. package/dist/components/onboarding-guides/index.js +4 -4
  94. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  95. package/dist/components/related-content/index.cjs +3 -3
  96. package/dist/components/related-content/index.js +2 -2
  97. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  98. package/dist/components/tickets/index.cjs +5 -5
  99. package/dist/components/tickets/index.js +4 -4
  100. package/dist/components/ui/data-table/data-table-column-filter.d.ts.map +1 -1
  101. package/dist/components/ui/data-table/data-table-header.d.ts.map +1 -1
  102. package/dist/components/ui/data-table/data-table-skeleton.d.ts.map +1 -1
  103. package/dist/components/ui/index.cjs +2 -44
  104. package/dist/components/ui/index.cjs.map +1 -1
  105. package/dist/components/ui/index.d.ts +2 -1
  106. package/dist/components/ui/index.d.ts.map +1 -1
  107. package/dist/components/ui/index.js +13 -55
  108. package/dist/components/ui/rich-markdown-renderer.d.ts +34 -0
  109. package/dist/components/ui/rich-markdown-renderer.d.ts.map +1 -0
  110. package/dist/components/ui/simple-markdown-renderer.d.ts +73 -0
  111. package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -0
  112. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  113. package/dist/index.cjs +2 -60
  114. package/dist/index.cjs.map +1 -1
  115. package/dist/index.js +13 -71
  116. package/dist/utils/index.cjs +40 -169
  117. package/dist/utils/index.cjs.map +1 -1
  118. package/dist/utils/index.d.ts +0 -1
  119. package/dist/utils/index.d.ts.map +1 -1
  120. package/dist/utils/index.js +41 -162
  121. package/dist/utils/index.js.map +1 -1
  122. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  123. package/package.json +1 -7
  124. package/src/components/chat/__tests__/chat-message-list.test.tsx +5 -293
  125. package/src/components/chat/chat-message-enhanced.tsx +20 -122
  126. package/src/components/chat/chat-message-list.tsx +109 -358
  127. package/src/components/chat/embeddable-chat.tsx +0 -6
  128. package/src/components/chat/hooks/.index.md +33 -30
  129. package/src/components/chat/hooks/.use-nats-chat-adapter.md +55 -36
  130. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +77 -0
  131. package/src/components/chat/hooks/index.ts +1 -0
  132. package/src/components/chat/hooks/use-chat.ts +0 -8
  133. package/src/components/chat/hooks/use-nats-chat-adapter.ts +825 -243
  134. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +489 -0
  135. package/src/components/chat/hooks/use-sse-chat-adapter.ts +684 -380
  136. package/src/components/chat/index.ts +0 -5
  137. package/src/components/chat/thinking-display.tsx +1 -1
  138. package/src/components/chat/types/.api.types.md +49 -39
  139. package/src/components/chat/types/.processing.types.md +58 -29
  140. package/src/components/chat/types/api.types.ts +73 -6
  141. package/src/components/chat/types/processing.types.ts +53 -11
  142. package/src/components/chat/utils/.chunk-parser.md +62 -0
  143. package/src/components/chat/utils/.index.md +40 -31
  144. package/src/components/chat/utils/chunk-parser.ts +262 -0
  145. package/src/components/chat/utils/extract-incomplete-message-state.ts +5 -63
  146. package/src/components/chat/utils/index.ts +8 -5
  147. package/src/components/chat/utils/process-historical-messages.ts +386 -358
  148. package/src/components/features/floating-walkthrough-video.tsx +3 -1
  149. package/src/components/navigation/navigation-sidebar-item.tsx +80 -30
  150. package/src/components/navigation/navigation-sidebar.tsx +106 -46
  151. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +4 -22
  152. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  153. package/src/components/shared/product-release/release-detail-page.tsx +4 -19
  154. package/src/components/ui/.simple-markdown-renderer.md +52 -0
  155. package/src/components/ui/__tests__/tab-navigation.test.tsx +159 -0
  156. package/src/components/ui/data-table/data-table-column-filter.tsx +7 -1
  157. package/src/components/ui/data-table/data-table-header.tsx +4 -1
  158. package/src/components/ui/data-table/data-table-skeleton.tsx +7 -0
  159. package/src/components/ui/index.ts +2 -1
  160. package/src/components/ui/release-changelog-section.tsx +1 -1
  161. package/src/components/ui/rich-markdown-renderer.tsx +1223 -0
  162. package/src/components/ui/simple-markdown-renderer.tsx +964 -0
  163. package/src/components/ui/tab-navigation.tsx +370 -171
  164. package/src/components/ui/ticket-info-section.tsx +1 -1
  165. package/src/utils/index.ts +0 -1
  166. package/src/utils/markdown-section-extractor.ts +56 -41
  167. package/dist/chat-protocol/decode.d.ts +0 -58
  168. package/dist/chat-protocol/decode.d.ts.map +0 -1
  169. package/dist/chat-protocol/encode.d.ts +0 -44
  170. package/dist/chat-protocol/encode.d.ts.map +0 -1
  171. package/dist/chat-protocol/env-flag.d.ts +0 -35
  172. package/dist/chat-protocol/env-flag.d.ts.map +0 -1
  173. package/dist/chat-protocol/events.d.ts +0 -207
  174. package/dist/chat-protocol/events.d.ts.map +0 -1
  175. package/dist/chat-protocol/frames.d.ts +0 -213
  176. package/dist/chat-protocol/frames.d.ts.map +0 -1
  177. package/dist/chat-protocol/index.cjs +0 -500
  178. package/dist/chat-protocol/index.cjs.map +0 -1
  179. package/dist/chat-protocol/index.d.ts +0 -18
  180. package/dist/chat-protocol/index.d.ts.map +0 -1
  181. package/dist/chat-protocol/index.js +0 -484
  182. package/dist/chat-protocol/index.js.map +0 -1
  183. package/dist/chat-protocol/ip-normalize.d.ts +0 -44
  184. package/dist/chat-protocol/ip-normalize.d.ts.map +0 -1
  185. package/dist/chat-protocol/nats-decoder.d.ts +0 -26
  186. package/dist/chat-protocol/nats-decoder.d.ts.map +0 -1
  187. package/dist/chunk-7N7YZCHR.cjs.map +0 -1
  188. package/dist/chunk-BEEPU44B.js.map +0 -1
  189. package/dist/chunk-JHEIOXXZ.js.map +0 -1
  190. package/dist/chunk-MEIGDK7M.cjs.map +0 -1
  191. package/dist/chunk-MEMRQTOI.js.map +0 -1
  192. package/dist/chunk-W56JYDSC.cjs.map +0 -1
  193. package/dist/components/chat/stream/chat-dialog-store.d.ts +0 -184
  194. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +0 -1
  195. package/dist/components/chat/stream/chat-stream-reducer.d.ts +0 -367
  196. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +0 -1
  197. package/dist/components/chat/stream/delta-batcher.d.ts +0 -51
  198. package/dist/components/chat/stream/delta-batcher.d.ts.map +0 -1
  199. package/dist/components/chat/stream/index.d.ts +0 -17
  200. package/dist/components/chat/stream/index.d.ts.map +0 -1
  201. package/dist/components/chat/stream/message-mutations.d.ts +0 -77
  202. package/dist/components/chat/stream/message-mutations.d.ts.map +0 -1
  203. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +0 -22
  204. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +0 -1
  205. package/dist/components/ui/markdown/base-components.d.ts +0 -51
  206. package/dist/components/ui/markdown/base-components.d.ts.map +0 -1
  207. package/dist/components/ui/markdown/engine.d.ts +0 -99
  208. package/dist/components/ui/markdown/engine.d.ts.map +0 -1
  209. package/dist/components/ui/markdown/heading-ids.d.ts +0 -65
  210. package/dist/components/ui/markdown/heading-ids.d.ts.map +0 -1
  211. package/dist/components/ui/markdown/index.d.ts +0 -18
  212. package/dist/components/ui/markdown/index.d.ts.map +0 -1
  213. package/dist/components/ui/markdown/mermaid-diagram.d.ts +0 -79
  214. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +0 -1
  215. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +0 -14
  216. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +0 -1
  217. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +0 -40
  218. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +0 -1
  219. package/dist/components/ui/markdown/rich/shortcodes.d.ts +0 -8
  220. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +0 -1
  221. package/dist/components/ui/markdown/sanitize.d.ts +0 -153
  222. package/dist/components/ui/markdown/sanitize.d.ts.map +0 -1
  223. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +0 -23
  224. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +0 -1
  225. package/dist/components/ui/markdown/streaming.d.ts +0 -78
  226. package/dist/components/ui/markdown/streaming.d.ts.map +0 -1
  227. package/dist/components/ui/markdown/text-size.d.ts +0 -34
  228. package/dist/components/ui/markdown/text-size.d.ts.map +0 -1
  229. package/dist/utils/markdown-fences.d.ts +0 -42
  230. package/dist/utils/markdown-fences.d.ts.map +0 -1
  231. package/dist/utils/markdown-heading-id.d.ts +0 -85
  232. package/dist/utils/markdown-heading-id.d.ts.map +0 -1
  233. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +0 -317
  234. package/src/chat-protocol/__tests__/chat-protocol.test.ts +0 -516
  235. package/src/chat-protocol/__tests__/env-flag.test.ts +0 -52
  236. package/src/chat-protocol/__tests__/ip-normalize.test.ts +0 -136
  237. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +0 -232
  238. package/src/chat-protocol/decode.ts +0 -278
  239. package/src/chat-protocol/encode.ts +0 -71
  240. package/src/chat-protocol/env-flag.ts +0 -39
  241. package/src/chat-protocol/events.ts +0 -245
  242. package/src/chat-protocol/frames.ts +0 -245
  243. package/src/chat-protocol/index.ts +0 -21
  244. package/src/chat-protocol/ip-normalize.ts +0 -146
  245. package/src/chat-protocol/nats-decoder.ts +0 -258
  246. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +0 -207
  247. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +0 -230
  248. package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +0 -37
  249. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  250. package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +0 -438
  251. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +0 -318
  252. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +0 -940
  253. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +0 -756
  254. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +0 -460
  255. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +0 -973
  256. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +0 -255
  257. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +0 -98
  258. package/src/components/chat/stream/chat-dialog-store.ts +0 -536
  259. package/src/components/chat/stream/chat-stream-reducer.ts +0 -1808
  260. package/src/components/chat/stream/delta-batcher.ts +0 -168
  261. package/src/components/chat/stream/index.ts +0 -55
  262. package/src/components/chat/stream/message-mutations.ts +0 -340
  263. package/src/components/chat/stream/use-chat-stream-reducer.ts +0 -126
  264. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +0 -420
  265. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +0 -605
  266. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +0 -394
  267. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +0 -270
  268. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +0 -2420
  269. package/src/components/ui/__tests__/markdown-parity.test.tsx +0 -2528
  270. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +0 -115
  271. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +0 -95
  272. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +0 -309
  273. package/src/components/ui/markdown/__tests__/streaming.test.tsx +0 -388
  274. package/src/components/ui/markdown/base-components.tsx +0 -360
  275. package/src/components/ui/markdown/engine.tsx +0 -315
  276. package/src/components/ui/markdown/heading-ids.ts +0 -239
  277. package/src/components/ui/markdown/index.ts +0 -49
  278. package/src/components/ui/markdown/mermaid-diagram.tsx +0 -291
  279. package/src/components/ui/markdown/rich/embed-overrides.tsx +0 -199
  280. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +0 -184
  281. package/src/components/ui/markdown/rich/shortcodes.ts +0 -170
  282. package/src/components/ui/markdown/sanitize.ts +0 -3499
  283. package/src/components/ui/markdown/simple-markdown-renderer.tsx +0 -28
  284. package/src/components/ui/markdown/streaming.ts +0 -362
  285. package/src/components/ui/markdown/text-size.ts +0 -106
  286. package/src/utils/markdown-fences.ts +0 -127
  287. package/src/utils/markdown-heading-id.ts +0 -348
  288. /package/dist/{chunk-PTXZB2KW.js.map → chunk-4J3CB4KO.js.map} +0 -0
  289. /package/dist/{chunk-XOFE2RT4.js.map → chunk-BTIRYGDT.js.map} +0 -0
  290. /package/dist/{chunk-PLB6CQSO.js.map → chunk-FG5ALORQ.js.map} +0 -0
  291. /package/dist/{chunk-NCAN6VUF.js.map → chunk-P3ITKTUE.js.map} +0 -0
  292. /package/dist/{chunk-34TDA22K.js.map → chunk-QHRNQTW5.js.map} +0 -0
  293. /package/dist/{chunk-OL3MJKUI.js.map → chunk-SZWMJIH3.js.map} +0 -0
  294. /package/dist/{chunk-MZRIRQYV.js.map → chunk-U5LU56JE.js.map} +0 -0
  295. /package/dist/{chunk-3LKB722U.js.map → chunk-WKWZ7LVC.js.map} +0 -0
  296. /package/dist/{chunk-J3VBVR4W.js.map → chunk-WPFLV4QJ.js.map} +0 -0
@@ -738,7 +738,9 @@ export function FloatingWalkthroughVideo({
738
738
  ) : (
739
739
  <VideoPlayBadge size="sm" className="h-5 w-5 shrink-0" />
740
740
  )}
741
- <span className="truncate">{label}</span>
741
+ {/* The video's OWN title is the point of the pill; `label` is only the
742
+ generic fallback for a video that carries no title. */}
743
+ <span className="truncate">{summaryTitle || label}</span>
742
744
  </span>
743
745
 
744
746
  {/* BIG centred glyph — the muted-fallback prompt (bite grammar). It IS
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
- import { cloneElement, type ReactElement } from 'react'
3
+ import { cloneElement, memo, type ReactElement, type ReactNode } from 'react'
4
+ import Link from '../../embed-shims/next-link'
4
5
  import { NavigationSidebarItem } from '../../types/navigation'
5
6
  import { cn } from '../../utils'
6
7
 
@@ -11,46 +12,72 @@ interface IconProps {
11
12
 
12
13
  export interface NavigationSidebarItemButtonProps {
13
14
  item: NavigationSidebarItem
15
+ /** "You are on this page" — pathname-derived, and the only thing that carries `aria-current`. */
16
+ isActive: boolean
17
+ /**
18
+ * "You clicked this one and it has not arrived yet" — deliberately a separate,
19
+ * weaker state. Never promoted to active: the accent belongs to the page you
20
+ * are actually on.
21
+ */
22
+ isPending: boolean
14
23
  showLabel: boolean
15
24
  disabled: boolean
16
25
  onClick: (item: NavigationSidebarItem, event?: React.MouseEvent) => void
17
26
  }
18
27
 
19
- export function NavigationSidebarItemButton({
28
+ /**
29
+ * One sidebar entry.
30
+ *
31
+ * Rendered as a real anchor whenever it has a `path` — which is what buys the
32
+ * two things a `<button>` + `router.push` cannot: Next prefetches links that
33
+ * are in the viewport (so the section is usually already loaded by the time it
34
+ * is clicked, instead of every first visit paying a cold segment fetch), and
35
+ * the browser's own affordances work — middle-click, ⌘/Ctrl-click, "open in new
36
+ * tab", copy link, and a status-bar URL on hover.
37
+ *
38
+ * Falls back to a `<button>` for entries that only carry an `onClick` (no
39
+ * destination to link to) and for the disabled state, which an anchor cannot
40
+ * express.
41
+ */
42
+ export const NavigationSidebarItemButton = memo(function NavigationSidebarItemButton({
20
43
  item,
44
+ isActive,
45
+ isPending,
21
46
  showLabel,
22
47
  disabled,
23
48
  onClick,
24
49
  }: NavigationSidebarItemButtonProps) {
25
- const isActive = item.isActive ?? false
26
50
  const unreadCount = item.unreadCount ?? 0
27
51
  const hasUnread = unreadCount > 0
28
52
 
29
- return (
30
- <button
31
- onClick={(event) => onClick(item, event)}
32
- disabled={disabled}
33
- className={cn(
34
- "w-full flex items-center justify-start relative",
35
- "h-14 p-[var(--spacing-system-m)]",
36
- "transition-colors duration-300",
37
- "[&_svg]:transition-colors [&_svg]:duration-300",
38
- "before:content-[''] before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1",
39
- "before:transition-colors before:duration-300",
40
- !isActive && !disabled && "hover:bg-ods-bg-hover text-ods-text-primary [&_svg]:fill-ods-text-secondary",
41
- !isActive && disabled && "text-ods-text-secondary [&_svg]:fill-ods-text-secondary",
42
- isActive && !disabled && [
43
- "bg-[var(--ods-open-yellow-light)] text-ods-accent",
44
- "[&_svg]:fill-ods-accent",
45
- "before:bg-ods-accent",
46
- ],
47
- isActive && disabled && "text-ods-text-secondary [&_svg]:fill-ods-text-secondary",
48
- disabled && "cursor-not-allowed opacity-50",
49
- )}
50
- title={!showLabel ? item.label : undefined}
51
- aria-label={item.label}
52
- aria-current={isActive ? 'page' : undefined}
53
- >
53
+ const className = cn(
54
+ "w-full flex items-center justify-start relative",
55
+ "h-14 p-[var(--spacing-system-m)]",
56
+ // 150ms, not 300: the outgoing and incoming entries cross-fade, so for the
57
+ // whole duration TWO rows read as half-active. At 300ms that window is long
58
+ // enough to look like a flicker rather than a transition.
59
+ "transition-colors duration-150",
60
+ "[&_svg]:transition-colors [&_svg]:duration-150",
61
+ "before:content-[''] before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1",
62
+ "before:transition-colors before:duration-150",
63
+ // Pending borrows the HOVER weight and nothing else — no accent bar, no
64
+ // accent text, no `aria-current`. It says "this row is working", not "this
65
+ // is where you are", and it reads as the row staying pressed under the
66
+ // cursor, which is exactly what happened.
67
+ !isActive && !disabled && "hover:bg-ods-bg-hover text-ods-text-primary [&_svg]:fill-ods-text-secondary",
68
+ !isActive && !disabled && isPending && "bg-ods-bg-hover",
69
+ !isActive && disabled && "text-ods-text-secondary [&_svg]:fill-ods-text-secondary",
70
+ isActive && !disabled && [
71
+ "bg-[var(--ods-open-yellow-light)] text-ods-accent",
72
+ "[&_svg]:fill-ods-accent",
73
+ "before:bg-ods-accent",
74
+ ],
75
+ isActive && disabled && "text-ods-text-secondary [&_svg]:fill-ods-text-secondary",
76
+ disabled && "cursor-not-allowed opacity-50",
77
+ )
78
+
79
+ const content: ReactNode = (
80
+ <>
54
81
  <div className="relative flex items-center justify-center flex-shrink-0">
55
82
  {cloneElement(item.icon as ReactElement<IconProps>, {
56
83
  color: isActive && !disabled ? "text-ods-accent" : "text-ods-text-secondary",
@@ -77,6 +104,29 @@ export function NavigationSidebarItemButton({
77
104
  </span>
78
105
  </span>
79
106
  )}
80
- </button>
107
+ </>
81
108
  )
82
- }
109
+
110
+ const shared = {
111
+ className,
112
+ title: !showLabel ? item.label : undefined,
113
+ 'aria-label': item.label,
114
+ 'aria-current': isActive ? ('page' as const) : undefined,
115
+ }
116
+
117
+ // An entry with a custom onClick has no destination of its own, and a
118
+ // disabled one must not be followable — both stay buttons.
119
+ if (disabled || !item.path || item.onClick) {
120
+ return (
121
+ <button type="button" onClick={(event) => onClick(item, event)} disabled={disabled} {...shared}>
122
+ {content}
123
+ </button>
124
+ )
125
+ }
126
+
127
+ return (
128
+ <Link href={item.path} onClick={(event) => onClick(item, event)} {...shared}>
129
+ {content}
130
+ </Link>
131
+ )
132
+ })
@@ -13,6 +13,10 @@ const MINIMIZED_WIDTH = 56 // 3.5rem = 56px
13
13
  const EXPANDED_WIDTH = 224 // 14rem = 224px
14
14
  const STORAGE_KEY = 'of.navigationSidebar.minimized'
15
15
 
16
+ /** A click the browser will resolve itself — new tab, new window, middle button. */
17
+ const isModifiedClick = (event?: React.MouseEvent): boolean =>
18
+ !!event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0)
19
+
16
20
  export interface NavigationSidebarProps {
17
21
  config: NavigationSidebarConfig
18
22
  /**
@@ -75,17 +79,68 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
75
79
  return () => document.removeEventListener('keydown', handleKeyDown)
76
80
  }, [isOverlayOpen, closeOverlay])
77
81
 
82
+ // The entry the user just clicked, held until the route commits.
83
+ //
84
+ // PENDING IS NOT ACTIVE, and keeping the two apart is the whole point. Active
85
+ // means "this is the page you are on" — it carries the accent bar and
86
+ // `aria-current="page"`, and it stays derived from the pathname, so it is
87
+ // never asserted before it is true. Pending only means "this is the one you
88
+ // clicked": a click deserves an answer, but the answer must not be a claim
89
+ // about where you are. Folding pending into active would light the accent on
90
+ // a section still loading and announce it as the current page to a screen
91
+ // reader — a statement that is simply false until the router says otherwise.
92
+ const [pendingItemId, setPendingItemId] = useState<string | null>(null)
93
+ const committedActiveId = useMemo(
94
+ () => config.items.find(item => item.isActive)?.id ?? null,
95
+ [config.items],
96
+ )
97
+
98
+ // Any committed change clears it — including one that landed somewhere else
99
+ // entirely (a redirect, a link elsewhere on the page). A navigation that never
100
+ // commits leaves a faint hold on the row it started from; because pending is
101
+ // only ever a soft hint, that costs nothing but a stale hover-weight tint,
102
+ // where a stale ACTIVE state would have been a lie about the current page.
103
+ useEffect(() => {
104
+ setPendingItemId(null)
105
+ }, [committedActiveId])
106
+
78
107
  const handleItemClick = useCallback((item: NavigationSidebarItem, event?: React.MouseEvent) => {
79
108
  event?.stopPropagation()
80
109
 
81
110
  if (item.onClick) {
82
111
  item.onClick()
83
- } else if (item.path) {
84
- config.onNavigate?.(item.path)
112
+ if (isTablet) setTabletMinimized(true)
113
+ return
114
+ }
115
+
116
+ if (!item.path) return
117
+
118
+ // ⌘/Ctrl/Shift-click and middle-click open the link somewhere else. THIS
119
+ // window is not going anywhere, so the browser is left to it: no
120
+ // preventDefault, no optimistic highlight, no closing the tablet overlay.
121
+ // Entries render as real anchors precisely so those gestures work at all.
122
+ if (isModifiedClick(event)) return
123
+
124
+ // A plain click stays the host's to perform, through `onNavigate` exactly
125
+ // as before — the anchor is here for the href (Next prefetches links in the
126
+ // viewport) and for the browser affordances, not to take over routing.
127
+ //
128
+ // `onNavigate` is optional, though, and with no host router there is nothing
129
+ // to hand the click to: swallowing it would leave a real anchor, with a real
130
+ // href, that does nothing at all. So the guard is on having somewhere to
131
+ // send it — otherwise the anchor navigates on its own and we only close the
132
+ // overlay behind it.
133
+ if (config.onNavigate) {
134
+ event?.preventDefault()
135
+ // Re-clicking the page you are already on starts no navigation, so there
136
+ // is nothing to mark — and nothing would ever clear it, since the
137
+ // committed active id is not about to change.
138
+ if (item.id !== committedActiveId) setPendingItemId(item.id)
139
+ config.onNavigate(item.path)
85
140
  }
86
141
 
87
142
  if (isTablet) setTabletMinimized(true)
88
- }, [config, isTablet])
143
+ }, [config, isTablet, committedActiveId])
89
144
 
90
145
  const { primaryItems, secondaryItems } = useMemo(() => ({
91
146
  primaryItems: config.items.filter(item => item.section !== 'secondary'),
@@ -97,16 +152,21 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
97
152
  [minimized],
98
153
  )
99
154
 
100
- const isHydrated = isMdUp !== undefined && isLgUp !== undefined
101
-
155
+ // There used to be an `isHydrated` gate here — `isMdUp !== undefined && ...`
156
+ // — meant to hold the sidebar's contents back until the media queries
157
+ // resolved. It could never be false: the `?? false` above had already
158
+ // swallowed the `undefined` it tested for. Removed rather than repaired,
159
+ // because repairing it would be the regression: the contents are server-
160
+ // rendered today, and gating them behind a client-only media query would
161
+ // trade a first paint of the real navigation for an empty rail.
102
162
  useLayoutEffect(() => {
103
- if (isHydrated && !transitionsEnabled) {
163
+ if (!transitionsEnabled) {
104
164
  const id = requestAnimationFrame(() => {
105
165
  setTransitionsEnabled(true)
106
166
  })
107
167
  return () => cancelAnimationFrame(id)
108
168
  }
109
- }, [isHydrated, transitionsEnabled])
169
+ }, [transitionsEnabled])
110
170
 
111
171
  return (
112
172
  <>
@@ -148,45 +208,45 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
148
208
  style={{ width: sidebarWidth }}
149
209
  aria-label="Main navigation sidebar"
150
210
  >
151
- {isHydrated && (
152
- <>
153
- <NavigationSidebarHeader minimized={minimized} />
154
-
155
- <div className="flex-1 flex flex-col justify-between py-4 overflow-y-auto">
156
- <nav className="flex flex-col" aria-label="Primary navigation">
157
- {primaryItems.map(item => (
158
- <NavigationSidebarItemButton
159
- key={item.id}
160
- item={item}
161
- showLabel={showLabel}
162
- disabled={disabled}
163
- onClick={handleItemClick}
164
- />
165
- ))}
166
- </nav>
167
-
168
- {secondaryItems.length > 0 && (
169
- <nav className="flex flex-col" aria-label="Secondary navigation">
170
- {secondaryItems.map(item => (
171
- <NavigationSidebarItemButton
172
- key={item.id}
173
- item={item}
174
- showLabel={showLabel}
175
- disabled={disabled}
176
- onClick={handleItemClick}
177
- />
178
- ))}
179
- </nav>
180
- )}
181
- </div>
182
-
183
- <NavigationSidebarToggle
184
- minimized={minimized}
185
- showLabel={showLabel}
186
- onToggle={handleToggle}
187
- />
188
- </>
189
- )}
211
+ <NavigationSidebarHeader minimized={minimized} />
212
+
213
+ <div className="flex-1 flex flex-col justify-between py-4 overflow-y-auto">
214
+ <nav className="flex flex-col" aria-label="Primary navigation">
215
+ {primaryItems.map(item => (
216
+ <NavigationSidebarItemButton
217
+ key={item.id}
218
+ item={item}
219
+ isActive={item.id === committedActiveId}
220
+ isPending={item.id === pendingItemId}
221
+ showLabel={showLabel}
222
+ disabled={disabled}
223
+ onClick={handleItemClick}
224
+ />
225
+ ))}
226
+ </nav>
227
+
228
+ {secondaryItems.length > 0 && (
229
+ <nav className="flex flex-col" aria-label="Secondary navigation">
230
+ {secondaryItems.map(item => (
231
+ <NavigationSidebarItemButton
232
+ key={item.id}
233
+ item={item}
234
+ isActive={item.id === committedActiveId}
235
+ isPending={item.id === pendingItemId}
236
+ showLabel={showLabel}
237
+ disabled={disabled}
238
+ onClick={handleItemClick}
239
+ />
240
+ ))}
241
+ </nav>
242
+ )}
243
+ </div>
244
+
245
+ <NavigationSidebarToggle
246
+ minimized={minimized}
247
+ showLabel={showLabel}
248
+ onToggle={handleToggle}
249
+ />
190
250
  </aside>
191
251
  </>
192
252
  )
@@ -15,7 +15,7 @@
15
15
  * renderer with extra plugins.
16
16
  */
17
17
 
18
- import { Fragment, type ComponentType, type ReactNode } from 'react'
18
+ import { type ComponentType, type ReactNode } from 'react'
19
19
  import { PageLayout } from '../layout/page-layout'
20
20
 
21
21
  import { useRouter } from '../../embed-shims'
@@ -30,7 +30,7 @@ import { VideoBitesStrip } from '../features/video-bites-strip'
30
30
  import { toStripProfile } from '../features/video-bites-shared'
31
31
  import { useVideoWarmup } from '../features/use-video-warmup'
32
32
  import { getCaptionsUrl } from '../features/captions-url'
33
- import { RichMarkdownRenderer } from '../ui/markdown'
33
+ import { RichMarkdownRenderer } from '../ui/rich-markdown-renderer'
34
34
  import { EntityTagBadges } from '../features/entity-tag-badges'
35
35
  import { LoadError } from '../ui/error-state'
36
36
  import { ArticleAuthorByline } from '../shared/article-author-byline'
@@ -244,26 +244,8 @@ export function OnboardingGuideDetailView({
244
244
  </div>
245
245
  )}
246
246
 
247
- {/*
248
- * Host slot — cross-type related-content / FAQ rail.
249
- *
250
- * KEYED DELIBERATELY. This is one member of PageLayout's static children
251
- * array, and the only member whose element may be created in a SERVER
252
- * component and crossed over the RSC boundary. Static JSX children are
253
- * normally exempt from the key warning because `jsxs` runs
254
- * `validateChildKeys` and stamps `_store.validated = 1` on each element —
255
- * but a slot arriving over the wire is referenced as a lazy chunk wrapper,
256
- * so validation marks the WRAPPER and the fulfilled path never copies the
257
- * flag onto the revived element. It reaches the reconciler with
258
- * `validated: 0, key: null` and warns, blaming PageLayout (the owner of the
259
- * enclosing element) rather than anything actually at fault.
260
- *
261
- * A keyed Fragment emits no DOM, so rendered output is byte-identical.
262
- * Do NOT "fix" this in PageLayout via `React.Children.toArray` — that would
263
- * auto-key every consumer's children and change reconciliation identity for
264
- * all of them, and that file carries an explicit FROZEN contract.
265
- */}
266
- <Fragment key="related-content">{relatedContent}</Fragment>
247
+ {/* Host slot — cross-type related-content / FAQ rail. */}
248
+ {relatedContent}
267
249
  </PageLayout>
268
250
  )
269
251
  }
@@ -19,7 +19,7 @@
19
19
 
20
20
  import type { ComponentType } from 'react';
21
21
  import { PageShell, PageLayout } from '../../ui';
22
- import { RichMarkdownRenderer } from '../../ui/markdown';
22
+ import { RichMarkdownRenderer } from '../../ui/rich-markdown-renderer';
23
23
  import { useRouter } from '../../../embed-shims/next-navigation';
24
24
  import { useLegalDocs, type LegalDocument } from './use-legal-docs';
25
25
  import { formatLegalDate } from '../../../utils/format';
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useState, useEffect, Fragment, ComponentType, type ReactNode } from 'react';
3
+ import { useState, useEffect, ComponentType, type ReactNode } from 'react';
4
4
  import Link from '../../../embed-shims/next-link';
5
5
  import { useRouter } from '../../../embed-shims/next-navigation';
6
6
  import { Card, CardContent } from '../../ui/card';
@@ -11,7 +11,7 @@ import { PageShell } from '../../layout/article-detail-layout';
11
11
  import { PageLayout } from '../../layout/page-layout';
12
12
  import { FadePreview } from '../../ui/fade-preview';
13
13
  import { ReleaseChangelogSection } from '../../ui/release-changelog-section';
14
- import { RichMarkdownRenderer } from '../../ui/markdown';
14
+ import { RichMarkdownRenderer } from '../../ui/rich-markdown-renderer';
15
15
  import { EntityTagBadges } from '../../features/entity-tag-badges';
16
16
  import { EntityMetadataAuthorCell } from '../../chat/entity-cards/entity-author-card';
17
17
  import type { EntityAuthor } from '../../../types/entity-author';
@@ -582,23 +582,8 @@ export function ReleaseDetailPage({
582
582
  )}
583
583
  </div>
584
584
 
585
- {/*
586
- * Host slot — end-of-article byline + related-content / FAQ rail.
587
- *
588
- * KEYED for the same reason as `onboarding-guide-detail-view.tsx`: this is a
589
- * member of PageLayout's static children array, and a slot element built in a
590
- * SERVER component crosses the RSC boundary as a lazy chunk wrapper, so
591
- * `validateChildKeys` marks the wrapper and the fulfilled path never copies
592
- * `_store.validated` onto the revived element — it reaches the reconciler
593
- * unvalidated and warns.
594
- *
595
- * Today the hub's only consumer of this view is a `'use client'` wrapper that
596
- * builds the slot locally, so no element actually crosses the boundary and
597
- * this is pre-emptive. It is kept because the trap is invisible from here: a
598
- * future server-built slot would warn with no clue pointing at this line.
599
- * A keyed Fragment emits no DOM, so output is byte-identical either way.
600
- */}
601
- <Fragment key="related-content">{relatedContent}</Fragment>
585
+ {/* Host slot — end-of-article byline + related-content / FAQ rail. */}
586
+ {relatedContent}
602
587
  </PageLayout>
603
588
  );
604
589
  }
@@ -0,0 +1,52 @@
1
+ <!-- source-hash: 9a47236bd9fd4f90eece958d3708b946 -->
2
+ A client-side React component that renders Markdown content with GFM support, syntax highlighting, raw HTML passthrough, and a layered XSS sanitization pipeline designed for safe rendering of LLM-generated output.
3
+
4
+ ## Key Components
5
+
6
+ ### Rehype Plugins
7
+
8
+ - **`rehypeStripUnsafe()`** — Custom HAST sanitizer that runs after `rehype-raw`. Strips dangerous elements (`<script>`, `<style>`, `<object>`, etc.), removes `on*` event handler attributes, and blocks `javascript:` / `data:` URI schemes on all URL-bearing attributes including multi-candidate `srcset`.
9
+
10
+ - **`srcsetHasUnsafeCandidate(srcset)`** — Helper that splits `srcset` attribute values by comma and validates each URL candidate individually to prevent injection via secondary candidates.
11
+
12
+ - **`escapeUnknownHtmlT...` (truncated)** — Pre-pass that escapes unrecognized HTML-like tags in raw markdown text before `rehype-raw` processes them, preventing React warnings/errors from LLM-emitted pseudo-XML tags like `<ticket>` or `<their>`.
13
+
14
+ ### URL Transform
15
+
16
+ - **`cardAwareUrlTransform(url, key)`** — Extends `react-markdown`'s default URL sanitizer to allow `card://` and `mention://` internal schemes on `href` attributes only (used by `remarkCardLinks` and `remarkMentionChips` plugins), while keeping all other dangerous schemes blocked.
17
+
18
+ ### Constants
19
+
20
+ - **`SAFE_HTML_TAGS`** — Allowlist of permitted HTML tag names forwarded to React. Explicitly excludes `<video>` (handled by a separate pipeline component).
21
+ - **`TAG_LIKE_REGEX`** — ReDoS-safe regex (CodeQL-hardened, bounded quantifiers) for matching HTML-like tags in markdown source.
22
+
23
+ ## Usage Example
24
+
25
+ ```typescript
26
+ import { SimpleMarkdownRenderer } from './simple-markdown-renderer';
27
+
28
+ // Basic rendering with safe LLM output
29
+ <SimpleMarkdownRenderer content="## Hello\n\nThis is **markdown** with `code`." />
30
+
31
+ // With authenticated image sources and link resolution
32
+ <SimpleMarkdownRenderer
33
+ content={llmMarkdownOutput}
34
+ resolveLink={(href) => ({ url: href, label: href })}
35
+ />
36
+ ```
37
+
38
+ ## Security Model
39
+
40
+ The sanitization runs in two layers:
41
+
42
+ ```mermaid
43
+ graph TD
44
+ A[Raw Markdown] --> B[escapeUnknownHtmlTags pre-pass]
45
+ B --> C[remark-gfm / remark-breaks]
46
+ C --> D[rehype-raw]
47
+ D --> E[rehypeStripUnsafe HAST sanitizer]
48
+ E --> F[rehype-highlight]
49
+ F --> G[Safe React Output]
50
+ ```
51
+
52
+ > **Source:** [`simple-markdown-renderer.tsx`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/simple-markdown-renderer.tsx)
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest'
2
+ import { render, fireEvent, screen } from '@testing-library/react'
3
+ import { mockReplace, setMockSearchParams } from '../../../../vitest.setup'
4
+ import { TabNavigation, type TabItem } from '../tab-navigation'
5
+
6
+ beforeAll(() => {
7
+ // jsdom ships no ResizeObserver; the tab strip observes itself on mount to
8
+ // keep the edge fades and the underline in sync with its own width.
9
+ globalThis.ResizeObserver = class {
10
+ observe() {}
11
+ unobserve() {}
12
+ disconnect() {}
13
+ } as unknown as typeof ResizeObserver
14
+ })
15
+
16
+ const TABS: TabItem[] = [
17
+ { id: 'general', label: 'General' },
18
+ { id: 'software', label: 'Software' },
19
+ ]
20
+
21
+ /**
22
+ * Renders with `urlSync` and reports the tab id the BODY was last rendered with
23
+ * — the one thing a consumer actually observes, and the value the deferred
24
+ * render settles on once React has flushed.
25
+ */
26
+ function renderNav(tabs: TabItem[]) {
27
+ let seen = '__never-rendered__'
28
+ const element = (next: TabItem[]) => (
29
+ <TabNavigation tabs={next} urlSync>
30
+ {(active) => {
31
+ seen = active
32
+ return null
33
+ }}
34
+ </TabNavigation>
35
+ )
36
+ const utils = render(element(tabs))
37
+ return {
38
+ get active() {
39
+ return seen
40
+ },
41
+ rerenderWith(next: TabItem[]) {
42
+ utils.rerender(element(next))
43
+ },
44
+ }
45
+ }
46
+
47
+ describe('TabNavigation with urlSync', () => {
48
+ describe('a tab list that arrives late', () => {
49
+ // The sync effect is guarded on the URL value changing, so that our own
50
+ // `router.replace` doesn't come back looking like someone else's edit. The
51
+ // guard must not also swallow the tabs themselves arriving — an empty list
52
+ // resolves to no active tab, and the URL never moves again to correct it.
53
+ it('applies the deep-linked tab once the tabs arrive', () => {
54
+ setMockSearchParams(new URLSearchParams('tab=software'))
55
+ const nav = renderNav([])
56
+ expect(nav.active).toBe('')
57
+
58
+ nav.rerenderWith(TABS)
59
+ expect(nav.active).toBe('software')
60
+ })
61
+
62
+ it('falls back to the first tab once the tabs arrive', () => {
63
+ const nav = renderNav([])
64
+ expect(nav.active).toBe('')
65
+
66
+ nav.rerenderWith(TABS)
67
+ expect(nav.active).toBe('general')
68
+ })
69
+
70
+ it('leaves a tab the user picked alone when the tabs array is merely re-created', () => {
71
+ const nav = renderNav(TABS)
72
+ fireEvent.click(screen.getByText('Software'))
73
+ expect(nav.active).toBe('software')
74
+
75
+ // Same ids, new array + new Set identity — what a consumer passing an
76
+ // inline literal produces on every one of its renders.
77
+ nav.rerenderWith([...TABS])
78
+ expect(nav.active).toBe('software')
79
+ })
80
+ })
81
+
82
+ describe('clicking a tab', () => {
83
+ // The regression this component was rewritten for: state moves on click,
84
+ // `router.replace` lands a tick or more later, and an effect comparing the
85
+ // state against the URL in between would read the tab just LEFT and push
86
+ // the tab back to it.
87
+ it('does not revert before the navigation lands', () => {
88
+ const nav = renderNav(TABS)
89
+ fireEvent.click(screen.getByText('Software'))
90
+
91
+ // The URL has deliberately NOT been updated here — this is the window in
92
+ // which the old effect fought its own click.
93
+ expect(nav.active).toBe('software')
94
+ })
95
+
96
+ it('writes the tab to the URL without scrolling to the top', () => {
97
+ renderNav(TABS)
98
+ fireEvent.click(screen.getByText('Software'))
99
+
100
+ expect(mockReplace).toHaveBeenCalledWith('/?tab=software', { scroll: false })
101
+ })
102
+
103
+ it('stays put when its own write finally lands', () => {
104
+ const nav = renderNav(TABS)
105
+ fireEvent.click(screen.getByText('Software'))
106
+
107
+ setMockSearchParams(new URLSearchParams('tab=software'))
108
+ nav.rerenderWith(TABS)
109
+ expect(nav.active).toBe('software')
110
+ })
111
+
112
+ // Two clicks inside one navigation: the FIRST write lands while the second
113
+ // is still in flight. Arriving out of step like that, it is indistinguishable
114
+ // from an external change unless the component remembers what it wrote.
115
+ it('ignores an earlier write landing after a second click', () => {
116
+ const nav = renderNav(TABS)
117
+ fireEvent.click(screen.getByText('Software'))
118
+ fireEvent.click(screen.getByText('General'))
119
+ expect(nav.active).toBe('general')
120
+
121
+ setMockSearchParams(new URLSearchParams('tab=software'))
122
+ nav.rerenderWith(TABS)
123
+ expect(nav.active).toBe('general')
124
+
125
+ setMockSearchParams(new URLSearchParams('tab=general'))
126
+ nav.rerenderWith(TABS)
127
+ expect(nav.active).toBe('general')
128
+ })
129
+
130
+ it('keeps unrelated query params', () => {
131
+ setMockSearchParams(new URLSearchParams('page=2&tab=general'))
132
+ renderNav(TABS)
133
+ fireEvent.click(screen.getByText('Software'))
134
+
135
+ expect(mockReplace).toHaveBeenCalledWith('/?page=2&tab=software', { scroll: false })
136
+ })
137
+ })
138
+
139
+ describe('an external URL change', () => {
140
+ it('moves the tab (back/forward, or a link into a tab)', () => {
141
+ setMockSearchParams(new URLSearchParams('tab=general'))
142
+ const nav = renderNav(TABS)
143
+ expect(nav.active).toBe('general')
144
+
145
+ setMockSearchParams(new URLSearchParams('tab=software'))
146
+ nav.rerenderWith(TABS)
147
+ expect(nav.active).toBe('software')
148
+ })
149
+
150
+ it('falls back when the URL names a tab that does not exist', () => {
151
+ setMockSearchParams(new URLSearchParams('tab=general'))
152
+ const nav = renderNav(TABS)
153
+
154
+ setMockSearchParams(new URLSearchParams('tab=nonsense'))
155
+ nav.rerenderWith(TABS)
156
+ expect(nav.active).toBe('general')
157
+ })
158
+ })
159
+ })