@flamingo-stack/openframe-frontend-core 0.0.449 → 0.0.450

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-DJP5HYFG.cjs → chunk-26JSJMMZ.cjs} +7 -7
  2. package/dist/{chunk-DJP5HYFG.cjs.map → chunk-26JSJMMZ.cjs.map} +1 -1
  3. package/dist/{chunk-NPBTNWRA.js → chunk-3ITY2LBM.js} +4 -4
  4. package/dist/{chunk-MWRFPLNB.js → chunk-4SZDO3TE.js} +3 -3
  5. package/dist/{chunk-LWT6G75Q.js → chunk-4VSTLJDO.js} +2 -2
  6. package/dist/{chunk-DRKQNXSZ.cjs → chunk-5HW5HMNF.cjs} +5 -5
  7. package/dist/{chunk-DRKQNXSZ.cjs.map → chunk-5HW5HMNF.cjs.map} +1 -1
  8. package/dist/{chunk-KKJRBMG3.cjs → chunk-5OY7HYII.cjs} +73 -73
  9. package/dist/{chunk-KKJRBMG3.cjs.map → chunk-5OY7HYII.cjs.map} +1 -1
  10. package/dist/{chunk-WYPROXL4.cjs → chunk-6GJKSYM2.cjs} +73 -73
  11. package/dist/{chunk-WYPROXL4.cjs.map → chunk-6GJKSYM2.cjs.map} +1 -1
  12. package/dist/{chunk-6TS4AIDX.cjs → chunk-6RA4NRBA.cjs} +15 -15
  13. package/dist/{chunk-6TS4AIDX.cjs.map → chunk-6RA4NRBA.cjs.map} +1 -1
  14. package/dist/{chunk-X754WDWO.cjs → chunk-76EN3GEH.cjs} +40 -40
  15. package/dist/{chunk-X754WDWO.cjs.map → chunk-76EN3GEH.cjs.map} +1 -1
  16. package/dist/{chunk-YSNMV53O.js → chunk-CM5XKHHS.js} +6 -6
  17. package/dist/{chunk-47AMTR2S.cjs → chunk-DQ5U6DCR.cjs} +18 -18
  18. package/dist/{chunk-47AMTR2S.cjs.map → chunk-DQ5U6DCR.cjs.map} +1 -1
  19. package/dist/{chunk-YHNBJ3F3.js → chunk-EQWE64RJ.js} +2 -2
  20. package/dist/{chunk-5SJGFLL2.js → chunk-HXCQHMS5.js} +6386 -6032
  21. package/dist/chunk-HXCQHMS5.js.map +1 -0
  22. package/dist/{chunk-VSWFYFIG.js → chunk-HYIHXXF3.js} +5 -5
  23. package/dist/{chunk-AZKVNRZP.cjs → chunk-J5BYYZVM.cjs} +32 -32
  24. package/dist/{chunk-AZKVNRZP.cjs.map → chunk-J5BYYZVM.cjs.map} +1 -1
  25. package/dist/{chunk-JAASDJOF.js → chunk-K3GXIGN6.js} +5 -5
  26. package/dist/{chunk-UNQVTGVG.js → chunk-M3AKWFAO.js} +2 -2
  27. package/dist/{chunk-TKGCTDQY.js → chunk-MLFFBQ5B.js} +180 -87
  28. package/dist/chunk-MLFFBQ5B.js.map +1 -0
  29. package/dist/{chunk-XLFAFH2B.js → chunk-N53FC732.js} +2 -2
  30. package/dist/{chunk-H56AEHFC.cjs → chunk-N5M72LJX.cjs} +1620 -1266
  31. package/dist/chunk-N5M72LJX.cjs.map +1 -0
  32. package/dist/{chunk-4IMXFDRI.js → chunk-PCW2TRZ4.js} +3 -3
  33. package/dist/{chunk-AN7MCJTV.cjs → chunk-QF3NQW7C.cjs} +122 -29
  34. package/dist/chunk-QF3NQW7C.cjs.map +1 -0
  35. package/dist/{chunk-EOFENGSZ.cjs → chunk-ROBGJH52.cjs} +12 -12
  36. package/dist/{chunk-EOFENGSZ.cjs.map → chunk-ROBGJH52.cjs.map} +1 -1
  37. package/dist/{chunk-HQ7S6JG5.js → chunk-RQHHACP2.js} +3 -3
  38. package/dist/{chunk-ROCJMSJI.cjs → chunk-UPRY4I63.cjs} +3 -3
  39. package/dist/{chunk-ROCJMSJI.cjs.map → chunk-UPRY4I63.cjs.map} +1 -1
  40. package/dist/{chunk-LQTX7EJS.cjs → chunk-VLDZ4363.cjs} +29 -29
  41. package/dist/{chunk-LQTX7EJS.cjs.map → chunk-VLDZ4363.cjs.map} +1 -1
  42. package/dist/{chunk-BSBWBQ7M.js → chunk-VUDZOCPW.js} +6 -6
  43. package/dist/{chunk-4S4RR7XC.cjs → chunk-XITG5ICQ.cjs} +40 -40
  44. package/dist/{chunk-4S4RR7XC.cjs.map → chunk-XITG5ICQ.cjs.map} +1 -1
  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 +26 -1
  48. package/dist/components/chat/chat-quick-action-row.d.ts.map +1 -1
  49. package/dist/components/chat/index.cjs +15 -3
  50. package/dist/components/chat/index.cjs.map +1 -1
  51. package/dist/components/chat/index.d.ts +1 -0
  52. package/dist/components/chat/index.d.ts.map +1 -1
  53. package/dist/components/chat/index.js +14 -2
  54. package/dist/components/chat/quick-action-chip.d.ts +45 -4
  55. package/dist/components/chat/quick-action-chip.d.ts.map +1 -1
  56. package/dist/components/chat/quick-action-marquee.d.ts +24 -9
  57. package/dist/components/chat/quick-action-marquee.d.ts.map +1 -1
  58. package/dist/components/chat/quick-action-wall.d.ts +96 -0
  59. package/dist/components/chat/quick-action-wall.d.ts.map +1 -0
  60. package/dist/components/contact/index.cjs +4 -4
  61. package/dist/components/contact/index.js +3 -3
  62. package/dist/components/content-loading-container.d.ts +1 -1
  63. package/dist/components/docs/index.cjs +6 -6
  64. package/dist/components/docs/index.js +5 -5
  65. package/dist/components/embeds/index.cjs +4 -4
  66. package/dist/components/embeds/index.js +3 -3
  67. package/dist/components/faq/index.cjs +5 -5
  68. package/dist/components/faq/index.js +4 -4
  69. package/dist/components/features/cards-strip.d.ts.map +1 -1
  70. package/dist/components/features/index.cjs +3 -3
  71. package/dist/components/features/index.js +2 -2
  72. package/dist/components/form.d.ts +1 -1
  73. package/dist/components/form.d.ts.map +1 -1
  74. package/dist/components/help-center-pages/index.cjs +22 -22
  75. package/dist/components/help-center-pages/index.js +13 -13
  76. package/dist/components/index.cjs +159 -135
  77. package/dist/components/index.cjs.map +1 -1
  78. package/dist/components/index.js +35 -11
  79. package/dist/components/index.js.map +1 -1
  80. package/dist/components/navigation/index.cjs +3 -3
  81. package/dist/components/navigation/index.js +2 -2
  82. package/dist/components/onboarding-guides/index.cjs +7 -7
  83. package/dist/components/onboarding-guides/index.js +6 -6
  84. package/dist/components/related-content/index.cjs +5 -5
  85. package/dist/components/related-content/index.js +4 -4
  86. package/dist/components/tickets/index.cjs +6 -6
  87. package/dist/components/tickets/index.js +5 -5
  88. package/dist/components/ui/index.cjs +27 -3
  89. package/dist/components/ui/index.cjs.map +1 -1
  90. package/dist/components/ui/index.d.ts +1 -0
  91. package/dist/components/ui/index.d.ts.map +1 -1
  92. package/dist/components/ui/index.js +26 -2
  93. package/dist/components/ui/marquee-wall.d.ts +159 -0
  94. package/dist/components/ui/marquee-wall.d.ts.map +1 -0
  95. package/dist/hooks/index.cjs +6 -2
  96. package/dist/hooks/index.cjs.map +1 -1
  97. package/dist/hooks/index.js +5 -1
  98. package/dist/hooks/ui/index.d.ts +2 -0
  99. package/dist/hooks/ui/index.d.ts.map +1 -1
  100. package/dist/hooks/ui/use-marquee-engine.d.ts +73 -0
  101. package/dist/hooks/ui/use-marquee-engine.d.ts.map +1 -0
  102. package/dist/hooks/ui/use-suppress-clone-focus.d.ts +24 -0
  103. package/dist/hooks/ui/use-suppress-clone-focus.d.ts.map +1 -0
  104. package/dist/index.cjs +31 -3
  105. package/dist/index.cjs.map +1 -1
  106. package/dist/index.js +30 -2
  107. package/dist/tailwind.config.cjs +1 -8
  108. package/dist/tailwind.config.cjs.map +1 -1
  109. package/dist/tailwind.config.js +1 -8
  110. package/dist/tailwind.config.js.map +1 -1
  111. package/package.json +1 -1
  112. package/src/components/chat/chat-quick-action-row.tsx +47 -3
  113. package/src/components/chat/index.ts +1 -0
  114. package/src/components/chat/quick-action-chip.tsx +81 -13
  115. package/src/components/chat/quick-action-marquee.tsx +54 -47
  116. package/src/components/chat/quick-action-wall.tsx +247 -0
  117. package/src/components/features/cards-strip.tsx +88 -158
  118. package/src/components/ui/index.ts +1 -0
  119. package/src/components/ui/marquee-wall.tsx +522 -0
  120. package/src/hooks/ui/index.ts +2 -0
  121. package/src/hooks/ui/use-marquee-engine.ts +158 -0
  122. package/src/hooks/ui/use-suppress-clone-focus.ts +61 -0
  123. package/src/stories/MarqueeWall.stories.tsx +132 -0
  124. package/src/stories/QuickActionMarquee.stories.tsx +8 -7
  125. package/src/stories/QuickActionWall.stories.tsx +116 -0
  126. package/tailwind.config.ts +0 -7
  127. package/dist/chunk-5SJGFLL2.js.map +0 -1
  128. package/dist/chunk-AN7MCJTV.cjs.map +0 -1
  129. package/dist/chunk-H56AEHFC.cjs.map +0 -1
  130. package/dist/chunk-TKGCTDQY.js.map +0 -1
  131. /package/dist/{chunk-NPBTNWRA.js.map → chunk-3ITY2LBM.js.map} +0 -0
  132. /package/dist/{chunk-MWRFPLNB.js.map → chunk-4SZDO3TE.js.map} +0 -0
  133. /package/dist/{chunk-LWT6G75Q.js.map → chunk-4VSTLJDO.js.map} +0 -0
  134. /package/dist/{chunk-YSNMV53O.js.map → chunk-CM5XKHHS.js.map} +0 -0
  135. /package/dist/{chunk-YHNBJ3F3.js.map → chunk-EQWE64RJ.js.map} +0 -0
  136. /package/dist/{chunk-VSWFYFIG.js.map → chunk-HYIHXXF3.js.map} +0 -0
  137. /package/dist/{chunk-JAASDJOF.js.map → chunk-K3GXIGN6.js.map} +0 -0
  138. /package/dist/{chunk-UNQVTGVG.js.map → chunk-M3AKWFAO.js.map} +0 -0
  139. /package/dist/{chunk-XLFAFH2B.js.map → chunk-N53FC732.js.map} +0 -0
  140. /package/dist/{chunk-4IMXFDRI.js.map → chunk-PCW2TRZ4.js.map} +0 -0
  141. /package/dist/{chunk-HQ7S6JG5.js.map → chunk-RQHHACP2.js.map} +0 -0
  142. /package/dist/{chunk-BSBWBQ7M.js.map → chunk-VUDZOCPW.js.map} +0 -0
