@flamingo-stack/openframe-frontend-core 0.0.673 → 0.0.674-2431.7289.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/dist/{chunk-3UDBK2YG.cjs → chunk-4J7CFT6D.cjs} +57 -57
  2. package/dist/chunk-4J7CFT6D.cjs.map +1 -0
  3. package/dist/{chunk-L3KN6HLJ.cjs → chunk-4Q6P3VHS.cjs} +26 -26
  4. package/dist/{chunk-L3KN6HLJ.cjs.map → chunk-4Q6P3VHS.cjs.map} +1 -1
  5. package/dist/{chunk-A6WWJR7C.cjs → chunk-7F73YNDS.cjs} +7 -7
  6. package/dist/{chunk-A6WWJR7C.cjs.map → chunk-7F73YNDS.cjs.map} +1 -1
  7. package/dist/{chunk-GH236QRY.js → chunk-BQICLH4C.js} +7 -7
  8. package/dist/{chunk-FO26QOTY.cjs → chunk-CHKIZRUP.cjs} +39 -39
  9. package/dist/{chunk-FO26QOTY.cjs.map → chunk-CHKIZRUP.cjs.map} +1 -1
  10. package/dist/{chunk-RNERYM2P.cjs → chunk-EGQOPBXC.cjs} +5 -5
  11. package/dist/{chunk-RNERYM2P.cjs.map → chunk-EGQOPBXC.cjs.map} +1 -1
  12. package/dist/{chunk-4IXJYRVE.cjs → chunk-EJZV4QGU.cjs} +403 -403
  13. package/dist/{chunk-4IXJYRVE.cjs.map → chunk-EJZV4QGU.cjs.map} +1 -1
  14. package/dist/{chunk-MT44R3LJ.js → chunk-EPKQTPNJ.js} +6 -6
  15. package/dist/{chunk-EP7QPXSI.cjs → chunk-FUEAXTZE.cjs} +17 -17
  16. package/dist/{chunk-EP7QPXSI.cjs.map → chunk-FUEAXTZE.cjs.map} +1 -1
  17. package/dist/{chunk-YIPFHZHY.js → chunk-G7HMYIDN.js} +2 -2
  18. package/dist/{chunk-YTGB5CHX.js → chunk-GJTLUW2H.js} +4 -4
  19. package/dist/{chunk-Y2CXELYC.cjs → chunk-HN7KNVKL.cjs} +6 -5
  20. package/dist/chunk-HN7KNVKL.cjs.map +1 -0
  21. package/dist/{chunk-3T4GPKSB.js → chunk-IGKXYFCP.js} +2 -1
  22. package/dist/{chunk-3T4GPKSB.js.map → chunk-IGKXYFCP.js.map} +1 -1
  23. package/dist/{chunk-V2A4MAAL.cjs → chunk-ILAHSBZI.cjs} +3 -2
  24. package/dist/chunk-ILAHSBZI.cjs.map +1 -0
  25. package/dist/{chunk-6MGQOZPT.cjs → chunk-L7PO6NSD.cjs} +10 -10
  26. package/dist/{chunk-6MGQOZPT.cjs.map → chunk-L7PO6NSD.cjs.map} +1 -1
  27. package/dist/{chunk-2HEZSBUU.js → chunk-M76D6OJZ.js} +8 -8
  28. package/dist/{chunk-MENONRB4.js → chunk-MMOXFVQN.js} +3 -3
  29. package/dist/{chunk-PWRRSAO6.cjs → chunk-MRT6DZG5.cjs} +50 -50
  30. package/dist/{chunk-PWRRSAO6.cjs.map → chunk-MRT6DZG5.cjs.map} +1 -1
  31. package/dist/{chunk-O4KO4CT6.cjs → chunk-N6CI2E7D.cjs} +12 -12
  32. package/dist/{chunk-O4KO4CT6.cjs.map → chunk-N6CI2E7D.cjs.map} +1 -1
  33. package/dist/{chunk-2OYZ5INK.js → chunk-NRHEKFPG.js} +4 -4
  34. package/dist/{chunk-WJJA42FG.cjs → chunk-OAQROGFN.cjs} +80 -80
  35. package/dist/{chunk-WJJA42FG.cjs.map → chunk-OAQROGFN.cjs.map} +1 -1
  36. package/dist/{chunk-DKFWIJD7.cjs → chunk-PP3N5MN4.cjs} +3 -3
  37. package/dist/{chunk-DKFWIJD7.cjs.map → chunk-PP3N5MN4.cjs.map} +1 -1
  38. package/dist/{chunk-SEGLWILF.js → chunk-PXWMRXVI.js} +55 -26
  39. package/dist/{chunk-SEGLWILF.js.map → chunk-PXWMRXVI.js.map} +1 -1
  40. package/dist/{chunk-32JKIRN4.js → chunk-QO4RASGC.js} +4 -3
  41. package/dist/chunk-QO4RASGC.js.map +1 -0
  42. package/dist/{chunk-JYDAEBWM.cjs → chunk-QZMKKQQN.cjs} +20 -20
  43. package/dist/{chunk-JYDAEBWM.cjs.map → chunk-QZMKKQQN.cjs.map} +1 -1
  44. package/dist/{chunk-6EBW4HFR.cjs → chunk-RCLYHHB3.cjs} +12 -12
  45. package/dist/{chunk-6EBW4HFR.cjs.map → chunk-RCLYHHB3.cjs.map} +1 -1
  46. package/dist/{chunk-QPPDBX5E.js → chunk-RSOBME56.js} +5 -5
  47. package/dist/{chunk-2GF77XVC.js → chunk-TECYEWVQ.js} +5 -5
  48. package/dist/{chunk-MU5ORFBU.cjs → chunk-TWQWCOJ7.cjs} +22 -22
  49. package/dist/{chunk-MU5ORFBU.cjs.map → chunk-TWQWCOJ7.cjs.map} +1 -1
  50. package/dist/{chunk-5WG4GVJS.cjs → chunk-UIQQBZLU.cjs} +90 -61
  51. package/dist/chunk-UIQQBZLU.cjs.map +1 -0
  52. package/dist/{chunk-JFTWABV6.js → chunk-UYPICURQ.js} +2 -2
  53. package/dist/{chunk-JD4U7QMG.js → chunk-VQX6JGWJ.js} +6 -6
  54. package/dist/chunk-VQX6JGWJ.js.map +1 -0
  55. package/dist/{chunk-EEIN4JOR.js → chunk-XBNO42PI.js} +3 -3
  56. package/dist/{chunk-OO45SNKF.js → chunk-XYZQXHAX.js} +5 -5
  57. package/dist/{chunk-EUL7CPOO.js → chunk-YRNYKT4J.js} +2 -2
  58. package/dist/{chunk-FGQNWXIT.js → chunk-YVJDVVHY.js} +4 -4
  59. package/dist/components/case-studies/index.cjs +17 -17
  60. package/dist/components/case-studies/index.js +5 -5
  61. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  62. package/dist/components/chat/index.cjs +3 -3
  63. package/dist/components/chat/index.js +2 -2
  64. package/dist/components/contact/index.cjs +6 -6
  65. package/dist/components/contact/index.js +5 -5
  66. package/dist/components/docs/index.cjs +9 -9
  67. package/dist/components/docs/index.js +8 -8
  68. package/dist/components/docs/use-scroll-spy.d.ts +17 -5
  69. package/dist/components/docs/use-scroll-spy.d.ts.map +1 -1
  70. package/dist/components/embeds/index.cjs +6 -6
  71. package/dist/components/embeds/index.js +5 -5
  72. package/dist/components/faq/index.cjs +6 -6
  73. package/dist/components/faq/index.js +5 -5
  74. package/dist/components/features/index.cjs +5 -5
  75. package/dist/components/features/index.js +4 -4
  76. package/dist/components/help-center-pages/index.cjs +85 -86
  77. package/dist/components/help-center-pages/index.cjs.map +1 -1
  78. package/dist/components/help-center-pages/index.js +20 -21
  79. package/dist/components/help-center-pages/index.js.map +1 -1
  80. package/dist/components/help-center-pages/trust-center-page.d.ts.map +1 -1
  81. package/dist/components/index.cjs +45 -45
  82. package/dist/components/index.js +15 -15
  83. package/dist/components/meeting-scheduler/index.cjs +30 -30
  84. package/dist/components/meeting-scheduler/index.js +4 -4
  85. package/dist/components/navigation/index.cjs +8 -8
  86. package/dist/components/navigation/index.js +7 -7
  87. package/dist/components/navigation/sticky-section-nav.d.ts.map +1 -1
  88. package/dist/components/onboarding-guides/index.cjs +8 -8
  89. package/dist/components/onboarding-guides/index.js +7 -7
  90. package/dist/components/tickets/index.cjs +9 -9
  91. package/dist/components/tickets/index.js +8 -8
  92. package/dist/components/ui/index.cjs +5 -5
  93. package/dist/components/ui/index.js +4 -4
  94. package/dist/hooks/index.cjs +3 -3
  95. package/dist/hooks/index.js +2 -2
  96. package/dist/index.cjs +10 -10
  97. package/dist/index.js +8 -8
  98. package/dist/utils/index.cjs.map +1 -1
  99. package/dist/utils/index.js.map +1 -1
  100. package/dist/utils/scroll-into-view.d.ts +10 -0
  101. package/dist/utils/scroll-into-view.d.ts.map +1 -1
  102. package/package.json +1 -1
  103. package/src/components/chat/chat-message-enhanced.tsx +12 -3
  104. package/src/components/chat/chat-message-list.tsx +1 -1
  105. package/src/components/chat/entity-cards/dispatch.tsx +1 -1
  106. package/src/components/docs/__tests__/use-scroll-spy.test.tsx +163 -3
  107. package/src/components/docs/use-scroll-spy.ts +109 -37
  108. package/src/components/help-center-pages/__tests__/trust-center-page.test.tsx +5 -1
  109. package/src/components/help-center-pages/trust-center-page.tsx +15 -11
  110. package/src/components/navigation/sticky-section-nav.tsx +3 -0
  111. package/src/stories/TrustCenterPage.stories.tsx +38 -0
  112. package/src/utils/scroll-into-view.ts +7 -2
  113. package/dist/chunk-32JKIRN4.js.map +0 -1
  114. package/dist/chunk-3UDBK2YG.cjs.map +0 -1
  115. package/dist/chunk-5WG4GVJS.cjs.map +0 -1
  116. package/dist/chunk-JD4U7QMG.js.map +0 -1
  117. package/dist/chunk-V2A4MAAL.cjs.map +0 -1
  118. package/dist/chunk-Y2CXELYC.cjs.map +0 -1
  119. /package/dist/{chunk-GH236QRY.js.map → chunk-BQICLH4C.js.map} +0 -0
  120. /package/dist/{chunk-MT44R3LJ.js.map → chunk-EPKQTPNJ.js.map} +0 -0
  121. /package/dist/{chunk-YIPFHZHY.js.map → chunk-G7HMYIDN.js.map} +0 -0
  122. /package/dist/{chunk-YTGB5CHX.js.map → chunk-GJTLUW2H.js.map} +0 -0
  123. /package/dist/{chunk-2HEZSBUU.js.map → chunk-M76D6OJZ.js.map} +0 -0
  124. /package/dist/{chunk-MENONRB4.js.map → chunk-MMOXFVQN.js.map} +0 -0
  125. /package/dist/{chunk-2OYZ5INK.js.map → chunk-NRHEKFPG.js.map} +0 -0
  126. /package/dist/{chunk-QPPDBX5E.js.map → chunk-RSOBME56.js.map} +0 -0
  127. /package/dist/{chunk-2GF77XVC.js.map → chunk-TECYEWVQ.js.map} +0 -0
  128. /package/dist/{chunk-JFTWABV6.js.map → chunk-UYPICURQ.js.map} +0 -0
  129. /package/dist/{chunk-EEIN4JOR.js.map → chunk-XBNO42PI.js.map} +0 -0
  130. /package/dist/{chunk-OO45SNKF.js.map → chunk-XYZQXHAX.js.map} +0 -0
  131. /package/dist/{chunk-EUL7CPOO.js.map → chunk-YRNYKT4J.js.map} +0 -0
  132. /package/dist/{chunk-FGQNWXIT.js.map → chunk-YVJDVVHY.js.map} +0 -0
