@flamingo-stack/openframe-frontend-core 0.0.447 → 0.0.448-snapshot.20260718005900

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 (97) hide show
  1. package/dist/{chunk-FR6NVZVM.cjs → chunk-47AMTR2S.cjs} +11 -11
  2. package/dist/{chunk-FR6NVZVM.cjs.map → chunk-47AMTR2S.cjs.map} +1 -1
  3. package/dist/{chunk-G7QSLGMI.js → chunk-4IMXFDRI.js} +2 -2
  4. package/dist/{chunk-ITJMUH4Q.cjs → chunk-4S4RR7XC.cjs} +31 -31
  5. package/dist/{chunk-ITJMUH4Q.cjs.map → chunk-4S4RR7XC.cjs.map} +1 -1
  6. package/dist/{chunk-YCSNDUDE.js → chunk-5SJGFLL2.js} +138 -43
  7. package/dist/chunk-5SJGFLL2.js.map +1 -0
  8. package/dist/{chunk-XUS75SQW.cjs → chunk-6TS4AIDX.cjs} +15 -15
  9. package/dist/{chunk-XUS75SQW.cjs.map → chunk-6TS4AIDX.cjs.map} +1 -1
  10. package/dist/{chunk-EZMM2OMP.cjs → chunk-AZKVNRZP.cjs} +26 -26
  11. package/dist/{chunk-EZMM2OMP.cjs.map → chunk-AZKVNRZP.cjs.map} +1 -1
  12. package/dist/{chunk-3GBLPPSW.js → chunk-BSBWBQ7M.js} +4 -4
  13. package/dist/{chunk-E6HKLQ3Q.cjs → chunk-DJP5HYFG.cjs} +7 -7
  14. package/dist/{chunk-E6HKLQ3Q.cjs.map → chunk-DJP5HYFG.cjs.map} +1 -1
  15. package/dist/{chunk-PF6CKZIH.cjs → chunk-DRKQNXSZ.cjs} +5 -5
  16. package/dist/{chunk-PF6CKZIH.cjs.map → chunk-DRKQNXSZ.cjs.map} +1 -1
  17. package/dist/{chunk-S7VMUDUB.cjs → chunk-EOFENGSZ.cjs} +9 -9
  18. package/dist/{chunk-S7VMUDUB.cjs.map → chunk-EOFENGSZ.cjs.map} +1 -1
  19. package/dist/{chunk-RNXMP7GB.cjs → chunk-H56AEHFC.cjs} +138 -43
  20. package/dist/chunk-H56AEHFC.cjs.map +1 -0
  21. package/dist/{chunk-5BJJRPDH.js → chunk-HQ7S6JG5.js} +2 -2
  22. package/dist/{chunk-TMZYFYSS.js → chunk-JAASDJOF.js} +4 -4
  23. package/dist/{chunk-2PNWAAU4.cjs → chunk-KKJRBMG3.cjs} +59 -59
  24. package/dist/{chunk-2PNWAAU4.cjs.map → chunk-KKJRBMG3.cjs.map} +1 -1
  25. package/dist/{chunk-KYLOPUIH.cjs → chunk-LQTX7EJS.cjs} +26 -26
  26. package/dist/{chunk-KYLOPUIH.cjs.map → chunk-LQTX7EJS.cjs.map} +1 -1
  27. package/dist/{chunk-4ONE3HQP.js → chunk-LWT6G75Q.js} +2 -2
  28. package/dist/{chunk-SHC5NFOH.js → chunk-MWRFPLNB.js} +2 -2
  29. package/dist/{chunk-ZYB3BYBA.js → chunk-NPBTNWRA.js} +2 -2
  30. package/dist/{chunk-6TAIXSF5.js → chunk-UNQVTGVG.js} +2 -2
  31. package/dist/{chunk-WMOF3WQT.js → chunk-VSWFYFIG.js} +4 -4
  32. package/dist/{chunk-WLU37UZO.cjs → chunk-WYPROXL4.cjs} +62 -62
  33. package/dist/{chunk-WLU37UZO.cjs.map → chunk-WYPROXL4.cjs.map} +1 -1
  34. package/dist/{chunk-X4XHW3OV.cjs → chunk-X754WDWO.cjs} +37 -37
  35. package/dist/{chunk-X4XHW3OV.cjs.map → chunk-X754WDWO.cjs.map} +1 -1
  36. package/dist/{chunk-D2NLNBLH.js → chunk-XLFAFH2B.js} +2 -2
  37. package/dist/{chunk-ZQVEB6KT.js → chunk-YSNMV53O.js} +4 -4
  38. package/dist/components/case-studies/index.cjs +8 -8
  39. package/dist/components/case-studies/index.js +2 -2
  40. package/dist/components/chat/index.cjs +2 -2
  41. package/dist/components/chat/index.js +1 -1
  42. package/dist/components/chat/types/message.types.d.ts +13 -0
  43. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  44. package/dist/components/chat/utils/history-merge.d.ts +14 -4
  45. package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
  46. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  47. package/dist/components/contact/index.cjs +3 -3
  48. package/dist/components/contact/index.js +2 -2
  49. package/dist/components/docs/index.cjs +5 -5
  50. package/dist/components/docs/index.js +4 -4
  51. package/dist/components/embeds/index.cjs +3 -3
  52. package/dist/components/embeds/index.js +2 -2
  53. package/dist/components/faq/index.cjs +3 -3
  54. package/dist/components/faq/index.js +2 -2
  55. package/dist/components/features/cards-strip.d.ts +2 -1
  56. package/dist/components/features/cards-strip.d.ts.map +1 -1
  57. package/dist/components/features/index.cjs +2 -2
  58. package/dist/components/features/index.js +1 -1
  59. package/dist/components/help-center-pages/index.cjs +20 -20
  60. package/dist/components/help-center-pages/index.js +11 -11
  61. package/dist/components/index.cjs +129 -129
  62. package/dist/components/index.js +9 -9
  63. package/dist/components/navigation/index.cjs +2 -2
  64. package/dist/components/navigation/index.js +1 -1
  65. package/dist/components/onboarding-guides/index.cjs +5 -5
  66. package/dist/components/onboarding-guides/index.js +4 -4
  67. package/dist/components/related-content/index.cjs +3 -3
  68. package/dist/components/related-content/index.js +2 -2
  69. package/dist/components/tickets/index.cjs +5 -5
  70. package/dist/components/tickets/index.js +4 -4
  71. package/dist/components/ui/index.cjs +2 -2
  72. package/dist/components/ui/index.js +1 -1
  73. package/dist/index.cjs +2 -2
  74. package/dist/index.js +1 -1
  75. package/dist/utils/index.cjs.map +1 -1
  76. package/dist/utils/index.js.map +1 -1
  77. package/package.json +1 -1
  78. package/src/components/chat/types/message.types.ts +13 -0
  79. package/src/components/chat/utils/__tests__/history-merge.test.ts +301 -0
  80. package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +35 -0
  81. package/src/components/chat/utils/history-merge.ts +157 -14
  82. package/src/components/chat/utils/process-historical-messages.ts +45 -6
  83. package/src/components/features/cards-strip.tsx +126 -40
  84. package/src/stories/CardsStrip.stories.tsx +5 -3
  85. package/dist/chunk-RNXMP7GB.cjs.map +0 -1
  86. package/dist/chunk-YCSNDUDE.js.map +0 -1
  87. /package/dist/{chunk-G7QSLGMI.js.map → chunk-4IMXFDRI.js.map} +0 -0
  88. /package/dist/{chunk-3GBLPPSW.js.map → chunk-BSBWBQ7M.js.map} +0 -0
  89. /package/dist/{chunk-5BJJRPDH.js.map → chunk-HQ7S6JG5.js.map} +0 -0
  90. /package/dist/{chunk-TMZYFYSS.js.map → chunk-JAASDJOF.js.map} +0 -0
  91. /package/dist/{chunk-4ONE3HQP.js.map → chunk-LWT6G75Q.js.map} +0 -0
  92. /package/dist/{chunk-SHC5NFOH.js.map → chunk-MWRFPLNB.js.map} +0 -0
  93. /package/dist/{chunk-ZYB3BYBA.js.map → chunk-NPBTNWRA.js.map} +0 -0
  94. /package/dist/{chunk-6TAIXSF5.js.map → chunk-UNQVTGVG.js.map} +0 -0
  95. /package/dist/{chunk-WMOF3WQT.js.map → chunk-VSWFYFIG.js.map} +0 -0
  96. /package/dist/{chunk-D2NLNBLH.js.map → chunk-XLFAFH2B.js.map} +0 -0
  97. /package/dist/{chunk-ZQVEB6KT.js.map → chunk-YSNMV53O.js.map} +0 -0
