@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.
- package/dist/{chunk-DQ5U6DCR.cjs → chunk-47AMTR2S.cjs} +18 -18
- package/dist/{chunk-DQ5U6DCR.cjs.map → chunk-47AMTR2S.cjs.map} +1 -1
- package/dist/{chunk-PCW2TRZ4.js → chunk-4IMXFDRI.js} +3 -3
- package/dist/{chunk-XITG5ICQ.cjs → chunk-4S4RR7XC.cjs} +40 -40
- package/dist/{chunk-XITG5ICQ.cjs.map → chunk-4S4RR7XC.cjs.map} +1 -1
- package/dist/{chunk-HXCQHMS5.js → chunk-5SJGFLL2.js} +6032 -6386
- package/dist/chunk-5SJGFLL2.js.map +1 -0
- package/dist/{chunk-6RA4NRBA.cjs → chunk-6TS4AIDX.cjs} +15 -15
- package/dist/{chunk-6RA4NRBA.cjs.map → chunk-6TS4AIDX.cjs.map} +1 -1
- package/dist/{chunk-QF3NQW7C.cjs → chunk-AN7MCJTV.cjs} +29 -122
- package/dist/chunk-AN7MCJTV.cjs.map +1 -0
- package/dist/{chunk-J5BYYZVM.cjs → chunk-AZKVNRZP.cjs} +32 -32
- package/dist/{chunk-J5BYYZVM.cjs.map → chunk-AZKVNRZP.cjs.map} +1 -1
- package/dist/{chunk-VUDZOCPW.js → chunk-BSBWBQ7M.js} +6 -6
- package/dist/{chunk-26JSJMMZ.cjs → chunk-DJP5HYFG.cjs} +7 -7
- package/dist/{chunk-26JSJMMZ.cjs.map → chunk-DJP5HYFG.cjs.map} +1 -1
- package/dist/{chunk-5HW5HMNF.cjs → chunk-DRKQNXSZ.cjs} +5 -5
- package/dist/{chunk-5HW5HMNF.cjs.map → chunk-DRKQNXSZ.cjs.map} +1 -1
- package/dist/{chunk-ROBGJH52.cjs → chunk-EOFENGSZ.cjs} +12 -12
- package/dist/{chunk-ROBGJH52.cjs.map → chunk-EOFENGSZ.cjs.map} +1 -1
- package/dist/{chunk-N5M72LJX.cjs → chunk-H56AEHFC.cjs} +1266 -1620
- package/dist/chunk-H56AEHFC.cjs.map +1 -0
- package/dist/{chunk-RQHHACP2.js → chunk-HQ7S6JG5.js} +3 -3
- package/dist/{chunk-K3GXIGN6.js → chunk-JAASDJOF.js} +5 -5
- package/dist/{chunk-5OY7HYII.cjs → chunk-KKJRBMG3.cjs} +73 -73
- package/dist/{chunk-5OY7HYII.cjs.map → chunk-KKJRBMG3.cjs.map} +1 -1
- package/dist/{chunk-VLDZ4363.cjs → chunk-LQTX7EJS.cjs} +29 -29
- package/dist/{chunk-VLDZ4363.cjs.map → chunk-LQTX7EJS.cjs.map} +1 -1
- package/dist/{chunk-4VSTLJDO.js → chunk-LWT6G75Q.js} +2 -2
- package/dist/{chunk-4SZDO3TE.js → chunk-MWRFPLNB.js} +3 -3
- package/dist/{chunk-3ITY2LBM.js → chunk-NPBTNWRA.js} +4 -4
- package/dist/{chunk-UPRY4I63.cjs → chunk-ROCJMSJI.cjs} +3 -3
- package/dist/{chunk-UPRY4I63.cjs.map → chunk-ROCJMSJI.cjs.map} +1 -1
- package/dist/{chunk-MLFFBQ5B.js → chunk-TKGCTDQY.js} +87 -180
- package/dist/chunk-TKGCTDQY.js.map +1 -0
- package/dist/{chunk-M3AKWFAO.js → chunk-UNQVTGVG.js} +2 -2
- package/dist/{chunk-HYIHXXF3.js → chunk-VSWFYFIG.js} +5 -5
- package/dist/{chunk-6GJKSYM2.cjs → chunk-WYPROXL4.cjs} +73 -73
- package/dist/{chunk-6GJKSYM2.cjs.map → chunk-WYPROXL4.cjs.map} +1 -1
- package/dist/{chunk-76EN3GEH.cjs → chunk-X754WDWO.cjs} +40 -40
- package/dist/{chunk-76EN3GEH.cjs.map → chunk-X754WDWO.cjs.map} +1 -1
- package/dist/{chunk-N53FC732.js → chunk-XLFAFH2B.js} +2 -2
- package/dist/{chunk-EQWE64RJ.js → chunk-YHNBJ3F3.js} +2 -2
- package/dist/{chunk-CM5XKHHS.js → chunk-YSNMV53O.js} +6 -6
- package/dist/components/case-studies/index.cjs +9 -9
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/chat/chat-quick-action-row.d.ts +1 -26
- package/dist/components/chat/chat-quick-action-row.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +3 -15
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +0 -1
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +2 -14
- package/dist/components/chat/quick-action-chip.d.ts +4 -45
- package/dist/components/chat/quick-action-chip.d.ts.map +1 -1
- package/dist/components/chat/quick-action-marquee.d.ts +9 -24
- package/dist/components/chat/quick-action-marquee.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/content-loading-container.d.ts +1 -1
- package/dist/components/docs/index.cjs +6 -6
- package/dist/components/docs/index.js +5 -5
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +5 -5
- package/dist/components/faq/index.js +4 -4
- package/dist/components/features/cards-strip.d.ts.map +1 -1
- package/dist/components/features/index.cjs +3 -3
- package/dist/components/features/index.js +2 -2
- package/dist/components/form.d.ts +1 -1
- package/dist/components/form.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +135 -159
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +11 -35
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/onboarding-guides/index.cjs +7 -7
- package/dist/components/onboarding-guides/index.js +6 -6
- package/dist/components/related-content/index.cjs +5 -5
- package/dist/components/related-content/index.js +4 -4
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +3 -27
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +0 -1
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +2 -26
- package/dist/hooks/index.cjs +2 -6
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +1 -5
- package/dist/hooks/ui/index.d.ts +0 -2
- package/dist/hooks/ui/index.d.ts.map +1 -1
- package/dist/index.cjs +3 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +2 -30
- package/dist/tailwind.config.cjs +8 -1
- package/dist/tailwind.config.cjs.map +1 -1
- package/dist/tailwind.config.js +8 -1
- package/dist/tailwind.config.js.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/chat-quick-action-row.tsx +3 -47
- package/src/components/chat/index.ts +0 -1
- package/src/components/chat/quick-action-chip.tsx +13 -81
- package/src/components/chat/quick-action-marquee.tsx +47 -54
- package/src/components/features/cards-strip.tsx +158 -88
- package/src/components/ui/index.ts +0 -1
- package/src/hooks/ui/index.ts +0 -2
- package/src/stories/QuickActionMarquee.stories.tsx +7 -8
- package/tailwind.config.ts +7 -0
- package/dist/chunk-HXCQHMS5.js.map +0 -1
- package/dist/chunk-MLFFBQ5B.js.map +0 -1
- package/dist/chunk-N5M72LJX.cjs.map +0 -1
- package/dist/chunk-QF3NQW7C.cjs.map +0 -1
- package/dist/components/chat/quick-action-wall.d.ts +0 -96
- package/dist/components/chat/quick-action-wall.d.ts.map +0 -1
- package/dist/components/ui/marquee-wall.d.ts +0 -159
- package/dist/components/ui/marquee-wall.d.ts.map +0 -1
- package/dist/hooks/ui/use-marquee-engine.d.ts +0 -73
- package/dist/hooks/ui/use-marquee-engine.d.ts.map +0 -1
- package/dist/hooks/ui/use-suppress-clone-focus.d.ts +0 -24
- package/dist/hooks/ui/use-suppress-clone-focus.d.ts.map +0 -1
- package/src/components/chat/quick-action-wall.tsx +0 -247
- package/src/components/ui/marquee-wall.tsx +0 -522
- package/src/hooks/ui/use-marquee-engine.ts +0 -158
- package/src/hooks/ui/use-suppress-clone-focus.ts +0 -61
- package/src/stories/MarqueeWall.stories.tsx +0 -132
- package/src/stories/QuickActionWall.stories.tsx +0 -116
- /package/dist/{chunk-PCW2TRZ4.js.map → chunk-4IMXFDRI.js.map} +0 -0
- /package/dist/{chunk-VUDZOCPW.js.map → chunk-BSBWBQ7M.js.map} +0 -0
- /package/dist/{chunk-RQHHACP2.js.map → chunk-HQ7S6JG5.js.map} +0 -0
- /package/dist/{chunk-K3GXIGN6.js.map → chunk-JAASDJOF.js.map} +0 -0
- /package/dist/{chunk-4VSTLJDO.js.map → chunk-LWT6G75Q.js.map} +0 -0
- /package/dist/{chunk-4SZDO3TE.js.map → chunk-MWRFPLNB.js.map} +0 -0
- /package/dist/{chunk-3ITY2LBM.js.map → chunk-NPBTNWRA.js.map} +0 -0
- /package/dist/{chunk-M3AKWFAO.js.map → chunk-UNQVTGVG.js.map} +0 -0
- /package/dist/{chunk-HYIHXXF3.js.map → chunk-VSWFYFIG.js.map} +0 -0
- /package/dist/{chunk-N53FC732.js.map → chunk-XLFAFH2B.js.map} +0 -0
- /package/dist/{chunk-EQWE64RJ.js.map → chunk-YHNBJ3F3.js.map} +0 -0
- /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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
190
|
-
*
|
|
191
|
-
|
|
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
|
|
302
|
-
|
|
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
|
|
5
|
-
import type
|
|
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
|
-
/** `'
|
|
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
|
-
/**
|
|
29
|
-
|
|
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)
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
-
|
|
49
|
+
duration = 40,
|
|
63
50
|
pauseOnHover = true,
|
|
64
51
|
onSelect,
|
|
65
|
-
minChips = 12,
|
|
66
|
-
sync,
|
|
67
52
|
className,
|
|
68
53
|
}: QuickActionMarqueeProps) {
|
|
69
|
-
const
|
|
70
|
-
()
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
//
|
|
157
|
-
//
|
|
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
|
-
//
|
|
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 &&
|
|
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`):
|
|
337
|
-
//
|
|
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 =>
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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:
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
|
|
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
|
|
671
|
+
if (sl >= half) {
|
|
603
672
|
sl -= half;
|
|
604
673
|
scroller.scrollLeft = sl;
|
|
605
674
|
marqueePosRef.current = sl;
|
|
606
|
-
} else if (sl
|
|
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
|
-
|
|
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'
|
package/src/hooks/ui/index.ts
CHANGED
|
@@ -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'
|