@flamingo-stack/openframe-frontend-core 0.0.510 → 0.0.511-snapshot.20260804140004

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 (248) hide show
  1. package/dist/chat-protocol/decode.d.ts.map +1 -1
  2. package/dist/chat-protocol/frames.d.ts +26 -1
  3. package/dist/chat-protocol/frames.d.ts.map +1 -1
  4. package/dist/chat-protocol/index.cjs +24 -0
  5. package/dist/chat-protocol/index.cjs.map +1 -1
  6. package/dist/chat-protocol/index.js +24 -0
  7. package/dist/chat-protocol/index.js.map +1 -1
  8. package/dist/{chunk-GE3JUTW2.js → chunk-27OKD7ZV.js} +44 -14
  9. package/dist/chunk-27OKD7ZV.js.map +1 -0
  10. package/dist/{chunk-RHQ7BJKK.cjs → chunk-2WWDZR4D.cjs} +73 -73
  11. package/dist/{chunk-RHQ7BJKK.cjs.map → chunk-2WWDZR4D.cjs.map} +1 -1
  12. package/dist/{chunk-J2JUAS4C.cjs → chunk-2XL7UK5Y.cjs} +11 -11
  13. package/dist/{chunk-J2JUAS4C.cjs.map → chunk-2XL7UK5Y.cjs.map} +1 -1
  14. package/dist/{chunk-Q4K3TGNP.cjs → chunk-3NECEXDF.cjs} +74 -44
  15. package/dist/chunk-3NECEXDF.cjs.map +1 -0
  16. package/dist/{chunk-JOCFPAS6.js → chunk-4NFDSMB5.js} +6 -6
  17. package/dist/{chunk-SYZFFRVD.cjs → chunk-5BAGE7AI.cjs} +13 -13
  18. package/dist/{chunk-SYZFFRVD.cjs.map → chunk-5BAGE7AI.cjs.map} +1 -1
  19. package/dist/{chunk-53JKCFFE.cjs → chunk-5JBDT2MQ.cjs} +12 -12
  20. package/dist/{chunk-53JKCFFE.cjs.map → chunk-5JBDT2MQ.cjs.map} +1 -1
  21. package/dist/{chunk-XJVRAKEX.cjs → chunk-67YDSTH7.cjs} +56 -33
  22. package/dist/chunk-67YDSTH7.cjs.map +1 -0
  23. package/dist/{chunk-WNOGR5AA.js → chunk-6LMTBNLN.js} +3 -3
  24. package/dist/{chunk-6FIDTXYC.js → chunk-7AQSXQEL.js} +3 -3
  25. package/dist/{chunk-JMBIGH4H.cjs → chunk-7VBEOGCR.cjs} +83 -83
  26. package/dist/{chunk-JMBIGH4H.cjs.map → chunk-7VBEOGCR.cjs.map} +1 -1
  27. package/dist/{chunk-5AZZQQYS.cjs → chunk-AWLFJMOJ.cjs} +40 -40
  28. package/dist/{chunk-5AZZQQYS.cjs.map → chunk-AWLFJMOJ.cjs.map} +1 -1
  29. package/dist/{chunk-RJ3D3PE2.cjs → chunk-DQDPDUI3.cjs} +9 -9
  30. package/dist/{chunk-RJ3D3PE2.cjs.map → chunk-DQDPDUI3.cjs.map} +1 -1
  31. package/dist/{chunk-2RLI5XVI.cjs → chunk-F2YI5N7E.cjs} +16 -16
  32. package/dist/{chunk-2RLI5XVI.cjs.map → chunk-F2YI5N7E.cjs.map} +1 -1
  33. package/dist/{chunk-LBOWSKGM.js → chunk-FI35LJ3L.js} +5 -5
  34. package/dist/{chunk-BTUHMCYW.js → chunk-FS3YSNSU.js} +193 -6
  35. package/dist/chunk-FS3YSNSU.js.map +1 -0
  36. package/dist/{chunk-RB2EW6IZ.cjs → chunk-GOFTC2HV.cjs} +3690 -3472
  37. package/dist/chunk-GOFTC2HV.cjs.map +1 -0
  38. package/dist/{chunk-CTMLY2GN.js → chunk-HGZPH7S5.js} +5 -5
  39. package/dist/{chunk-7VUKIWF6.cjs → chunk-HIV2GIFP.cjs} +3 -3
  40. package/dist/{chunk-7VUKIWF6.cjs.map → chunk-HIV2GIFP.cjs.map} +1 -1
  41. package/dist/{chunk-DBR5JLX3.js → chunk-IHNUMRMU.js} +46 -23
  42. package/dist/chunk-IHNUMRMU.js.map +1 -0
  43. package/dist/{chunk-R4HKB3YD.cjs → chunk-IIPBTWQR.cjs} +22 -22
  44. package/dist/{chunk-R4HKB3YD.cjs.map → chunk-IIPBTWQR.cjs.map} +1 -1
  45. package/dist/{chunk-TBGSQTJK.js → chunk-IK2WRYVA.js} +6 -6
  46. package/dist/{chunk-WSAPKKDU.cjs → chunk-JIW5JI3E.cjs} +9 -9
  47. package/dist/{chunk-WSAPKKDU.cjs.map → chunk-JIW5JI3E.cjs.map} +1 -1
  48. package/dist/{chunk-MLGGF24T.js → chunk-JKAX5K7C.js} +8 -8
  49. package/dist/{chunk-UWPT23SA.js → chunk-JO3NPEK6.js} +3 -3
  50. package/dist/{chunk-HCZJZMU7.js → chunk-JOFGJYIT.js} +3 -3
  51. package/dist/{chunk-A62C4C4V.cjs → chunk-KI4EGKNV.cjs} +3 -2
  52. package/dist/chunk-KI4EGKNV.cjs.map +1 -0
  53. package/dist/{chunk-OB7YDOTK.cjs → chunk-NJADHL2G.cjs} +8 -8
  54. package/dist/{chunk-OB7YDOTK.cjs.map → chunk-NJADHL2G.cjs.map} +1 -1
  55. package/dist/{chunk-EYTXD62B.cjs → chunk-OGVIGAMZ.cjs} +232 -45
  56. package/dist/chunk-OGVIGAMZ.cjs.map +1 -0
  57. package/dist/{chunk-HX4CJYXL.js → chunk-OT4RV6WY.js} +10 -10
  58. package/dist/{chunk-Z35QWWJH.cjs → chunk-RJXI25TJ.cjs} +34 -34
  59. package/dist/{chunk-Z35QWWJH.cjs.map → chunk-RJXI25TJ.cjs.map} +1 -1
  60. package/dist/{chunk-6TL5XIAW.cjs → chunk-SG25J6LV.cjs} +119 -119
  61. package/dist/{chunk-6TL5XIAW.cjs.map → chunk-SG25J6LV.cjs.map} +1 -1
  62. package/dist/{chunk-M4MKQKFF.js → chunk-UBTOOE55.js} +2 -2
  63. package/dist/{chunk-FEGEC6FY.js → chunk-V5WLIFKN.js} +5660 -5442
  64. package/dist/chunk-V5WLIFKN.js.map +1 -0
  65. package/dist/{chunk-EJ3EBYNV.cjs → chunk-VVXUUBML.cjs} +32 -32
  66. package/dist/{chunk-EJ3EBYNV.cjs.map → chunk-VVXUUBML.cjs.map} +1 -1
  67. package/dist/{chunk-SRYV4H3M.js → chunk-VZX3SSFL.js} +3 -3
  68. package/dist/{chunk-5CDPRCPS.cjs → chunk-WVZHCDB5.cjs} +23 -23
  69. package/dist/{chunk-5CDPRCPS.cjs.map → chunk-WVZHCDB5.cjs.map} +1 -1
  70. package/dist/{chunk-VDJF4ZAU.js → chunk-XG7FLAKQ.js} +3 -3
  71. package/dist/{chunk-SZMA65OW.js → chunk-XURRAFIB.js} +2 -2
  72. package/dist/{chunk-RG2WRBPT.js → chunk-Z3ADCYC5.js} +2 -2
  73. package/dist/{chunk-2AASLY6D.js → chunk-ZIQMVAGA.js} +3 -3
  74. package/dist/{chunk-4GPWLGKT.js → chunk-ZRILYK5U.js} +2 -1
  75. package/dist/components/calendar.cjs +4 -4
  76. package/dist/components/calendar.js +3 -3
  77. package/dist/components/case-studies/index.cjs +17 -17
  78. package/dist/components/case-studies/index.js +7 -7
  79. package/dist/components/chat/approval-batch-message.d.ts +2 -2
  80. package/dist/components/chat/approval-batch-message.d.ts.map +1 -1
  81. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  82. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  83. package/dist/components/chat/entity-cards/deleted-data-card.d.ts +27 -0
  84. package/dist/components/chat/entity-cards/deleted-data-card.d.ts.map +1 -0
  85. package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
  86. package/dist/components/chat/hooks/use-chat-card-item.d.ts +3 -0
  87. package/dist/components/chat/hooks/use-chat-card-item.d.ts.map +1 -1
  88. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  89. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  90. package/dist/components/chat/index.cjs +13 -7
  91. package/dist/components/chat/index.cjs.map +1 -1
  92. package/dist/components/chat/index.js +12 -6
  93. package/dist/components/chat/remark-mention-chips.d.ts +5 -2
  94. package/dist/components/chat/remark-mention-chips.d.ts.map +1 -1
  95. package/dist/components/chat/remark-strip-citations.d.ts +22 -0
  96. package/dist/components/chat/remark-strip-citations.d.ts.map +1 -0
  97. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -1
  98. package/dist/components/chat/stream/message-mutations.d.ts +49 -5
  99. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -1
  100. package/dist/components/chat/types/component.types.d.ts +7 -7
  101. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  102. package/dist/components/chat/types/message.types.d.ts +11 -4
  103. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  104. package/dist/components/chat/types/unified-chat-state.types.d.ts +4 -1
  105. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  106. package/dist/components/chat/utils/discuss-ref-prompt.d.ts +48 -0
  107. package/dist/components/chat/utils/discuss-ref-prompt.d.ts.map +1 -0
  108. package/dist/components/chat/utils/index.d.ts +2 -0
  109. package/dist/components/chat/utils/index.d.ts.map +1 -1
  110. package/dist/components/chat/utils/message-segment-accumulator.d.ts +5 -3
  111. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  112. package/dist/components/chat/utils/resolve-fetched-card-href.d.ts +63 -0
  113. package/dist/components/chat/utils/resolve-fetched-card-href.d.ts.map +1 -0
  114. package/dist/components/chat/utils/source-row-cta.d.ts.map +1 -1
  115. package/dist/components/contact/index.cjs +8 -8
  116. package/dist/components/contact/index.js +7 -7
  117. package/dist/components/docs/index.cjs +10 -10
  118. package/dist/components/docs/index.js +9 -9
  119. package/dist/components/embeds/index.cjs +8 -8
  120. package/dist/components/embeds/index.js +7 -7
  121. package/dist/components/faq/index.cjs +9 -9
  122. package/dist/components/faq/index.js +8 -8
  123. package/dist/components/features/index.cjs +7 -7
  124. package/dist/components/features/index.js +6 -6
  125. package/dist/components/help-center-pages/index.cjs +28 -28
  126. package/dist/components/help-center-pages/index.js +19 -19
  127. package/dist/components/icons/index.cjs +3 -3
  128. package/dist/components/icons/index.js +2 -2
  129. package/dist/components/index.cjs +253 -247
  130. package/dist/components/index.cjs.map +1 -1
  131. package/dist/components/index.js +23 -17
  132. package/dist/components/index.js.map +1 -1
  133. package/dist/components/meeting-scheduler/index.cjs +92 -92
  134. package/dist/components/meeting-scheduler/index.js +9 -9
  135. package/dist/components/navigation/index.cjs +7 -7
  136. package/dist/components/navigation/index.js +6 -6
  137. package/dist/components/onboarding-guides/index.cjs +11 -11
  138. package/dist/components/onboarding-guides/index.js +10 -10
  139. package/dist/components/related-content/index.cjs +9 -9
  140. package/dist/components/related-content/index.js +8 -8
  141. package/dist/components/shared/doc-search/resolve-search-result-action.d.ts +11 -3
  142. package/dist/components/shared/doc-search/resolve-search-result-action.d.ts.map +1 -1
  143. package/dist/components/shared/doc-search/use-doc-search.d.ts +7 -0
  144. package/dist/components/shared/doc-search/use-doc-search.d.ts.map +1 -1
  145. package/dist/components/tickets/index.cjs +11 -11
  146. package/dist/components/tickets/index.js +10 -10
  147. package/dist/components/ui/actions-menu.d.ts.map +1 -1
  148. package/dist/components/ui/dropdown-menu.d.ts.map +1 -1
  149. package/dist/components/ui/file-manager/index.cjs +70 -70
  150. package/dist/components/ui/file-manager/index.js +3 -3
  151. package/dist/components/ui/index.cjs +13 -7
  152. package/dist/components/ui/index.cjs.map +1 -1
  153. package/dist/components/ui/index.js +12 -6
  154. package/dist/components/ui/markdown/base-components.d.ts.map +1 -1
  155. package/dist/components/ui/overlay-open-registry.d.ts +50 -0
  156. package/dist/components/ui/overlay-open-registry.d.ts.map +1 -0
  157. package/dist/components/ui/portal-container.d.ts +24 -0
  158. package/dist/components/ui/portal-container.d.ts.map +1 -1
  159. package/dist/hooks/index.cjs +4 -4
  160. package/dist/hooks/index.js +3 -3
  161. package/dist/index.cjs +20 -10
  162. package/dist/index.cjs.map +1 -1
  163. package/dist/index.js +24 -14
  164. package/dist/utils/anchor-id.d.ts +32 -0
  165. package/dist/utils/anchor-id.d.ts.map +1 -0
  166. package/dist/utils/content-href.d.ts +27 -6
  167. package/dist/utils/content-href.d.ts.map +1 -1
  168. package/dist/utils/index.cjs +213 -152
  169. package/dist/utils/index.cjs.map +1 -1
  170. package/dist/utils/index.d.ts +1 -0
  171. package/dist/utils/index.d.ts.map +1 -1
  172. package/dist/utils/index.js +212 -153
  173. package/dist/utils/index.js.map +1 -1
  174. package/dist/utils/list-url.d.ts.map +1 -1
  175. package/dist/utils/same-page-hash-nav.d.ts +17 -6
  176. package/dist/utils/same-page-hash-nav.d.ts.map +1 -1
  177. package/package.json +1 -1
  178. package/src/chat-protocol/decode.ts +43 -0
  179. package/src/chat-protocol/frames.ts +27 -0
  180. package/src/components/chat/__tests__/remark-mention-chips.test.ts +67 -0
  181. package/src/components/chat/approval-batch-message.tsx +2 -2
  182. package/src/components/chat/chat-message-enhanced.tsx +5 -3
  183. package/src/components/chat/chat-message-list.tsx +49 -5
  184. package/src/components/chat/embeddable-chat.tsx +19 -2
  185. package/src/components/chat/entity-cards/deleted-data-card.tsx +61 -0
  186. package/src/components/chat/entity-cards/dispatch.tsx +205 -102
  187. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +1 -1
  188. package/src/components/chat/hooks/use-chat-card-item.ts +15 -1
  189. package/src/components/chat/hooks/use-nats-chat-adapter.ts +28 -4
  190. package/src/components/chat/hooks/use-sse-chat-adapter.ts +57 -32
  191. package/src/components/chat/remark-mention-chips.ts +6 -3
  192. package/src/components/chat/remark-strip-citations.ts +39 -0
  193. package/src/components/chat/stream/__tests__/chat-stream-reducer-approval-batch.test.ts +196 -0
  194. package/src/components/chat/stream/chat-stream-reducer.ts +67 -2
  195. package/src/components/chat/stream/message-mutations.ts +170 -40
  196. package/src/components/chat/types/component.types.ts +9 -7
  197. package/src/components/chat/types/message.types.ts +14 -4
  198. package/src/components/chat/types/unified-chat-state.types.ts +4 -1
  199. package/src/components/chat/utils/__tests__/discuss-ref-prompt.test.ts +67 -0
  200. package/src/components/chat/utils/__tests__/resolve-fetched-card-href.test.ts +156 -0
  201. package/src/components/chat/utils/discuss-ref-prompt.ts +63 -0
  202. package/src/components/chat/utils/index.ts +16 -0
  203. package/src/components/chat/utils/message-segment-accumulator.ts +24 -29
  204. package/src/components/chat/utils/resolve-fetched-card-href.ts +104 -0
  205. package/src/components/chat/utils/source-row-cta.ts +5 -1
  206. package/src/components/shared/doc-search/__tests__/resolve-search-result-action.test.ts +145 -0
  207. package/src/components/shared/doc-search/resolve-search-result-action.ts +46 -10
  208. package/src/components/shared/doc-search/use-doc-search.ts +50 -14
  209. package/src/components/ui/__tests__/overlay-open-registry.test.tsx +87 -0
  210. package/src/components/ui/actions-menu.tsx +65 -2
  211. package/src/components/ui/dropdown-menu.tsx +62 -12
  212. package/src/components/ui/markdown/base-components.tsx +26 -0
  213. package/src/components/ui/overlay-open-registry.tsx +99 -0
  214. package/src/components/ui/portal-container.tsx +31 -0
  215. package/src/utils/__tests__/anchor-id.test.ts +139 -0
  216. package/src/utils/__tests__/content-href.test.ts +74 -0
  217. package/src/utils/__tests__/list-url.test.ts +11 -1
  218. package/src/utils/anchor-id.ts +106 -0
  219. package/src/utils/content-href.ts +113 -13
  220. package/src/utils/index.ts +8 -0
  221. package/src/utils/list-url.ts +6 -1
  222. package/src/utils/same-page-hash-nav.ts +28 -9
  223. package/dist/chunk-A62C4C4V.cjs.map +0 -1
  224. package/dist/chunk-BTUHMCYW.js.map +0 -1
  225. package/dist/chunk-DBR5JLX3.js.map +0 -1
  226. package/dist/chunk-EYTXD62B.cjs.map +0 -1
  227. package/dist/chunk-FEGEC6FY.js.map +0 -1
  228. package/dist/chunk-GE3JUTW2.js.map +0 -1
  229. package/dist/chunk-Q4K3TGNP.cjs.map +0 -1
  230. package/dist/chunk-RB2EW6IZ.cjs.map +0 -1
  231. package/dist/chunk-XJVRAKEX.cjs.map +0 -1
  232. /package/dist/{chunk-JOCFPAS6.js.map → chunk-4NFDSMB5.js.map} +0 -0
  233. /package/dist/{chunk-WNOGR5AA.js.map → chunk-6LMTBNLN.js.map} +0 -0
  234. /package/dist/{chunk-6FIDTXYC.js.map → chunk-7AQSXQEL.js.map} +0 -0
  235. /package/dist/{chunk-LBOWSKGM.js.map → chunk-FI35LJ3L.js.map} +0 -0
  236. /package/dist/{chunk-CTMLY2GN.js.map → chunk-HGZPH7S5.js.map} +0 -0
  237. /package/dist/{chunk-TBGSQTJK.js.map → chunk-IK2WRYVA.js.map} +0 -0
  238. /package/dist/{chunk-MLGGF24T.js.map → chunk-JKAX5K7C.js.map} +0 -0
  239. /package/dist/{chunk-UWPT23SA.js.map → chunk-JO3NPEK6.js.map} +0 -0
  240. /package/dist/{chunk-HCZJZMU7.js.map → chunk-JOFGJYIT.js.map} +0 -0
  241. /package/dist/{chunk-HX4CJYXL.js.map → chunk-OT4RV6WY.js.map} +0 -0
  242. /package/dist/{chunk-M4MKQKFF.js.map → chunk-UBTOOE55.js.map} +0 -0
  243. /package/dist/{chunk-SRYV4H3M.js.map → chunk-VZX3SSFL.js.map} +0 -0
  244. /package/dist/{chunk-VDJF4ZAU.js.map → chunk-XG7FLAKQ.js.map} +0 -0
  245. /package/dist/{chunk-SZMA65OW.js.map → chunk-XURRAFIB.js.map} +0 -0
  246. /package/dist/{chunk-RG2WRBPT.js.map → chunk-Z3ADCYC5.js.map} +0 -0
  247. /package/dist/{chunk-2AASLY6D.js.map → chunk-ZIQMVAGA.js.map} +0 -0
  248. /package/dist/{chunk-4GPWLGKT.js.map → chunk-ZRILYK5U.js.map} +0 -0