@@ -61,6 +61,16 @@ export interface ScrollElementIntoViewOptions {
61
61
  /** Tween duration in ms (smooth only). Default 320. */
62
62
  durationMs?: number;
63
63
  }
64
+ /** Nearest ancestor that is a *real* scroll container, or `null` when the
65
+ * window/document is the scroller. Only `auto | scroll | overlay` count —
66
+ * `clip` / `hidden` are intentionally excluded (a wrapper using `overflow-clip`
67
+ * purely to round corners must let the scroll bubble to the page).
68
+ *
69
+ * THE one rule for "what scrolls this element": `useScrollSpy` resolves the
70
+ * scroller it listens to with it, so the section a spy highlights is the
71
+ * section this helper scrolls to — inside a `<main overflow-y-auto>` shell as
72
+ * much as on a window-scrolled page. */
73
+ export declare function getScrollableAncestor(el: HTMLElement): HTMLElement | null;
64
74
  /**
65
75
  * Scroll the page so `target` lands at the top of the viewport (below sticky
66
76
  * chrome via `headerOffset`). SSR-safe; `null`/`undefined` target is a no-op so
@@ -1 +1 @@
1
- {"version":3,"file":"scroll-into-view.d.ts","sourceRoot":"","sources":["../../src/utils/scroll-into-view.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AAEH,MAAM,WAAW,4BAA4B;IAC3C;+EAC2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;oFACgF;IAChF,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B;;;6EAGyE;IACzE,aAAa,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IAC/C,uDAAuD;IACvD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAsCD;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,EACtC,OAAO,GAAE,4BAAiC,GACzC,IAAI,CA+EN"}
1
+ {"version":3,"file":"scroll-into-view.d.ts","sourceRoot":"","sources":["../../src/utils/scroll-into-view.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AAEH,MAAM,WAAW,4BAA4B;IAC3C;+EAC2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;oFACgF;IAChF,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B;;;6EAGyE;IACzE,aAAa,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IAC/C,uDAAuD;IACvD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAqBD;;;;;;;;yCAQyC;AACzC,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,WAAW,GAAG,WAAW,GAAG,IAAI,CAWzE;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,EACtC,OAAO,GAAE,4BAAiC,GACzC,IAAI,CA+EN"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.673",
3
+ "version": "0.0.674-2431.7289.1",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -542,8 +542,17 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
542
542
  Avatar is INLINE in the name row below (2025-2026 chat
543
543
  pattern — Claude.ai, ChatGPT, Gemini, Perplexity).
544
544
  Legacy hanging-avatar layout (`absolute -left-16`) wasted
545
- 64px of gutter and clipped in narrow panels. */}
546
- <div className="flex min-w-0 flex-col gap-[var(--spacing-system-xxs)]">
545
+ 64px of gutter and clipped in narrow panels.
546
+
547
+ Single-column GRID, not `flex flex-col` — here, in the segment
548
+ stack below, in the thread's per-message wrapper and in the
549
+ card/video pair (entity-cards/dispatch): WebKit re-lays-out the
550
+ children of every nested column flexbox to measure them, so the
551
+ cost compounds per level. One 9-level Mingo reply took ~155ms of
552
+ layout per drawer-resize frame in Safari / the desktop WKWebView
553
+ vs ~9ms as grid (Chrome: ~2ms either way). Same gap and stretch,
554
+ no margin collapsing — visually identical. */}
555
+ <div className="grid min-w-0 grid-cols-1 gap-[var(--spacing-system-xxs)]">
547
556
  {/* Avatar + Name + Timestamp Row.
548
557
  Sizing rationale (per design-token measurements):
549
558
  - Name uses `text-h3` = 14px mobile / 18px desktop.
@@ -604,7 +613,7 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
604
613
 
605
614
  {/* Message segments — hidden for system messages without content */}