@@ -47,6 +47,7 @@ function pushStandaloneMessages(
47
47
  name: data.text,
48
48
  authorType: 'system',
49
49
  timestamp: new Date(msg.createdAt),
50
+ ...(typeof msg.lastChunkStreamSeq === 'number' ? { streamSeq: msg.lastChunkStreamSeq } : {}),
50
51
  })
51
52
  }
52
53
  })
@@ -99,6 +100,9 @@ export function processHistoricalMessages(
99
100
  let currentAssistantId: string | null = null
100
101
  let currentAssistantTimestamp: Date | null = null
101
102
  let lastAssistantId: string | null = null
103
+ // MAX persisted seq across the rows grouped into the current assistant turn
104
+ // — carried onto the flushed message's streamSeq for per-role merge coverage.
105
+ let currentAssistantStreamSeq: number | undefined
102
106
 
103
107
  /**
104
108
  * Flush the current assistant message to processedMessages.
@@ -116,12 +120,21 @@ export function processHistoricalMessages(
116
120
  authorType: assistantType as AuthorType,
117
121
  timestamp: currentAssistantTimestamp || new Date(),
118
122
  avatar: assistantAvatar,
123
+ ...(currentAssistantStreamSeq !== undefined ? { streamSeq: currentAssistantStreamSeq } : {}),
119
124
  })
120
125
  accumulator.resetSegments()
121
- currentAssistantId = null
122
- currentAssistantTimestamp = null
123
- lastAssistantId = null
124
126
  }
127
+ // Reset grouping identity + seq UNCONDITIONALLY — even on an EMPTY flush (an
128
+ // assistant turn whose only data was a filtered/escalated approval renders
129
+ // nothing, so `hasContent()` is false and nothing is pushed). Left inside
130
+ // the push-block, a stale id/timestamp and (worse) a stale
131
+ // `currentAssistantStreamSeq` bleed into the NEXT assistant turn: `!currentAssistantId`
132
+ // stays false so it keeps the old id/timestamp, and `Math.max` inflates its
133
+ // streamSeq — which then over-covers synthetics in the history merge.
134
+ currentAssistantId = null
135
+ currentAssistantTimestamp = null
136
+ lastAssistantId = null
137
+ currentAssistantStreamSeq = undefined
125
138
  }
126
139
 
127
140
  messages.forEach((msg, index) => {
@@ -171,6 +184,7 @@ export function processHistoricalMessages(
171
184
  authorType: userAuthorType,
172
185
  timestamp: new Date(msg.createdAt),
173
186
  ...(contextItems && contextItems.length > 0 ? { contextItems } : {}),
187
+ ...(typeof msg.lastChunkStreamSeq === 'number' ? { streamSeq: msg.lastChunkStreamSeq } : {}),
174
188
  })
175
189
  }
176
190
  })
@@ -180,6 +194,12 @@ export function processHistoricalMessages(
180
194
  currentAssistantTimestamp = new Date(msg.createdAt)
181
195
  }
182
196
  lastAssistantId = msg.id
197
+ if (typeof msg.lastChunkStreamSeq === 'number') {
198
+ currentAssistantStreamSeq =
199
+ currentAssistantStreamSeq === undefined
200
+ ? msg.lastChunkStreamSeq
201
+ : Math.max(currentAssistantStreamSeq, msg.lastChunkStreamSeq)
202
+ }
183
203
 
184
204
  messageDataArray.forEach((data) => {
185
205
  processMessageData(
@@ -501,6 +521,9 @@ export function processHistoricalMessagesWithErrors(
501
521
  let currentAssistantId: string | null = null
502
522
  let currentAssistantTimestamp: Date | null = null
503
523
  let lastAssistantId: string | null = null
524
+ // MAX persisted seq across the rows grouped into the current assistant turn
525
+ // — carried onto the flushed message's streamSeq for per-role merge coverage.
526
+ let currentAssistantStreamSeq: number | undefined
504
527
 
505
528
  const flushAssistantMessage = () => {
506
529
  const idToUse = lastAssistantId || currentAssistantId
@@ -514,12 +537,21 @@ export function processHistoricalMessagesWithErrors(
514
537
  authorType: assistantType as AuthorType,
515
538
  timestamp: currentAssistantTimestamp || new Date(),
516
539
  avatar: assistantAvatar,
540
+ ...(currentAssistantStreamSeq !== undefined ? { streamSeq: currentAssistantStreamSeq } : {}),
517
541
  })
518
542
  accumulator.resetSegments()
519
- currentAssistantId = null
520
- currentAssistantTimestamp = null
521
- lastAssistantId = null
522
543
  }
544
+ // Reset grouping identity + seq UNCONDITIONALLY — even on an EMPTY flush (an
545
+ // assistant turn whose only data was a filtered/escalated approval renders
546
+ // nothing, so `hasContent()` is false and nothing is pushed). Left inside
547
+ // the push-block, a stale id/timestamp and (worse) a stale
548
+ // `currentAssistantStreamSeq` bleed into the NEXT assistant turn: `!currentAssistantId`
549
+ // stays false so it keeps the old id/timestamp, and `Math.max` inflates its
550
+ // streamSeq — which then over-covers synthetics in the history merge.
551
+ currentAssistantId = null
552
+ currentAssistantTimestamp = null
553
+ lastAssistantId = null
554
+ currentAssistantStreamSeq = undefined
523
555
  }
524
556
 
525
557
  messages.forEach((msg, index) => {
@@ -568,6 +600,7 @@ export function processHistoricalMessagesWithErrors(
568
600
  authorType: userAuthorType,
569
601
  timestamp: new Date(msg.createdAt),
570
602
  ...(contextItems && contextItems.length > 0 ? { contextItems } : {}),
603
+ ...(typeof msg.lastChunkStreamSeq === 'number' ? { streamSeq: msg.lastChunkStreamSeq } : {}),
571
604
  })
572
605
  }
573
606
  })
@@ -577,6 +610,12 @@ export function processHistoricalMessagesWithErrors(
577
610
  currentAssistantTimestamp = new Date(msg.createdAt)
578
611
  }
579
612
  lastAssistantId = msg.id
613
+ if (typeof msg.lastChunkStreamSeq === 'number') {
614
+ currentAssistantStreamSeq =
615
+ currentAssistantStreamSeq === undefined
616
+ ? msg.lastChunkStreamSeq
617
+ : Math.max(currentAssistantStreamSeq, msg.lastChunkStreamSeq)
618
+ }
580
619
 
581
620
  messageDataArray.forEach((data) => {
582
621
  processMessageData(
@@ -17,7 +17,8 @@
17
17
  *
18
18
  * MODE B — children (organic): ANY card components as plain JSX children.
19
19
  * Each child is auto-wrapped in a managed cell (fixed width, hover/keyboard
20
- * marquee-pause, clone `aria-hidden` + `inert`, row-height stretch).
20
+ * marquee-pause, clone `aria-hidden` + focus-suppressed but still CLICKABLE,
21
+ * row-height stretch).
21
22
  * Adding a new card type requires ZERO strip code. Contract:
22
23
  * (a) pass an ARRAY of children (`{rows.map(...)}`) — `Children.toArray`
23
24
  * does NOT flatten through a single <Fragment> child (it would become
@@ -146,6 +147,122 @@ export const STRIP_CELL_MAX_WIDTH = '90vw';
146
147
 
147
148
  const EMPTY_CHILDREN: ReadonlyArray<React.ReactNode> = [];
148
149
 
150
+ // =============================================================================
151
+ // Children-mode managed cell
152
+ // =============================================================================
153
+
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
+ }
208
+
209
+ /**
210
+ * Children-mode cell: fixed width + hover/keyboard activation (wired here, never
211
+ * on the card — the single-place activation invariant). The OUTER cell is never
212
+ * inert/aria-hidden — an inert cell would retarget its pointer events to the
213
+ * track and defeat the clone hover-pause; the INNER wrapper carries the clone
214
+ * a11y treatment.
215
+ */
216
+ function ManagedCell({
217
+ item,
218
+ ctx,
219
+ cardWidthMobile,
220
+ cardWidthDesktop,
221
+ }: {
222
+ item: unknown;
223
+ ctx: CardStripRenderCtx;
224
+ cardWidthMobile: number;
225
+ cardWidthDesktop: number;
226
+ }) {
227
+ // Per-child DESKTOP cell-width hint: heterogeneous card designs need
228
+ // heterogeneous cells (wide horizontal cards vs 400px vertical cards —
229
+ // variable widths are native to the engine, exactly like bite ratios). Read
230
+ // from the child ELEMENT's `data-strip-cell-width` prop; mobile keeps the
231
+ // strip-level width (wide cards stack vertically below md anyway).
232
+ const hintedWidth = React.isValidElement(item)
233
+ ? (item.props as Record<string, unknown>)['data-strip-cell-width']
234
+ : undefined;
235
+ const desktopWidth = typeof hintedWidth === 'number' ? hintedWidth : cardWidthDesktop;
236
+ const cloneRef = useSuppressCloneFocus(ctx.isClone);
237
+ return (
238
+ <div
239
+ className="shrink-0 self-stretch"
240
+ style={{ width: ctx.isMobile ? cardWidthMobile : desktopWidth, maxWidth: STRIP_CELL_MAX_WIDTH }}
241
+ data-strip-card-key={ctx.cardKey}
242
+ onPointerEnter={ctx.isTouch ? undefined : () => ctx.onActivate(ctx.cardKey)}
243
+ onPointerLeave={ctx.isTouch ? undefined : () => ctx.onDeactivate(ctx.cardKey)}
244
+ // Keyboard pause parity (focus bubbles from the card's anchor; WCAG 2.2.2).
245
+ onFocus={() => ctx.onActivate(ctx.cardKey)}
246
+ onBlur={e => {
247
+ // Intra-cell focus moves must not blip the pause off.
248
+ if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
249
+ ctx.onDeactivate(ctx.cardKey);
250
+ }}
251
+ >
252
+ {/* INNER: clone a11y — `aria-hidden` + focusable descendants forced out of
253
+ the tab order (useSuppressCloneFocus), but pointer events preserved so
254
+ a visible clone card stays clickable. Cards untouched. */}
255
+ <div
256
+ ref={cloneRef}
257
+ className="h-full [&>*]:h-full [&>a>*]:h-full"
258
+ aria-hidden={ctx.isClone || undefined}
259
+ >
260
+ {item as React.ReactNode}
261
+ </div>
262
+ </div>
263
+ );
264
+ }
265
+
149
266
  // =============================================================================
150
267
  // Component
151
268
  // =============================================================================
@@ -570,47 +687,16 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
570
687
  // Seam assertion (see normalization comment above).
571
688
  return (props.renderCard as (item: unknown, ctx: CardStripRenderCtx) => React.ReactNode)(item, ctx);
572
689
  }
