@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
@@ -14,10 +14,6 @@ import { EntityIcon, type EntityIconValue } from '../icon-display'
14
14
  * (agent/persona `icon_props.color`). */
15
15
  export type QuickActionAccent = 'pink' | 'cyan' | (string & {})
16
16
 
17
- /** THE agent→accent pairs (one spelling — {@link getAgentAccent} and
18
- * {@link QUICK_ACTION_THEMES} both derive from it). */
19
- const AGENT_ACCENTS = { fae: 'pink', mingo: 'cyan' } as const
20
-
21
17
  /**
22
18
  * FALLBACK-ONLY agent→accent mapping (fae→pink, mingo→cyan) for the built-in
23
19
  * agents when nothing is configured. A color configured on the entity itself —
@@ -27,7 +23,9 @@ const AGENT_ACCENTS = { fae: 'pink', mingo: 'cyan' } as const
27
23
  * comparison elsewhere.
28
24
  */
29
25
  export function getAgentAccent(slug: string | null | undefined): QuickActionAccent | undefined {
30
- return slug === 'fae' || slug === 'mingo' ? AGENT_ACCENTS[slug] : undefined
26
+ if (slug === 'fae') return 'pink'
27
+ if (slug === 'mingo') return 'cyan'
28
+ return undefined
31
29
  }
32
30
 
33
31
  /** Admin-configured identity color (`icon_props.color` on the agent/persona
@@ -65,24 +63,18 @@ const ACCENT_CLASS: Record<string, string> = {
65
63
  cyan: 'text-ods-flamingo-cyan',
66
64
  }
67
65
 
68
- /** A spec is a plain data object, never a React element — so any non-element,
69
- * non-array object is a spec; strings, elements, fragments, and ReactNode
70
- * ARRAYS stay ReactNode. */
71
- export function isQuickActionIconSpec(
72
- icon: React.ReactNode | QuickActionIconSpec | undefined,
73
- ): icon is QuickActionIconSpec {
74
- return typeof icon === 'object' && icon !== null && !Array.isArray(icon) && !React.isValidElement(icon)
75
- }
76
-
77
66
  /** Resolve a chip `icon` prop to a renderable node. Specs go through
78
67
  * `<EntityIcon>`; ReactNodes pass through untouched. */