606
615
  {(!isSystem || segments.length > 0) && (
607
- <div className="flex flex-col gap-2">
616
+ <div className="grid grid-cols-1 gap-2">
608
617
  {segments.map((segment, index) => {
609
618
  // The engine's streaming path (atomic-block memoization +
610
619
  // fence tail-completion + aria-live) applies ONLY to the
@@ -1017,7 +1017,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
1017
1017
  // thread without any stays byte-identical to before.
1018
1018
  if (!ownedContent) return row;
1019
1019
  return (
1020
- <div key={message.id} className="flex w-full min-w-0 flex-col">
1020
+ <div key={message.id} className="grid w-full min-w-0 grid-cols-1">
1021
1021
  {row}
1022
1022
  {ownedContent}
1023
1023
  </div>
@@ -2203,7 +2203,7 @@ export function ChatCardLoader({
2203
2203
  // Same 12px rhythm as the message renderer's block-sibling wrapper
2204
2204
  // (`my-3` in chat-message-enhanced) so card→player spacing matches
2205
2205
  // the spacing between any two hoisted blocks.
2206
- <div className="flex min-w-0 flex-col gap-3">
2206
+ <div className="grid min-w-0 grid-cols-1 gap-3">
2207
2207
  {finish(entry.render(item, finalChatRef, renderOpts))}
2208
2208
  <ChatVideoEntityCard chatRef={videoRef} />
2209
2209
  </div>
@@ -1,11 +1,21 @@
1
1
  import { act, renderHook } from '@testing-library/react';
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { scrollElementIntoView } from '../../../utils/scroll-into-view';
3
4
  import { useScrollSpy } from '../use-scroll-spy';
4
5
 
6
+ // The click's scroll is the shared tween; only its arguments matter here.
7
+ // `getScrollableAncestor` stays real — it is what the spy resolves its scroller with.
8
+ vi.mock('../../../utils/scroll-into-view', async importOriginal => ({
9
+ ...(await importOriginal()),
10
+ scrollElementIntoView: vi.fn(),
11
+ }));
12
+
5
13
  /**
6
- * `useScrollSpy` highlights the section whose DOCUMENT-absolute top has passed
7
- * the offset line (not `offsetTop`, which is relative to a positioned
8
- * ancestor), and the last section once the page is scrolled to the bottom.
14
+ * `useScrollSpy` highlights the section whose top — measured in the SCROLLER's
15
+ * own coordinates (not `offsetTop`, which is relative to a positioned
16
+ * ancestor) — has passed the offset line, and the last section once the
17
+ * scroller is at the bottom. The scroller is the window on a plain page, and
18
+ * the nearest scroll container when an app shell scrolls a `<main>` instead.
9
19
  */
10
20
 
11
21
  const TOPS: Record<string, number> = { a: 300, b: 900, c: 1500 };
@@ -107,3 +117,153 @@ describe('useScrollSpy — syncHash', () => {
107
117
  expect(replaceState).not.toHaveBeenCalled();
108
118
  });
109
119
  });
120
+
121
+ describe('useScrollSpy — a rail click', () => {
122
+ const sections = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
123
+
124
+ it('highlights the clicked section at once and scrolls it under the header offset', () => {
125
+ placeSections(0);
126
+ const { result } = renderHook(() => useScrollSpy(sections, { headerOffset: 96 }));
127
+ act(() => result.current.handleSectionClick('c'));
128
+ expect(result.current.activeSection).toBe('c');
129
+ expect(scrollElementIntoView).toHaveBeenCalledWith(created.get('c'), { headerOffset: 96 });
130
+ });
131
+
132
+ it('with syncHash, writes #section right away — replaceState, no history entry, no hashchange', () => {
133
+ const replaceState = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {});
134
+ const pushState = vi.spyOn(window.history, 'pushState');
135
+ const hashchange = vi.fn();
136
+ window.addEventListener('hashchange', hashchange);
137
+ placeSections(0);
138
+ const { result } = renderHook(() => useScrollSpy(sections, { syncHash: true }));
139
+ act(() => result.current.handleSectionClick('b'));
140
+ expect(replaceState).toHaveBeenCalledWith(null, '', '/#b');
141
+ expect(pushState).not.toHaveBeenCalled();
142
+ expect(hashchange).not.toHaveBeenCalled();
143
+ window.removeEventListener('hashchange', hashchange);
144
+ });
145
+
146
+ it('without syncHash, a click leaves the URL alone', () => {
147
+ const replaceState = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {});
148
+ placeSections(0);
149
+ const { result } = renderHook(() => useScrollSpy(sections));
150
+ act(() => result.current.handleSectionClick('b'));
151
+ expect(replaceState).not.toHaveBeenCalled();
152
+ });
153
+ });
154
+
155
+ describe('useScrollSpy — headerOffset', () => {
156
+ const sections = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
157
+
158
+ it('is the detection line too, so the highlight agrees with where a click lands', () => {
159
+ placeSections(0);
160
+ const { result } = renderHook(() => useScrollSpy(sections, { headerOffset: 50 }));
161
+ scrollTo(860); // 860 + 50 ≥ 900 → b
162
+ expect(result.current.activeSection).toBe('b');
163
+ scrollTo(840); // 890 < 900 → still a (the default 100 would already say b)
164
+ expect(result.current.activeSection).toBe('a');
165
+ });
166
+ });
167
+
168
+ /**
169
+ * An app shell that scrolls a fixed-height `<main overflow-y-auto>` instead of
170
+ * the window (OpenFrame's `AppLayout`): the window never scrolls and never
171
+ * emits `scroll`, so the spy must listen to the container and measure section
172
+ * tops relative to it — the shell's header sits ABOVE the container, so a
173
+ * viewport-relative top is off by that much.
174
+ */
175
+ describe('useScrollSpy — inside a scroll container', () => {
176
+ const sections = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
177
+ /** The shell header above `<main>`: where the container starts in the viewport. */
178
+ const CONTAINER_TOP = 64;
179
+ let container: HTMLElement;
180
+ let containerScrollTop = 0;
181
+
182
+ function placeInContainer(scrollTop: number) {
183
+ for (const [id, top] of Object.entries(TOPS)) {
184
+ const el = created.get(id);
185
+ if (!el) throw new Error(`section ${id} not mounted`);
186
+ // Viewport-relative top = container's viewport top + (offset inside the container − its scrollTop).
187
+ vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ top: CONTAINER_TOP + top - scrollTop } as DOMRect);
188
+ }
189
+ }
190
+
191
+ function mountContainer(scrollHeight = 5000) {
192
+ container = document.createElement('main');
193
+ container.style.overflowY = 'auto';
194
+ document.body.append(container);
195
+ containerScrollTop = 0;
196
+ // jsdom lays nothing out: give the container the geometry of a scrolling <main>.
197
+ Object.defineProperty(container, 'scrollHeight', { configurable: true, value: scrollHeight });
198
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 700 });
199
+ Object.defineProperty(container, 'scrollTop', {
200
+ configurable: true,
201
+ get: () => containerScrollTop,
202
+ set: (value: number) => {
203
+ containerScrollTop = value;
204
+ },
205
+ });
206
+ vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({ top: CONTAINER_TOP } as DOMRect);
207
+ for (const id of Object.keys(TOPS)) {
208
+ const el = document.createElement('section');
209
+ el.id = id;
210
+ container.append(el);
211
+ created.set(id, el);
212
+ }
213
+ placeInContainer(0);
214
+ // The window stays parked: a window-bound spy would read these and see nothing.
215
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: 0 });
216
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 });
217
+ Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 800 });
218
+ }
219
+
220
+ function scrollContainerTo(scrollTop: number) {
221
+ container.scrollTop = scrollTop;
222
+ placeInContainer(scrollTop);
223
+ act(() => {
224
+ container.dispatchEvent(new Event('scroll'));
225
+ vi.advanceTimersByTime(150);
226
+ });
227
+ }
228
+
229
+ afterEach(() => container.remove());
230
+
231
+ it('follows the container scroll, measuring section tops in the container coordinates', () => {
232
+ mountContainer();
233
+ const { result } = renderHook(() => useScrollSpy(sections));
234
+ expect(result.current.activeSection).toBe('a');
235
+ scrollContainerTo(850); // 850 + 100 ≥ 900 → b (viewport top 114 alone would have said a)
236
+ expect(result.current.activeSection).toBe('b');
237
+ scrollContainerTo(250); // 350 ≥ 300 → a
238
+ expect(result.current.activeSection).toBe('a');
239
+ });
240
+
241
+ it('ignores the window: a window scroll event changes nothing', () => {
242
+ mountContainer();
243
+ const { result } = renderHook(() => useScrollSpy(sections));
244
+ scrollContainerTo(850);
245
+ expect(result.current.activeSection).toBe('b');
246
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: 4000 });
247
+ act(() => {
248
+ window.dispatchEvent(new Event('scroll'));
249
+ vi.advanceTimersByTime(150);
250
+ });
251
+ expect(result.current.activeSection).toBe('b');
252
+ });
253
+
254
+ it('highlights the last section at the bottom of the container', () => {
255
+ mountContainer(2200);
256
+ const { result } = renderHook(() => useScrollSpy(sections));
257
+ scrollContainerTo(1500); // 700 + 1500 ≥ 2200 → bottom → c (its top, 1500, never passes 1600)
258
+ expect(result.current.activeSection).toBe('c');
259
+ });
260
+
261
+ it('syncHash follows the container scroll', () => {
262
+ const replaceState = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {});
263
+ mountContainer();
264
+ renderHook(() => useScrollSpy(sections, { syncHash: true }));
265
+ scrollContainerTo(850);
266
+ scrollContainerTo(50); // 150 < 300: above section a → cleared
267
+ expect(replaceState.mock.calls.map(call => call[2])).toEqual(['/#b', '/']);
268
+ });
269
+ });
@@ -1,14 +1,14 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
4
- import { scrollElementIntoView } from '../../utils/scroll-into-view';
5
-
6
- // Single source for the sticky-chrome height. Used for BOTH the scroll target
7
- // offset (where a clicked section lands) AND the active-section detection
8
- // threshold (where the scroll listener flips highlight). They must match —
9
- // previously 100 vs 150 caused a 50px window where the indicator jumped to
10
- // the next section even though that section's top was still below the
11
- // clicked one's resting offset.
4
+ import { getScrollableAncestor, scrollElementIntoView } from '../../utils/scroll-into-view';
5
+
6
+ // Default sticky-chrome height. Used for BOTH the scroll target offset (where a
7
+ // clicked section lands) AND the active-section detection threshold (where the
8
+ // scroll listener flips highlight). They must match — previously 100 vs 150
9
+ // caused a 50px window where the indicator jumped to the next section even
10
+ // though that section's top was still below the clicked one's resting offset.
11
+ // `headerOffset` overrides both at once, so they cannot drift apart.
12
12
  const SCROLL_OFFSET = 100;