573
- // Per-child DESKTOP cell-width hint: heterogeneous card designs need
574
- // heterogeneous cells (wide horizontal cards vs 400px vertical cards —
575
- // variable widths are native to the engine, exactly like bite ratios).
576
- // Read from the child ELEMENT's `data-strip-cell-width` prop; mobile keeps
577
- // the strip-level width (wide cards stack vertically below md anyway).
578
- const hintedWidth = React.isValidElement(item)
579
- ? (item.props as Record<string, unknown>)['data-strip-cell-width']
580
- : undefined;
581
- const desktopWidth = typeof hintedWidth === 'number' ? hintedWidth : cardWidthDesktop;
582
690
  // Managed cell — the ONLY place cell width / activation / clone a11y are
583
- // encoded for children mode.
691
+ // encoded for children mode (extracted to a component so the clone
692
+ // focus-suppression hook can run per cell; see ManagedCell).
584
693
  return (
585
- // OUTER cell: width + activation handlers (never inert — an inert cell
586
- // would retarget pointer events to the track and defeat hover-pause on
587
- // clones).
588
- <div
589
- className="shrink-0 self-stretch"
590
- style={{ width: ctx.isMobile ? cardWidthMobile : desktopWidth, maxWidth: STRIP_CELL_MAX_WIDTH }}
591
- data-strip-card-key={ctx.cardKey}
592
- onPointerEnter={ctx.isTouch ? undefined : () => ctx.onActivate(ctx.cardKey)}
593
- onPointerLeave={ctx.isTouch ? undefined : () => ctx.onDeactivate(ctx.cardKey)}
594
- // Keyboard pause parity (focus bubbles from the card's anchor; WCAG 2.2.2).
595
- onFocus={() => ctx.onActivate(ctx.cardKey)}
596
- onBlur={e => {
597
- // Intra-cell focus moves must not blip the pause off.
598
- if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
599
- ctx.onDeactivate(ctx.cardKey);
600
- }}
601
- >
602
- {/* INNER: clone a11y + uniform stretch — cards untouched. `inert` on
603
- the clone strips anchor focusability without modifying any card
604
- (WCAG aria-hidden-focus); clone content is pointer/focus-dead by
605
- design while the outer cell still hover-pauses. */}
606
- <div
607
- className="h-full [&>*]:h-full [&>a>*]:h-full"
608
- aria-hidden={ctx.isClone || undefined}
609
- inert={ctx.isClone || undefined}
610
- >
611
- {item as React.ReactNode}
612
- </div>
613
- </div>
694
+ <ManagedCell
695
+ item={item}
696
+ ctx={ctx}
697
+ cardWidthMobile={cardWidthMobile}
698
+ cardWidthDesktop={cardWidthDesktop}
699
+ />
614
700
  );
615
701
  };
616
702
 
@@ -65,9 +65,11 @@ export const MixedCardTypes: Story = {
65
65
  }
66
66
 
67
67
  /** Marquee opt-in (engine default). Accepted clone behaviors: the clone
68
- * copy's inner cell is `aria-hidden` + `inert` (content pointer/focus-dead,
69
- * links unclickable — WCAG aria-hidden-focus), while the OUTER cell still
70
- * hover-pauses the marquee; keyboard focus on original cards pauses too. */
68
+ * copy's inner cell is `aria-hidden` + focus-suppressed (every focusable
69
+ * descendant forced to `tabindex="-1"`) yet still pointer-CLICKABLE — a click
70
+ * on a visible clone opens its link, since the endless loop always paints
71
+ * clone cards in the viewport near the seam. The OUTER cell still hover-pauses
72
+ * the marquee; keyboard focus on original cards pauses too. */
71
73
  export const MarqueeOptIn: Story = {
72
74
  render: () => (
73
75
  <CardsStrip title="Case Studies">