@flamingo-stack/openframe-frontend-core 0.0.772 → 0.0.774

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 (133) hide show
  1. package/dist/build/export-map.json +1 -1
  2. package/dist/{chunk-ZVKJKWAB.cjs → chunk-4JZC624S.cjs} +226 -206
  3. package/dist/{chunk-JJNOAYD4.cjs → chunk-5AOKXXHV.cjs} +4 -4
  4. package/dist/{chunk-VZAOLKKF.cjs → chunk-5Z3XL2VU.cjs} +72 -72
  5. package/dist/{chunk-YI3CDGKM.cjs → chunk-BTVO676B.cjs} +148 -131
  6. package/dist/{chunk-TZ4GKUO2.cjs → chunk-CZIMISAR.cjs} +5 -1
  7. package/dist/{chunk-7FYOVKQD.cjs → chunk-E5E7IE7X.cjs} +12 -12
  8. package/dist/{chunk-XVFM6SJA.cjs → chunk-E64HPPRH.cjs} +31 -31
  9. package/dist/{chunk-CTRJNTYU.cjs → chunk-EQBJHJS7.cjs} +2 -2
  10. package/dist/{chunk-S3NEBAKD.cjs → chunk-HTSRAQVG.cjs} +4 -4
  11. package/dist/{chunk-FT5NPAZY.cjs → chunk-IQQ3SLUK.cjs} +19 -19
  12. package/dist/{chunk-RNLRS4ID.cjs → chunk-KKAY6GPU.cjs} +2 -2
  13. package/dist/{chunk-ILZEXCJH.cjs → chunk-KWZ62LPZ.cjs} +124 -104
  14. package/dist/{chunk-REFL3YNE.cjs → chunk-L5CKY5WA.cjs} +15 -15
  15. package/dist/{chunk-IHCHU355.cjs → chunk-LEVA5WO3.cjs} +39 -39
  16. package/dist/{chunk-CW3ML76U.cjs → chunk-NTPX5GXI.cjs} +4 -0
  17. package/dist/{chunk-J5ZNEAGY.cjs → chunk-NX7P2T2U.cjs} +15 -15
  18. package/dist/{chunk-E25TJPWV.cjs → chunk-Q6B2DC65.cjs} +3 -3
  19. package/dist/{chunk-QOEOY7BJ.cjs → chunk-Q7EXOVMT.cjs} +31 -31
  20. package/dist/{chunk-ZYGC2A7G.cjs → chunk-SWEPHSIP.cjs} +4 -4
  21. package/dist/{chunk-KSCEAIBS.cjs → chunk-TOPGBOCN.cjs} +2 -2
  22. package/dist/{chunk-W5LX7DFB.cjs → chunk-TTQHSSHF.cjs} +6 -6
  23. package/dist/{chunk-FPCGKH5K.cjs → chunk-TUXPFF4P.cjs} +31 -29
  24. package/dist/{chunk-QPK2433W.cjs → chunk-U332FL4C.cjs} +22 -22
  25. package/dist/{chunk-2MAENZL3.cjs → chunk-UAKXZ2KD.cjs} +47 -45
  26. package/dist/{chunk-HFNWBNLE.cjs → chunk-USMR73ND.cjs} +4 -4
  27. package/dist/{chunk-X5Z4HRH7.cjs → chunk-VISK6LKE.cjs} +2 -2
  28. package/dist/{chunk-UR3I3J5K.cjs → chunk-VWUQVJ2D.cjs} +81 -81
  29. package/dist/{chunk-5V7BIUGA.cjs → chunk-Y3EBUFX4.cjs} +92 -21
  30. package/dist/{chunk-5HBGBLJT.cjs → chunk-ZHGOXDA3.cjs} +63 -59
  31. package/dist/{cloud-tenants-CUNC5KBD.cjs → cloud-tenants-7RNZ6GHM.cjs} +11 -11
  32. package/dist/components/case-studies/index.cjs +28 -28
  33. package/dist/components/chat/ask-prompts.d.ts.map +1 -1
  34. package/dist/components/chat/ask-prompts.js +28 -19
  35. package/dist/components/chat/embeddable-chat.d.ts +3 -3
  36. package/dist/components/chat/embeddable-chat.js +7 -17
  37. package/dist/components/chat/entity-cards/entity-portrait-card.d.ts +3 -1
  38. package/dist/components/chat/entity-cards/entity-portrait-card.d.ts.map +1 -1
  39. package/dist/components/chat/entity-cards/entity-portrait-card.js +2 -0
  40. package/dist/components/chat/entity-cards/program-card.d.ts +6 -1
  41. package/dist/components/chat/entity-cards/program-card.d.ts.map +1 -1
  42. package/dist/components/chat/entity-cards/program-card.js +19 -2
  43. package/dist/components/chat/hooks/use-what-to-ask.d.ts +2 -2
  44. package/dist/components/chat/index.cjs +10 -8
  45. package/dist/components/chat/index.d.ts +1 -1
  46. package/dist/components/chat/index.d.ts.map +1 -1
  47. package/dist/components/chat/index.js +10 -4
  48. package/dist/components/chat/utils/ask-ai-events.d.ts +10 -0
  49. package/dist/components/chat/utils/ask-ai-events.d.ts.map +1 -1
  50. package/dist/components/chat/utils/ask-ai-events.js +21 -1
  51. package/dist/components/contact/index.cjs +12 -12
  52. package/dist/components/docs/index.cjs +15 -15
  53. package/dist/components/embeds/index.cjs +12 -12
  54. package/dist/components/faq/index.cjs +13 -13
  55. package/dist/components/features/index.cjs +11 -11
  56. package/dist/components/features/video-hover-preview.d.ts.map +1 -1
  57. package/dist/components/features/video-hover-preview.js +5 -2
  58. package/dist/components/features/video-shared.d.ts +2 -1
  59. package/dist/components/features/video-shared.d.ts.map +1 -1
  60. package/dist/components/features/video.d.ts.map +1 -1
  61. package/dist/components/features/video.js +64 -13
  62. package/dist/components/help-center-pages/index.cjs +100 -100
  63. package/dist/components/index.cjs +63 -61
  64. package/dist/components/meeting-scheduler/index.cjs +46 -46
  65. package/dist/components/navigation/index.cjs +14 -14
  66. package/dist/components/navigation/mingo-ai-button.d.ts.map +1 -1
  67. package/dist/components/navigation/mingo-ai-button.js +2 -1
  68. package/dist/components/navigation/site-header.d.ts.map +1 -1
  69. package/dist/components/navigation/site-header.js +3 -1
  70. package/dist/components/onboarding-guides/index.cjs +14 -14
  71. package/dist/components/product-demo/index.cjs +20 -20
  72. package/dist/components/related-content/index.cjs +7 -6
  73. package/dist/components/related-content/index.d.ts +2 -1
  74. package/dist/components/related-content/index.d.ts.map +1 -1
  75. package/dist/components/related-content/index.js +2 -2
  76. package/dist/components/related-content/related-content-section.d.ts +8 -10
  77. package/dist/components/related-content/related-content-section.d.ts.map +1 -1
  78. package/dist/components/related-content/related-content-section.js +11 -7
  79. package/dist/components/tickets/index.cjs +17 -17
  80. package/dist/components/ui/index.cjs +13 -11
  81. package/dist/components/ui/photo-wall.d.ts +7 -0
  82. package/dist/components/ui/photo-wall.d.ts.map +1 -1
  83. package/dist/components/ui/photo-wall.js +16 -2
  84. package/dist/components/ui/tab-navigation.d.ts +8 -1
  85. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  86. package/dist/components/ui/tab-navigation.js +9 -3
  87. package/dist/{devices-TO4VUO7F.cjs → devices-NNDBC6IH.cjs} +12 -12
  88. package/dist/{embeddable-chat-CEAP424I.cjs → embeddable-chat-DI7ZZTBP.cjs} +82 -92
  89. package/dist/hooks/index.cjs +4 -4
  90. package/dist/hooks/use-near-viewport.d.ts.map +1 -1
  91. package/dist/hooks/use-near-viewport.js +4 -0
  92. package/dist/index.cjs +23 -15
  93. package/dist/{knowledge-CYJP4XYY.cjs → knowledge-DPUMWQED.cjs} +12 -12
  94. package/dist/{logs-NL3ZBQKC.cjs → logs-EE25MWYM.cjs} +19 -19
  95. package/dist/{policies-WTUV4EIT.cjs → policies-H5LPZWEM.cjs} +15 -15
  96. package/dist/{scripts-EE6MLBQP.cjs → scripts-YGO4LOES.cjs} +16 -16
  97. package/dist/{software-update-HX6PN4SB.cjs → software-update-5BEB62ZH.cjs} +14 -14
  98. package/dist/utils/content-ref-groups.d.ts +8 -0
  99. package/dist/utils/content-ref-groups.d.ts.map +1 -1
  100. package/dist/utils/content-ref-groups.js +2 -0
  101. package/dist/utils/dismiss-cookie.d.ts +14 -0
  102. package/dist/utils/dismiss-cookie.d.ts.map +1 -1
  103. package/dist/utils/dismiss-cookie.js +4 -0
  104. package/dist/utils/index.cjs +10 -0
  105. package/dist/utils/index.d.ts +1 -1
  106. package/dist/utils/index.d.ts.map +1 -1
  107. package/dist/utils/index.js +2 -0
  108. package/dist/utils/visitor-os.d.ts +9 -0
  109. package/dist/utils/visitor-os.d.ts.map +1 -1
  110. package/dist/utils/visitor-os.js +4 -0
  111. package/dist/{video-file-player-COXRF4M5.cjs → video-file-player-4TXA7BZF.cjs} +3 -3
  112. package/package.json +1 -1
  113. package/src/components/chat/ask-prompts.tsx +6 -1
  114. package/src/components/chat/embeddable-chat.tsx +15 -28
  115. package/src/components/chat/entity-cards/entity-portrait-card.tsx +4 -0
  116. package/src/components/chat/entity-cards/program-card.tsx +24 -2
  117. package/src/components/chat/hooks/use-what-to-ask.ts +2 -2
  118. package/src/components/chat/index.ts +16 -9
  119. package/src/components/chat/utils/ask-ai-events.ts +26 -0
  120. package/src/components/features/video-hover-preview.tsx +46 -37
  121. package/src/components/features/video-shared.tsx +2 -1
  122. package/src/components/features/video.tsx +102 -19
  123. package/src/components/navigation/mingo-ai-button.tsx +2 -1
  124. package/src/components/navigation/site-header.tsx +3 -1
  125. package/src/components/related-content/index.ts +2 -1
  126. package/src/components/related-content/related-content-section.tsx +26 -12
  127. package/src/components/ui/photo-wall.tsx +24 -2
  128. package/src/components/ui/tab-navigation.tsx +18 -3
  129. package/src/hooks/use-near-viewport.ts +6 -0
  130. package/src/utils/content-ref-groups.ts +9 -0
  131. package/src/utils/dismiss-cookie.ts +17 -0
  132. package/src/utils/index.ts +1 -0
  133. package/src/utils/visitor-os.ts +12 -0