13
13
 
14
14
  // Separator for the section-id key. A newline cannot occur inside an HTML id,
@@ -23,12 +23,19 @@ interface ScrollSpySection {
23
23
 
24
24
  export interface UseScrollSpyOptions {
25
25
  /**
26
- * Keep the URL's `#hash` on the section being read as the user SCROLLS
27
- * (`replaceState`: no history entry, and no `hashchange`, so
28
- * `useScrollToHash` never re-scrolls to it). Above the first section the hash
29
- * is cleared. Only a scroll writes it — mounting a page never adds a hash.
26
+ * Keep the URL's `#hash` on the section being read (`replaceState`: no
27
+ * history entry, and no `hashchange`, so `useScrollToHash` never re-scrolls
28
+ * to it) — as the user SCROLLS, and when a rail click scrolls for them.
29
+ * Above the first section the hash is cleared. Only a scroll or a click
30
+ * writes it — mounting a page never adds a hash.
30
31
  */
31
32
  syncHash?: boolean;
33
+ /**
34
+ * Sticky-chrome height in px: where a clicked section lands AND the line a
35
+ * section's top must pass to become the active one. One number for both, so
36
+ * the highlight agrees with where the click put the section. Default 100.
37
+ */
38
+ headerOffset?: number;
32
39
  }
33
40
 
34
41
  /** Point the URL's hash at `sectionId` (or clear it) without a history entry or a `hashchange`. */
@@ -39,6 +46,50 @@ function replaceHash(sectionId: string | null): void {
39
46
  window.history.replaceState(window.history.state, '', `${pathname}${search}${next}`);
40
47
  }
41
48
 
49
+ /**
50
+ * The thing that scrolls the sections: the nearest real scroll container
51
+ * around them, or the window. Reads are routed through it so the same
52
+ * arithmetic serves both — the window is just the scroller whose visible area
53
+ * starts at viewport y = 0.
54
+ */
55
+ interface Scroller {
56
+ target: HTMLElement | Window;
57
+ scrollTop: () => number;
58
+ /** Where the scroller's visible area starts, in viewport coordinates. */
59
+ viewportTop: () => number;
60
+ clientHeight: () => number;
61
+ scrollHeight: () => number;
62
+ }
63
+
64
+ /**
65
+ * Resolve the scroller from the first section, with the SAME rule
66
+ * `scrollElementIntoView` uses to pick where it scrolls (`getScrollableAncestor`).
67
+ * App shells that put page content in a fixed-height `<main overflow-y-auto>`
68
+ * (OpenFrame's `AppLayout`) never scroll the window: a window-bound spy there
69
+ * sees `scrollY` 0 forever and never gets a `scroll` event, so the first
70
+ * section stayed highlighted whatever was on screen. No such ancestor (a plain
71
+ * page, or the sections not rendered yet) → the window, as before.
72
+ */
73
+ function resolveScroller(firstSection: HTMLElement | null): Scroller {
74
+ const container = firstSection ? getScrollableAncestor(firstSection) : null;
75
+ if (container) {
76
+ return {
77
+ target: container,
78
+ scrollTop: () => container.scrollTop,
79
+ viewportTop: () => container.getBoundingClientRect().top,
80
+ clientHeight: () => container.clientHeight,
81
+ scrollHeight: () => container.scrollHeight,
82
+ };
83
+ }
84
+ return {
85
+ target: window,
86
+ scrollTop: () => window.scrollY,
87
+ viewportTop: () => 0,
88
+ clientHeight: () => window.innerHeight,
89
+ scrollHeight: () => document.documentElement.scrollHeight,
90
+ };
91
+ }
92
+
42
93
  interface UseScrollSpyReturn {
43
94
  activeSection: string;
44
95
  handleSectionClick: (sectionId: string) => void;
@@ -46,13 +97,18 @@ interface UseScrollSpyReturn {
46
97
 
47
98
  /**
48
99
  * Shared scroll spy hook for tracking active section based on scroll position.
49
- * Used by DocViewer for sticky section navigation.
100
+ * Used by DocViewer and TrustCenterPage for sticky section navigation.
101
+ *
102
+ * Listens to whatever actually scrolls the sections — the nearest scroll
103
+ * container, else the window (see `resolveScroller`) — and measures section
104
+ * tops in that scroller's own coordinates, which is exactly how
105
+ * `scrollElementIntoView` computes the target of a click.
50
106
  */
51
107
  export function useScrollSpy(
52
108
  sections: ScrollSpySection[] | undefined,
53
109
  options: UseScrollSpyOptions = {},
54
110
  ): UseScrollSpyReturn {
55
- const { syncHash = false } = options;
111
+ const { syncHash = false, headerOffset = SCROLL_OFFSET } = options;
56
112
  const [activeSection, setActiveSection] = useState('');
57
113
  const isScrollingFromClick = useRef(false);
58
114
 
@@ -63,46 +119,63 @@ export function useScrollSpy(
63
119
  // of the dependency array.
64
120
  const sectionIdsKey = useMemo(() => sections?.map(s => s.id).join(ID_SEPARATOR) ?? '', [sections]);
65
121
 
66
- const handleSectionClick = useCallback((sectionId: string) => {
67
- const targetElement = document.getElementById(sectionId);
68
- if (!targetElement) return;
122
+ const handleSectionClick = useCallback(
123
+ (sectionId: string) => {
124
+ const targetElement = document.getElementById(sectionId);
125
+ if (!targetElement) return;
69
126
 
70
- isScrollingFromClick.current = true;
71
- setActiveSection(sectionId);
127
+ isScrollingFromClick.current = true;
128
+ setActiveSection(sectionId);
129
+ // The click IS the reader's position now: say so in the URL right away
130
+ // rather than waiting for a scroll event the click guard below swallows.
131
+ if (syncHash) replaceHash(sectionId);
72
132
 
73
- scrollElementIntoView(targetElement, { headerOffset: SCROLL_OFFSET });
133
+ scrollElementIntoView(targetElement, { headerOffset });
74
134
 
75
- setTimeout(() => {
76
- isScrollingFromClick.current = false;
77
- }, 800);
78
- }, []);
135
+ setTimeout(() => {
136
+ isScrollingFromClick.current = false;
137
+ }, 800);
138
+ },
139
+ [syncHash, headerOffset],
140
+ );
79
141
 
80
142
  useEffect(() => {
81
143
  const sectionIds = sectionIdsKey === '' ? [] : sectionIdsKey.split(ID_SEPARATOR);
82
144
  if (sectionIds.length === 0) return undefined;
83
145
 
146
+ // Pick the scroller ONCE per section set, like `scrollElementIntoView` does
147
+ // per call: the sections and their container render together, so the
148
+ // effect sees the final layout.
149
+ const scroller = resolveScroller(document.getElementById(sectionIds[0] ?? ''));
150
+
84
151
  // `fromScroll`: a real scroll settled (not the mount-time pass).
85
152
  const handleScroll = (fromScroll = false) => {
86
153
  if (isScrollingFromClick.current) return;
87
154
 
88
- const scrollPosition = window.scrollY + SCROLL_OFFSET;
155
+ const scrollTop = scroller.scrollTop();
156
+ const viewportTop = scroller.viewportTop();
157
+ const scrollPosition = scrollTop + headerOffset;
158
+ // A section's top in the scroller's coordinates. `offsetTop` is relative
159
+ // to the nearest POSITIONED ancestor, which is neither the document nor
160
+ // the scroll container inside most layouts — measure from the viewport
161
+ // and translate.
162
+ const sectionTop = (element: HTMLElement) => element.getBoundingClientRect().top - viewportTop + scrollTop;
89
163
  let currentSection = sectionIds[0] ?? '';
90
164
 
91
165
  // At the bottom of the page the last sections can never reach the offset
92
166
  // line, so they would never highlight: the last one wins there. Only on a
93
167
  // page that actually SCROLLS — a short page is "at the bottom" at
94
- // scrollY 0, which would otherwise highlight the last section on load.
95
- const { scrollHeight } = document.documentElement;
96
- const scrollable = scrollHeight > window.innerHeight + 2;
97
- const atBottom = scrollable && window.innerHeight + window.scrollY >= scrollHeight - 2;
168
+ // scrollTop 0, which would otherwise highlight the last section on load.
169
+ const scrollHeight = scroller.scrollHeight();
170
+ const clientHeight = scroller.clientHeight();
171
+ const scrollable = scrollHeight > clientHeight + 2;
172
+ const atBottom = scrollable && clientHeight + scrollTop >= scrollHeight - 2;
98
173
  if (atBottom) {
99
174
  currentSection = sectionIds[sectionIds.length - 1] ?? currentSection;
100
175
  } else {
101
176
  for (let i = sectionIds.length - 1; i >= 0; i--) {
102
177
  const element = document.getElementById(sectionIds[i]);
103
- // Document-absolute top: `offsetTop` is relative to the nearest
104
- // POSITIONED ancestor, which is not the document inside most layouts.
105
- if (element && scrollPosition >= element.getBoundingClientRect().top + window.scrollY) {
178
+ if (element && scrollPosition >= sectionTop(element)) {
106
179
  currentSection = sectionIds[i];
107
180
  break;
108
181
  }
@@ -113,8 +186,7 @@ export function useScrollSpy(
113
186
 
114
187
  if (syncHash && fromScroll) {
115
188
  const first = document.getElementById(sectionIds[0] ?? '');
116
- const aboveFirst =
117
- !atBottom && first !== null && scrollPosition < first.getBoundingClientRect().top + window.scrollY;
189
+ const aboveFirst = !atBottom && first !== null && scrollPosition < sectionTop(first);
118
190
  replaceHash(aboveFirst ? null : currentSection);
119
191
  }
120
192
  };
@@ -125,14 +197,14 @@ export function useScrollSpy(
125
197
  scrollTimer = setTimeout(() => handleScroll(true), 100);
126
198
  };
127
199
 
128
- window.addEventListener('scroll', throttledScroll);
200
+ scroller.target.addEventListener('scroll', throttledScroll);
129
201
  handleScroll();
130
202
 
131
203
  return () => {
132
- window.removeEventListener('scroll', throttledScroll);
204
+ scroller.target.removeEventListener('scroll', throttledScroll);
133
205
  clearTimeout(scrollTimer);
134
206
  };
135
- }, [sectionIdsKey, syncHash]);
207
+ }, [sectionIdsKey, syncHash, headerOffset]);
136
208
 
137
209
  return { activeSection, handleSectionClick };
138
210
  }
@@ -397,7 +397,7 @@ describe('TrustCenterPage', () => {
397
397
  expect(subprocessors.queryByText('Location')).toBeNull();
398
398
  });
399
399
 
400
- it('anchors: a rail click puts #section in the URL through the shared same-page hash navigation (replace, not push)', () => {
400
+ it('anchors: a rail click highlights the section at once and puts #section in the URL (replace, not push)', () => {
401
401
  // The shared setup stubs `window.location` without an origin; give it one for this navigation.
402
402
  const stubbed = window.location;
403
403
  Object.defineProperty(window, 'location', {
@@ -417,6 +417,10 @@ describe('TrustCenterPage', () => {
417
417
  render(<TrustCenterPage initialData={makeData()} />);
418
418
  const rail = within(screen.getByRole('complementary', { name: 'Trust center sections' }));
419
419
  fireEvent.click(rail.getByRole('button', { name: 'Documents' }));
420
+ // The click IS the reader's position: the rail says so before any scroll
421
+ // event lands (inside a `<main overflow-y-auto>` shell none ever did).
422
+ expect(rail.getByRole('button', { name: 'Documents' })).toHaveAttribute('aria-current', 'location');
423
+ expect(rail.getByRole('button', { name: 'Compliance' })).not.toHaveAttribute('aria-current');
420
424
  expect(replaceState).toHaveBeenCalledWith(null, '', '/trust-center#documents');
421
425
  expect(pushState).not.toHaveBeenCalled();
422
426
  } finally {
@@ -43,7 +43,7 @@ import {
43
43
  type TrustCenterPublic,
44
44
  type TrustCenterSectionId,
45
45
  } from '../../types/trust-center';
46
- import { navigateSamePageHash, STICKY_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav';
46
+ import { STICKY_HEADER_OFFSET_PX } from '../../utils/same-page-hash-nav';
47
47
  import { useScrollSpy } from '../docs/use-scroll-spy';
48
48
  import { FaqSection } from '../faq/faq-section';
49
49
  import { PageShell } from '../layout/article-detail-layout';
@@ -178,16 +178,20 @@ export function TrustCenterPage({
178
178
  });
179
179
 
180
180
  const sections = useMemo(() => (data ? visibleSections(data) : []), [data]);
181
- // Anchors: the shared same-page hash navigation (the FAQ section's pattern).
182
- // A rail click puts `#section` in the URL (`replace`: the rail is a table of
183
- // contents, not a navigation step) and scrolls below the sticky header; a
184
- // visit that ARRIVES with `#section`, and back/forward, scroll there once the
185
- // sections have rendered. The scroll spy lights the rail and, as the reader
186
- // scrolls, keeps the URL's hash on the section in view (`syncHash`).
187
- const { activeSection } = useScrollSpy(sections, { syncHash: true });
188
- const handleSectionClick = useCallback((sectionId: string) => {
189
- navigateSamePageHash(`#${sectionId}`, { headerOffset: STICKY_HEADER_OFFSET_PX, history: 'replace' });
190
- }, []);
181
+ // Anchors. The scroll spy lights the rail from whatever scrolls the sections
182
+ // (the window, or a host shell's `<main overflow-y-auto>` — OpenFrame's
183
+ // `AppLayout`) and keeps the URL's hash on the section in view (`syncHash`;
184
+ // `replace`: the rail is a table of contents, not a navigation step). A rail
185
+ // click goes through the spy too: it highlights the section at once, writes
186
+ // `#section` and scrolls it below the sticky header — the SAME offset the
187
+ // spy's detection line uses, so the highlight agrees with where the section
188
+ // landed. A visit that ARRIVES with `#section`, and back/forward, scroll
189
+ // there once the sections have rendered (`hashchange`, which `replaceState`
190
+ // never fires — so a click never re-scrolls through that path).
191
+ const { activeSection, handleSectionClick } = useScrollSpy(sections, {
192
+ syncHash: true,
193
+ headerOffset: STICKY_HEADER_OFFSET_PX,
194
+ });
191
195
  useScrollToHash(sections.length > 0 ? sections.map(section => section.id).join('|') : null, {
192
196
  headerOffset: STICKY_HEADER_OFFSET_PX,
193
197
  });
@@ -66,6 +66,9 @@ export function StickySectionNav({
66
66
  {/* Navigation button */}
67
67
  <button
68
68
  onClick={() => onSectionClick(section.id)}
69
+ // The ribbon is the visual; this is the same state for assistive
70
+ // tech (a table of contents marks the reader's current location).
71
+ aria-current={activeSection === section.id ? 'location' : undefined}
69
72
  className="relative flex flex-1 cursor-pointer items-center gap-2 px-3 py-2"
70
73
  >
71
74
  <span
@@ -1,9 +1,12 @@
1
1
  import type { Meta, StoryObj } from '@storybook/nextjs-vite';
2
+ import { fn } from 'storybook/test';
2
3
  import {
3
4
  TRUST_CENTER_FIXTURE_FAQ,
4
5
  makeTrustCenterData,
5
6
  } from '../components/help-center-pages/__fixtures__/trust-center';
6
7
  import { TrustCenterPage } from '../components/help-center-pages/trust-center-page';
8
+ import { ChartDonutIcon, QuestionCircleIcon } from '../components/icons-v2-generated';
9
+ import { AppLayout } from '../components/navigation/app-layout';
7
10
 
8
11
  // Stories pass `initialData`, so the page never fetches. The shared fixture
9
12
  // computes its instants when called, so the "monitored" story stays inside its window.
@@ -93,6 +96,41 @@ export const NotConnected: Story = {
93
96
  },
94
97
  };
95
98
 
99
+ /**
100
+ * Embedded in an app shell (OpenFrame's `AppLayout`), where the page content
101
+ * scrolls inside a fixed-height `<main overflow-y-auto>` and the window never
102
+ * scrolls. The section rail must still follow the reader: the scroll spy
103
+ * listens to that container, not the window.
104
+ */
105
+ export const InAppShell: Story = {
106
+ args: { shell: false, backButton: { label: 'Back to Help Center', href: '/help-center' } },
107
+ render: args => (
108
+ <AppLayout
109
+ sidebarConfig={{
110
+ items: [
111
+ { id: 'dashboard', label: 'Dashboard', icon: <ChartDonutIcon size={24} />, path: '/dashboard' },
112
+ {
113
+ id: 'help-center',
114
+ label: 'Help Center',
115
+ icon: <QuestionCircleIcon size={24} />,
116
+ path: '/help-center',
117
+ section: 'secondary',
118
+ isActive: true,
119
+ },
120
+ ],
121
+ onNavigate: fn(),
122
+ onToggleMinimized: fn(),
123
+ }}
124
+ headerProps={{ showUser: true, userName: 'Alex Developer', userEmail: 'alex@openframe.dev' }}
125
+ mobileBurgerMenuProps={{ user: { userName: 'Alex Developer', userEmail: 'alex@openframe.dev' } }}
126
+ >
127
+ <div className="page-shell-content">
128
+ <TrustCenterPage {...args} />
129
+ </div>
130
+ </AppLayout>
131
+ ),
132
+ };
133
+
96
134
  /** Certified framework with a published percent (progress ring + report period). */
97
135
  export const CertifiedWithPercent: Story = {
98
136
  args: {
@@ -85,8 +85,13 @@ const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3);
85
85
  /** Nearest ancestor that is a *real* scroll container, or `null` when the
86
86
  * window/document is the scroller. Only `auto | scroll | overlay` count —
87
87
  * `clip` / `hidden` are intentionally excluded (a wrapper using `overflow-clip`
88
- * purely to round corners must let the scroll bubble to the page). */
89
- function getScrollableAncestor(el: HTMLElement): HTMLElement | null {
88
+ * purely to round corners must let the scroll bubble to the page).
89
+ *
90
+ * THE one rule for "what scrolls this element": `useScrollSpy` resolves the
91
+ * scroller it listens to with it, so the section a spy highlights is the
92
+ * section this helper scrolls to — inside a `<main overflow-y-auto>` shell as
93
+ * much as on a window-scrolled page. */
94
+ export function getScrollableAncestor(el: HTMLElement): HTMLElement | null {
90
95
  for (let node = el.parentElement; node; node = node.parentElement) {
91
96
  const overflowY = getComputedStyle(node).overflowY;
92
97
  if (