@@ -0,0 +1,158 @@
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
+ }
@@ -0,0 +1,61 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+
5
+ // Every focusable a clone might contain — matched so it can be pulled out of the
6
+ // keyboard tab order (see useSuppressCloneFocus). `[contenteditable]:not(
7
+ // [contenteditable="false"])` catches the empty/`"true"`/`"plaintext-only"`
8
+ // editable forms; `summary` is focusable inside a <details>.
9
+ const CLONE_FOCUSABLE_SELECTOR =
10
+ 'a[href],button,input,select,textarea,iframe,summary,audio[controls],video[controls],[tabindex],[contenteditable]:not([contenteditable="false"])'
11
+
12
+ /**
13
+ * Keep a marquee clone's content pointer-CLICKABLE while taking it out of the
14
+ * accessibility tree and the keyboard tab order. Shared by every endless-loop
15
+ * surface that renders a second track copy (<CardsStrip> managed cells,
16
+ * <MarqueeWall> clone copies).
17
+ *
18
+ * `inert` (the previous approach) does all three at once: a11y-hidden,
19
+ * un-focusable AND un-clickable. That third effect was the "news marquee stops
20
+ * being clickable after you scroll it for a while" bug — the 2-copy endless
21
+ * loop ALWAYS paints clone cards inside the viewport near the copy seam, so a
22
+ * user who manually scrolls and parks there is looking at real-looking cards
23
+ * that silently swallow every click. Clones must therefore stay clickable (a
24
+ * click on ANY visible card must open its link — mouse, ⌘-click, middle-click
25
+ * and context-menu all handled natively by the clone's own anchor).
26
+ *
27
+ * The a11y contract is still met without `inert`: the caller keeps
28
+ * `aria-hidden` on the clone wrapper (out of the a11y tree) and this hook
29
+ * forces every focusable descendant to `tabindex="-1"` (out of the tab order →
30
+ * no duplicate tab stops, no aria-hidden-focus violation). Re-applied on
31
+ * subtree mutation. This is the standard loop-clone treatment used by
32
+ * Swiper/Splide.
33
+ */
34
+ export function useSuppressCloneFocus(active: boolean) {
35
+ const ref = useRef<HTMLDivElement | null>(null)
36
+ useEffect(() => {
37
+ const root = ref.current
38
+ if (!root || !active) return
39
+ const suppress = () => {
40
+ root.querySelectorAll<HTMLElement>(CLONE_FOCUSABLE_SELECTOR).forEach(el => {
41
+ // Only stamp the ones not already stamped — avoids touching the DOM (and
42
+ // clobbering an intentional tabindex) on every mutation.
43
+ if (el.getAttribute('tabindex') !== '-1') el.setAttribute('tabindex', '-1')
44
+ })
45
+ }
46
+ suppress()
47
+ // Watch structure AND the attributes that can turn a descendant tabbable
48
+ // (an anchor gaining `href`, React restoring `tabindex="0"`, an element
49
+ // becoming editable, media gaining `controls`). The `!== '-1'` guard above
50
+ // makes our own tabindex writes a no-op, so the observer never loops.
51
+ const mo = new MutationObserver(suppress)
52
+ mo.observe(root, {
53
+ childList: true,
54
+ subtree: true,
55
+ attributes: true,
56
+ attributeFilter: ['href', 'tabindex', 'contenteditable', 'controls'],
57
+ })
58
+ return () => mo.disconnect()
59
+ }, [active])
60
+ return ref
61
+ }
@@ -0,0 +1,132 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
2
+ import { MarqueeWall, useMarqueeSync } from '../components/ui/marquee-wall'
3
+
4
+ // Arbitrary-children demo content: simple lockup tiles (the wall is
5
+ // content-agnostic — chips, logos, feed rows all ride the same engine).
6
+ const TILES = [
7
+ 'Datto RMM',
8
+ 'ConnectWise',
9
+ 'HaloPSA',
10
+ 'SentinelOne',
11
+ 'Proofpoint',
12
+ 'Veeam',
13
+ 'Auvik',
14
+ 'IT Glue',
15
+ ]
16
+
17
+ function Tile({ label }: { label: string }) {
18
+ return (
19
+ <div className="flex h-16 w-48 shrink-0 items-center justify-center rounded-md border border-ods-border bg-ods-card text-h6 text-ods-text-secondary">
20
+ {label}
21
+ </div>
22
+ )
23
+ }
24
+
25
+ const meta: Meta<typeof MarqueeWall> = {
26
+ title: 'UI/MarqueeWall',
27
+ component: MarqueeWall,
28
+ parameters: {
29
+ layout: 'padded',
30
+ docs: {
31
+ description: {
32
+ component:
33
+ 'THE endless-scroll wall container — a clipped area whose content loops as a marquee along either axis, fading where the clip cuts. Transform-driven on the shared `useMarqueeEngine` core (no inner scroller). Auto-static when the content fits or under `prefers-reduced-motion`. `useMarqueeSync` pins multiple walls to one position driver; `trackId` publishes live offsets for FLIP-morph coordination.',
34
+ },
35
+ },
36
+ },
37
+ decorators: [
38
+ (Story) => (
39
+ <div className="bg-ods-bg p-[var(--spacing-system-xl)]">
40
+ <Story />
41
+ </div>
42
+ ),
43
+ ],
44
+ }
45
+
46
+ export default meta
47
+ type Story = StoryObj<typeof MarqueeWall>
48
+
49
+ /** Horizontal loop of arbitrary children with the default right fade. */
50
+ export const Horizontal: Story = {
51
+ render: () => (
52
+ <MarqueeWall fade="right" copyGap="var(--spacing-system-mf)" className="w-full" contentClassName="flex items-center gap-[var(--spacing-system-mf)]">
53
+ {TILES.map(t => (
54
+ <Tile key={t} label={t} />
55
+ ))}
56
+ </MarqueeWall>
57
+ ),
58
+ }
59
+
60
+ /** Vertical loop (bottom fade → bottom→top travel) inside a height cap. */
61
+ export const Vertical: Story = {
62
+ render: () => (
63
+ <div className="w-64 rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-mf)]">
64
+ <MarqueeWall
65
+ fade="bottom"
66
+ fadeColor="var(--color-bg-card)"
67
+ copyGap="var(--spacing-system-mf)"
68
+ className="max-h-[240px]"
69
+ contentClassName="flex flex-col gap-[var(--spacing-system-mf)]"
70
+ >
71
+ {TILES.map(t => (
72
+ <Tile key={t} label={t} />
73
+ ))}
74
+ </MarqueeWall>
75
+ </div>
76
+ ),
77
+ }
78
+
79
+ /** Two walls pinned to ONE position driver via `useMarqueeSync` — scroll
80
+ * stays pixel-locked (the FreeTrialCTA resolve-strip mechanism). */
81
+ export const SyncedPair: Story = {
82
+ render: function SyncedPairStory() {
83
+ const sync = useMarqueeSync()
84
+ return (
85
+ <div className="flex flex-col gap-[var(--spacing-system-mf)]">
86
+ {[0, 1].map(row => (
87
+ <MarqueeWall
88
+ key={row}
89
+ sync={sync}
90
+ axis="x"
91
+ copyGap="var(--spacing-system-mf)"
92
+ className="w-full"
93
+ contentClassName="flex items-center gap-[var(--spacing-system-mf)]"
94
+ >
95
+ {TILES.map(t => (
96
+ <Tile key={t} label={t} />
97
+ ))}
98
+ </MarqueeWall>
99
+ ))}
100
+ </div>
101
+ )
102
+ },
103
+ }
104
+
105
+ /** Plain mode — the consumer's opt-out: same wall, no motion, no fades. */
106
+ export const Plain: Story = {
107
+ render: () => (
108
+ <MarqueeWall mode="plain" className="w-full" contentClassName="flex items-center gap-[var(--spacing-system-mf)]">
109
+ {TILES.map(t => (
110
+ <Tile key={t} label={t} />
111
+ ))}
112
+ </MarqueeWall>
113
+ ),
114
+ }
115
+
116
+ /** Reverse travel (content moves right) with a custom-size leading fade. */
117
+ export const Reversed: Story = {
118
+ render: () => (
119
+ <MarqueeWall
120
+ reverse
121
+ fade={['left', 'right']}
122
+ fadeSize={{ left: 48 }}
123
+ copyGap="var(--spacing-system-mf)"
124
+ className="w-full"
125
+ contentClassName="flex items-center gap-[var(--spacing-system-mf)]"
126
+ >
127
+ {TILES.map(t => (
128
+ <Tile key={t} label={t} />
129
+ ))}
130
+ </MarqueeWall>
131
+ ),
132
+ }
@@ -32,14 +32,14 @@ const meta: Meta<typeof QuickActionMarquee> = {
32
32
  docs: {
33
33
  description: {
34
34
  component:
35
- 'Endless horizontally-scrolling strip of quick-action chips (Figma fae/mingo marquee rows). The item list is padded to ≥6 chips and rendered twice so the `qa-marquee` keyframe’s `translateX(-50%)` loops seamlessly; the duplicate half is `aria-hidden` and never focusable. Pauses on hover (`pauseOnHover`, default on) and always under `prefers-reduced-motion`. Pass `onSelect` to make chips interactive buttons; omit it for a purely decorative strip.',
35
+ 'Endless horizontally-scrolling strip of quick-action chips (Figma fae/mingo marquee rows) — a single-row preset of the shared `<MarqueeWall>` engine (the same rAF core as the card strips). The item list is padded to ≥12 chips and the wall clones the track for the seamless loop; the clone copy is `aria-hidden` and never focusable. Speed is in px/s (constant on-screen speed regardless of item count). Pauses on hover (`pauseOnHover`, default on) and always under `prefers-reduced-motion`. Pass `onSelect` to make chips interactive buttons; omit it for a purely decorative strip.',
36
36
  },
37
37
  },
38
38
  },
