@flamingo-stack/openframe-frontend-core 0.0.449-snapshot.20260718143201 → 0.0.449

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 (142) hide show
  1. package/dist/{chunk-DQ5U6DCR.cjs → chunk-47AMTR2S.cjs} +18 -18
  2. package/dist/{chunk-DQ5U6DCR.cjs.map → chunk-47AMTR2S.cjs.map} +1 -1
  3. package/dist/{chunk-PCW2TRZ4.js → chunk-4IMXFDRI.js} +3 -3
  4. package/dist/{chunk-XITG5ICQ.cjs → chunk-4S4RR7XC.cjs} +40 -40
  5. package/dist/{chunk-XITG5ICQ.cjs.map → chunk-4S4RR7XC.cjs.map} +1 -1
  6. package/dist/{chunk-HXCQHMS5.js → chunk-5SJGFLL2.js} +6032 -6386
  7. package/dist/chunk-5SJGFLL2.js.map +1 -0
  8. package/dist/{chunk-6RA4NRBA.cjs → chunk-6TS4AIDX.cjs} +15 -15
  9. package/dist/{chunk-6RA4NRBA.cjs.map → chunk-6TS4AIDX.cjs.map} +1 -1
  10. package/dist/{chunk-QF3NQW7C.cjs → chunk-AN7MCJTV.cjs} +29 -122
  11. package/dist/chunk-AN7MCJTV.cjs.map +1 -0
  12. package/dist/{chunk-J5BYYZVM.cjs → chunk-AZKVNRZP.cjs} +32 -32
  13. package/dist/{chunk-J5BYYZVM.cjs.map → chunk-AZKVNRZP.cjs.map} +1 -1
  14. package/dist/{chunk-VUDZOCPW.js → chunk-BSBWBQ7M.js} +6 -6
  15. package/dist/{chunk-26JSJMMZ.cjs → chunk-DJP5HYFG.cjs} +7 -7
  16. package/dist/{chunk-26JSJMMZ.cjs.map → chunk-DJP5HYFG.cjs.map} +1 -1
  17. package/dist/{chunk-5HW5HMNF.cjs → chunk-DRKQNXSZ.cjs} +5 -5
  18. package/dist/{chunk-5HW5HMNF.cjs.map → chunk-DRKQNXSZ.cjs.map} +1 -1
  19. package/dist/{chunk-ROBGJH52.cjs → chunk-EOFENGSZ.cjs} +12 -12
  20. package/dist/{chunk-ROBGJH52.cjs.map → chunk-EOFENGSZ.cjs.map} +1 -1
  21. package/dist/{chunk-N5M72LJX.cjs → chunk-H56AEHFC.cjs} +1266 -1620
  22. package/dist/chunk-H56AEHFC.cjs.map +1 -0
  23. package/dist/{chunk-RQHHACP2.js → chunk-HQ7S6JG5.js} +3 -3
  24. package/dist/{chunk-K3GXIGN6.js → chunk-JAASDJOF.js} +5 -5
  25. package/dist/{chunk-5OY7HYII.cjs → chunk-KKJRBMG3.cjs} +73 -73
  26. package/dist/{chunk-5OY7HYII.cjs.map → chunk-KKJRBMG3.cjs.map} +1 -1
  27. package/dist/{chunk-VLDZ4363.cjs → chunk-LQTX7EJS.cjs} +29 -29
  28. package/dist/{chunk-VLDZ4363.cjs.map → chunk-LQTX7EJS.cjs.map} +1 -1
  29. package/dist/{chunk-4VSTLJDO.js → chunk-LWT6G75Q.js} +2 -2
  30. package/dist/{chunk-4SZDO3TE.js → chunk-MWRFPLNB.js} +3 -3
  31. package/dist/{chunk-3ITY2LBM.js → chunk-NPBTNWRA.js} +4 -4
  32. package/dist/{chunk-UPRY4I63.cjs → chunk-ROCJMSJI.cjs} +3 -3
  33. package/dist/{chunk-UPRY4I63.cjs.map → chunk-ROCJMSJI.cjs.map} +1 -1
  34. package/dist/{chunk-MLFFBQ5B.js → chunk-TKGCTDQY.js} +87 -180
  35. package/dist/chunk-TKGCTDQY.js.map +1 -0
  36. package/dist/{chunk-M3AKWFAO.js → chunk-UNQVTGVG.js} +2 -2
  37. package/dist/{chunk-HYIHXXF3.js → chunk-VSWFYFIG.js} +5 -5
  38. package/dist/{chunk-6GJKSYM2.cjs → chunk-WYPROXL4.cjs} +73 -73
  39. package/dist/{chunk-6GJKSYM2.cjs.map → chunk-WYPROXL4.cjs.map} +1 -1
  40. package/dist/{chunk-76EN3GEH.cjs → chunk-X754WDWO.cjs} +40 -40
  41. package/dist/{chunk-76EN3GEH.cjs.map → chunk-X754WDWO.cjs.map} +1 -1
  42. package/dist/{chunk-N53FC732.js → chunk-XLFAFH2B.js} +2 -2
  43. package/dist/{chunk-EQWE64RJ.js → chunk-YHNBJ3F3.js} +2 -2
  44. package/dist/{chunk-CM5XKHHS.js → chunk-YSNMV53O.js} +6 -6
  45. package/dist/components/case-studies/index.cjs +9 -9
  46. package/dist/components/case-studies/index.js +3 -3
  47. package/dist/components/chat/chat-quick-action-row.d.ts +1 -26
  48. package/dist/components/chat/chat-quick-action-row.d.ts.map +1 -1
  49. package/dist/components/chat/index.cjs +3 -15
  50. package/dist/components/chat/index.cjs.map +1 -1
  51. package/dist/components/chat/index.d.ts +0 -1
  52. package/dist/components/chat/index.d.ts.map +1 -1
  53. package/dist/components/chat/index.js +2 -14
  54. package/dist/components/chat/quick-action-chip.d.ts +4 -45
  55. package/dist/components/chat/quick-action-chip.d.ts.map +1 -1
  56. package/dist/components/chat/quick-action-marquee.d.ts +9 -24
  57. package/dist/components/chat/quick-action-marquee.d.ts.map +1 -1
  58. package/dist/components/contact/index.cjs +4 -4
  59. package/dist/components/contact/index.js +3 -3
  60. package/dist/components/content-loading-container.d.ts +1 -1
  61. package/dist/components/docs/index.cjs +6 -6
  62. package/dist/components/docs/index.js +5 -5
  63. package/dist/components/embeds/index.cjs +4 -4
  64. package/dist/components/embeds/index.js +3 -3
  65. package/dist/components/faq/index.cjs +5 -5
  66. package/dist/components/faq/index.js +4 -4
  67. package/dist/components/features/cards-strip.d.ts.map +1 -1
  68. package/dist/components/features/index.cjs +3 -3
  69. package/dist/components/features/index.js +2 -2
  70. package/dist/components/form.d.ts +1 -1
  71. package/dist/components/form.d.ts.map +1 -1
  72. package/dist/components/help-center-pages/index.cjs +22 -22
  73. package/dist/components/help-center-pages/index.js +13 -13
  74. package/dist/components/index.cjs +135 -159
  75. package/dist/components/index.cjs.map +1 -1
  76. package/dist/components/index.js +11 -35
  77. package/dist/components/index.js.map +1 -1
  78. package/dist/components/navigation/index.cjs +3 -3
  79. package/dist/components/navigation/index.js +2 -2
  80. package/dist/components/onboarding-guides/index.cjs +7 -7
  81. package/dist/components/onboarding-guides/index.js +6 -6
  82. package/dist/components/related-content/index.cjs +5 -5
  83. package/dist/components/related-content/index.js +4 -4
  84. package/dist/components/tickets/index.cjs +6 -6
  85. package/dist/components/tickets/index.js +5 -5
  86. package/dist/components/ui/index.cjs +3 -27
  87. package/dist/components/ui/index.cjs.map +1 -1
  88. package/dist/components/ui/index.d.ts +0 -1
  89. package/dist/components/ui/index.d.ts.map +1 -1
  90. package/dist/components/ui/index.js +2 -26
  91. package/dist/hooks/index.cjs +2 -6
  92. package/dist/hooks/index.cjs.map +1 -1
  93. package/dist/hooks/index.js +1 -5
  94. package/dist/hooks/ui/index.d.ts +0 -2
  95. package/dist/hooks/ui/index.d.ts.map +1 -1
  96. package/dist/index.cjs +3 -31
  97. package/dist/index.cjs.map +1 -1
  98. package/dist/index.js +2 -30
  99. package/dist/tailwind.config.cjs +8 -1
  100. package/dist/tailwind.config.cjs.map +1 -1
  101. package/dist/tailwind.config.js +8 -1
  102. package/dist/tailwind.config.js.map +1 -1
  103. package/package.json +1 -1
  104. package/src/components/chat/chat-quick-action-row.tsx +3 -47
  105. package/src/components/chat/index.ts +0 -1
  106. package/src/components/chat/quick-action-chip.tsx +13 -81
  107. package/src/components/chat/quick-action-marquee.tsx +47 -54
  108. package/src/components/features/cards-strip.tsx +158 -88
  109. package/src/components/ui/index.ts +0 -1
  110. package/src/hooks/ui/index.ts +0 -2
  111. package/src/stories/QuickActionMarquee.stories.tsx +7 -8
  112. package/tailwind.config.ts +7 -0
  113. package/dist/chunk-HXCQHMS5.js.map +0 -1
  114. package/dist/chunk-MLFFBQ5B.js.map +0 -1
  115. package/dist/chunk-N5M72LJX.cjs.map +0 -1
  116. package/dist/chunk-QF3NQW7C.cjs.map +0 -1
  117. package/dist/components/chat/quick-action-wall.d.ts +0 -96
  118. package/dist/components/chat/quick-action-wall.d.ts.map +0 -1
  119. package/dist/components/ui/marquee-wall.d.ts +0 -159
  120. package/dist/components/ui/marquee-wall.d.ts.map +0 -1
  121. package/dist/hooks/ui/use-marquee-engine.d.ts +0 -73
  122. package/dist/hooks/ui/use-marquee-engine.d.ts.map +0 -1
  123. package/dist/hooks/ui/use-suppress-clone-focus.d.ts +0 -24
  124. package/dist/hooks/ui/use-suppress-clone-focus.d.ts.map +0 -1
  125. package/src/components/chat/quick-action-wall.tsx +0 -247
  126. package/src/components/ui/marquee-wall.tsx +0 -522
  127. package/src/hooks/ui/use-marquee-engine.ts +0 -158
  128. package/src/hooks/ui/use-suppress-clone-focus.ts +0 -61
  129. package/src/stories/MarqueeWall.stories.tsx +0 -132
  130. package/src/stories/QuickActionWall.stories.tsx +0 -116
  131. /package/dist/{chunk-PCW2TRZ4.js.map → chunk-4IMXFDRI.js.map} +0 -0
  132. /package/dist/{chunk-VUDZOCPW.js.map → chunk-BSBWBQ7M.js.map} +0 -0
  133. /package/dist/{chunk-RQHHACP2.js.map → chunk-HQ7S6JG5.js.map} +0 -0
  134. /package/dist/{chunk-K3GXIGN6.js.map → chunk-JAASDJOF.js.map} +0 -0
  135. /package/dist/{chunk-4VSTLJDO.js.map → chunk-LWT6G75Q.js.map} +0 -0
  136. /package/dist/{chunk-4SZDO3TE.js.map → chunk-MWRFPLNB.js.map} +0 -0
  137. /package/dist/{chunk-3ITY2LBM.js.map → chunk-NPBTNWRA.js.map} +0 -0
  138. /package/dist/{chunk-M3AKWFAO.js.map → chunk-UNQVTGVG.js.map} +0 -0
  139. /package/dist/{chunk-HYIHXXF3.js.map → chunk-VSWFYFIG.js.map} +0 -0
  140. /package/dist/{chunk-N53FC732.js.map → chunk-XLFAFH2B.js.map} +0 -0
  141. /package/dist/{chunk-EQWE64RJ.js.map → chunk-YHNBJ3F3.js.map} +0 -0
  142. /package/dist/{chunk-CM5XKHHS.js.map → chunk-YSNMV53O.js.map} +0 -0