79
68
  export function renderQuickActionIcon(
80
69
  icon: React.ReactNode | QuickActionIconSpec | undefined,
81
70
  ): React.ReactNode {
82
- if (!isQuickActionIconSpec(icon)) {
71
+ // A spec is a plain data object, never a React element — so any non-element,
72
+ // non-array object is a spec; strings, elements, fragments, and ReactNode
73
+ // ARRAYS stay ReactNode.
74
+ if (typeof icon !== 'object' || icon === null || Array.isArray(icon) || React.isValidElement(icon)) {
83
75
  return icon
84
76
  }
85
- const spec = icon
77
+ const spec = icon as QuickActionIconSpec
86
78
  if (!spec.name && !spec.url) return undefined
87
79
  // Named brand tokens tint via a text class on `currentColor`; any other
88
80
  // accent value is an admin-configured CSS color, delivered as the glyph's
@@ -112,48 +104,6 @@ export interface QuickActionChipLozenge {
112
104
  className?: string
113
105
  }
114
106
 
115
- // =============================================================================
116
- // Themes
117
- // =============================================================================
118
-
119
- /**
120
- * THE quick-action wall/chip theme set — one spelling of "who does this work"
121
- * across every surface (homepage hero tabs, marketing marquees, company-hub
122
- * deck panels, chat empty states):
123
- * - `fae` / `mingo` — the agent identities (pink / cyan), same values
124
- * {@link getAgentAccent} resolves for the built-in agents.
125
- * - `it` / `sec` — work-category themes on the ODS attention pair
126
- * (IT = yellow routine work, security = red defense), deliberately NOT the
127
- * agent identities so a category never reads as agent attribution. These two
128
- * carry the classification lozenge (leading-edge affix) as part of the theme.
129
- */
130
- export type QuickActionTheme = 'fae' | 'mingo' | 'it' | 'sec'
131
-
132
- export interface QuickActionThemeSpec {
133
- /** Chip icon tint ({@link QuickActionAccent}: brand token or CSS color). */
134
- accent: QuickActionAccent
135
- /** Classification affix rendered inside the chip (category themes only). */
136
- lozenge?: QuickActionChipLozenge
137
- }
138
-
139
- /** Caller-injected per-theme accent overrides — how SERVER-CONFIGURED agent
140
- * colors reach the chips. Consumers resolve the configured value (e.g.
141
- * {@link accentFromIdentityIcon} on the agent row's `icon_props.color`) and
142
- * inject it here; {@link QUICK_ACTION_THEMES} below is fallback-only. */
143
- export type QuickActionThemeAccents = Partial<Record<QuickActionTheme, QuickActionAccent>>
144
-
145
- /** FALLBACK-ONLY theme specs (same contract as {@link getAgentAccent}): the
146
- * built-in accents apply when the caller injects nothing. The fae/mingo
147
- * accents in particular are server-configured per agent — walls/chips accept
148
- * a {@link QuickActionThemeAccents} override (`themeAccents`/`themeAccent`)
149
- * that always wins over this map. */
150
- export const QUICK_ACTION_THEMES: Record<QuickActionTheme, QuickActionThemeSpec> = {
151
- fae: { accent: AGENT_ACCENTS.fae },
152
- mingo: { accent: AGENT_ACCENTS.mingo },
153
- it: { accent: 'var(--color-warning)', lozenge: { label: 'IT', className: 'text-ods-warning' } },
154
- sec: { accent: 'var(--color-error)', lozenge: { label: 'SEC', className: 'text-ods-error' } },
155
- }
156
-
157
107
  /** Resolve a chip label + optional lozenge to the Tag label node. */
158
108
  function composeChipLabel(
159
109
  label: React.ReactNode,
@@ -186,17 +136,9 @@ export interface QuickActionChipButtonProps {
186
136
  /** Icon: a declarative {@link QuickActionIconSpec} (preferred — unified
187
137
  * EntityIcon resolution) or a pre-rendered ReactNode. */
188
138
  icon?: React.ReactNode | QuickActionIconSpec
189
- /** Chip theme ({@link QuickActionTheme}): supplies the icon accent when the
190
- * icon spec doesn't carry its own, and the lozenge when `lozenge` is
191
- * `true`. Explicit values always win over the theme. */
192
- theme?: QuickActionTheme
193
- /** Caller-injected accent for `theme` — the server-configured agent color
194
- * (resolve via {@link accentFromIdentityIcon}). Overrides the theme's
195
- * built-in fallback accent; the icon spec's own `accent` still wins. */
196
- themeAccent?: QuickActionAccent
197
- /** {@link QuickActionChipLozenge} at the label's leading edge (e.g. an
198
- * IT/SEC classification affix). `true` renders the `theme`'s lozenge. */
199
- lozenge?: QuickActionChipLozenge | boolean
139
+ /** Optional {@link QuickActionChipLozenge} at the label's leading edge
140
+ * (e.g. an IT/SEC classification affix). */
141
+ lozenge?: QuickActionChipLozenge
200
142
  /** `'primary'` = accent (yellow) chip, `'outline'` = bordered chip (default). */
201
143
  variant?: 'primary' | 'outline'
202
144
  /** Active single-select state (Figma "Feature Item" active): renders the
@@ -285,8 +227,6 @@ export function QuickActionChipSkeleton({ labelCh = 16, icon = true, lozenge = f
285
227
  export function QuickActionChipButton({
286
228
  label,
287
229
  icon,
288
- theme,
289
- themeAccent,
290
230
  lozenge,
291
231
  variant = 'outline',
292
232
  selected = false,
@@ -298,16 +238,8 @@ export function QuickActionChipButton({
298
238
  interactive = true,
299
239
  className,
300
240
  }: QuickActionChipButtonProps) {
301
- const themeSpec = theme ? QUICK_ACTION_THEMES[theme] : undefined
302
- // Accent precedence: the icon spec's own accent (admin-configured color,
303
- // agent identity) → caller-injected `themeAccent` (server-configured agent
304
- // color) → the theme's built-in fallback.
305
- const accent = themeAccent ?? themeSpec?.accent
306
- const themedIcon =
307
- accent && isQuickActionIconSpec(icon) && !icon.accent ? { ...icon, accent } : icon
308
- const resolvedLozenge = lozenge === true ? themeSpec?.lozenge : lozenge === false ? undefined : lozenge
309
- const resolvedIcon = renderQuickActionIcon(themedIcon)
310
- const resolvedLabel = composeChipLabel(label, resolvedLozenge)
241
+ const resolvedIcon = renderQuickActionIcon(icon)
242
+ const resolvedLabel = composeChipLabel(label, lozenge)
311
243
  const tagVariant = selected ? (selectedAccent === 'cyan' ? 'selectedCyan' : 'selected') : variant
312
244
  if (!interactive) {
313
245
  return <Tag variant={tagVariant} size={size} icon={resolvedIcon} label={resolvedLabel} className={className} />
@@ -1,10 +1,8 @@
1
1
  'use client'
2
2
 
3
3
  import * as React from 'react'
4
- import type { MarqueeSyncController } from '../ui/marquee-wall'
5
- import type { QuickActionIconSpec } from './quick-action-chip'
6
- import type { QuickActionChip } from './chat-quick-action-row'
7
- import { QuickActionWall } from './quick-action-wall'
4
+ import { cn } from '../../utils/cn'
5
+ import { QuickActionChipButton, type QuickActionIconSpec } from './quick-action-chip'
8
6
 
9
7
  // =============================================================================
10
8
  // Types
@@ -19,81 +17,76 @@ export interface QuickActionMarqueeItem {
19
17
 
20
18
  export interface QuickActionMarqueeProps {
21
19
  items: ReadonlyArray<QuickActionMarqueeItem>
22
- /** `'animated'` (default): endless scroll. `'plain'`: a static chip row —
23
- * the consumer's opt-out of the marquee (no motion, no track padding
24
- * effects beyond the plain layout). */
25
- mode?: 'animated' | 'plain'
26
- /** Scroll direction. `'right'` reverses the travel. Default `'left'`. */
20
+ /** Scroll direction. `'right'` reverses the animation. Default `'left'`. */
27
21
  direction?: 'left' | 'right'
28
- /** Scroll speed in px/s — the shared marquee unit (CardsStrip cruises at
29
- * 60). Default 40: on-screen speed stays constant no matter how many quick
30
- * actions an agent has (the engine is position-based, not loop-based). */
31
- speed?: number
22
+ /** Seconds per full loop. Default 40. */
23
+ duration?: number
32
24
  /** Pause the scroll while hovered. Default true. */
33
25
  pauseOnHover?: boolean
34
26
  /** When provided chips are interactive buttons; omitted → decorative tags. */
35
27
  onSelect?: (item: QuickActionMarqueeItem) => void
36
- /** Repeat-pad the track to at least this many chips so one copy overflows
37
- * the widest container this strip can get (the loop only engages on
38
- * overflow). Default 12 ≈ 2600px+, enough for a full-bleed max-w-[1920px]
39
- * section. */
40
- minChips?: number
41
- /** Pair with a sibling marquee under ONE position driver (the resolve
42
- * strips' pending/resolved copies stay pixel-locked). */
43
- sync?: MarqueeSyncController
44
28
  className?: string
45
29
  }
46
30
 
31
+ // Heuristic: ~6 chips ≈ one 1280px viewport width, so the halved track always
32
+ // fills the screen; bump if chips shrink.
33
+ const MIN_TRACK_ITEMS = 6
34
+
47
35
  // =============================================================================
48
36
  // Component
49
37
  // =============================================================================
50
38
 
51
39
  /**
52
40
  * Endless horizontally-scrolling strip of quick-action chips (Figma fae/mingo
53
- * marquee rows) — the single-row preset of {@link QuickActionWall} (same
54
- * chips, same engine, `flex-nowrap` layout). Clone-copy chips stay
55
- * interactive when `onSelect` is set (clone wrapper a11y applies). Pauses on
56
- * hover (optional) and always under `prefers-reduced-motion`.
41
+ * marquee rows). The item list is padded to at least {@link MIN_TRACK_ITEMS}
42
+ * and rendered twice so the `qa-marquee` keyframe's `translateX(-50%)` loops
43
+ * seamlessly; the second half is `aria-hidden`. Pauses on hover (optional)
44
+ * and always under `prefers-reduced-motion`.
57
45
  */
58
46
  export function QuickActionMarquee({
59
47
  items,
60
- mode = 'animated',
61
48
  direction = 'left',
62
- speed = 40,
49
+ duration = 40,
63
50
  pauseOnHover = true,
64
51
  onSelect,
65
- minChips = 12,
66
- sync,
67
52
  className,
68
53
  }: QuickActionMarqueeProps) {
69
- const chips = React.useMemo<QuickActionChip[]>(
70
- () =>
71
- items.map(item => ({
72
- id: item.id,
73
- label: item.label,
74
- icon: item.icon,
75
- onSelect: onSelect ? () => onSelect(item) : undefined,
76
- })),
77
- [items, onSelect],
78
- )
54
+ const { track, half } = React.useMemo(() => {
55
+ const repeats = Math.max(1, Math.ceil(MIN_TRACK_ITEMS / Math.max(1, items.length)))
56
+ const padded = Array.from({ length: repeats }, () => items).flat()
57
+ return { track: [...padded, ...padded], half: padded.length }
58
+ }, [items])
79
59
 
80
60
  if (items.length === 0) return null
81
61
 
82
62
  return (
83
- <QuickActionWall
84
- chips={chips}
85
- mode={mode}
86
- axis="x"
87
- reverse={direction === 'right'}
88
- speed={speed}
89
- pauseOnHover={pauseOnHover}
90
- // Plain mode shows the items once, untruncated; padding only serves
91
- // the endless loop.
92
- minChips={mode === 'plain' ? undefined : minChips}
93
- copyGap="var(--spacing-system-xsf)"
94
- className={className}
95
- contentClassName="flex-nowrap items-center"
96
- sync={sync}
97
- />
63
+ <div className={cn('group overflow-hidden', className)}>
64
+ <div
65
+ className={cn(
66
+ 'flex w-max items-center gap-2 animate-qa-marquee motion-reduce:[animation-play-state:paused]',
67
+ direction === 'right' && '[animation-direction:reverse]',
68
+ pauseOnHover && 'group-hover:[animation-play-state:paused]',
69
+ )}
70
+ style={{ '--qa-marquee-duration': `${duration}s` } as React.CSSProperties}
71
+ >
72
+ {track.map((item, index) => {
73
+ const isHiddenCopy = index >= half
74
+ return (
75
+ <span key={`${item.id}__${index}`} aria-hidden={isHiddenCopy || undefined}>
76
+ {/* The aria-hidden loop copy must contain NO focusable elements
77
+ (WCAG: no focusable descendants under aria-hidden) — its
78
+ chips render as plain tags even when the strip is
79
+ interactive. */}
80
+ <QuickActionChipButton
81
+ label={item.label}
82
+ icon={item.icon}
83
+ interactive={!!onSelect && !isHiddenCopy}
84
+ onSelect={onSelect && !isHiddenCopy ? () => onSelect(item) : undefined}
85
+ />
86
+ </span>
87
+ )
88
+ })}
89
+ </div>
90
+ </div>
98
91
  )
99
92
  }
@@ -44,8 +44,6 @@
44
44
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
45
45
  import { cn } from '../../utils/cn';
46
46
  import { NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport';
47
- import { useMarqueeEngine } from '../../hooks/ui/use-marquee-engine';
48
- import { useSuppressCloneFocus } from '../../hooks/ui/use-suppress-clone-focus';
49
47
  import { Button } from '../ui/button';
50
48
  import { SECTION_HEADING_CLASS } from '../layout/page-heading';
51
49
  import { Chevron02LeftIcon } from '../icons-v2-generated/arrows/chevron-02-left-icon';
@@ -153,8 +151,60 @@ const EMPTY_CHILDREN: ReadonlyArray<React.ReactNode> = [];
153
151
  // Children-mode managed cell
154
152
  // =============================================================================
155
153
 
156
- // (Clone focus suppression lives in hooks/ui/use-suppress-clone-focus — shared
157
- // with <MarqueeWall>, which renders the same 2-copy endless-loop structure.)
154
+ // Every focusable a clone might contain — matched so it can be pulled out of the
155
+ // keyboard tab order (see useSuppressCloneFocus). `[contenteditable]:not(
156
+ // [contenteditable="false"])` catches the empty/`"true"`/`"plaintext-only"`
157
+ // editable forms; `summary` is focusable inside a <details>.
158
+ const CLONE_FOCUSABLE_SELECTOR =
159
+ 'a[href],button,input,select,textarea,iframe,summary,audio[controls],video[controls],[tabindex],[contenteditable]:not([contenteditable="false"])';
160
+
161
+ /**
162
+ * Keep a clone cell's content pointer-CLICKABLE while taking it out of the
163
+ * accessibility tree and the keyboard tab order.
164
+ *
165
+ * `inert` (the previous approach) does all three at once: a11y-hidden,
166
+ * un-focusable AND un-clickable. That third effect was the "news marquee stops
167
+ * being clickable after you scroll it for a while" bug — the 2-copy endless
168
+ * loop ALWAYS paints clone cards inside the viewport near the copy seam, so a
169
+ * user who manually scrolls and parks there is looking at real-looking cards
170
+ * that silently swallow every click. Clones must therefore stay clickable (a
171
+ * click on ANY visible card must open its link — mouse, ⌘-click, middle-click
172
+ * and context-menu all handled natively by the clone's own anchor).
173
+ *
174
+ * The a11y contract is still met without `inert`: the cell keeps `aria-hidden`
175
+ * (out of the a11y tree) and every focusable descendant is forced to
176
+ * `tabindex="-1"` (out of the tab order → no duplicate tab stops, no
177
+ * aria-hidden-focus violation). Re-applied on subtree mutation. This is the
178
+ * standard loop-clone treatment used by Swiper/Splide.
179
+ */
180
+ function useSuppressCloneFocus(active: boolean) {
181
+ const ref = useRef<HTMLDivElement | null>(null);
182
+ useEffect(() => {
183
+ const root = ref.current;
184
+ if (!root || !active) return;
185
+ const suppress = () => {
186
+ root.querySelectorAll<HTMLElement>(CLONE_FOCUSABLE_SELECTOR).forEach(el => {
187
+ // Only stamp the ones not already stamped — avoids touching the DOM (and
188
+ // clobbering an intentional tabindex) on every mutation.
189
+ if (el.getAttribute('tabindex') !== '-1') el.setAttribute('tabindex', '-1');
190
+ });
191
+ };
192
+ suppress();
193
+ // Watch structure AND the attributes that can turn a descendant tabbable
194
+ // (an anchor gaining `href`, React restoring `tabindex="0"`, an element
195
+ // becoming editable, media gaining `controls`). The `!== '-1'` guard above
196
+ // makes our own tabindex writes a no-op, so the observer never loops.
197
+ const mo = new MutationObserver(suppress);
198
+ mo.observe(root, {
199
+ childList: true,
200
+ subtree: true,
201
+ attributes: true,
202
+ attributeFilter: ['href', 'tabindex', 'contenteditable', 'controls'],
203
+ });
204
+ return () => mo.disconnect();
205
+ }, [active]);
206
+ return ref;
207
+ }
158
208
 
159
209
  /**
160
210
  * Children-mode cell: fixed width + hover/keyboard activation (wired here, never
@@ -292,18 +342,10 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
292
342
  }, []);
293
343
 
294
344
  // ---- overflow measurement ---------------------------------------------------
295
- // TWO overflow states on purpose: `overflows` (any real overflow) gates
296
- // chevrons — a strip clipping even a few px must stay navigable —
297
- // while `marqueeEligible` (overflow beyond the joining gap) additionally
298
- // gates cloning + rAF: the cloned track's physical maxScroll is
299
- // 2·copy − gap − viewport, so a copy overflowing by less than the gap
300
- // makes even the zero-buffer wrap range [0, copy) unreachable — the
301
- // engine would clamp at the track end and freeze. Razor-thin overflow
302
- // (≤16px) stays a static, chevron-navigable row instead.
345
+ // `overflows` gates chevrons; `marqueeActive` additionally gates cloning + rAF.
303
346
  const [overflows, setOverflows] = useState(false);
304
- const [marqueeEligible, setMarqueeEligible] = useState(false);
305
347
  const singleCopyWidthRef = useRef(0);
306
- const marqueeActive = autoScroll && !reducedMotion && marqueeEligible && items.length > 0;
348
+ const marqueeActive = autoScroll && !reducedMotion && overflows && items.length > 0;
307
349
 
308
350
  const measure = useCallback(() => {
309
351
  const scroller = scrollerRef.current;
@@ -314,7 +356,6 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
314
356
  const singleCopy = copies === 2 ? (track.scrollWidth - TRACK_GAP_PX) / 2 + TRACK_GAP_PX : track.scrollWidth;
315
357
  singleCopyWidthRef.current = singleCopy;
316
358
  setOverflows(singleCopy > scroller.clientWidth + 1);
317
- setMarqueeEligible(singleCopy > scroller.clientWidth + TRACK_GAP_PX);
318
359
  }, []);
319
360
 
320
361
  useEffect(() => {
@@ -330,26 +371,108 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
330
371
 
331
372
  // ---- pause-reason set + suppress timestamps ---------------------------------
332
373
  // (Card hover — the only hover-based pause reason — lives in activeKeyRef below.)
374
+ const nearViewportRef = useRef(true);
333
375
  const chevronSuppressUntilRef = useRef(0);
334
376
  const userScrollSuppressUntilRef = useRef(0);
335
377
 
336
- // Two-way viewport gate (unlike the fire-once `useNearViewport`): STATE,
337
- // not a pause reason — it gates the engine's `active` so the rAF fully
338
- // stops while the strip is far off-screen (a paused engine would keep
339
- // scheduling frames forever), and because the velocity envelope persists
340
- // across engine restarts, scrolling back resumes at cruise speed instantly.
341
- const [nearViewport, setNearViewport] = useState(true);
378
+ // Two-way viewport gate (unlike the fire-once `useNearViewport`): the rAF
379
+ // should stop again when the strip scrolls far off-screen.
342
380
  useEffect(() => {
343
381
  const el = wrapperRef.current;
344
382
  if (!el || typeof IntersectionObserver === 'undefined') return;
345
383
  const io = new IntersectionObserver(
346
- entries => setNearViewport(entries[0]?.isIntersecting ?? true),
384
+ entries => { nearViewportRef.current = entries[0]?.isIntersecting ?? true; },
347
385
  { rootMargin: '200px' },
348
386
  );
349
387
  io.observe(el);
350
388
  return () => io.disconnect();
351
389
  }, []);
352
390
 
391
+ // ---- marquee rAF engine ------------------------------------------------------
392
+ // Position lives in a FLOAT ref, not in scrollLeft read-modify-write: reading
393
+ // scrollLeft back each frame returns a rounded value, so sub-pixel increments
394
+ // (speed/fps < 1px) get eaten by rounding — the old engine both stuttered AND
395
+ // ran measurably slower than the configured speed because of it.
396
+ //
397
+ // Chevron GLIDE also runs through this engine (glideRemainingRef): browser
398
+ // `scrollBy({behavior:'smooth'})` animates toward an ABSOLUTE target, so a
399
+ // seam warp mid-animation made it lunge a full copy-width to catch up, warp
400
+ // again, and oscillate — rapid chevron clicks left every card flickering.
401
+ // The rAF tween consumes a signed remaining-distance instead, wrapping
402
+ // modulo the copy width each frame, so warps are invisible to it.
403
+ const marqueePosRef = useRef(0);
404
+ const glideRemainingRef = useRef(0);
405
+ // Velocity envelope (px/s): the marquee EASES between 0 and autoScrollSpeed
406
+ // (~250ms time constant) instead of binary stop/start — pause decelerates,
407
+ // resume accelerates from the current position (GSAP-marquee behavior;
408
+ // hard cuts read as jank).
409
+ const speedEnvRef = useRef(0);
410
+ useEffect(() => {
411
+ if (!marqueeActive) return;
412
+ const scroller = scrollerRef.current;
413
+ if (!scroller) return;
414
+ let raf = 0;
415
+ let last = performance.now();
416
+ marqueePosRef.current = scroller.scrollLeft;
417
+ const wrap = (pos: number) => {
418
+ const half = singleCopyWidthRef.current;
419
+ if (half <= 0) return pos;
420
+ while (pos >= half) pos -= half;
421
+ while (pos < 0) pos += half;
422
+ return pos;
423
+ };
424
+ const tick = (now: number) => {
425
+ const dt = Math.min(now - last, 100) / 1000; // clamp tab-wake jumps
426
+ last = now;
427
+ // Container-level pause (industry-standard marquee behavior): the
428
+ // marquee freezes whenever a hover-capable pointer is ANYWHERE over the
429
+ // cards row — content must never move under a pointing cursor. Per-card
430
+ // activeKey still pauses for keyboard focus (no pointer involved).
431
+ const paused =
432
+ (pauseOnHover && (hoverPointerRef.current.inside || activeKeyRef.current !== null)) ||
433
+ !nearViewportRef.current ||
434
+ document.visibilityState === 'hidden' ||
435
+ now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current);
436
+ // Ease the marquee velocity toward its target (0 when paused) — smooth
437
+ // decel on hover, smooth accel on leave, always from the CURRENT position.
438
+ const targetSpeed = paused ? 0 : autoScrollSpeed;
439
+ speedEnvRef.current += (targetSpeed - speedEnvRef.current) * Math.min(1, dt / 0.25);
440
+ if (targetSpeed === 0 && speedEnvRef.current < 0.5) speedEnvRef.current = 0;
441
+
442
+ const glide = glideRemainingRef.current;
443
+ if (glide !== 0) {
444
+ // Ease-out toward the chevron target; runs even while "paused"
445
+ // (the chevron click itself sets the suppress window).
446
+ if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
447
+ marqueePosRef.current = scroller.scrollLeft;
448
+ }
449
+ const speed = Math.max(Math.abs(glide) * 6, 240); // px/s, proportional
450
+ const step = Math.sign(glide) * Math.min(Math.abs(glide), speed * dt);
451
+ glideRemainingRef.current = Math.abs(glide - step) < 0.5 ? 0 : glide - step;
452
+ marqueePosRef.current = wrap(marqueePosRef.current + step);
453
+ scroller.scrollLeft = marqueePosRef.current;
454
+ } else if (speedEnvRef.current > 0) {
455
+ // Resync after external movement (user scroll, seam warp).
456
+ if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
457
+ marqueePosRef.current = scroller.scrollLeft;
458
+ }
459
+ marqueePosRef.current = wrap(marqueePosRef.current + speedEnvRef.current * dt);
460
+ scroller.scrollLeft = marqueePosRef.current;
461
+ } else {
462
+ marqueePosRef.current = scroller.scrollLeft;
463
+ }
464
+ // Track moved under a possibly-stationary pointer → re-resolve which
465
+ // card is hovered (declared below; stable useCallback identity, called
466
+ // lazily per frame so source order is irrelevant — deliberately NOT a
467
+ // dep, same as the activeKeyRef read above).
468
+ syncHoverIfScrolled();
469
+ raf = requestAnimationFrame(tick);
470
+ };
471
+ raf = requestAnimationFrame(tick);
472
+ return () => cancelAnimationFrame(raf);
473
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- syncHoverIfScrolled is identity-stable
474
+ }, [marqueeActive, autoScrollSpeed, pauseOnHover]);
475
+
353
476
  // ---- hover / overlay state -----------------------------------------------------
354
477
  // activeKey identifies the hovered/tap-activated CARD (copy-aware so hovering
355
478
  // a clone near the wrap seam works too). It only drives per-card `active`
@@ -439,54 +562,6 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
439
562
  syncHoverToPointer();
440
563
  }, [syncHoverToPointer]);
441
564
 
442
- // ---- marquee rAF engine (shared core) ---------------------------------------
443
- // Position/envelope/wrap/glide live in `useMarqueeEngine` — THE marquee
444
- // animation, shared with <MarqueeWall> (quick-action walls, chip marquees).
445
- // CardsStrip contributes the scroller driver (apply/readBack on scrollLeft:
446
- // the user can also drag this surface) and its pause-reason set: container-
447
- // level hover (industry-standard — content must never move under a pointing
448
- // cursor; per-card activeKey still pauses for keyboard focus), hidden tab,
449
- // and the chevron / user-scroll suppress windows. Chevron GLIDE
450
- // runs through the engine too: browser `scrollBy({behavior:'smooth'})`
451
- // animates toward an ABSOLUTE target, so a seam warp mid-animation made it
452
- // lunge a full copy-width to catch up, warp again, and oscillate.
453
- // Seam-warp BUFFER: the re-center zones sit this far inside the physical
454
- // track edges. An exact-edge check (`scrollLeft <= 0`) is racy against the
455
- // engine — the rAF nudges the scroller off 0 before the scroll handler
456
- // samples it, so a user scrolling left "sticks" 1–2px from the edge and
457
- // the loop never wraps. The engine's wrap range is aligned to
458
- // [buffer, buffer + copy) so engine-written positions can never enter a
459
- // zone and ping-pong with the handler. The buffer is ALSO capped by the
460
- // physical slack (copy − viewport − gap): the range's upper end
461
- // buffer + copy must stay reachable below maxScroll = 2·copy − gap −
462
- // viewport, or a barely-overflowing strip clamps at the track end and the
463
- // wrap threshold is never crossed — a permanently frozen marquee.
464
- const seamBuffer = useCallback(() => {
465
- const half = singleCopyWidthRef.current;
466
- const viewport = scrollerRef.current?.clientWidth ?? 0;
467
- return Math.max(0, Math.min(200, half / 4, half - viewport - TRACK_GAP_PX - 1));
468
- }, []);
469
-
470
- const { posRef: marqueePosRef, glideBy } = useMarqueeEngine({
471
- // Viewport gates `active` (the rAF fully stops off-screen — a paused
472
- // engine would keep scheduling frames forever), same treatment as
473
- // <MarqueeWall>; the envelope persists, so re-entry resumes at cruise.
474
- active: marqueeActive && nearViewport,
475
- speed: autoScrollSpeed,
476
- isPaused: now =>
477
- (pauseOnHover && (hoverPointerRef.current.inside || activeKeyRef.current !== null)) ||
478
- document.visibilityState === 'hidden' ||
479
- now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current),
480
- getWrapSize: () => singleCopyWidthRef.current,
481
- getWrapMin: seamBuffer,
482
- apply: pos => {
483
- const scroller = scrollerRef.current;
484
- if (scroller) scroller.scrollLeft = pos;
485
- },
486
- readBack: () => scrollerRef.current?.scrollLeft ?? 0,
487
- onAfterFrame: syncHoverIfScrolled,
488
- });
489
-
490
565
  // ---- shared card-mount gate (by ITEM index, across both copies) -------------
491
566
  // A card and its clone show identical content half a copy-width apart. If
492
567
  // each card gated its own expensive content (e.g. video players), a seam warp
@@ -553,12 +628,12 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
553
628
  if (marqueeActiveRef.current) {
554
629
  // Wrap-aware glide via the rAF engine (see engine comment) — clicks
555
630
  // accumulate distance instead of racing browser smooth-scroll targets.
556
- glideBy(dir * step);
631
+ glideRemainingRef.current += dir * step;
557
632
  } else {
558
633
  // No clones/seam without the marquee — native smooth scroll is safe.
559
634
  scroller.scrollBy({ left: dir * step, behavior: 'smooth' });
560
635
  }
561
- }, [glideBy]);
636
+ }, []);
562
637
 
563
638
  const onUserScrollIntent = useCallback(() => {
564
639
  userScrollSuppressUntilRef.current = performance.now() + USER_SCROLL_SUPPRESS_MS;
@@ -573,18 +648,13 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
573
648
  if (e.pointerType === 'touch') onUserScrollIntent();
574
649
  }, [onUserScrollIntent]);
575
650
 
576
- // Never-ending strip: re-center across the clone seam on EVERY scroll
577
- // (manual wheel/drag/chevron included — the rAF only wraps while the
578
- // marquee runs, so without this a user scrolling during a pause/suppress
579
- // window hits the physical track edges). Both copies render identical
580
- // content, so a ±half jump is pixel-invisible. ZONES, not exact edges:
581
- // below `buffer` warp forward (+half, lands under half+buffer — outside
582
- // the other zone, so the pair is hysteresis-stable), at/above
583
- // half+buffer warp back (−half, lands at ≥buffer). The user can never
584
- // reach a physical edge in either direction, and the racy "observe
585
- // exactly 0" check that stuck leftward scrolling is gone. Initial rest at
586
- // 0 warps once to `half` — pixel-identical, and it buys leftward headroom
587
- // immediately.
651
+ // Never-ending strip: warp across the clone seam on EVERY scroll (manual
652
+ // wheel/drag/chevron included — the rAF only wraps while the marquee runs,
653
+ // so without this a user scrolling during a pause/suppress window hits the
654
+ // physical track edges). Both copies render identical content, so a ±half
655
+ // jump is pixel-invisible. Backward warp only when arriving AT the left
656
+ // edge from the right (never on initial rest at 0).
657
+ const lastScrollLeftRef = useRef(0);
588
658
  const marqueeActiveRef = useRef(false);
589
659
  marqueeActiveRef.current = marqueeActive;
590
660
  const onSeamWarp = useCallback(() => {
@@ -597,18 +667,18 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
597
667
  if (!marqueeActiveRef.current) return;
598
668
  const half = singleCopyWidthRef.current;
599
669
  if (half <= 0) return;
600
- const buffer = seamBuffer();
601
670
  let sl = scroller.scrollLeft;
602
- if (sl >= half + buffer) {
671
+ if (sl >= half) {
603
672
  sl -= half;
604
673
  scroller.scrollLeft = sl;
605
674
  marqueePosRef.current = sl;
606
- } else if (sl < buffer) {
675
+ } else if (sl <= 0 && lastScrollLeftRef.current > 0.5) {
607
676
  sl += half;
608
677
  scroller.scrollLeft = sl;
609
678
  marqueePosRef.current = sl;
610
679
  }
611
- }, [syncHoverIfScrolled, seamBuffer]);
680
+ lastScrollLeftRef.current = sl;
681
+ }, [syncHoverIfScrolled]);
612
682
 
613
683
  // ---- render ----------------------------------------------------------------
614
684
 
@@ -172,7 +172,6 @@ export * from './ticket-detail-section'
172
172
  export * from './ticket-attachments-list'
173
173
  export * from './ticket-note-card'
174
174
  export * from './ticket-notes-section'
175
- export * from './marquee-wall'
176
175
  export * from './simple-markdown-renderer'
177
176
  export { RichMarkdownRenderer, type RichMarkdownRendererProps } from './rich-markdown-renderer'
178
177
  export * from './filter-pill-row'
@@ -7,13 +7,11 @@ export * from './use-header-height'
7
7
  export * from './use-horizontal-scrollbar'
8
8
  export * from './use-image-edge-color'
9
9
  export * from './use-local-storage'
10
- export * from './use-marquee-engine'
11
10
  export * from './use-media-query'
12
11
  export * from './use-memoized-callback'
13
12
  export * from './use-notification-permission'
14
13
  export * from './use-onboarding-state'
15
14
  export * from './use-search'
16
- export * from './use-suppress-clone-focus'
17
15
  export * from './use-table-pagination'
18
16
  export * from './use-throttle'
19
17
  export * from './use-window-size'