39
39
  argTypes: {
40
40
  items: { control: false },
41
41
  direction: { control: 'inline-radio', options: ['left', 'right'] },
42
- duration: { control: { type: 'number', min: 5, max: 120, step: 5 } },
42
+ speed: { control: { type: 'number', min: 10, max: 200, step: 10 } },
43
43
  pauseOnHover: { control: 'boolean' },
44
44
  onSelect: { control: false },
45
45
  },
@@ -66,7 +66,7 @@ export const Default: Story = {
66
66
  /** The two Figma rows: fae (left) over mingo (right, reversed). */
67
67
  export const TwoRows: Story = {
68
68
  render: () => (
69
- <div className="flex flex-col gap-4">
69
+ <div className="flex flex-col gap-[var(--spacing-system-mf)]">
70
70
  <QuickActionMarquee items={FAE_ITEMS} direction="left" onSelect={(i) => console.log(i.id)} />
71
71
  <QuickActionMarquee items={MINGO_ITEMS} direction="right" onSelect={(i) => console.log(i.id)} />
72
72
  </div>
@@ -93,8 +93,9 @@ export const Decorative: Story = {
93
93
  }
94
94
 
95
95
  /**
96
- * Fewer than 6 items — the track is auto-repeated up to the `MIN_TRACK_ITEMS`
97
- * heuristic so the halved loop still fills the viewport with no visible gap.
96
+ * Fewer than 12 items — the track is auto-repeated up to the `minChips`
97
+ * padding target so one copy always overflows the container and the loop
98
+ * engages.
98
99
  */
99
100
  export const FewItems: Story = {
100
101
  args: {
@@ -103,11 +104,11 @@ export const FewItems: Story = {
103
104
  },
104
105
  }
105
106
 
106
- /** Faster loop (`duration={15}`) and hover-pause disabled. */
107
+ /** Faster scroll (`speed={120}` px/s) and hover-pause disabled. */
107
108
  export const FastNoPause: Story = {
108
109
  args: {
109
110
  items: FAE_ITEMS,
110
- duration: 15,
111
+ speed: 120,
111
112
  pauseOnHover: false,
112
113
  onSelect: (item) => console.log('select', item.id),
113
114
  },
@@ -0,0 +1,116 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
2
+ import { QuickActionWall, interleave } from '../components/chat/quick-action-wall'
3
+ import type { QuickActionChip } from '../components/chat/chat-quick-action-row'
4
+
5
+ // A mixed IT/SEC stream (per-chip themes + lozenges) and a fae wall — the two
6
+ // wall registers: category-classified deck panels vs agent-themed hero walls.
7
+ const IT_ACTIONS: ReadonlyArray<QuickActionChip> = [
8
+ { id: 'reset-password', label: 'Reset a password', icon: { name: 'key' }, theme: 'it' },
9
+ { id: 'new-laptop', label: 'Provision a new laptop', icon: { name: 'laptop' }, theme: 'it' },
10
+ { id: 'printer', label: 'Fix the printer', icon: { name: 'printer' }, theme: 'it' },
11
+ { id: 'vpn', label: 'VPN will not connect', icon: { name: 'globe' }, theme: 'it' },
12
+ { id: 'disk-full', label: 'Disk almost full', icon: { name: 'hard-drive' }, theme: 'it' },
13
+ { id: 'onboard', label: 'Onboard a new hire', icon: { name: 'user-plus' }, theme: 'it' },
14
+ ]
15
+
16
+ const SEC_ACTIONS: ReadonlyArray<QuickActionChip> = [
17
+ { id: 'phishing', label: 'Phishing report triage', icon: { name: 'fish' }, theme: 'sec' },
18
+ { id: 'mfa', label: 'Enforce MFA', icon: { name: 'shield-check' }, theme: 'sec' },
19
+ { id: 'patch', label: 'Patch the CVE', icon: { name: 'bug' }, theme: 'sec' },
20
+ { id: 'offboard', label: 'Revoke access on exit', icon: { name: 'user-minus' }, theme: 'sec' },
21
+ { id: 'edr-alert', label: 'EDR alert review', icon: { name: 'radar' }, theme: 'sec' },
22
+ ]
23
+
24
+ const FAE_ACTIONS: ReadonlyArray<QuickActionChip> = [
25
+ { id: 'how-to-start', label: 'How to start', icon: { name: 'fae' } },
26
+ { id: 'connect-device', label: 'Connect device', icon: { name: 'search' }, theme: 'fae' },
27
+ { id: 'find-device', label: 'Find device', icon: { name: 'compass' }, theme: 'fae' },
28
+ { id: 'remote-connection', label: 'Remote connection', icon: { name: 'rocket' }, theme: 'fae' },
29
+ { id: 'run-scripts', label: 'Run scripts', icon: { name: 'bracket-curly' }, theme: 'fae' },
30
+ { id: 'device-software', label: 'Device software', icon: { name: 'package' }, theme: 'fae' },
31
+ ]
32
+
33
+ const meta: Meta<typeof QuickActionWall> = {
34
+ title: 'Chat/QuickActionWall',
35
+ component: QuickActionWall,
36
+ parameters: {
37
+ layout: 'padded',
38
+ docs: {
39
+ description: {
40
+ component:
41
+ 'THE quick-action chip wall: a themed pile of the shared chip on the shared `<MarqueeWall>` engine. Static-with-fade when content fits (or `prefers-reduced-motion`); an endless marquee when it overflows — the axis follows the fade (`bottom` fade → content travels bottom→top; `right` fade → right→left). Themes: `fae`/`mingo` (agent identities) and `it`/`sec` (classification pair, with lozenges). Interactive chips pause the track on hover; clone-copy chips stay clickable with wrapper-level a11y. `rows` renders the brick-wall mode (stacked independent row marquees).',
42
+ },
43
+ },
44
+ },
45
+ decorators: [
46
+ (Story) => (
47
+ <div className="bg-ods-bg p-[var(--spacing-system-xl)]">
48
+ <Story />
49
+ </div>
50
+ ),
51
+ ],
52
+ }
53
+
54
+ export default meta
55
+ type Story = StoryObj<typeof QuickActionWall>
56
+
57
+ /** Vertical wall (bottom fade → bottom→top marquee): the deck-panel register —
58
+ * mixed IT/SEC stream with lozenges, decorative chips. */
59
+ export const VerticalMixedStream: Story = {
60
+ render: () => (
61
+ <div className="rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-mf)]">
62
+ <QuickActionWall
63
+ chips={interleave(IT_ACTIONS, SEC_ACTIONS)}
64
+ lozenges
65
+ fade="bottom"
66
+ fadeColor="var(--color-bg-card)"
67
+ className="max-h-[180px]"
68
+ />
69
+ </div>
70
+ ),
71
+ }
72
+
73
+ /** Horizontal wall (right fade): the hero-tab register — a wide fae wall
74
+ * drifting right→left, interactive chips (hover pauses the track). */
75
+ export const HorizontalInteractive: Story = {
76
+ render: () => (
77
+ <QuickActionWall
78
+ chips={FAE_ACTIONS.map((c, i) => ({ ...c, selected: i === 0, selectedAccent: 'pink', onSelect: () => console.log(c.id) }))}
79
+ fade="right"
80
+ className="w-full"
81
+ contentClassName="w-[1400px]"
82
+ />
83
+ ),
84
+ }
85
+
86
+ /** Plain mode — the consumer's opt-out: same chips and layout, no marquee,
87
+ * no blur. */
88
+ export const Plain: Story = {
89
+ render: () => (
90
+ <div className="rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-mf)]">
91
+ <QuickActionWall
92
+ chips={interleave(IT_ACTIONS, SEC_ACTIONS)}
93
+ mode="plain"
94
+ lozenges
95
+ className="max-h-[180px]"
96
+ />
97
+ </div>
98
+ ),
99
+ }
100
+
101
+ /** Loading: skeleton chips (1:1 geometry, lozenge slots reserved) — static
102
+ * until real chips land. */
103
+ export const Loading: Story = {
104
+ render: () => (
105
+ <div className="rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-mf)]">
106
+ <QuickActionWall
107
+ chips={[]}
108
+ loading
109
+ lozenges
110
+ fade="bottom"
111
+ fadeColor="var(--color-bg-card)"
112
+ className="max-h-[180px]"
113
+ />
114
+ </div>
115
+ ),
116
+ }
@@ -375,18 +375,11 @@ const config: Config = {
375
375
  from: { opacity: "0" },
376
376
  to: { opacity: "1" },
377
377
  },
378
- // Endless chip strip (QuickActionMarquee): the track renders its items
379
- // twice, so -50% lands exactly one copy over for a seamless loop.
380
- "qa-marquee": {
381
- from: { transform: "translateX(0)" },
382
- to: { transform: "translateX(-50%)" },
383
- },
384
378
  },
385
379
  animation: {
386
380
  "accordion-down": "accordion-down 0.2s ease-out",
387
381
  "accordion-up": "accordion-up 0.2s ease-out",
388
382
  "fade-in": "fade-in 0.3s ease-out",
389
- "qa-marquee": "qa-marquee var(--qa-marquee-duration,40s) linear infinite",
390
383
  },
391
384
  },
392
385
  },