@flamingo-stack/openframe-frontend-core 0.0.292 → 0.0.293

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 (155) hide show
  1. package/dist/{chunk-6FHO73AP.js → chunk-26PKDALD.js} +7 -79
  2. package/dist/chunk-26PKDALD.js.map +1 -0
  3. package/dist/{chunk-B2U6INNO.js → chunk-2U2M2TG2.js} +4 -4
  4. package/dist/{chunk-OXOTKEYY.cjs → chunk-4W7NYJ3B.cjs} +23 -23
  5. package/dist/{chunk-OXOTKEYY.cjs.map → chunk-4W7NYJ3B.cjs.map} +1 -1
  6. package/dist/{chunk-KBKZYJRI.cjs → chunk-5E2HOSSH.cjs} +66 -19
  7. package/dist/chunk-5E2HOSSH.cjs.map +1 -0
  8. package/dist/{chunk-PZZGDS5I.cjs → chunk-6G2INVGG.cjs} +24 -22
  9. package/dist/chunk-6G2INVGG.cjs.map +1 -0
  10. package/dist/{chunk-CUQH4SHH.js → chunk-6GCI7JOE.js} +2 -2
  11. package/dist/{chunk-N6ZM5PYZ.js → chunk-7RIYT7ZH.js} +49 -2
  12. package/dist/chunk-7RIYT7ZH.js.map +1 -0
  13. package/dist/{chunk-E2YXRSDG.js → chunk-BRNHX6C6.js} +15 -13
  14. package/dist/chunk-BRNHX6C6.js.map +1 -0
  15. package/dist/{chunk-5FK7X3EE.js → chunk-DOMJSNXW.js} +8 -7
  16. package/dist/chunk-DOMJSNXW.js.map +1 -0
  17. package/dist/{chunk-VK4B6UGU.js → chunk-E4XABBSU.js} +16 -8
  18. package/dist/{chunk-VK4B6UGU.js.map → chunk-E4XABBSU.js.map} +1 -1
  19. package/dist/{chunk-DUIWR7RQ.js → chunk-EJXHZX2E.js} +3 -3
  20. package/dist/{chunk-5PELVUFT.cjs → chunk-EYEW6PTA.cjs} +44 -36
  21. package/dist/chunk-EYEW6PTA.cjs.map +1 -0
  22. package/dist/{chunk-HTYUZXQP.js → chunk-FJDPUPXC.js} +5 -5
  23. package/dist/{chunk-SLP4KXP6.js → chunk-FQJK446R.js} +8 -2
  24. package/dist/chunk-FQJK446R.js.map +1 -0
  25. package/dist/{chunk-JC5RN7ZS.cjs → chunk-FT4FCV7L.cjs} +6 -6
  26. package/dist/{chunk-JC5RN7ZS.cjs.map → chunk-FT4FCV7L.cjs.map} +1 -1
  27. package/dist/{chunk-ZHNL2IPK.cjs → chunk-J54Z3OCR.cjs} +8 -2
  28. package/dist/chunk-J54Z3OCR.cjs.map +1 -0
  29. package/dist/{chunk-2NJ44RTT.cjs → chunk-JSOMFVEV.cjs} +30 -30
  30. package/dist/{chunk-2NJ44RTT.cjs.map → chunk-JSOMFVEV.cjs.map} +1 -1
  31. package/dist/{chunk-Z6BK4XHH.cjs → chunk-KXCRGTRN.cjs} +10 -82
  32. package/dist/chunk-KXCRGTRN.cjs.map +1 -0
  33. package/dist/{chunk-5KD3S25X.cjs → chunk-LFGGF7OT.cjs} +139 -2
  34. package/dist/chunk-LFGGF7OT.cjs.map +1 -0
  35. package/dist/{chunk-N45M3TK3.js → chunk-NSPOYUBH.js} +2 -2
  36. package/dist/{chunk-TYZEMPPH.js → chunk-OQ6X7ZOC.js} +138 -1
  37. package/dist/chunk-OQ6X7ZOC.js.map +1 -0
  38. package/dist/{chunk-MDLWEJAV.cjs → chunk-RJL6PIOK.cjs} +454 -453
  39. package/dist/chunk-RJL6PIOK.cjs.map +1 -0
  40. package/dist/{chunk-IXDTNQF4.js → chunk-SOJCR63T.js} +4 -4
  41. package/dist/{chunk-5R5OODNE.cjs → chunk-TYMUKFP2.cjs} +40 -40
  42. package/dist/{chunk-5R5OODNE.cjs.map → chunk-TYMUKFP2.cjs.map} +1 -1
  43. package/dist/{chunk-CDJOKNCS.cjs → chunk-VTY7S2QG.cjs} +25 -19
  44. package/dist/chunk-VTY7S2QG.cjs.map +1 -0
  45. package/dist/{chunk-FFP2A77V.cjs → chunk-X3TSMCKX.cjs} +12 -12
  46. package/dist/{chunk-FFP2A77V.cjs.map → chunk-X3TSMCKX.cjs.map} +1 -1
  47. package/dist/{chunk-C667P6LZ.js → chunk-YICTMMXP.js} +13 -7
  48. package/dist/{chunk-C667P6LZ.js.map → chunk-YICTMMXP.js.map} +1 -1
  49. package/dist/{chunk-2BMVBPC7.cjs → chunk-YIGPRLQY.cjs} +9 -9
  50. package/dist/{chunk-2BMVBPC7.cjs.map → chunk-YIGPRLQY.cjs.map} +1 -1
  51. package/dist/components/chat/entity-cards/roadmap-card.d.ts +7 -1
  52. package/dist/components/chat/entity-cards/roadmap-card.d.ts.map +1 -1
  53. package/dist/components/chat/index.cjs +7 -7
  54. package/dist/components/chat/index.js +6 -6
  55. package/dist/components/contact/index.cjs +8 -8
  56. package/dist/components/contact/index.js +7 -7
  57. package/dist/components/docs/index.cjs +6 -6
  58. package/dist/components/docs/index.js +5 -5
  59. package/dist/components/docs/use-document-tree.d.ts.map +1 -1
  60. package/dist/components/embeds/index.cjs +8 -8
  61. package/dist/components/embeds/index.js +7 -7
  62. package/dist/components/faq/faq-section.d.ts.map +1 -1
  63. package/dist/components/faq/index.cjs +8 -8
  64. package/dist/components/faq/index.js +7 -7
  65. package/dist/components/features/index.cjs +7 -7
  66. package/dist/components/features/index.js +6 -6
  67. package/dist/components/index.cjs +214 -193
  68. package/dist/components/index.cjs.map +1 -1
  69. package/dist/components/index.js +50 -29
  70. package/dist/components/index.js.map +1 -1
  71. package/dist/components/navigation/index.cjs +7 -7
  72. package/dist/components/navigation/index.js +6 -6
  73. package/dist/components/onboarding-guides/index.cjs +24 -24
  74. package/dist/components/onboarding-guides/index.js +4 -4
  75. package/dist/components/related-content/index.cjs +8 -8
  76. package/dist/components/related-content/index.js +7 -7
  77. package/dist/components/shared/delivery/delivery-lists.d.ts.map +1 -1
  78. package/dist/components/shared/delivery/delivery-row.d.ts +8 -1
  79. package/dist/components/shared/delivery/delivery-row.d.ts.map +1 -1
  80. package/dist/components/shared/delivery/delivery-table.d.ts.map +1 -1
  81. package/dist/components/shared/roadmap/roadmap-grid.d.ts.map +1 -1
  82. package/dist/components/shared/roadmap/roadmap-view.d.ts.map +1 -1
  83. package/dist/components/tickets/help-center-card.d.ts +7 -1
  84. package/dist/components/tickets/help-center-card.d.ts.map +1 -1
  85. package/dist/components/tickets/help-center-list.d.ts.map +1 -1
  86. package/dist/components/tickets/index.cjs +82 -73
  87. package/dist/components/tickets/index.cjs.map +1 -1
  88. package/dist/components/tickets/index.js +24 -15
  89. package/dist/components/tickets/index.js.map +1 -1
  90. package/dist/components/tickets/ticket-center.d.ts.map +1 -1
  91. package/dist/components/tickets/ticket-row.d.ts +6 -1
  92. package/dist/components/tickets/ticket-row.d.ts.map +1 -1
  93. package/dist/components/ui/index.cjs +7 -7
  94. package/dist/components/ui/index.js +6 -6
  95. package/dist/hooks/index.cjs +5 -3
  96. package/dist/hooks/index.cjs.map +1 -1
  97. package/dist/hooks/index.d.ts +1 -0
  98. package/dist/hooks/index.d.ts.map +1 -1
  99. package/dist/hooks/index.js +4 -2
  100. package/dist/hooks/use-scroll-to-hash.d.ts +17 -0
  101. package/dist/hooks/use-scroll-to-hash.d.ts.map +1 -0
  102. package/dist/index.cjs +19 -7
  103. package/dist/index.cjs.map +1 -1
  104. package/dist/index.js +19 -7
  105. package/dist/utils/dev-sections/dev-section-param-keys.d.ts +10 -0
  106. package/dist/utils/dev-sections/dev-section-param-keys.d.ts.map +1 -1
  107. package/dist/utils/index.cjs +71 -1
  108. package/dist/utils/index.cjs.map +1 -1
  109. package/dist/utils/index.d.ts +2 -1
  110. package/dist/utils/index.d.ts.map +1 -1
  111. package/dist/utils/index.js +67 -2
  112. package/dist/utils/index.js.map +1 -1
  113. package/dist/utils/same-page-hash-nav.d.ts +37 -0
  114. package/dist/utils/same-page-hash-nav.d.ts.map +1 -0
  115. package/dist/utils/source-icons.d.ts.map +1 -1
  116. package/package.json +1 -1
  117. package/src/components/chat/entity-cards/roadmap-card.tsx +8 -1
  118. package/src/components/docs/use-document-tree.ts +45 -3
  119. package/src/components/faq/faq-section.tsx +22 -9
  120. package/src/components/shared/delivery/delivery-lists.tsx +9 -0
  121. package/src/components/shared/delivery/delivery-row.tsx +15 -2
  122. package/src/components/shared/delivery/delivery-table.tsx +7 -1
  123. package/src/components/shared/roadmap/roadmap-grid.tsx +7 -0
  124. package/src/components/shared/roadmap/roadmap-view.tsx +11 -0
  125. package/src/components/tickets/help-center-card.tsx +9 -17
  126. package/src/components/tickets/help-center-list.tsx +13 -0
  127. package/src/components/tickets/ticket-center.tsx +2 -0
  128. package/src/components/tickets/ticket-row.tsx +7 -1
  129. package/src/hooks/index.ts +5 -0
  130. package/src/hooks/use-scroll-to-hash.ts +74 -0
  131. package/src/utils/.source-icons.md +1 -1
  132. package/src/utils/dev-sections/dev-section-param-keys.ts +14 -0
  133. package/src/utils/index.ts +17 -1
  134. package/src/utils/same-page-hash-nav.ts +115 -0
  135. package/src/utils/source-icons.ts +7 -1
  136. package/dist/chunk-5FK7X3EE.js.map +0 -1
  137. package/dist/chunk-5KD3S25X.cjs.map +0 -1
  138. package/dist/chunk-5PELVUFT.cjs.map +0 -1
  139. package/dist/chunk-6FHO73AP.js.map +0 -1
  140. package/dist/chunk-CDJOKNCS.cjs.map +0 -1
  141. package/dist/chunk-E2YXRSDG.js.map +0 -1
  142. package/dist/chunk-KBKZYJRI.cjs.map +0 -1
  143. package/dist/chunk-MDLWEJAV.cjs.map +0 -1
  144. package/dist/chunk-N6ZM5PYZ.js.map +0 -1
  145. package/dist/chunk-PZZGDS5I.cjs.map +0 -1
  146. package/dist/chunk-SLP4KXP6.js.map +0 -1
  147. package/dist/chunk-TYZEMPPH.js.map +0 -1
  148. package/dist/chunk-Z6BK4XHH.cjs.map +0 -1
  149. package/dist/chunk-ZHNL2IPK.cjs.map +0 -1
  150. /package/dist/{chunk-B2U6INNO.js.map → chunk-2U2M2TG2.js.map} +0 -0
  151. /package/dist/{chunk-CUQH4SHH.js.map → chunk-6GCI7JOE.js.map} +0 -0
  152. /package/dist/{chunk-DUIWR7RQ.js.map → chunk-EJXHZX2E.js.map} +0 -0
  153. /package/dist/{chunk-HTYUZXQP.js.map → chunk-FJDPUPXC.js.map} +0 -0
  154. /package/dist/{chunk-N45M3TK3.js.map → chunk-NSPOYUBH.js.map} +0 -0
  155. /package/dist/{chunk-IXDTNQF4.js.map → chunk-SOJCR63T.js.map} +0 -0