@@ -21,6 +21,11 @@ import {
21
21
  useHeadingId,
22
22
  } from './heading-ids';
23
23
  import { slugifyHeadingText } from '../../../utils/markdown-heading-id';
24
+ import {
25
+ getHashTargetElement,
26
+ navigateSamePageHash,
27
+ HUB_HEADER_OFFSET_PX,
28
+ } from '../../../utils/same-page-hash-nav';
24
29
 
25
30
  /**
26
31
  * True when an image `src` is worth rendering. Shared by the base `img` and
@@ -284,12 +289,33 @@ export function buildBaseComponents({
284
289
  );
285
290
  }
286
291
 
292
+ // In-page anchor. Doc TOCs are authored against GitHub's slugger
293
+ // (`## 📚 Table of Contents` → `#-table-of-contents`) while our heading
294
+ // ids trim the emoji's leftover hyphen, so the raw href resolves to
295
+ // nothing and the browser silently ignores the click. Resolve through
296
+ // `getHashTargetElement` — the same resolver deep links use — then hand
297
+ // the real id to the canonical hash-nav helper, which also lands the
298
+ // heading BELOW the sticky header instead of under it, matching how the
299
+ // "On this page" rail scrolls.
300
+ const isInPageAnchor = typeof href === 'string' && href.startsWith('#') && href.length > 1;
301
+ const handleAnchorClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
302
+ // Let modifier / non-primary clicks keep their native behavior.
303
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
304
+ const target = getHashTargetElement(href.slice(1));
305
+ // Nothing matched anywhere in the document → leave the browser's
306
+ // default alone rather than swallowing a click that isn't ours.
307
+ if (!target?.id) return;
308
+ e.preventDefault();
309
+ navigateSamePageHash(`#${target.id}`, { headerOffset: HUB_HEADER_OFFSET_PX });
310
+ };
311
+
287
312
  return (
288
313
  <a
289
314
  href={href}
290
315
  className={`text-ods-accent no-underline relative transition-colors duration-200 hover:after:w-full after:content-[''] after:absolute after:w-0 after:h-0.5 after:-bottom-0.5 after:left-0 after:bg-ods-accent after:transition-all after:duration-300 ${linkClassName || ''}`}
291
316
  target={href?.startsWith('http') ? '_blank' : undefined}
292
317
  rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
318
+ onClick={isInPageAnchor ? handleAnchorClick : undefined}
293
319
  >
294
320
  {children}
295
321
  </a>
@@ -0,0 +1,99 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * A surface-scoped count of overlays (⋯ menus, popovers) currently open INSIDE
5
+ * that surface, so the surface can suspend behaviour that would move the ground
6
+ * under them.
7
+ *
8
+ * ## Why
9
+ *
10
+ * An open menu stays anchored to its trigger. In a chat thread the ground moves
11
+ * on its own: a streaming reply grows the transcript and the auto-scroll follows
12
+ * the bottom, carrying the trigger — and its menu — upward until the menu is
13
+ * pinned to an off-screen anchor at the panel edge, visually detached from the
14
+ * card it belongs to.
15
+ *
16
+ * The honest fix is not to dismiss the menu on every scroll event (that breaks
17
+ * touch, where momentum scrolling fires constantly) but to stop pulling the
18
+ * ground: while an overlay is open the thread stops chasing the bottom, exactly
19
+ * how ChatGPT/Claude stop chasing once you interact with the transcript. When
20
+ * the overlay closes, the thread catches up if it was at the bottom before.
21
+ *
22
+ * ## Wiring
23
+ *
24
+ * Surface: wrap the region in `<OverlayOpenRegistryProvider onOpenChange={…}>`.
25
+ * Overlay: call `useReportOverlayOpen(open)`. No provider → the hook is inert,
26
+ * so overlays outside such a surface behave exactly as before.
27
+ */
28
+
29
+ import * as React from 'react'
30
+
31
+ export interface OverlayOpenRegistry {
32
+ /** Register one open overlay; call the returned fn to release it. */
33
+ acquire: () => () => void
34
+ }
35
+
36
+ export const OverlayOpenRegistryContext =
37
+ React.createContext<OverlayOpenRegistry | null>(null)
38
+
39
+ /** Read the surrounding registry (or `null` when there is no provider). */
40
+ export function useOverlayOpenRegistry(): OverlayOpenRegistry | null {
41
+ return React.useContext(OverlayOpenRegistryContext)
42
+ }
43
+
44
+ /**
45
+ * Report this overlay's open state to the surrounding surface. Safe to call
46
+ * unconditionally — with no provider it does nothing.
47
+ */
48
+ export function useReportOverlayOpen(open: boolean): void {
49
+ const registry = useOverlayOpenRegistry()
50
+ React.useEffect(() => {
51
+ if (!open || !registry) return
52
+ return registry.acquire()
53
+ }, [open, registry])
54
+ }
55
+
56
+ export interface OverlayOpenRegistryProviderProps {
57
+ /** Fires on 0 → 1 and 1 → 0 transitions only, not on every acquire. */
58
+ onOpenChange?: (hasOpenOverlay: boolean) => void
59
+ children: React.ReactNode
60
+ }
61
+
62
+ /**
63
+ * Owns the count for one surface. The context value is referentially stable, so
64
+ * mounting this provider does not re-render overlays when the count changes —
65
+ * only the surface's own `onOpenChange` callback fires.
66
+ */
67
+ export function OverlayOpenRegistryProvider({
68
+ onOpenChange,
69
+ children,
70
+ }: OverlayOpenRegistryProviderProps) {
71
+ const countRef = React.useRef(0)
72
+ const onOpenChangeRef = React.useRef(onOpenChange)
73
+ onOpenChangeRef.current = onOpenChange
74
+
75
+ const registry = React.useMemo<OverlayOpenRegistry>(
76
+ () => ({
77
+ acquire: () => {
78
+ countRef.current += 1
79
+ if (countRef.current === 1) onOpenChangeRef.current?.(true)
80
+ let released = false
81
+ return () => {
82
+ // Guard against a double release (StrictMode double-invokes effect
83
+ // cleanups in development) driving the count negative.
84
+ if (released) return
85
+ released = true
86
+ countRef.current -= 1
87
+ if (countRef.current === 0) onOpenChangeRef.current?.(false)
88
+ }
89
+ },
90
+ }),
91
+ [],
92
+ )
93
+
94
+ return (
95
+ <OverlayOpenRegistryContext.Provider value={registry}>
96
+ {children}
97
+ </OverlayOpenRegistryContext.Provider>
98
+ )
99
+ }
@@ -26,3 +26,34 @@ export const PortalContainerContext = React.createContext<HTMLElement | null>(
26
26
  export function usePortalContainer(): HTMLElement | null {
27
27
  return React.useContext(PortalContainerContext)
28
28
  }
29
+
30
+ /**
31
+ * The element Radix overlays should treat as their COLLISION boundary — the
32
+ * surface they must stay inside of.
33
+ *
34
+ * Separate from {@link PortalContainerContext} on purpose: the portal target is
35
+ * a `display: contents` node (no box, so it can't be measured), while a
36
+ * collision boundary must be a real, laid-out element.
37
+ *
38
+ * Why it matters: Radix positions overlays `position: fixed` and, by default,
39
+ * flips/shifts them against the VIEWPORT. A menu opened near the top of a
40
+ * panel therefore happily grows upward past the panel's edge and lands on top
41
+ * of the app header — visually detached from the surface it belongs to. Handing
42
+ * Radix the panel element instead keeps flip/shift (and the
43
+ * `--radix-popper-available-height` a menu sizes itself by) inside that panel.
44
+ *
45
+ * Default `null` → viewport, i.e. Radix's own default, unchanged everywhere a
46
+ * provider isn't present.
47
+ */
48
+ export const CollisionBoundaryContext = React.createContext<HTMLElement | null>(
49
+ null,
50
+ )
51
+
52
+ /** Read the active collision boundary (or `null` for the viewport). */
53
+ export function useCollisionBoundary(): HTMLElement | null {
54
+ return React.useContext(CollisionBoundaryContext)
55
+ }
56
+
57
+ /** Padding (px) kept between an overlay and its boundary edge. Matches the
58
+ * panel's own gutter so a shifted menu never sits flush against the edge. */
59
+ export const COLLISION_PADDING_PX = 8
@@ -0,0 +1,139 @@
1
+ // @vitest-environment jsdom
2
+ import { beforeEach, describe, expect, it } from 'vitest'
3
+ import { extractSections } from '../markdown-section-extractor'
4
+ import { findAnchorElementByNormalizedId, normalizeAnchorId } from '../anchor-id'
5
+ import { getHashTargetElement } from '../same-page-hash-nav'
6
+
7
+ describe('normalizeAnchorId', () => {
8
+ it('drops the hyphen GitHub leaves behind a stripped emoji', () => {
9
+ expect(normalizeAnchorId('-getting-started')).toBe('getting-started')
10
+ expect(normalizeAnchorId('-architecture-diagrams')).toBe('architecture-diagrams')
11
+ })
12
+
13
+ it('collapses the hyphen run GitHub emits around a dropped "&"', () => {
14
+ expect(normalizeAnchorId('-community--support')).toBe('community-support')
15
+ })
16
+
17
+ it('is a no-op for an already-clean id', () => {
18
+ expect(normalizeAnchorId('table-of-contents')).toBe('table-of-contents')
19
+ })
20
+
21
+ it('normalizes raw heading text too', () => {
22
+ expect(normalizeAnchorId('🚀 Getting Started')).toBe('getting-started')
23
+ })
24
+ })
25
+
26
+ describe('findAnchorElementByNormalizedId', () => {
27
+ beforeEach(() => {
28
+ document.body.innerHTML = `
29
+ <h1 id="openframe-cli-documentation">OpenFrame CLI — Documentation</h1>
30
+ <h2 id="table-of-contents">📚 Table of Contents</h2>
31
+ <h2 id="getting-started">🚀 Getting Started</h2>
32
+ <h2 id="community-support">💬 Community &amp; Support</h2>
33
+ <h3 id="a---b">A - B</h3>
34
+ `
35
+ })
36
+
37
+ it('resolves a GitHub-style anchor onto our heading id', () => {
38
+ expect(findAnchorElementByNormalizedId('-getting-started', document)?.id).toBe('getting-started')
39
+ expect(findAnchorElementByNormalizedId('-table-of-contents', document)?.id).toBe(
40
+ 'table-of-contents',
41
+ )
42
+ })
43
+
44
+ it('resolves the double-hyphen "&" form', () => {
45
+ expect(findAnchorElementByNormalizedId('-community--support', document)?.id).toBe(
46
+ 'community-support',
47
+ )
48
+ })
49
+
50
+ it('matches an id whose own raw form normalizes differently', () => {
51
+ // `a---b` is not reachable by normalized lookup — only the heading scan finds it.
52
+ expect(findAnchorElementByNormalizedId('a-b', document)?.id).toBe('a---b')
53
+ })
54
+
55
+ it('returns null for an anchor with no heading behind it', () => {
56
+ expect(findAnchorElementByNormalizedId('nope', document)).toBeNull()
57
+ expect(findAnchorElementByNormalizedId('', document)).toBeNull()
58
+ })
59
+ })
60
+
61
+ describe('duplicate headings — GitHub numbers from -1, our deduper from -2', () => {
62
+ beforeEach(() => {
63
+ document.body.innerHTML = `
64
+ <h2 id="setup">Setup</h2>
65
+ <h2 id="setup-2">Setup</h2>
66
+ <h2 id="setup-3">Setup</h2>
67
+ <h2 id="step-2">Step 2</h2>
68
+ `
69
+ })
70
+
71
+ it('shifts a GitHub duplicate fragment onto our id', () => {
72
+ expect(getHashTargetElement('setup-1')?.id).toBe('setup-2')
73
+ expect(getHashTargetElement('setup-2')?.id).toBe('setup-2')
74
+ })
75
+
76
+ it('an id that genuinely ends in a number is matched exactly, never shifted', () => {
77
+ // `step-2` exists, so the exact lookup wins and `step-3` is never consulted.
78
+ expect(getHashTargetElement('step-2')?.id).toBe('step-2')
79
+ })
80
+
81
+ it('does not invent a match when the shifted id is absent too', () => {
82
+ expect(getHashTargetElement('missing-1')).toBeNull()
83
+ })
84
+ })
85
+
86
+ describe('getHashTargetElement — the fuzzy pass is wired into THE resolver', () => {
87
+ beforeEach(() => {
88
+ document.body.innerHTML = `
89
+ <h2 id="getting-started">🚀 Getting Started</h2>
90
+ <div id="ticket-a/b">encoded-id row</div>
91
+ `
92
+ })
93
+
94
+ it('exact ids still win, unchanged', () => {
95
+ expect(getHashTargetElement('getting-started')?.id).toBe('getting-started')
96
+ })
97
+
98
+ it('percent-decoding still runs before the fuzzy pass', () => {
99
+ expect(getHashTargetElement('ticket-a%2Fb')?.id).toBe('ticket-a/b')
100
+ })
101
+
102
+ it('falls back to a normalized match — the TOC bug, through the public entry point', () => {
103
+ expect(getHashTargetElement('-getting-started')?.id).toBe('getting-started')
104
+ })
105
+
106
+ it('still returns null when nothing matches', () => {
107
+ expect(getHashTargetElement('nope')).toBeNull()
108
+ expect(getHashTargetElement('')).toBeNull()
109
+ })
110
+ })
111
+
112
+ describe('the openframe-cli doc that reported the bug', () => {
113
+ // Verbatim shapes from
114
+ // GET /api/docs/sources/openframe-docs/content?path=openframe-cli
115
+ const headings = [
116
+ '# OpenFrame CLI — Documentation',
117
+ '## 📚 Table of Contents',
118
+ '## 🚀 Getting Started',
119
+ '## 🛠️ Development',
120
+ '## 💬 Community & Support',
121
+ ].join('\n\n')
122
+ const tocHrefs = ['#-getting-started', '#-development', '#-community--support']
123
+
124
+ it('every TOC href resolves to the id the section extractor emits', () => {
125
+ const sections = extractSections(headings)
126
+ document.body.innerHTML = sections
127
+ .map((s) => `<h2 id="${s.id}">${s.title}</h2>`)
128
+ .join('')
129
+
130
+ const resolved = tocHrefs.map((href) => getHashTargetElement(href.slice(1))?.id ?? null)
131
+ expect(resolved).toEqual(['getting-started', 'development', 'community-support'])
132
+ // …and none of them resolved before this helper existed:
133
+ expect(tocHrefs.map((href) => document.getElementById(href.slice(1)))).toEqual([
134
+ null,
135
+ null,
136
+ null,
137
+ ])
138
+ })
139
+ })
@@ -32,6 +32,80 @@ describe('makeComposeContentUrl (unified single-object seam)', () => {
32
32
  })
33
33
  })