@@ -1,522 +0,0 @@
1
- 'use client'
2
-
3
- import * as React from 'react'
4
- import { cn } from '../../utils/cn'
5
- import { useMarqueeEngine } from '../../hooks/ui/use-marquee-engine'
6
- import { useMediaQuery } from '../../hooks/ui/use-media-query'
7
- import { useSuppressCloneFocus } from '../../hooks/ui/use-suppress-clone-focus'
8
- import { NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport'
9
-
10
- // =============================================================================
11
- // Types
12
- // =============================================================================
13
-
14
- export type MarqueeWallFadeEdge = 'top' | 'bottom' | 'left' | 'right'
15
-
16
- /** One spelling of the `fade` prop's edge-list normalization. */
17
- function normalizeFades(
18
- fade: MarqueeWallFadeEdge | ReadonlyArray<MarqueeWallFadeEdge> | undefined,
19
- ): ReadonlyArray<MarqueeWallFadeEdge> {
20
- return fade ? (Array.isArray(fade) ? fade : [fade as MarqueeWallFadeEdge]) : []
21
- }
22
-
23
- /** @internal per-instance sync membership (transform writer + driver-role
24
- * promotion callback, so a surviving member can be re-elected). */
25
- interface MarqueeSyncMember {
26
- apply: (pos: number) => void
27
- makeDriver: () => void
28
- }
29
-
30
- /**
31
- * Pairs two (or more) <MarqueeWall> instances to ONE position driver so their
32
- * tracks stay pixel-locked (the FreeTrialCTA resolve strips: a pending and a
33
- * resolved copy of the same chips, clip-path'd at the avatar line). The first
34
- * mounted instance claims the driver role and runs the shared rAF; the others
35
- * only register their `apply` transforms. If the driver unmounts while
36
- * members survive, the next member is promoted — the pair must merely be
37
- * geometry-identical while mounted together.
38
- */
39
- export interface MarqueeSyncController {
40
- /** @internal registered members in mount order. */
41
- members: Set<MarqueeSyncMember>
42
- /** @internal current driver member. */
43
- driver: MarqueeSyncMember | null
44
- /** @internal last driver-written position — a promoted driver's engine
45
- * seeds from it, so re-election never snaps the pair back to 0. */
46
- position: number
47
- }
48
-
49
- export function useMarqueeSync(): MarqueeSyncController {
50
- return React.useRef<MarqueeSyncController>({ members: new Set(), driver: null, position: 0 }).current
51
- }
52
-
53
- // =============================================================================
54
- // FLIP coordination — live track offsets + loop-clone helpers
55
- // =============================================================================
56
-
57
- export interface MarqueeTrackOffset {
58
- /** Applied translation (px) along each axis. */
59
- x: number
60
- y: number
61
- /** Loop wrap size (single copy + seam gap) along each axis; 0 on the
62
- * non-scroll axis. Lets morph engines treat a chip and its loop clone as
63
- * ONE identity `wrap` apart (the Swiper loop-clone model) and fly the
64
- * copy the viewer actually sees. */
65
- wrapX: number
66
- wrapY: number
67
- }
68
-
69
- /**
70
- * Registry of each named wall's CURRENT applied translation, written every
71
- * engine frame and deliberately kept after unmount: a FLIP-across-remounts
72
- * morph (deck `useFlipMorph`) captures element positions at COMMIT time, so
73
- * chips inside a drifting marquee would fly from stale origins. With a
74
- * `trackId` the wall stamps `data-marquee-track` on its container and
75
- * publishes its offset here; the morph engine corrects captured points by
76
- * the wall's drift since capture (including drift accrued right up to the
77
- * source wall's unmount — hence the survive-unmount map).
78
- */
79
- const MARQUEE_TRACK_OFFSETS = new Map<string, MarqueeTrackOffset>()
80
-
81
- const ZERO_OFFSET: MarqueeTrackOffset = { x: 0, y: 0, wrapX: 0, wrapY: 0 }
82
-
83
- /** Last known translation + wrap of the wall registered under `trackId`
84
- * (live or already unmounted). Zeros for unknown ids / static walls. */
85
- export function getMarqueeTrackOffset(trackId: string): MarqueeTrackOffset {
86
- return MARQUEE_TRACK_OFFSETS.get(trackId) ?? ZERO_OFFSET
87
- }
88
-
89
- /**
90
- * The translation ACTUALLY applied to a wall's track right now, read from the
91
- * DOM of the `data-marquee-track` container. Morph engines use this on the
92
- * CAPTURE side (never the registry): a same-id wall freshly remounted in the
93
- * current commit has not applied any transform yet, while the registry still
94
- * holds its predecessor's final drift for the flight-side lookup.
95
- */
96
- export function getMarqueeAppliedOffset(trackContainer: Element): { x: number; y: number } {
97
- const track = trackContainer.firstElementChild
98
- const t = track ? getComputedStyle(track).transform : 'none'
99
- if (!t || t === 'none') return { x: 0, y: 0 }
100
- const m = new DOMMatrixReadOnly(t)
101
- return { x: m.e, y: m.f }
102
- }
103
-
104
- /**
105
- * The loop-clone twin of an element inside a wall's PRIMARY copy (same index
106
- * in the `data-marquee-copy="clone"` copy), or null when there is no clone /
107
- * the element isn't in the primary copy. Owns the wall's internal DOM shape
108
- * so morph engines don't hardcode it; `selector` scopes which descendants
109
- * count as items (default: direct structure-preserving lookup by class is the
110
- * caller's job — pass the item selector, e.g. `'.flipchip'`).
111
- */
112
- export function getMarqueeCloneTwin(
113
- trackContainer: Element,
114
- el: Element,
115
- selector: string,
116
- ): HTMLElement | null {
117
- const primary = trackContainer.querySelector('[data-marquee-copy="primary"]')
118
- const clone = trackContainer.querySelector('[data-marquee-copy="clone"]')
119
- if (!primary || !clone || !primary.contains(el)) return null
120
- const idx = Array.prototype.indexOf.call(primary.querySelectorAll(selector), el)
121
- if (idx < 0) return null
122
- return (clone.querySelectorAll(selector)[idx] as HTMLElement | undefined) ?? null
123
- }
124
-
125
- export interface MarqueeWallProps {
126
- /**
127
- * Presentation mode — the consumer's one switch for "do I want the
128
- * blur + marquee here at all":
129
- * - `'animated'` (default): endless marquee when content overflows, with
130
- * the overflow fades.
131
- * - `'plain'`: no motion, no fades — a plain clipped wall. All marquee
132
- * machinery (engine, clone copy, fades) is skipped entirely.
133
- */
134
- mode?: 'animated' | 'plain'
135
- /**
136
- * Marquee axis. Defaults to where the fade sits — the animation's job is to
137
- * reveal what the fade hides: a `bottom`/`top` fade clips rows → vertical
138
- * (content travels bottom→top); a `left`/`right`-only fade clips columns →
139
- * horizontal (content travels right→left). No fade → horizontal.
140
- */
141
- axis?: 'x' | 'y'
142
- /** Reverse travel: `x` reversed moves content right, `y` reversed moves it
143
- * down. Default false (left / up). */
144
- reverse?: boolean
145
- /** Scroll speed in px/s (the CardsStrip unit — one speed language across
146
- * every marquee). Default 40: calm chip-wall drift, slower than the 60 the
147
- * card strips cruise at. */
148
- speed?: number
149
- /** Master switch. Even when true the marquee auto-disables while the content
150
- * fits the container and under `prefers-reduced-motion` (static wall). */
151
- animate?: boolean
152
- /** Freeze while a hover-capable pointer is anywhere over the wall — content
153
- * must never move under a pointing cursor (required for interactive chip
154
- * walls). Default true. */
155
- pauseOnHover?: boolean
156
- /** Edge fade(s) — the "there's more" affordance. ONE spelling of the
157
- * clip-and-fade across every wall surface. */
158
- fade?: MarqueeWallFadeEdge | ReadonlyArray<MarqueeWallFadeEdge>
159
- /** The color the fades blend INTO — must match the surface behind the wall
160
- * or the gradient paints a visible box. Default the page background. */
161
- fadeColor?: string
162
- /** Fade thickness: one value for all edges or a per-edge map. Defaults:
163
- * 40px vertical edges, 112px horizontal edges (the shipped hero-wall
164
- * geometry). */
165
- fadeSize?: number | string | Partial<Record<MarqueeWallFadeEdge, number | string>>
166
- /** Gap between the two track copies — match the content's own item gap so
167
- * the seam is invisible. A CSS length string (e.g. `var(--tlcl-chipgap)`)
168
- * lets responsive CSS own the value; the true seam distance is MEASURED
169
- * from the rendered copies either way. Default 8. */
170
- copyGap?: number | string
171
- /** Outer container class — sizing lives here (height cap for `y`, width for
172
- * `x`). The container is always `relative overflow-hidden`. */
173
- className?: string
174
- /** Class on EACH track copy — the wall's own layout (e.g. a wrapping chip
175
- * row `flex flex-wrap gap-2`, or a fixed-width grid). */
176
- contentClassName?: string
177
- /** Inline style on EACH track copy — for values classes can't carry (e.g.
178
- * an item gap driven by the same variable as `copyGap`, so chip pitch and
179
- * seam pitch stay one value by construction). */
180
- contentStyle?: React.CSSProperties
181
- /**
182
- * Wall content — ONE copy; the wall clones it for the endless loop (clone
183
- * `aria-hidden` + focus-suppressed but still clickable). Pass a render
184
- * callback to vary the clone copy (e.g. omit `data-flip-id`s / render
185
- * decorative chips). Children must be clone-safe: ref-free, unique-DOM-id-
186
- * free, mount-effect-free (same contract as <CardsStrip> children mode).
187
- */
188
- children: React.ReactNode | ((ctx: { isClone: boolean }) => React.ReactNode)
189
- /** Pair this wall with sibling walls under one shared position driver. */
190
- sync?: MarqueeSyncController
191
- /** Stable id for FLIP coordination: publishes the wall's live offset via
192
- * {@link getMarqueeTrackOffset} and stamps `data-marquee-track` on the
193
- * container so morph engines can drift-correct captured positions. */
194
- trackId?: string
195
- }
196
-
197
- // =============================================================================
198
- // Internals
199
- // =============================================================================
200
-
201
- const DEFAULT_FADE_SIZE: Record<MarqueeWallFadeEdge, string> = {
202
- top: '40px',
203
- bottom: '40px',
204
- left: '112px',
205
- right: '112px',
206
- }
207
-
208
- const FADE_POSITION: Record<MarqueeWallFadeEdge, string> = {
209
- top: 'inset-x-0 top-0',
210
- bottom: 'inset-x-0 bottom-0',
211
- left: 'inset-y-0 left-0',
212
- right: 'inset-y-0 right-0',
213
- }
214
-
215
- /** The wall's edge-fade overlays — exported so composite walls (stacked row
216
- * marquees) can draw ONE set of fades over the whole stack instead of
217
- * per-row copies. Parent must be `relative`. Overlays sit at z-1, ABOVE
218
- * FLIP flight ghosts (z-0 by contract): an in-flight chip dissolves under
219
- * the blur exactly like a resting chip. */
220
- export function MarqueeWallFades({
221
- fade,
222
- fadeColor = 'var(--color-bg)',
223
- fadeSize,
224
- }: {
225
- fade?: MarqueeWallFadeEdge | ReadonlyArray<MarqueeWallFadeEdge>
226
- fadeColor?: string
227
- fadeSize?: MarqueeWallProps['fadeSize']
228
- }) {
229
- return (
230
- <>
231
- {normalizeFades(fade).map(edge => {
232
- const size =
233
- (typeof fadeSize === 'object' && fadeSize !== null ? fadeSize[edge] : fadeSize) ??
234
- DEFAULT_FADE_SIZE[edge]
235
- const px = typeof size === 'number' ? `${size}px` : size
236
- const isY = edge === 'top' || edge === 'bottom'
237
- return (
238
- <div
239
- key={edge}
240
- aria-hidden
241
- // Edge marker: lets morph engines measure the fade bands when
242
- // they need band-aware behavior.
243
- data-marquee-fade={edge}
244
- className={cn('pointer-events-none absolute z-[1]', FADE_POSITION[edge])}
245
- style={{
246
- [isY ? 'height' : 'width']: px,
247
- backgroundImage: `linear-gradient(to ${edge}, transparent, ${fadeColor})`,
248
- }}
249
- />
250
- )
251
- })}
252
- </>
253
- )
254
- }
255
-
256
- /** Clone track copy — aria-hidden with focusable descendants suppressed (but
257
- * clickable, the shared loop-clone treatment). */
258
- function CloneCopy({
259
- className,
260
- style,
261
- children,
262
- }: {
263
- className?: string
264
- style?: React.CSSProperties
265
- children: React.ReactNode
266
- }) {
267
- const ref = useSuppressCloneFocus(true)
268
- return (
269
- <div ref={ref} aria-hidden data-marquee-copy="clone" className={className} style={style}>
270
- {children}
271
- </div>
272
- )
273
- }
274
-
275
- // =============================================================================
276
- // Component
277
- // =============================================================================
278
-
279
- /**
280
- * <MarqueeWall> — THE endless-scroll wall container: a clipped area whose
281
- * content loops as a marquee along either axis, fading out where the clip
282
- * cuts. Built on the same `useMarqueeEngine` core as <CardsStrip> (the news /
283
- * logo / bites strips) with a TRANSFORM driver instead of a scroller — no
284
- * inner scrollable area is created, so it is safe inside surfaces that forbid
285
- * nested scrollers (the deck's mobile single-fold panels).
286
- *
287
- * Static by circumstance, not by variant: when the content fits the container
288
- * (or `prefers-reduced-motion`) the wall renders a single static copy with
289
- * its fades — byte-identical to the old non-animated walls.
290
- */
291
- export function MarqueeWall({
292
- mode = 'animated',
293
- axis: axisProp,
294
- reverse = false,
295
- speed = 40,
296
- animate = true,
297
- pauseOnHover = true,
298
- fade,
299
- fadeColor = 'var(--color-bg)',
300
- fadeSize,
301
- copyGap = 8,
302
- className,
303
- contentClassName,
304
- contentStyle,
305
- children,
306
- sync,
307
- trackId,
308
- }: MarqueeWallProps) {
309
- const fades = React.useMemo(() => normalizeFades(fade), [fade])
310
- const axis = axisProp ?? (fades.includes('top') || fades.includes('bottom') ? 'y' : 'x')
311
-
312
- const containerRef = React.useRef<HTMLDivElement | null>(null)
313
- const trackRef = React.useRef<HTMLDivElement | null>(null)
314
- const copyRef = React.useRef<HTMLDivElement | null>(null)
315
-
316
- // ---- environment -----------------------------------------------------------
317
- const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)') ?? false
318
-
319
- // ---- overflow + seam measurement -------------------------------------------
320
- // One copy vs the container along the axis decides overflow (no overflow →
321
- // nothing hidden → no marquee, no clone). The WRAP size — the loop's seam
322
- // distance — is MEASURED as the offset delta between the two rendered
323
- // copies, so it is exact whatever produced the gap (numeric prop or a
324
- // responsive CSS variable); before the clone mounts it falls back to
325
- // copy size + numeric gap, corrected on the next measure.
326
- const [overflows, setOverflows] = React.useState(false)
327
- const copySizeRef = React.useRef(0)
328
- const wrapSizeRef = React.useRef(0)
329
- const measure = React.useCallback(() => {
330
- const container = containerRef.current
331
- const copy = copyRef.current
332
- if (!container || !copy) return
333
- const size = axis === 'y' ? copy.offsetHeight : copy.offsetWidth
334
- copySizeRef.current = size
335
- const clone = trackRef.current?.querySelector<HTMLElement>('[data-marquee-copy="clone"]')
336
- wrapSizeRef.current = clone
337
- ? axis === 'y'
338
- ? clone.offsetTop - copy.offsetTop
339
- : clone.offsetLeft - copy.offsetLeft
340
- : size + (typeof copyGap === 'number' ? copyGap : 0)
341
- const avail = axis === 'y' ? container.clientHeight : container.clientWidth
342
- setOverflows(size > avail + 1)
343
- }, [axis, copyGap])
344
- const marqueeActive = mode === 'animated' && animate && !reducedMotion && overflows
345
- React.useEffect(() => {
346
- measure()
347
- const container = containerRef.current
348
- const copy = copyRef.current
349
- if (!container || !copy || typeof ResizeObserver === 'undefined') return
350
- const ro = new ResizeObserver(measure)
351
- ro.observe(container)
352
- ro.observe(copy)
353
- return () => ro.disconnect()
354
- // marqueeActive: the clone mounts with it — re-measure for the true seam.
355
- }, [measure, marqueeActive])
356
-
357
- // Viewport gates `active` (rAF fully stops off-screen) instead of being a
358
- // pause REASON: the velocity envelope persists across engine restarts, so
359
- // scrolling a wall back into view resumes at cruise speed instantly — a
360
- // pause-reason resume would ramp 0→speed over ~250ms, a visible
361
- // stopped-then-start on every slide/section entry.
362
- const [nearViewport, setNearViewport] = React.useState(true)
363
- React.useEffect(() => {
364
- const el = containerRef.current
365
- if (!el || typeof IntersectionObserver === 'undefined') return
366
- const io = new IntersectionObserver(
367
- entries => setNearViewport(entries[0]?.isIntersecting ?? true),
368
- { rootMargin: NEAR_VIEWPORT_ROOT_MARGIN },
369
- )
370
- io.observe(el)
371
- return () => io.disconnect()
372
- }, [])
373
-
374
- // ---- pause-reason set ------------------------------------------------------
375
- const pointerInsideRef = React.useRef(false)
376
- const onPointerEnter = React.useCallback((e: React.PointerEvent) => {
377
- if (e.pointerType === 'touch') return
378
- pointerInsideRef.current = true
379
- }, [])
380
- const onPointerLeave = React.useCallback((e: React.PointerEvent) => {
381
- if (e.pointerType === 'touch') return
382
- pointerInsideRef.current = false
383
- }, [])
384
-
385
- // ---- transform driver ------------------------------------------------------
386
- const applyTransform = React.useCallback(
387
- (pos: number) => {
388
- const track = trackRef.current
389
- if (!track) return
390
- const wrapSize = wrapSizeRef.current
391
- const t = reverse ? pos - wrapSize : -pos
392
- track.style.transform = axis === 'y' ? `translate3d(0, ${t}px, 0)` : `translate3d(${t}px, 0, 0)`
393
- if (trackId) {
394
- MARQUEE_TRACK_OFFSETS.set(
395
- trackId,
396
- axis === 'y'
397
- ? { x: 0, y: t, wrapX: 0, wrapY: wrapSize }
398
- : { x: t, y: 0, wrapX: wrapSize, wrapY: 0 },
399
- )
400
- }
401
- },
402
- [axis, reverse, trackId],
403
- )
404
- // Clear any residual offset when the marquee turns off (content shrank,
405
- // reduced-motion flipped) — the static wall must sit at its natural origin.
406
- // The engine position resets with it (below the engine call) so a later
407
- // re-activation re-seeds consistently.
408
- React.useEffect(() => {
409
- if (marqueeActive) return
410
- const track = trackRef.current
411
- if (track) track.style.transform = ''
412
- if (trackId) MARQUEE_TRACK_OFFSETS.set(trackId, ZERO_OFFSET)
413
- }, [marqueeActive, trackId])
414
- // NOTE deliberately no reset of the registry entry on mount: when a layout
415
- // change remounts the SAME trackId in one commit, the morph engine must
416
- // still read the OLD wall's final drift for that commit's flight origins.
417
- // The new wall's first applied frame (rAF, after all layout effects)
418
- // overwrites it. Capture-side reads take the translation from the DOM
419
- // ({@link getMarqueeAppliedOffset}), so a stale registry value can never
420
- // leak into captured points.
421
-
422
- // ---- sync pairing (driver election by mount order, with re-election) -------
423
- const [isDriver, setIsDriver] = React.useState(!sync)
424
- React.useEffect(() => {
425
- if (!sync) {
426
- setIsDriver(true)
427
- return
428
- }
429
- const member: MarqueeSyncMember = { apply: applyTransform, makeDriver: () => setIsDriver(true) }
430
- sync.members.add(member)
431
- if (sync.driver === null) sync.driver = member
432
- setIsDriver(sync.driver === member)
433
- return () => {
434
- sync.members.delete(member)
435
- if (sync.driver === member) {
436
- // Promote the next surviving member so the pair never goes driverless
437
- // (a driverless pair would freeze — nothing runs the shared rAF).
438
- const next = sync.members.values().next()
439
- sync.driver = next.done ? null : next.value
440
- sync.driver?.makeDriver()
441
- }
442
- }
443
- }, [sync, applyTransform])
444
-
445
- const { posRef } = useMarqueeEngine({
446
- active: marqueeActive && nearViewport && isDriver,
447
- speed,
448
- isPaused: () =>
449
- (pauseOnHover && pointerInsideRef.current) || document.visibilityState === 'hidden',
450
- getWrapSize: () => wrapSizeRef.current,
451
- apply: sync
452
- ? pos => {
453
- sync.position = pos
454
- sync.members.forEach(m => m.apply(pos))
455
- }
456
- : applyTransform,
457
- // Synced pairs: a re-elected driver resumes from the pair's live position
458
- // (engine seeds from readBack on activation) instead of restarting at 0.
459
- readBack: sync ? () => sync.position : undefined,
460
- })
461
- // REVERSE-travel continuity: an unstyled track (t = 0) corresponds to
462
- // pos = wrapSize, not pos = 0 — seed the position on activation so the
463
- // engine's first frame continues from the freshly-painted state instead of
464
- // teleporting the track a full wrap upward. Without this, a FLIP morph
465
- // capturing against the mount paint saw every chip (and every in-flight
466
- // ghost target) jump a copy-height on the next frame — the "chips pour in
467
- // from the top instead of flying between panels" breakage.
468
- React.useLayoutEffect(() => {
469
- if (!marqueeActive) {
470
- posRef.current = 0
471
- return
472
- }
473
- if (reverse && posRef.current === 0) {
474
- posRef.current = Math.max(0, wrapSizeRef.current - 0.01)
475
- }
476
- }, [marqueeActive, reverse, posRef])
477
-
478
- // ---- render ----------------------------------------------------------------
479
- const renderContent = (isClone: boolean) =>
480
- typeof children === 'function' ? children({ isClone }) : children
481
-
482
- return (
483
- <div
484
- ref={containerRef}
485
- className={cn('relative overflow-hidden', className)}
486
- data-marquee-track={trackId}
487
- onPointerEnter={onPointerEnter}
488
- onPointerLeave={onPointerLeave}
489
- >
490
- <div
491
- ref={trackRef}
492
- className={cn(
493
- axis === 'y' ? 'flex w-full flex-col' : 'flex w-max items-stretch',
494
- marqueeActive && 'will-change-transform',
495
- )}
496
- style={marqueeActive ? { gap: copyGap } : undefined}
497
- >
498
- <div
499
- ref={copyRef}
500
- data-marquee-copy="primary"
501
- className={cn(axis === 'x' && 'w-max shrink-0', contentClassName)}
502
- style={contentStyle}
503
- >
504
- {renderContent(false)}
505
- </div>
506
- {marqueeActive && (
507
- <CloneCopy className={cn(axis === 'x' && 'w-max shrink-0', contentClassName)} style={contentStyle}>
508
- {renderContent(true)}
509
- </CloneCopy>
510
- )}
511
- </div>
512
- {/* A fade means "there's more": it only paints while the content
513
- actually overflows the container. A wall taller than its content
514
- (e.g. a flex-filled panel) must show every chip crisp — a veil over
515
- real rows and empty surface reads as a rendering bug. Plain mode
516
- skips fades entirely (the consumer opted out of blur + marquee). */}
517
- {mode === 'animated' && overflows && (
518
- <MarqueeWallFades fade={fade} fadeColor={fadeColor} fadeSize={fadeSize} />
519
- )}
520
- </div>
521
- )
522
- }
@@ -1,158 +0,0 @@
1
- 'use client'
2
-
3
- import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
4
-
5
- /**
6
- * THE marquee animation core (single source of truth) — extracted verbatim
7
- * from <CardsStrip> so every endless-scroll surface (card strips, logo strips,
8
- * quick-action walls, chip marquees) shares ONE rAF position engine ("unify
9
- * means delete variations").
10
- *
11
- * The engine is axis- and driver-agnostic: it owns a FLOAT position ref, a
12
- * velocity envelope, seam wrapping, and an optional chevron-style glide; the
13
- * caller decides what a position "means" via `apply` — writing
14
- * `scroller.scrollLeft` (CardsStrip: a real scroller the user can also drag)
15
- * or a `translate` transform (MarqueeWall: decorative tracks, incl. vertical —
16
- * mandatory inside surfaces that forbid inner scrollers, e.g. the deck's
17
- * mobile single-fold panels).
18
- *
19
- * Engine semantics (do not "simplify"):
20
- * - Position lives in a FLOAT ref, never read-modify-write from the DOM:
21
- * scrollLeft reads back rounded values, so sub-pixel increments
22
- * (speed/fps < 1px) get eaten by rounding — stutter + measurably slower
23
- * than the configured speed.
24
- * - Velocity envelope (~250ms time constant) eases between 0 and `speed`
25
- * instead of binary stop/start — pause decelerates, resume accelerates from
26
- * the current position (GSAP-marquee behavior; hard cuts read as jank).
27
- * - Glide consumes a signed remaining-distance, wrapping modulo the copy size
28
- * each frame, so seam warps are invisible to it (browser smooth-scroll
29
- * animates toward an ABSOLUTE target and lunges after a warp).
30
- * - `isPaused` is evaluated every frame — the caller owns the pause-reason
31
- * set (hover, viewport, tab visibility, suppress windows); the engine only
32
- * turns it into a smooth velocity target.
33
- */
34
- export interface MarqueeEngineOptions {
35
- /** Master switch — the rAF runs only while true. On (re)activation the
36
- * position resyncs from `readBack` when provided. */
37
- active: boolean
38
- /** Cruise speed in px/s. */
39
- speed: number
40
- /** Per-frame pause verdict (caller-owned reason set). `now` is the rAF
41
- * timestamp, for suppress-until comparisons. */
42
- isPaused: (now: number) => boolean
43
- /** Single-copy size in px (the wrap modulus). Return <= 0 to disable
44
- * wrapping (position advances unwrapped — callers should size tracks so
45
- * this never happens while active). */
46
- getWrapSize: () => number
47
- /** Lower bound of the wrap range: positions stay in
48
- * [wrapMin, wrapMin + wrapSize). Default 0. Scroller drivers with USER
49
- * scrolling pass their seam-warp buffer here so engine-written positions
50
- * never enter the re-center zones — an exact-edge warp check is racy
51
- * against the rAF (the engine nudges the scroller off the edge before the
52
- * scroll handler samples it → "stuck at the left edge"). */
53
- getWrapMin?: () => number
54
- /** Write the position to the DOM (scrollLeft assignment or transform). */
55
- apply: (pos: number) => void
56
- /** Read the externally-mutated position back (real scrollers: user drag /
57
- * native smooth scroll move scrollLeft under the engine). Omit for
58
- * transform drivers — nothing external can move them. */
59
- readBack?: () => number
60
- /** Called at the end of every frame (e.g. hover re-resolution after the
61
- * track moved under a stationary pointer). */
62
- onAfterFrame?: () => void
63
- }
64
-
65
- export interface MarqueeEngineHandle {
66
- /** The engine's float position (px along the axis, 0 ≤ pos < wrap while
67
- * wrapping). Callers may write it together with their own DOM write (seam
68
- * warp on manual scroll). */
69
- posRef: MutableRefObject<number>
70
- /** Accumulate a signed glide distance (chevron navigation). Runs through
71
- * the engine's own tween — never native smooth scroll while wrapping. */
72
- glideBy: (px: number) => void
73
- /** Wrap an arbitrary position into [0, wrapSize). */
74
- wrap: (pos: number) => number
75
- }
76
-
77
- // Resync threshold (px): external movement beyond this adopts the DOM value.
78
- const RESYNC_EPSILON = 1.5
79
-
80
- export function useMarqueeEngine(options: MarqueeEngineOptions): MarqueeEngineHandle {
81
- const posRef = useRef(0)
82
- const glideRemainingRef = useRef(0)
83
- const speedEnvRef = useRef(0)
84
-
85
- // Latest-options ref: the rAF loop always reads current values without the
86
- // effect restarting on every render.
87
- const optsRef = useRef(options)
88
- optsRef.current = options
89
-
90
- const wrap = useCallback((pos: number) => {
91
- const half = optsRef.current.getWrapSize()
92
- if (half <= 0) return pos
93
- const min = optsRef.current.getWrapMin?.() ?? 0
94
- while (pos >= min + half) pos -= half
95
- while (pos < min) pos += half
96
- return pos
97
- }, [])
98
-
99
- const glideBy = useCallback((px: number) => {
100
- glideRemainingRef.current += px
101
- }, [])
102
-
103
- const active = options.active
104
- useEffect(() => {
105
- if (!active) return
106
- let raf = 0
107
- let last = performance.now()
108
- const readBack = optsRef.current.readBack
109
- if (readBack) posRef.current = readBack()
110
-
111
- const tick = (now: number) => {
112
- const opts = optsRef.current
113
- const dt = Math.min(now - last, 100) / 1000 // clamp tab-wake jumps
114
- last = now
115
-
116
- const paused = opts.isPaused(now)
117
- // Ease the velocity toward its target (0 when paused) — smooth decel on
118
- // pause, smooth accel on resume, always from the CURRENT position.
119
- const targetSpeed = paused ? 0 : opts.speed
120
- speedEnvRef.current += (targetSpeed - speedEnvRef.current) * Math.min(1, dt / 0.25)
121
- if (targetSpeed === 0 && speedEnvRef.current < 0.5) speedEnvRef.current = 0
122
-
123
- const external = opts.readBack ? opts.readBack() : null
124
- const resync = () => {
125
- if (external !== null && Math.abs(external - posRef.current) > RESYNC_EPSILON) {
126
- posRef.current = external
127
- }
128
- }
129
-
130
- const glide = glideRemainingRef.current
131
- if (glide !== 0) {
132
- // Ease-out toward the glide target; runs even while "paused" (the
133
- // interaction that queued it sets its own suppress window).
134
- resync()
135
- const speed = Math.max(Math.abs(glide) * 6, 240) // px/s, proportional
136
- const step = Math.sign(glide) * Math.min(Math.abs(glide), speed * dt)
137
- glideRemainingRef.current = Math.abs(glide - step) < 0.5 ? 0 : glide - step
138
- posRef.current = wrap(posRef.current + step)
139
- opts.apply(posRef.current)
140
- } else if (speedEnvRef.current > 0) {
141
- // Resync after external movement (user scroll, seam warp).
142
- resync()
143
- posRef.current = wrap(posRef.current + speedEnvRef.current * dt)
144
- opts.apply(posRef.current)
145
- } else if (external !== null) {
146
- // Idle: follow external movement so resumes start from where the
147
- // user left the scroller.
148
- posRef.current = external
149
- }
150
- opts.onAfterFrame?.()
151
- raf = requestAnimationFrame(tick)
152
- }
153
- raf = requestAnimationFrame(tick)
154
- return () => cancelAnimationFrame(raf)
155
- }, [active, wrap])
156
-
157
- return { posRef, glideBy, wrap }
158
- }