@@ -32,6 +32,7 @@
32
32
  import type React from 'react';
33
33
  import {
34
34
  createElement,
35
+ forwardRef,
35
36
  useCallback,
36
37
  useEffect,
37
38
  useMemo,
@@ -40,10 +41,12 @@ import {
40
41
  useSyncExternalStore,
41
42
  type ComponentType,
42
43
  } from 'react';
44
+ import Image from '../../embed-shims/next-image';
43
45
  import { useNearViewport } from '../../hooks/use-near-viewport';
44
46
  import { cn } from '../../utils/cn';
45
47
  import { fetchPriorityProp } from '../../utils/fetch-priority';
46
48
  import { UnifiedSkeleton } from '../loading/unified-skeleton';
49
+ import { Button } from '../ui/button/button';
47
50
  import { useLoadingScope } from '../ui/loading-scope';
48
51
  import { VideoPlayBadge } from './video-center-badge';
49
52
  import type { FilePlayerProps } from './video-file-player';
@@ -97,26 +100,105 @@ function subscribeToFilePlayer(listener: () => void): () => void {
97
100
  const loadedFilePlayer = () => filePlayer;
98
101
  const noFilePlayer = () => null;
99
102
 
100
- /** The player's box while its module loads: the poster at the player's own size, so nothing moves. */
101
- function FilePlayerFallback({ poster, className }: { poster?: string | null; className?: string }) {
103
+ /**
104
+ * The video's box with its poster in it, until the player is drawn. The poster is a real image, not a CSS background:
105
+ * a background is found only after layout and is never ranked, so on a page whose video is the largest thing painted
106
+ * the poster arrived last (CU-17tkuw5xp4q). A `priority` video's poster is fetched at once and ranked high.
107
+ */
108
+ const PosterBox = forwardRef<
109
+ HTMLDivElement,
110
+ { poster?: string | null; className?: string; priority?: boolean; fill?: boolean }
111
+ >(function PosterBoxView({ poster, className, priority, fill }, ref) {
102
112
  return (
103
113
  <div
114
+ ref={ref}
104
115
  aria-hidden="true"
105
- className={className}
106
- style={{
107
- width: '100%',
108
- height: '100%',
109
- ...(poster ? { backgroundImage: `url(${poster})`, backgroundSize: 'cover', backgroundPosition: 'center' } : {}),
110
- }}
111
- />
116
+ className={cn('relative overflow-hidden', className)}
117
+ style={fill ? { width: '100%', height: '100%' } : undefined}
118
+ >
119
+ {poster ? (
120
+ <Image src={poster} alt="" fill sizes="100vw" priority={priority} unoptimized className="object-cover" />
121
+ ) : null}
122
+ </div>
112
123
  );
124
+ });
125
+
126
+ /** The player's box while its module loads: the poster at the player's own size, so nothing moves. */
127
+ function FilePlayerFallback({
128
+ poster,
129
+ className,
130
+ priority,
131
+ }: {
132
+ poster?: string | null;
133
+ className?: string;
134
+ priority?: boolean;
135
+ }) {
136
+ return <PosterBox poster={poster} className={className} priority={priority} fill />;
113
137
  }
114
138
 
115
- function FilePlayer(props: FilePlayerProps): React.ReactElement {
139
+ /**
140
+ * A video nobody has asked to play yet starts as its poster and a play button, the way a YouTube video does
141
+ * (`YouTubeFacade`): the player and hls.js are the largest script a page can load, and on a page whose video waits for
142
+ * a click (a session, an episode, a release) loading them up front cost every visitor a long main-thread task before
143
+ * anyone pressed play (CU-17tkuw5xp4q). A pointer, focus or touch on the button starts the download, so the player is
144
+ * usually there by the click, and the click plays it with sound. A video that plays by itself (autoplay, hover
145
+ * playback, a resumed position, chrome-less) or whose host drives it through a handle mounts its player as before.
146
+ */
147
+ function waitsForPlay(props: FilePlayerProps): boolean {
148
+ return (
149
+ !props.autoPlay &&
150
+ !props.autoPlayUnmuted &&
151
+ !props.playOnHover &&
152
+ props.playWhenHovered === undefined &&
153
+ props.startTime === undefined &&
154
+ !props.playerHandleRef &&
155
+ !props.chromeless
156
+ );
157
+ }
158
+
159
+ function FilePlayer({
160
+ title,
161
+ priority,
162
+ ...props
163
+ }: FilePlayerProps & { title?: string; priority?: boolean }): React.ReactElement {
164
+ // Decided once: a video that started as a facade stays the player once pressed, whatever its props do later.
165
+ const [facade] = useState(() => waitsForPlay(props));
166
+ const [pressed, setPressed] = useState(false);
167
+ if (facade && !pressed) {
168
+ return (
169
+ <div className={cn('relative', props.className)} style={{ width: '100%', height: '100%' }}>
170
+ <FilePlayerFallback poster={props.poster} className="absolute inset-0" priority={priority} />
171
+ <Button
172
+ type="button"
173
+ variant="glyph"
174
+ size="wrap"
175
+ aria-label={title ? `Play: ${title}` : 'Play video'}
176
+ onPointerEnter={loadFilePlayer}
177
+ onFocus={loadFilePlayer}
178
+ onTouchStart={loadFilePlayer}
179
+ onClick={() => {
180
+ loadFilePlayer();
181
+ setPressed(true);
182
+ }}
183
+ className="group absolute inset-0 rounded-none"
184
+ >
185
+ <VideoPlayBadge
186
+ size="lg"
187
+ className="transition-transform duration-200 group-hover:scale-110 group-hover:text-ods-accent"
188
+ />
189
+ </Button>
190
+ </div>
191
+ );
192
+ }
193
+ // Pressed: the click is the visitor's gesture, so the player starts with sound (muted if the browser refuses).
194
+ return <LoadedFilePlayer {...props} priority={priority} {...(facade ? { autoPlayUnmuted: true } : {})} />;
195
+ }
196
+
197
+ function LoadedFilePlayer({ priority, ...props }: FilePlayerProps & { priority?: boolean }): React.ReactElement {
116
198
  // The player's component when its module has arrived (a loaded module, drawn through `createElement`).
117
199
  const arrived = useSyncExternalStore(subscribeToFilePlayer, loadedFilePlayer, noFilePlayer);
118
200
  if (arrived) return createElement(arrived, props);
119
- return <FilePlayerFallback poster={props.poster} className={props.className} />;
201
+ return <FilePlayerFallback poster={props.poster} className={props.className} priority={priority} />;
120
202
  }
121
203
 
122
204
  // =============================================================================
@@ -207,18 +289,17 @@ export function Video(props: VideoProps): React.ReactElement | null {
207
289
 
208
290
  const effectiveKind = resolveKind(props, url);
209
291
 
210
- if (props.lazy && !isNear) {
292
+ // Drawn when it comes near the screen, like it plays when it is on it: the player and hls.js are the largest
293
+ // script a page can load, so a video below the fold costs its poster until it is approached. `lazy={false}`
294
+ // mounts the player at once (a caller that drives playback itself before it is seen).
295
+ if ((props.lazy ?? true) && !isNear) {
211
296
  // Same box, poster only: the observer target IS the reserved space.
212
297
  return wrapWithLayout(
213
- <div
298
+ <PosterBox
214
299
  ref={nearRef}
300
+ poster={props.poster}
215
301
  className={props.className}
216
- style={
217
- props.poster
218
- ? { backgroundImage: `url(${props.poster})`, backgroundSize: 'cover', backgroundPosition: 'center' }
219
- : undefined
220
- }
221
- aria-hidden="true"
302
+ priority={'priority' in props ? props.priority : undefined}
222
303
  />,
223
304
  layout,
224
305
  );
@@ -246,6 +327,8 @@ export function Video(props: VideoProps): React.ReactElement | null {
246
327
  <FilePlayer
247
328
  url={url}
248
329
  poster={props.poster}
330
+ title={props.title}
331
+ priority={props.priority}
249
332
  muted={props.muted}
250
333
  {...pickFilePlayback(props)}
251
334
  className={props.className}
@@ -7,7 +7,7 @@ import { useVisitorOs } from '../../hooks/ui/use-visitor-os';
7
7
  import { cn } from '../../utils';
8
8
  import { shortcutLabel, usesCommandKey } from '../../utils/visitor-os';
9
9
  import { AgentMark } from '../agent-mark';
10
- import { ASK_AI_OPEN_EVENT } from '../chat/utils/ask-ai-events';
10
+ import { ASK_AI_OPEN_EVENT, ASK_AI_TRIGGER_ATTRIBUTE } from '../chat/utils/ask-ai-events';
11
11
  import { Button } from '../ui/button';
12
12
 
13
13
  export interface MingoAiButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
@@ -132,6 +132,7 @@ export function MingoAiButton({
132
132
  <Button
133
133
  {...props}
134
134
  type="button"
135
+ {...{ [ASK_AI_TRIGGER_ATTRIBUTE]: '' }}
135
136
  variant={field ? 'glyph' : 'transparent'}
136
137
  size="wrap"
137
138
  font="regular"
@@ -314,7 +314,9 @@ export function SiteHeader({
314
314
  />
315
315
  )}
316
316
  {nav.signIn && (
317
- <div className="hidden lg:flex">
317
+ // `data-site-signin`: a host whose page is the same for every visitor (served from a CDN) hides it
318
+ // before paint for a visitor with a session, so a signed-in visitor never sees it flash.
319
+ <div className="hidden lg:flex" data-site-signin="">
318
320
  <Button
319
321
  asChild
320
322
  variant="transparent"
@@ -3,10 +3,11 @@ export {
3
3
  RelatedContentSection,
4
4
  RelatedContentSectionSkeleton,
5
5
  type RelatedContentSectionSkeletonProps,
6
- GROUP_PAGE_SIZE,
7
6
  type RelatedContentSectionProps,
8
7
  type CardLinkProvider,
9
8
  type CardLinkProviderProps,
10
9
  type AdminCampaignCardSlot,
11
10
  } from './related-content-section';
12
11
  export { type CardLinkAnchorProps } from './card-registry';
12
+ // The page size is server-safe data (utils/content-ref-groups); kept on this subpath for its existing importers.
13
+ export { GROUP_PAGE_SIZE } from '../../utils/content-ref-groups';
@@ -54,6 +54,7 @@ import { useSelfFetch } from '../../hooks/use-self-fetch';
54
54
  import type { ContentRef, ContentRefWithReason } from '../../types/content-ref';
55
55
  import {
56
56
  CONTENT_REF_GROUPS,
57
+ GROUP_PAGE_SIZE,
57
58
  getContentRefLabelOrTitleCase,
58
59
  orderContentRefTypes,
59
60
  type ContentRefGroupConfig,
@@ -259,10 +260,18 @@ function CardForType({
259
260
  // refetch, no cross-mount cache.
260
261
  // =============================================================================
261
262
 
262
- function useGroupItems(type: string, refs: ContentRef[], buildUrl: (type: string, ids: string[]) => string | null) {
263
+ function useGroupItems(
264
+ type: string,
265
+ refs: ContentRef[],
266
+ buildUrl: (type: string, ids: string[]) => string | null,
267
+ seedRows?: ReadonlyArray<unknown>,
268
+ ) {
263
269
  const ids = refs.map(r => r.id);
264
270
  const url = ids.length > 0 ? buildUrl(type, ids) : null;
265
- const [pages, setPages] = useState<ReadonlyMap<string, unknown[]>>(() => new Map());
271
+ // The page the server already read (`initialRows`) is in hand from the first render, the server's included.
272
+ const [pages, setPages] = useState<ReadonlyMap<string, unknown[]>>(() =>
273
+ seedRows && url !== null ? new Map([[url, [...seedRows]]]) : new Map(),
274
+ );
266
275
  const cached = url !== null ? pages.get(url) : undefined;
267
276
  const { data, dataUrl, error } = useSelfFetch<unknown>(cached ? null : url);
268
277
  if (data != null && dataUrl !== null && !pages.has(dataUrl)) {
@@ -302,15 +311,8 @@ function resolveGroupConfig(type: string): ContentRefGroupConfig {
302
311
  );
303
312
  }
304
313
 
305
- /** Items per page within one type group. Groups larger than this paginate
306
- * with the standard Pagination control (NO nested scrolling — a bounded
307
- * scrollbox inside the page traps wheel events and hides the sections
308
- * below it). MUST stay at or above the largest suggestion fill
309
- * (RELATED_SAME_TYPE_COUNT in the hub's lib/constants/suggestions.ts) so
310
- * current rails never paginate — only genuinely big groups (author pages)
311
- * do. Exported through the subpath barrel for the hub's module-load
312
- * assertion of that relation (entity-suggestion-sections.tsx). */
313
- export const GROUP_PAGE_SIZE = 12;
314
+ // The page size (`GROUP_PAGE_SIZE`) lives with the groups' other settings (`utils/content-ref-groups`, server-safe), so
315
+ // a server page can read a group's first page for `initialRows`.
314
316
 
315
317
  /** A group's layout, read by the group and by the rail's skeleton, so the skeleton is the rail's size. */
316
318
  const GROUP_CLASS = 'space-y-4';
@@ -358,9 +360,12 @@ function ContentGroup({
358
360
  adminCampaignCard,
359
361
  heading,
360
362
  onLoadingChange,
363
+ seedRows,
361
364
  }: {
362
365
  type: string;
363
366
  refs: ContentRef[];
367
+ /** The rows of this group's FIRST page, read by the server (`initialRows`). */
368
+ seedRows?: ReadonlyArray<unknown>;
364
369
  columns: 2 | 3;
365
370
  buildUrl: (type: string, ids: string[]) => string | null;
366
371
  resolveHref: (ref: ContentRef) => { href: string | null; targetPlatform: string | null };
@@ -392,7 +397,7 @@ function ContentGroup({
392
397
  }
393
398
  const totalGroupPages = pageCount(refs.length, GROUP_PAGE_SIZE);
394
399
  const pageRefs = refs.slice((page - 1) * GROUP_PAGE_SIZE, page * GROUP_PAGE_SIZE);
395
- const { items, isLoading } = useGroupItems(type, pageRefs, buildUrl);
400
+ const { items, isLoading } = useGroupItems(type, pageRefs, buildUrl, seedRows);
396
401
  const groupPagination =
397
402
  totalGroupPages > 1 ? <Pagination currentPage={page} totalPages={totalGroupPages} onPageChange={setPage} /> : null;
398
403
  useEffect(() => {
@@ -540,6 +545,13 @@ export interface RelatedContentSectionProps {
540
545
  * drills the refs here; the first client fetch is skipped (useSelfFetch
541
546
  * initialData contract). */
542
547
  initialItems?: ContentRefWithReason[];
548
+ /**
549
+ * The rows of each group's FIRST page, read by the server, keyed by the refs' `type`: exactly what the group's list
550
+ * URL answers for those ids (`buildListUrl(type, firstPageIds)`). The cards are then in the server HTML instead of
551
+ * appearing after a request per group in the browser, which on an author page made a card the page's largest paint
552
+ * seconds after load (CU-17tkuw5xp4q). A group with no entry fetches as before; later pages always fetch.
553
+ */
554
+ initialRows?: Readonly<Record<string, ReadonlyArray<unknown>>>;
543
555
  /** Section title (default: "Related Content") */
544
556
  title?: string;
545
557
  /**
@@ -612,6 +624,7 @@ export function RelatedContentSection({
612
624
  sameTypeMinResults,
613
625
  includeTypes,
614
626
  initialItems,
627
+ initialRows,
615
628
  title = 'Related Content',
616
629
  columns = 2,
617
630
  excludeTypes,
@@ -787,6 +800,7 @@ export function RelatedContentSection({
787
800
  adminCampaignCard={adminCampaignCard}
788
801
  heading={<GroupHeading type={type} />}
789
802
  onLoadingChange={onGroupLoadingChange}
803
+ seedRows={initialRows?.[type]}
790
804
  />
791
805
  ))}
792
806
  </LoadingRegion>
@@ -26,6 +26,13 @@ import { MarqueeWall } from './marquee-wall';
26
26
  export interface PhotoWallImage {
27
27
  url: string;
28
28
  alt?: string;
29
+ /**
30
+ * The picture's pixel size, when the host knows it (a server that read the file). A picture with a size is laid
31
+ * out at once; one without is measured in the browser first, and the wall waits for that, so it is the page's
32
+ * largest paint only after every picture has loaded (CU-17tkuw5xp4q).
33
+ */
34
+ width?: number;
35
+ height?: number;
29
36
  }
30
37
 
31
38
  export type PhotoOrientation = 'tall' | 'wide' | 'square';
@@ -228,8 +235,23 @@ export function PhotoWall({
228
235
  // `optimizedWidth`: the host's resizer at the column's width), so it is downloaded once.
229
236
  const drawnWidth = requestWidth ?? undefined;
230
237
  const drawnUrl = useCallback((url: string) => (drawnWidth ? resizedImageUrl(url, drawnWidth) : url), [drawnWidth]);
231
- const urls = useMemo(() => unique.map(image => drawnUrl(image.url)), [unique, drawnUrl]);
232
- const orientations = usePhotoOrientations(urls);
238
+ // A picture whose size the host stated has its orientation now; only the others are measured.
239
+ const known = useMemo(() => {
240
+ const byUrl = new Map<string, PhotoOrientation>();
241
+ for (const image of unique) {
242
+ if (image.width && image.height) byUrl.set(drawnUrl(image.url), photoOrientation(image.width, image.height));
243
+ }
244
+ return byUrl;
245
+ }, [unique, drawnUrl]);
246
+ const urls = useMemo(
247
+ () => unique.map(image => drawnUrl(image.url)).filter(url => !known.has(url)),
248
+ [unique, drawnUrl, known],
249
+ );
250
+ const probed = usePhotoOrientations(urls);
251
+ const orientations = useMemo(() => {
252
+ if (urls.length === 0) return known;
253
+ return probed ? new Map([...known, ...probed]) : null;
254
+ }, [urls, known, probed]);
233
255
 
234
256
  const layout = useMemo(() => {
235
257
  if (!orientations) return null;
@@ -657,7 +657,22 @@ export function TabNavigationSkeleton({
657
657
  );
658
658
  }
659
659
 
660
- export function TabNavigation({
660
+ /**
661
+ * Only a URL-synced bar reads the URL. `useSearchParams()` in a component makes Next render the whole page in the
662
+ * browser when it is prerendered (or fail the build without a Suspense boundary), so a page whose tabs are plain
663
+ * controlled state (`urlSync` off, the default) must not call it: that is what kept every page with a tab strip
664
+ * (the flamingo homepage) from being served from a CDN (CU-17tkuw5xp4q). `urlSync` is fixed per bar; switching it
665
+ * remounts the bar.
666
+ */
667
+ export function TabNavigation(props: TabNavigationProps) {
668
+ return props.urlSync ? <UrlSyncedTabNavigation {...props} /> : <TabNavigationBody {...props} searchParams={null} />;
669
+ }
670
+
671
+ function UrlSyncedTabNavigation(props: TabNavigationProps) {
672
+ return <TabNavigationBody {...props} searchParams={useSearchParams()} />;
673
+ }
674
+
675
+ function TabNavigationBody({
661
676
  activeTab: controlledActiveTab,
662
677
  onTabChange: controlledOnTabChange,
663
678
  tabs,
@@ -673,12 +688,12 @@ export function TabNavigation({
673
688
  defaultTab,
674
689
  staticLabels = false,
675
690
  children,
676
- }: TabNavigationProps) {
691
+ searchParams,
692
+ }: TabNavigationProps & { searchParams: ReturnType<typeof useSearchParams> | null }) {
677
693
  const providedVariant = useContext(TabNavigationVariantContext);
678
694
  const variant = variantProp ?? providedVariant;
679
695
  const router = useRouter();
680
696
  const pathname = usePathname();
681
- const searchParams = useSearchParams();
682
697
 
683
698
  // Determine URL sync settings
684
699
  const isUrlSyncEnabled = !!urlSync;
@@ -126,6 +126,12 @@ export function useNearViewport<T extends Element = HTMLElement>(
126
126
 
127
127
  elRef.current = node;
128
128
  if (!node) return;
129
+ // No observer (an old browser, a test DOM): draw now. A gate that can never open would hide the content for
130
+ // good, and every caller's fallback is the content itself.
131
+ if (typeof IntersectionObserver === 'undefined') {
132
+ setIsNear(true);
133
+ return;
134
+ }
129
135
 
130
136
  const cb = () => setIsNear(true);
131
137
  subscribers.set(node, cb);
@@ -87,3 +87,12 @@ export function orderContentRefTypes(present: Iterable<string>): string[] {
87
87
  const unregistered = [...presentSet].filter(type => !CONTENT_REF_GROUPS[type]);
88
88
  return [...registeredInOrder, ...unregistered];
89
89
  }
90
+
91
+ /** Items per page within one type group of the related-content rail. Groups larger than this paginate
92
+ * with the standard Pagination control (NO nested scrolling — a bounded
93
+ * scrollbox inside the page traps wheel events and hides the sections
94
+ * below it). MUST stay at or above the largest suggestion fill
95
+ * (RELATED_SAME_TYPE_COUNT in the hub's lib/constants/suggestions.ts) so
96
+ * current rails never paginate — only genuinely big groups (author pages)
97
+ * do. Server-safe here (no client boundary), so a server page can read a group's first page (`initialRows`). */
98
+ export const GROUP_PAGE_SIZE = 12;
@@ -125,3 +125,20 @@ export function clearDismissCookie(name: string): void {
125
125
  fallbackStore(name).clear();
126
126
  document.cookie = `${name}=; path=/; max-age=0`;
127
127
  }
128
+
129
+ /**
130
+ * The inline script that applies a dismissal BEFORE the first paint, for a page
131
+ * the server renders the same for every visitor (and a CDN may cache), so the
132
+ * server cannot read the cookie: placed early in `<body>`, it marks the
133
+ * document root with `attribute` when the cookie holds `id`, and the host's CSS
134
+ * hides the surface on that attribute. Without it the surface paints and then
135
+ * collapses once the bundle hydrates: a flash and a layout shift.
136
+ *
137
+ * Same match rule as `isDismissedCookieValue` (the id, never mere presence) and
138
+ * the same encoding `writeDismissCookie` uses. Cookie tier only: a
139
+ * cookie-averse document (a native shell) is not a cached web page. Any throw
140
+ * leaves the surface shown, the failing-open side.
141
+ */
142
+ export function dismissedBeforePaintScript(name: string, id: string, attribute: string): string {
143
+ return `(function(){try{var n=${JSON.stringify(`${name}=`)},c=document.cookie.split('; ');for(var i=0;i<c.length;i++){if(c[i].indexOf(n)===0&&decodeURIComponent(c[i].slice(n.length))===${JSON.stringify(id)}){document.documentElement.setAttribute(${JSON.stringify(attribute)},'');break}}}catch(e){}})();`;
144
+ }
@@ -383,6 +383,7 @@ export type { FaqHashTarget } from './faq-anchor';
383
383
  // pure + server-safe; the hub re-exports these from its config/util shims.
384
384
  export {
385
385
  CONTENT_REF_GROUPS,
386
+ GROUP_PAGE_SIZE,
386
387
  getContentRefLabel,
387
388
  getContentRefLabelOrTitleCase,
388
389
  orderContentRefTypes,
@@ -82,3 +82,15 @@ export function usesCommandKey(os: VisitorOs | null): boolean {
82
82
  export function shortcutLabel(os: VisitorOs | null, key: string): string {
83
83
  return usesCommandKey(os) ? `⌘${key}` : `Ctrl ${key}`;
84
84
  }
85
+
86
+ /**
87
+ * The inline script that marks the document with the visitor's DESKTOP system before the first paint
88
+ * (`attribute` = `mac` | `windows` | `linux`, absent for a phone, a tablet or an unknown system), for a page the
89
+ * server renders the same for every visitor (and a CDN may cache): it draws every system's variant and the host's
90
+ * CSS shows the one the attribute names, so the right one is there from the first frame with no hydration
91
+ * mismatch. The same rule as `detectVisitorOs` (Client Hints platform, then the User-Agent, touch points telling an
92
+ * iPad from a Mac), held equal to it by the tests. Any throw leaves the attribute off: the host's no-system variant.
93
+ */
94
+ export function desktopOsBeforePaintScript(attribute: string): string {
95
+ return `(function(){try{var n=window.navigator,h=n.userAgentData&&n.userAgentData.platform,p=String(h||'').replace(/"/g,'').trim().toLowerCase(),t=(n.maxTouchPoints||0)>1,o=null,m=${JSON.stringify(HINT_PLATFORMS)};if(m[p])o=m[p];else{var u=n.userAgent||'';if(/iPhone|iPad|iPod/i.test(u))o='ios';else if(/Android/i.test(u))o='android';else if(/Windows NT|Win64|Win32/i.test(u))o='windows';else if(/Macintosh|Mac OS X/i.test(u))o='mac';else if(/Linux|X11|CrOS/i.test(u))o='linux'}if(o==='mac'&&t)o='ios';if(o==='mac'||o==='windows'||o==='linux')document.documentElement.setAttribute(${JSON.stringify(attribute)},o)}catch(e){}})();`;
96
+ }