34
34
 
35
+ it('canonicalizes rail-vocab aliases before matching suffixes', () => {
36
+ // `blog_post_existing` is the legacy ContentRef spelling `buildListUrl`
37
+ // accepts. Without canonicalization here it missed `suffixes` entirely and
38
+ // emitted `<hub>/blog_post_existing/<slug>` — the same post reachable at
39
+ // two different urls depending on which call site produced the link.
40
+ expect(compose({ type: 'blog_post_existing', identifier: 'hello' })).toEqual(
41
+ compose({ type: 'blog_post', identifier: 'hello' }),
42
+ )
43
+ expect(compose({ type: 'blog_post_existing', identifier: 'hello' })).toEqual({
44
+ href: `${HUB}/blog/hello`,
45
+ targetPlatform: null,
46
+ })
47
+ })
48
+
49
+ it('non-hosted row resolves to the OWNING platform, not to contentOrigin', () => {
50
+ // The reported bug: an OpenMSP-owned blog post linked to flamingo.run.
51
+ expect(
52
+ compose({ type: 'blog_post', identifier: 'why-mdr', platforms: [{ name: 'openmsp' }] }),
53
+ ).toEqual({ href: 'https://www.openmsp.ai/blog/why-mdr', targetPlatform: 'openmsp' })
54
+ })
55
+
56
+ it('reads all three junction shapes the DALs produce', () => {
57
+ const shapes = [
58
+ [{ name: 'openmsp' }],
59
+ [{ platform_name: 'openmsp' } as unknown as { name?: string }],
60
+ [{ platforms: { name: 'openmsp' } } as unknown as { name?: string }],
61
+ ]
62
+ for (const platforms of shapes) {
63
+ expect(compose({ type: 'blog_post', identifier: 'x', platforms }).href).toBe(
64
+ 'https://www.openmsp.ai/blog/x',
65
+ )
66
+ }
67
+ })
68
+
69
+ it('explicit targetPlatform wins over the junction', () => {
70
+ expect(
71
+ compose({
72
+ type: 'blog_post',
73
+ identifier: 'x',
74
+ targetPlatform: 'tmcg',
75
+ platforms: [{ name: 'openmsp' }],
76
+ }),
77
+ ).toEqual({ href: 'https://www.tmcg.miami/blog/x', targetPlatform: 'tmcg' })
78
+ })
79
+
80
+ it('falls back to contentOrigin for an unknown or absent platform', () => {
81
+ expect(compose({ type: 'blog_post', identifier: 'x' })).toEqual({
82
+ href: `${HUB}/blog/x`,
83
+ targetPlatform: null,
84
+ })
85
+ // An unresolvable owner reports `null`, NOT its own name: the href came
86
+ // from `contentOrigin`, and `decideNewTab` prefers `targetPlatform` over
87
+ // its origin check — so claiming a platform here forced a same-host link
88
+ // into a new tab.
89
+ expect(
90
+ compose({ type: 'blog_post', identifier: 'x', platforms: [{ name: 'not-a-platform' }] }),
91
+ ).toEqual({ href: `${HUB}/blog/x`, targetPlatform: null })
92
+ expect(
93
+ compose({ type: 'blog_post', identifier: 'x', targetPlatform: 'not-a-platform' }),
94
+ ).toEqual({ href: `${HUB}/blog/x`, targetPlatform: null })
95
+ })
96
+
97
+ it('externalUrl still wins — the RAG url is authoritative', () => {
98
+ expect(
99
+ compose({
100
+ type: 'blog_post',
101
+ identifier: 'x',
102
+ externalUrl: 'https://www.openmsp.ai/blog/x',
103
+ targetPlatform: 'openmsp',
104
+ platforms: [{ name: 'flamingo' }],
105
+ }),
106
+ ).toEqual({ href: 'https://www.openmsp.ai/blog/x', targetPlatform: 'openmsp' })
107
+ })
108
+
35
109
  it('always returns a tuple (never null) — even for an unknown type', () => {
36
110
  expect(compose({ type: 'totally_unknown', identifier: 'x' })).toEqual({
37
111
  href: `${HUB}/totally_unknown/x`,
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { buildListUrl } from '../list-url'
2
+ import { buildListUrl, canonicalContentRefType } from '../list-url'
3
3
 
4
4
  /**
5
5
  * Byte-parity gate for the shared `buildListUrl`.
@@ -33,6 +33,16 @@ describe('buildListUrl — byte parity with the hub mappers', () => {
33
33
  expect(buildListUrl(type, ['a', 'b'])).toBe(expected)
34
34
  })
35
35
 
36
+ it('canonicalContentRefType: aliases resolve, prototype keys do not', () => {
37
+ expect(canonicalContentRefType('blog_post_existing')).toBe('blog_post')
38
+ expect(canonicalContentRefType('roadmap_item')).toBe('roadmap_item')
39
+ // A bare `ALIASES[key]` read returned `Object` here, which downstream
40
+ // interpolated into a URL path.
41
+ expect(canonicalContentRefType('constructor')).toBe('constructor')
42
+ expect(canonicalContentRefType('__proto__')).toBe('__proto__')
43
+ expect(canonicalContentRefType('toString')).toBe('toString')
44
+ })
45
+
36
46
  it('un-aliases blog_post_existing → blog_post (mirrors hub LEGACY_TYPE_ALIASES)', () => {
37
47
  expect(buildListUrl('blog_post_existing', ['a', 'b'])).toBe(BASELINE.blog_post)
38
48
  })
@@ -0,0 +1,106 @@
1
+ /**
2
+ * GitHub-compatible anchor resolution for in-page `#hash` links.
3
+ *
4
+ * ## The mismatch this exists to bridge
5
+ *
6
+ * Doc authors write their table of contents against GitHub's slugger, which
7
+ * keeps the separator left behind by a stripped emoji:
8
+ *
9
+ * `## 📚 Table of Contents` → GitHub anchor `#-table-of-contents`
10
+ *
11
+ * Our heading ids come from the shared slug SSOT (`markdown-heading-id`),
12
+ * whose `slugifyHeadingBase` trims leading and trailing hyphens — so the same
13
+ * heading renders as `id="table-of-contents"`. The "On this page" rail
14
+ * navigates by those ids and works; the in-body TOC link points at an id that
15
+ * does not exist and the browser silently does nothing.
16
+ *
17
+ * Rather than change the id shape (it is already baked into shared URLs and
18
+ * the rail), resolution is made tolerant: compare a *normalized* form of both
19
+ * sides. This module owns ONLY that tolerance — the slug chain itself is
20
+ * imported, never re-implemented, so a change to the id shape lands in one
21
+ * place and both sides move together.
22
+ *
23
+ * Callers do not reach for this directly: `getHashTargetElement`
24
+ * (`utils/same-page-hash-nav`) is the ONE resolver every hash consumer goes
25
+ * through, and it applies this as its last pass.
26
+ */
27
+ import { slugifyHeadingText } from './markdown-heading-id'
28
+
29
+ /**
30
+ * Reduce an anchor id (ours or GitHub's) to a comparable form.
31
+ *
32
+ * Everything but the final hyphen-run collapse is the shared heading-slug
33
+ * chain: emoji strip → lowercase → drop non-word chars → spaces to hyphens →
34
+ * trim edge hyphens. The collapse is this module's own addition, covering
35
+ * GitHub's *interior* divergence — `## 💬 Community & Support` slugs to
36
+ * `#-community--support` (a double hyphen around the dropped `&`) against our
37
+ * `community-support`.
38
+ *
39
+ * `'-getting-started'` → `'getting-started'`
40
+ * `'-community--support'` → `'community-support'`
41
+ * `'Getting Started'` → `'getting-started'`
42
+ */
43
+ export function normalizeAnchorId(raw: string): string {
44
+ return slugifyHeadingText(raw).replace(/-+/g, '-').replace(/^-+|-+$/g, '')
45
+ }
46
+
47
+ /** Heading elements carrying an id — the candidate set for the fuzzy pass. */
48
+ const HEADING_SELECTOR = 'h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]'
49
+
50
+ /** A `base-N` id, split into its base and ordinal. */
51
+ const DUPLICATE_SUFFIX_RE = /^(.+)-(\d+)$/
52
+
53
+ /**
54
+ * The two sides also disagree on where duplicate-heading numbering STARTS.
55
+ * For a document with two `## Setup` headings GitHub emits `setup` and
56
+ * `setup-1`, while `createHeadingIdDeduper` emits `setup` and `setup-2` (its
57
+ * suffix is the occurrence COUNT, not the offset). So a GitHub `#setup-1`
58
+ * needs our `setup-2` — every duplicate is off by exactly one.
59
+ *
60
+ * Runs LAST, after the exact lookup and the heading scan have both missed: a
61
+ * heading that genuinely slugs to `step-2` is resolved long before this, and
62
+ * the shift is the one pass that can match an id it was not asked for.
63
+ */
64
+ function findDuplicateShifted(normalized: string, doc: Document): HTMLElement | null {
65
+ const parts = DUPLICATE_SUFFIX_RE.exec(normalized)
66
+ if (!parts) return null
67
+ const ordinal = Number(parts[2])
68
+ if (!Number.isSafeInteger(ordinal)) return null
69
+ return doc.getElementById(`${parts[1]}-${ordinal + 1}`)
70
+ }
71
+
72
+ /**
73
+ * Resolve a `#hash` id that no exact lookup matched, by comparing normalized
74
+ * forms. Returns `null` when nothing in the document matches, so the caller
75
+ * can leave the browser's default behavior alone rather than hijacking a
76
+ * click that was never ours to handle.
77
+ *
78
+ * Three passes, in order of how much they assume:
79
+ * 1. a single `getElementById` on the normalized id — catches the whole
80
+ * emoji-hyphen family without walking the DOM;
81
+ * 2. a heading scan — catches ids whose own raw form normalizes differently
82
+ * (e.g. `a---b`, from a title containing a literal ` - `);
83
+ * 3. the duplicate-heading shift (see `findDuplicateShifted`) — last,
84
+ * because it is the only pass that resolves to an id the fragment did
85
+ * not name.
86
+ */
87
+ export function findAnchorElementByNormalizedId(
88
+ rawId: string,
89
+ doc: Document,
90
+ ): HTMLElement | null {
91
+ const normalized = normalizeAnchorId(rawId)
92
+ if (!normalized) return null
93
+
94
+ if (normalized !== rawId) {
95
+ const direct = doc.getElementById(normalized)
96
+ if (direct) return direct
97
+ }
98
+
99
+ for (const heading of Array.from(doc.querySelectorAll<HTMLElement>(HEADING_SELECTOR))) {
100
+ if (normalizeAnchorId(heading.id) === normalized) return heading
101
+ }
102
+
103
+ // Last: the shift would happily match an unrelated `x-3` for a `x-2` that
104
+ // has no heading at all, so it runs only once the scan has come up empty.
105
+ return findDuplicateShifted(normalized, doc)
106
+ }