@@ -0,0 +1,37 @@
1
+ /** Pages with a section-nav STRIP on top of the global hub header
2
+ * (dev-center roadmap/delivery/tickets, FAQ category-pill nav).
3
+ * Anchor lands BELOW both layers. */
4
+ export declare const STICKY_HEADER_OFFSET_PX = 96;
5
+ /** Pages with only the global hub header (docs, blog, vendor detail).
6
+ * Anchor lands BELOW the header bar. */
7
+ export declare const HUB_HEADER_OFFSET_PX = 80;
8
+ /**
9
+ * Take only the FIRST hash segment from a fragment that may contain extra
10
+ * `#` characters. `'' → ''`, `'#a' → '#a'`, `'#a#b' → '#a'`.
11
+ *
12
+ * No real DOM id contains `#`, so a multi-fragment hash is always a bug at
13
+ * the composer site; `navigateSamePageHash` + `useScrollToHash` both call
14
+ * this so URL bar and `getElementById` stay in sync.
15
+ */
16
+ export declare function normalizeHashFragment(hash: string): string;
17
+ export interface NavigateSamePageHashOptions {
18
+ /** Pixels to subtract for sticky chrome. */
19
+ headerOffset?: number;
20
+ /** `'push'` (default) — new history entry; `'replace'` — overwrite
21
+ * current entry (use for TOC-style in-page navigators). */
22
+ history?: 'push' | 'replace';
23
+ }
24
+ /**
25
+ * Same-page hash navigation primitive: pushState + synthetic `hashchange`
26
+ * + anchoring-proof smooth scroll. Replaces `router.push` for hash CTAs
27
+ * (Next.js suppresses smooth-scroll during navigation; `router.push` on
28
+ * an exact-URL match is a no-op). Returns `true` when the helper claimed
29
+ * the nav (same pathname + search); `false` for cross-page targets so
30
+ * callers fall through to `router.push`.
31
+ *
32
+ * `target` accepts an origin-stripped path (`/x#anchor`) or a bare hash
33
+ * (`#anchor`); bare-hash callers don't need to reconstruct `pathname +
34
+ * search` themselves.
35
+ */
36
+ export declare function navigateSamePageHash(target: string, options?: NavigateSamePageHashOptions): boolean;
37
+ //# sourceMappingURL=same-page-hash-nav.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"same-page-hash-nav.d.ts","sourceRoot":"","sources":["../../src/utils/same-page-hash-nav.ts"],"names":[],"mappings":"AAEA;;sCAEsC;AACtC,eAAO,MAAM,uBAAuB,KAAK,CAAA;AAEzC;yCACyC;AACzC,eAAO,MAAM,oBAAoB,KAAK,CAAA;AAEtC;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAI1D;AAED,MAAM,WAAW,2BAA2B;IAC1C,4CAA4C;IAC5C,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;gEAC4D;IAC5D,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAC7B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,2BAAgC,GACxC,OAAO,CAkET"}
@@ -1 +1 @@
1
- {"version":3,"file":"source-icons.d.ts","sourceRoot":"","sources":["../../src/utils/source-icons.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,6EAA6E;AAC7E,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CA2CpD,CAAA;AAED;;6DAE6D;AAC7D,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAGxF;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,sBAAsB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CA2CzD,CAAA;AAED;;kEAEkE;AAClE,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEtD;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,iCAAiC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CA2CpE,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAEjF"}
1
+ {"version":3,"file":"source-icons.d.ts","sourceRoot":"","sources":["../../src/utils/source-icons.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,6EAA6E;AAC7E,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CA2CpD,CAAA;AAED;;6DAE6D;AAC7D,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAGxF;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,sBAAsB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAiDzD,CAAA;AAED;;kEAEkE;AAClE,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEtD;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,iCAAiC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CA2CpE,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAEjF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.292",
3
+ "version": "0.0.293",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -100,6 +100,12 @@ export interface RoadmapCardProps {
100
100
  cardType?: 'roadmap_item' | 'delivery_item' | 'internal_task'
101
101
  size?: CardSize
102
102
  className?: string
103
+ /** DOM `id` applied to the card's outer element. `RoadmapGrid` sets
104
+ * `roadmap-<external_id>` so chat-card deep-links
105
+ * (`?search=<id>#roadmap-<id>`) have a target for `useScrollToHash`
106
+ * to scroll to. `scroll-mt-24` on the outer element keeps the card
107
+ * BELOW the sticky chrome. */
108
+ id?: string
103
109
  // Default-branch vote controls (ignored in `sm`):
104
110
  userVote?: VoteType | null
105
111
  onVote?: (voteType: 'up' | 'down') => void
@@ -116,6 +122,7 @@ export function RoadmapCard({
116
122
  size = 'default',
117
123
  cardType = 'roadmap_item',
118
124
  className,
125
+ id,
119
126
  userVote,
120
127
  onVote,
121
128
  isVoting = false,
@@ -233,7 +240,7 @@ export function RoadmapCard({
233
240
  }
234
241
 
235
242
  return (
236
- <div className={`bg-ods-card border border-ods-border rounded-[6px] p-[24px] flex flex-col gap-[16px] hover:border-ods-accent transition-all h-full ${className ?? ''}`}>
243
+ <div id={id} className={`bg-ods-card border border-ods-border rounded-[6px] p-[24px] flex flex-col gap-[16px] hover:border-ods-accent transition-all h-full scroll-mt-24 ${className ?? ''}`}>
237
244
  <div className="flex gap-[16px] items-center w-full">
238
245
  <div className="w-16 h-16 rounded-lg flex items-center justify-center flex-shrink-0 bg-ods-bg border border-ods-border">
239
246
  {iconSrc ? (
@@ -10,11 +10,12 @@ import {
10
10
  } from '../../utils/doc-tree-nav'
11
11
  import { useDocNavigation } from './doc-navigation-context'
12
12
  import { scrollElementIntoView } from '../../utils/scroll-into-view'
13
+ import { navigateSamePageHash, HUB_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav'
13
14
 
14
15
  function scrollToContent() {
15
16
  const article = document.querySelector('article') as HTMLElement | null
16
17
  if (article) {
17
- scrollElementIntoView(article, { headerOffset: 80 })
18
+ scrollElementIntoView(article, { headerOffset: HUB_HEADER_OFFSET_PX })
18
19
  } else {
19
20
  window.scrollTo({ top: 0, behavior: 'smooth' })
20
21
  }
@@ -329,12 +330,52 @@ export function useDocumentTree(
329
330
  const anchor = hashIndex !== -1 ? path.substring(hashIndex) : ''
330
331
  const cleanPath = path.replace(/\/$/, '').split('#')[0]
331
332
 
333
+ // Same-doc-different-anchor shortcut. Content is already mounted, so we
334
+ // don't need the 300ms "wait-for-fetch" bandaid — the canonical helper
335
+ // owns pushState + synthetic `hashchange` (so any in-doc TOC / accordion
336
+ // bound to the URL hash re-renders) + the anchoring-proof tween in one
337
+ // sync call. `headerOffset: HUB_HEADER_OFFSET_PX` matches the cross-doc path below so
338
+ // anchors land BELOW the docs sticky header on every same-doc internal
339
+ // link click. Cross-doc nav (different cleanPath) falls through to the
340
+ // existing fetch-then-scroll path below.
341
+ //
342
+ // We pass the BARE-hash form to the helper rather than reconstructing
343
+ // a full `${normalizedBaseRoute}/${cleanPath}${anchor}` path: the
344
+ // helper's pathname check compares against `window.location.pathname`,
345
+ // which carries the FOLDER-INDEX-STRIPPED form (`/docs/foo` for
346
+ // `foo/README.md`, `/docs` for the root index). Handing it `cleanPath`
347
+ // — the raw resolved path — produces e.g. `/docs/foo/README.md` and
348
+ // the compare fails → helper returns false → silent dead-click. The
349
+ // bare-hash form sidesteps that entirely: the helper reconstructs
350
+ // `pathname + search + hash` from `window.location`, so the compare
351
+ // is trivially equal. Covers bare `#anchor` links (resolve to
352
+ // `cleanPath=''`) AND folder-index links (`foo/README.md` resolving
353
+ // to the current `/docs/foo`).
354
+ // Bare-hash internal links (`[Click](#section)`) come in as
355
+ // `path === '#section'`, so `cleanPath` becomes `''` and the naive
356
+ // strip-then-compare misses the same-doc shortcut on every NON-root
357
+ // doc (selectedPath is e.g. `'foo/bar'`, not `''`). For that case the
358
+ // current doc IS the same-doc target by definition — short-circuit
359
+ // pathForSelection to the current selection so the shortcut fires.
360
+ const pathForSelection =
361
+ anchor && options?.fromInternalLink && cleanPath === ''
362
+ ? selectedPathRef.current
363
+ : stripFolderIndexFromPath(cleanPath, folderIndexFile)
364
+ if (
365
+ anchor &&
366
+ options?.fromInternalLink &&
367
+ pathForSelection === selectedPathRef.current
368
+ ) {
369
+ navigateSamePageHash(anchor, { headerOffset: HUB_HEADER_OFFSET_PX })
370
+ return
371
+ }
372
+
332
373
  const scrollAfterNav = () => {
333
374
  if (anchor) {
334
375
  setTimeout(() => {
335
376
  const el = document.getElementById(anchor.substring(1))
336
377
  if (el) {
337
- scrollElementIntoView(el, { headerOffset: 80 })
378
+ scrollElementIntoView(el, { headerOffset: HUB_HEADER_OFFSET_PX })
338
379
  } else {
339
380
  scrollToContent()
340
381
  }
@@ -370,7 +411,8 @@ export function useDocumentTree(
370
411
  return
371
412
  }
372
413
 
373
- const pathForSelection = stripFolderIndexFromPath(cleanPath, folderIndexFile)
414
+ // `pathForSelection` was already computed above (inside the
415
+ // same-doc-anchor shortcut check); reuse it here for cross-doc nav.
374
416
  const urlPath = pathForSelection
375
417
 
376
418
  lastFetchedPath.current = null
@@ -7,6 +7,7 @@ import { useSelfFetch } from '../../hooks/use-self-fetch'
7
7
  import { buildSuggestionUrl } from '../../utils/suggestion-url'
8
8
  import { serializeJsonLd } from '../../utils/common'
9
9
  import { scrollElementIntoView } from '../../utils/scroll-into-view'
10
+ import { navigateSamePageHash, STICKY_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav'
10
11
  import { faqSectionSlug, faqItemAnchor, parseFaqHash, type FaqHashTarget } from '../../utils/faq-anchor'
11
12
  import { cn } from '../../utils/cn'
12
13
  import { buildFaqJsonLdFromFaqs, type FaqSchemaOptions } from './json-ld'
@@ -97,9 +98,6 @@ function groupFaqsBySection(faqs: Faq[]): FaqGroup[] {
97
98
  return groups
98
99
  }
99
100
 
100
- /** The standard hub sticky-header height — same offset `useNavLink`'s hash
101
- * scroll uses, so a category jump lands below the header, not under it. */
102
- const FAQ_NAV_HEADER_OFFSET = 96
103
101
 
104
102
  /** Map key for the uncategorized bucket — `group.slug` is null for it, so
105
103
  * every per-group map (default-open ids, accordion keys) uses this sentinel
@@ -160,7 +158,7 @@ function GroupedFaqList({
160
158
  }
161
159
  if (bestId) setActiveSlug(bestId)
162
160
  },
163
- { rootMargin: `-${FAQ_NAV_HEADER_OFFSET}px 0px -55% 0px`, threshold: 0 },
161
+ { rootMargin: `-${STICKY_HEADER_OFFSET_PX}px 0px -55% 0px`, threshold: 0 },
164
162
  )
165
163
  for (const group of navGroups) {
166
164
  const el = group.slug ? document.getElementById(group.slug) : null
@@ -215,18 +213,33 @@ function GroupedFaqList({
215
213
  const elId =
216
214
  hashTarget.kind === 'item' ? faqItemAnchor(hashTarget.rawId) : hashTarget.slug
217
215
  const el = document.getElementById(elId)
218
- if (el) scrollElementIntoView(el, { headerOffset: FAQ_NAV_HEADER_OFFSET })
216
+ if (el) scrollElementIntoView(el, { headerOffset: STICKY_HEADER_OFFSET_PX })
219
217
  if (hashTarget.kind === 'section') setActiveSlug(hashTarget.slug)
220
218
  }, [hashTarget])
221
219
 
220
+ // Category pill click. `navigateSamePageHash` owns the entire transition:
221
+ // replaceState → synthetic `hashchange` → `scrollElementIntoView` tween
222
+ // with `STICKY_HEADER_OFFSET_PX` so the section heading lands BELOW the
223
+ // sticky category nav on the FIRST tween (covers the same-target
224
+ // re-click case, where the `hashTarget` effect at L214 is a no-op
225
+ // because the state reference is equal). For DIFFERENT-target clicks
226
+ // the helper's synthetic `hashchange` re-fires that effect, which
227
+ // re-scrolls with the same offset and cancels this tween (singleton)
228
+ // — both paths land at the same position. The effect is still
229
+ // required for back/forward + direct URL edits, where the helper
230
+ // isn't in the call chain.
231
+ //
232
+ // `history: 'replace'` matches the pre-helper behavior: category pills
233
+ // are a TOC, not a navigation step, so the Back button leaves the
234
+ // FAQ page in one step regardless of how many categories the user
235
+ // clicked through.
222
236
  const handleJump = useCallback(
223
237
  (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
224
238
  e.preventDefault()
225
- setActiveSlug(slug)
226
- scrollElementIntoView(document.getElementById(slug), {
227
- headerOffset: FAQ_NAV_HEADER_OFFSET,
239
+ navigateSamePageHash('#' + slug, {
240
+ headerOffset: STICKY_HEADER_OFFSET_PX,
241
+ history: 'replace',
228
242
  })
229
- if (typeof history !== 'undefined') history.replaceState(null, '', `#${slug}`)
230
243
  },
231
244
  [],
232
245
  )
@@ -35,7 +35,9 @@ import { DeliveryTable } from './delivery-table';
35
35
  import { EmptyState } from '../../empty-state';
36
36
  import { LoadError } from '../../ui/error-state';
37
37
  import { DEV_SECTION_PARAM_KEYS } from '../../../utils/dev-sections/dev-section-param-keys';
38
+ import { STICKY_HEADER_OFFSET_PX } from '../../../utils/same-page-hash-nav';
38
39
  import { contentFetch } from '../../../utils/embed-content-fetch';
40
+ import { useScrollToHash } from '../../../hooks/use-scroll-to-hash';
39
41
 
40
42
  const DEFAULT_COMPLETED_ENDPOINT = '/api/delivery/completed';
41
43
  const DEFAULT_IN_PROGRESS_ENDPOINT = '/api/delivery/in-progress';
@@ -129,6 +131,13 @@ export function DeliveryLists({
129
131
  const filteredCompleted = data?.completed || [];
130
132
  const filteredInProgress = data?.inProgress || [];
131
133
 
134
+ // Deep-link hash dispatch — `?search=<id>#delivery-<id>` from a chat
135
+ // card or a linked-delivery card on a ticket. Shared hook owns the
136
+ // poll-until-mount + hashchange-listener wiring (same instance used
137
+ // by RoadmapView). 96 matches the sticky-header offset every
138
+ // hash-scroll surface in the app uses.
139
+ useScrollToHash(data, { headerOffset: STICKY_HEADER_OFFSET_PX });
140
+
132
141
  const showCompleted = true;
133
142
  const showInProgress = true;
134
143
 
@@ -60,6 +60,13 @@ export interface DeliveryRowProps {
60
60
  /** Small uppercase caption rendered above the title. Used by the
61
61
  * linked-delivery card variant ("LINKED DELIVERY"). */
62
62
  caption?: string
63
+ /** DOM `id` applied to the row's outer element. `DeliveryTable`
64
+ * always sets `delivery-<external_id>` so chat-card deep-links
65
+ * (`?search=<id>#delivery-<id>`) and the ticket linked-card path
66
+ * both have a target for `useScrollToHash` to scroll to. Always
67
+ * paired with `scroll-mt-24` on the outer element so the row lands
68
+ * BELOW the sticky chrome after the scroll. */
69
+ id?: string
63
70
  className?: string
64
71
  }
65
72
 
@@ -67,6 +74,7 @@ export function DeliveryRow({
67
74
  item,
68
75
  href,
69
76
  caption,
77
+ id,
70
78
  className,
71
79
  }: DeliveryRowProps) {
72
80
  const taskType = item.taskType as keyof typeof TASK_TYPE_LABELS
@@ -121,6 +129,11 @@ export function DeliveryRow({
121
129
 
122
130
  const baseClass = cn(
123
131
  'block p-[12px] md:p-[16px] no-underline text-inherit transition-colors duration-150',
132
+ // `scroll-mt-24` is paid for whether `id` is set or not (it's a
133
+ // single Tailwind utility, no runtime cost). Keeping it
134
+ // unconditional means a future caller adding `id` doesn't also
135
+ // have to remember to ask for the offset.
136
+ 'scroll-mt-24',
124
137
  href && 'hover:bg-ods-bg-hover cursor-pointer',
125
138
  className,
126
139
  )
@@ -133,11 +146,11 @@ export function DeliveryRow({
133
146
  // losing TanStack-Query state on back, leaving /tickets stuck on
134
147
  // its skeleton.
135
148
  return (
136
- <Link href={href} className={baseClass} prefetch={false}>
149
+ <Link href={href} id={id} className={baseClass} prefetch={false}>
137
150
  {inner}
138
151
  </Link>
139
152
  )
140
153
  }
141
154
 
142
- return <div className={baseClass}>{inner}</div>
155
+ return <div id={id} className={baseClass}>{inner}</div>
143
156
  }
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { DeliveryRow } from './delivery-row';
21
21
  import type { DeliveryItem } from '../../../types/delivery';
22
+ import { devSectionAnchorId } from '../../../utils/dev-sections/dev-section-param-keys';
22
23
 
23
24
  interface DeliveryTableProps {
24
25
  items: DeliveryItem[];
@@ -95,11 +96,16 @@ export function DeliveryTable({ items, isLoading = false }: DeliveryTableProps)
95
96
  <div className="bg-ods-card border border-ods-border rounded-[6px] overflow-hidden w-full">
96
97
  <div className="w-full">
97
98
  {items.map((item) => (
99
+ // DOM id lives on DeliveryRow's own outer element (no wrapper
100
+ // div). Anchor mirrors `buildDevSectionUrl('delivery', <id>)`
101
+ // → `#delivery-<external_id>`; `useScrollToHash` in
102
+ // `delivery-lists.tsx` finds the row by id and scrolls. The
103
+ // outer wrapper here ONLY exists for the row separators.
98
104
  <div
99
105
  key={item.id}
100
106
  className="border-b border-ods-border last:border-b-0"
101
107
  >
102
- <DeliveryRow item={item} />
108
+ <DeliveryRow item={item} id={devSectionAnchorId('delivery', item.id)} />
103
109
  </div>
104
110
  ))}
105
111
  </div>
@@ -33,6 +33,7 @@ import {
33
33
  AccordionContent,
34
34
  } from '../../ui';
35
35
  import { cn } from '../../../utils/cn';
36
+ import { devSectionAnchorId } from '../../../utils/dev-sections/dev-section-param-keys';
36
37
  import { contentFetch } from '../../../utils/embed-content-fetch';
37
38
  import type { RoadmapItem } from '../../chat/types/entities/roadmap-item';
38
39
 
@@ -134,9 +135,15 @@ function RoadmapGridSingle({
134
135
  return (
135
136
  <div className={`grid grid-cols-1 md:grid-cols-2 gap-6 ${showLeftMargin ? 'md:ml-[120px]' : ''}`}>
136
137
  {items.map((item) => (
138
+ // DOM id + sticky-header scroll offset live ON RoadmapCard's own
139
+ // outer element (no wrapper div). Anchor mirrors
140
+ // `buildDevSectionUrl('roadmap', <id>)` → `#roadmap-<external_id>`;
141
+ // `useScrollToHash` in `roadmap-view.tsx` finds the card by id
142
+ // and scrolls.
137
143
  <RoadmapCard
138
144
  key={item.id}
139
145
  item={item}
146
+ id={devSectionAnchorId('roadmap', item.id)}
140
147
  userVote={getVote(item.id)}
141
148
  onVote={(voteType) => onVote(item.id, voteType)}
142
149
  isVoting={votingTasks.has(item.id)}
@@ -15,11 +15,13 @@ import { useMemo } from 'react'
15
15
  import { useSearchParams } from '../../../embed-shims'
16
16
  import { LoadError } from '../../ui/error-state'
17
17
  import { useSelfFetch } from '../../../hooks/use-self-fetch'
18
+ import { useScrollToHash } from '../../../hooks/use-scroll-to-hash'
18
19
  import type { RoadmapItem } from '../../chat/types/entities/roadmap-item'
19
20
  import { RoadmapGrid } from './roadmap-grid'
20
21
  import { RoadmapGridSkeleton } from './roadmap-grid-skeleton'
21
22
  import type { UseRoadmapVotingOptions } from './use-roadmap-voting'
22
23
  import { DEV_SECTION_PARAM_KEYS } from '../../../utils/dev-sections/dev-section-param-keys'
24
+ import { STICKY_HEADER_OFFSET_PX } from '../../../utils/same-page-hash-nav'
23
25
 
24
26
  const DEFAULT_ENDPOINT = '/api/roadmap'
25
27
  // Defaults sourced from the ONE param-key registry the chrome (OPENFRAME_DEV_SECTIONS) also
@@ -77,6 +79,15 @@ export function RoadmapView({
77
79
  )
78
80
  const items = data?.items ?? []
79
81
 
82
+ // Deep-link hash dispatch — `?search=<id>#roadmap-<id>` from a chat card.
83
+ // Shared hook owns the poll-until-mount + hashchange-listener wiring
84
+ // (same instance used by DeliveryLists). The rAF poll inside the hook
85
+ // handles the Radix `<AccordionItem>` lazy-unmount: on first paint
86
+ // every quarter is collapsed; an effect in `roadmap-grid.tsx` expands
87
+ // them when `hasActiveFilters` is true (chat URL carries `?search=<id>`).
88
+ // The card mounts one tick after `data` lands; the hook waits.
89
+ useScrollToHash(data, { headerOffset: STICKY_HEADER_OFFSET_PX })
90
+
80
91
  if (error) {
81
92
  return <LoadError message="Failed to load roadmap." onRetry={reload} />
82
93
  }
@@ -19,6 +19,7 @@ import { useCallback, useEffect, useRef } from 'react'
19
19
  import { StatusBadge, type StatusBadgeProps } from '../ui'
20
20
  import { formatRelativeTime } from '../../utils/date-utils'
21
21
  import { scrollElementIntoView } from '../../utils/scroll-into-view'
22
+ import { STICKY_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav'
22
23
  import { getStatusColorScheme } from '../chat/utils/agent-status-message'
23
24
  import { DevCardRowContent } from '../shared/dev-section/dev-card-row'
24
25
  import {
@@ -28,23 +29,6 @@ import {
28
29
  import type { AnyTicket } from './types'
29
30
  import { isOptimistic } from './types'
30
31
 
31
- /** Sticky page-chrome offset, applied two ways from this ONE constant:
32
- *
33
- * 1. As `scrollMarginTop` inline style on the wrapper — so any
34
- * anchor-driven or `scrollIntoView()`-driven scroll (browser
35
- * `#hash` navigation, Tab-focus into the card) lands BELOW the
36
- * sticky header.
37
- * 2. As `headerOffset` passed to `scrollElementIntoView(...)` — for
38
- * the click-to-expand `window.scrollTo` path, which pre-computes
39
- * its target pixel and ignores CSS `scroll-margin-top`.
40
- *
41
- * Single source of truth: change 96 here and BOTH paths follow. The
42
- * previous code combined a `scroll-mt-24` (=96px) Tailwind class
43
- * with this constant — two declarations, one comment binding them,
44
- * drift hazard. Now there's nothing to keep in sync.
45
- */
46
- const STICKY_HEADER_OFFSET_PX = 96
47
-
48
32
  export interface HelpCenterCardProps {
49
33
  ticket: AnyTicket
50
34
  expanded: boolean
@@ -59,6 +43,12 @@ export interface HelpCenterCardProps {
59
43
  * (`HelpCenterList`) reads via `actions.replyErrorFor(external_id)`. */
60
44
  replyError?: TicketDetailDrawerProps['replyError']
61
45
  onClearReplyError?: TicketDetailDrawerProps['onClearReplyError']
46
+ /** DOM `id` applied to the row's outer element. Parent (`HelpCenterList`)
47
+ * sets `ticket-<external_id>` so `useScrollToHash` can deep-link from
48
+ * a chat card's `?ticket=<id>#ticket-<id>` URL. The sticky-header
49
+ * offset is already baked in via `STICKY_HEADER_OFFSET_PX` so the row
50
+ * lands BELOW the chrome regardless of whether `id` is set. */
51
+ id?: string
62
52
  }
63
53
 
64
54
  export function HelpCenterCard({
@@ -73,6 +63,7 @@ export function HelpCenterCard({
73
63
  onActionCollapsed,
74
64
  replyError,
75
65
  onClearReplyError,
66
+ id,
76
67
  }: HelpCenterCardProps) {
77
68
  const optimistic = isOptimistic(ticket)
78
69
  const rawStatus = (ticket.status ?? 'OPEN').toUpperCase()
@@ -149,6 +140,7 @@ export function HelpCenterCard({
149
140
  return (
150
141
  <div
151
142
  ref={rowRef}
143
+ id={id}
152
144
  style={{ scrollMarginTop: STICKY_HEADER_OFFSET_PX }}
153
145
  className={`border-b border-ods-border last:border-b-0 ${optimistic ? 'opacity-60' : ''}`}
154
146
  aria-busy={optimistic || undefined}
@@ -35,6 +35,9 @@ import { DevSectionPage } from '../shared/dev-section'
35
35
  import { DevCardRowSkeletonList } from '../shared/dev-section/dev-card-row'
36
36
  import { UnifiedPagination } from '../unified-pagination'
37
37
  import { useChatIdentity } from '../chat/hooks/use-chat-identity'
38
+ import { useScrollToHash } from '../../hooks/use-scroll-to-hash'
39
+ import { STICKY_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav'
40
+ import { devSectionAnchorId } from '../../utils/dev-sections/dev-section-param-keys'
38
41
  import { toast as defaultToast } from '../../hooks/use-toast'
39
42
  import { useTicketsList } from './hooks/use-tickets-list'
40
43
  import { useTicketActions } from './hooks/use-ticket-actions'
@@ -264,6 +267,15 @@ function HelpCenterListAuthed({
264
267
  )
265
268
 
266
269
  const merged: AnyTicket[] = [...optimisticTickets, ...tickets]
270
+
271
+ // Deep-link hash dispatch — `/tickets#ticket-<external_id>` from a
272
+ // chat card (or any other in-app link). The `?ticket=<external_id>`
273
+ // query param keeps owning drawer auto-open; this hook owns the
274
+ // scroll-to-row independently. Both can fire on the same URL
275
+ // (`/tickets?ticket=X#ticket-X`) — drawer opens AND row scrolls into
276
+ // view. Shared `useScrollToHash` polls until the row mounts (handles
277
+ // the SWR fetch race), uses the canonical `scrollElementIntoView` tween.
278
+ useScrollToHash(tickets, { headerOffset: STICKY_HEADER_OFFSET_PX })
267
279
  const hasActiveFilters = search !== '' || (status !== '' && status !== 'all')
268
280
  const hasResults = merged.length > 0
269
281
 
@@ -343,6 +355,7 @@ function HelpCenterListAuthed({
343
355
  {merged.map((ticket) => (
344
356
  <HelpCenterCard
345
357
  key={ticket.id}
358
+ id={devSectionAnchorId('ticket', ticket.external_id)}
346
359
  ticket={ticket}
347
360
  expanded={expandedTicketId === ticket.id}
348
361
  onToggle={toggleRow}
@@ -25,6 +25,7 @@ import { RefreshCw } from 'lucide-react'
25
25
  import { useChatIdentity } from '../chat/hooks/use-chat-identity'
26
26
  import { toast as defaultToast } from '../../hooks/use-toast'
27
27
  import { formatRelativeTime } from '../../utils/date-utils'
28
+ import { devSectionAnchorId } from '../../utils/dev-sections/dev-section-param-keys'
28
29
  import { TicketOpenForm } from './ticket-open-form'
29
30
  import { TicketRow } from './ticket-row'
30
31
  import { useTicketsList } from './hooks/use-tickets-list'
@@ -168,6 +169,7 @@ function TicketCenterAuthed({
168
169
  {merged.map((ticket) => (
169
170
  <TicketRow
170
171
  key={ticket.id}
172
+ id={devSectionAnchorId('ticket', ticket.external_id)}
171
173
  ticket={ticket}
172
174
  expanded={expandedTicketId === ticket.id}
173
175
  onToggle={toggleRow}
@@ -48,6 +48,11 @@ export interface TicketRowProps {
48
48
  /** Called after a successful close/reopen so the parent can collapse
49
49
  * the drawer (status flipped — current action set is now stale). */
50
50
  onActionCollapsed: TicketDetailDrawerProps['onActionCollapsed']
51
+ /** DOM `id` applied to the row's outer element. Parents that surface
52
+ * a deep-link target set `ticket-<external_id>` so `useScrollToHash`
53
+ * resolves it. `scroll-mt-24` is already baked into the outer
54
+ * element regardless. */
55
+ id?: string
51
56
  }
52
57
 
53
58
  export function TicketRow({
@@ -60,6 +65,7 @@ export function TicketRow({
60
65
  onClose,
61
66
  onReopen,
62
67
  onActionCollapsed,
68
+ id,
63
69
  }: TicketRowProps) {
64
70
  // Optimistic placeholders have no drawer — the real id hasn't
65
71
  // arrived yet, so action targets would be undefined.
@@ -123,7 +129,7 @@ export function TicketRow({
123
129
  }
124
130
 
125
131
  return (
126
- <div ref={rowRef} className="scroll-mt-24">
132
+ <div ref={rowRef} id={id} className="scroll-mt-24">
127
133
  <Collapsible
128
134
  open={expanded && !optimistic}
129
135
  className="border-b border-ods-border last:border-b-0"
@@ -28,6 +28,11 @@ export * from './use-access-code-integration'
28
28
  // OG placeholder URL builder hook (requires host-supplied URL builder)
29
29
  export * from './use-og-placeholder'
30
30
 
31
+ // Deep-link "scroll to URL hash" after data loads. Pairs with URL
32
+ // composers that emit `?<filter>=<id>#<prefix>-<id>` — the filter
33
+ // narrows the list, the hash scrolls the matching DOM id.
34
+ export * from './use-scroll-to-hash'
35
+
31
36
  // Invisible bot-protection client primitive (honeypot ref + submit-timing).
32
37
  // Pairs with the server-safe decision fn in `utils/humanity-signals`.
33
38
  export * from './use-humanity-signals'
@@ -0,0 +1,74 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+ import { scrollElementIntoView } from '../utils/scroll-into-view'
5
+ import { normalizeHashFragment } from '../utils/same-page-hash-nav'
6
+
7
+ /** ~1s at 60fps — long enough to outlast Radix accordion expand + SWR
8
+ * mount, short enough that a missed anchor doesn't hang the page. */
9
+ const MAX_POLL_FRAMES = 60
10
+
11
+ export interface UseScrollToHashOptions {
12
+ /** Pixels to subtract for sticky chrome. */
13
+ headerOffset?: number
14
+ }
15
+
16
+ /**
17
+ * Scroll the page to `window.location.hash` once `readyDep` resolves
18
+ * to a truthy value. Polls via rAF for ~1s so lazy-mounted rows (Radix
19
+ * accordion, SWR fetch) have time to render. Re-runs on `readyDep`
20
+ * reference change AND on `hashchange` (browser back/forward + the
21
+ * synthetic event `navigateSamePageHash` dispatches).
22
+ *
23
+ * Skipped when `readyDep == null || readyDep === false`. Default
24
+ * `true` makes the hook run on mount for pages whose target is in the
25
+ * initial SSR render.
26
+ */
27
+ export function useScrollToHash(
28
+ readyDep: unknown = true,
29
+ options?: UseScrollToHashOptions,
30
+ ): void {
31
+ const headerOffset = options?.headerOffset ?? 0
32
+ useEffect(() => {
33
+ if (typeof window === 'undefined') return
34
+ if (readyDep === null || readyDep === false) return
35
+ let rafId: number | null = null
36
+ const cancelPoll = () => {
37
+ if (rafId !== null) {
38
+ cancelAnimationFrame(rafId)
39
+ rafId = null
40
+ }
41
+ }
42
+ const tryScrollToHash = () => {
43
+ // `normalizeHashFragment` heals a malformed multi-fragment hash
44
+ // so `getElementById` resolves on deep-link entries that bypass
45
+ // `navigateSamePageHash`'s own normalize.
46
+ const hash = normalizeHashFragment(window.location.hash).slice(1)
47
+ if (!hash) return
48
+ // Cancel any in-flight poll from a prior invocation so two
49
+ // concurrent ticks can't both call scrollElementIntoView.
50
+ cancelPoll()
51
+ let frames = 0
52
+ const tick = () => {
53
+ const el = document.getElementById(hash)
54
+ if (el) {
55
+ rafId = null
56
+ scrollElementIntoView(el, { headerOffset })
57
+ return
58
+ }
59
+ if (frames++ < MAX_POLL_FRAMES) {
60
+ rafId = requestAnimationFrame(tick)
61
+ } else {
62
+ rafId = null
63
+ }
64
+ }
65
+ tick()
66
+ }
67
+ tryScrollToHash()
68
+ window.addEventListener('hashchange', tryScrollToHash)
69
+ return () => {
70
+ window.removeEventListener('hashchange', tryScrollToHash)
71
+ cancelPoll()
72
+ }
73
+ }, [readyDep, headerOffset])
74
+ }
@@ -27,7 +27,7 @@ const iconName = getSourceIconName('github-commits')
27
27
 
28
28
  // Get display label for a chip strip
29
29
  const label = getSourceLabel('hubspot-tickets-anon')
30
- // → 'Known Issues'
30
+ // → 'Tickets'
31
31
 
32
32
  // Map a documentType emitted by the LLM to its table ID
33
33
  const tableId = defaultTableIdForDocumentType('slack_message')
@@ -19,3 +19,17 @@ export const DEV_SECTION_PARAM_KEYS = {
19
19
  /** Delivery (bug-fix / enhancement) task-type filter. */
20
20
  deliveryTaskType: 'task_type',
21
21
  } as const
22
+
23
+ /** Section keys that participate in `<section>-<id>` anchor IDs.
24
+ * The URL composer (`appendSearchAndHash` in hub `dev-section-url.ts`)
25
+ * and the row components (`DeliveryRow`, `RoadmapCard`, `HelpCenterCard`)
26
+ * both call `devSectionAnchorId` so the DOM `id` and the URL hash stay
27
+ * in lockstep — adding a new section means adding the literal here
28
+ * ONCE, not at every render site. */
29
+ export type DevSectionAnchorKind = 'roadmap' | 'delivery' | 'ticket'
30
+
31
+ /** Compose the canonical `<section>-<id>` anchor id used by the dev-center
32
+ * rows + URL composer. */
33
+ export function devSectionAnchorId(section: DevSectionAnchorKind, id: string): string {
34
+ return `${section}-${id}`
35
+ }