@flamingo-stack/openframe-frontend-core 0.0.533 → 0.0.535-1762.4618.1

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 (104) hide show
  1. package/dist/{chunk-NAV4FRNL.cjs → chunk-2AMYXNQ2.cjs} +26 -26
  2. package/dist/{chunk-NAV4FRNL.cjs.map → chunk-2AMYXNQ2.cjs.map} +1 -1
  3. package/dist/{chunk-2XGHEJZK.js → chunk-5OPVJBHQ.js} +4 -4
  4. package/dist/{chunk-4QKXNWH5.js → chunk-6OZWDOP5.js} +2 -2
  5. package/dist/{chunk-6YXN5RHW.js → chunk-7TQDW3PE.js} +2 -2
  6. package/dist/{chunk-U555TMPC.js → chunk-A5EA3YO7.js} +5 -5
  7. package/dist/{chunk-DFQLUK75.cjs → chunk-CMIHA6TT.cjs} +3 -3
  8. package/dist/{chunk-DFQLUK75.cjs.map → chunk-CMIHA6TT.cjs.map} +1 -1
  9. package/dist/{chunk-QZ2NAPLF.cjs → chunk-DK6IYFLD.cjs} +9 -9
  10. package/dist/{chunk-QZ2NAPLF.cjs.map → chunk-DK6IYFLD.cjs.map} +1 -1
  11. package/dist/{chunk-LE3BJ5H2.js → chunk-DRJC7TTW.js} +2 -2
  12. package/dist/{chunk-ENPGPZIB.cjs → chunk-ENOER2QA.cjs} +61 -61
  13. package/dist/{chunk-ENPGPZIB.cjs.map → chunk-ENOER2QA.cjs.map} +1 -1
  14. package/dist/{chunk-LIUARTOZ.js → chunk-GBWS62LR.js} +106 -285
  15. package/dist/chunk-GBWS62LR.js.map +1 -0
  16. package/dist/{chunk-2MYDGLJS.cjs → chunk-GF5A23J3.cjs} +87 -87
  17. package/dist/{chunk-2MYDGLJS.cjs.map → chunk-GF5A23J3.cjs.map} +1 -1
  18. package/dist/{chunk-4ZRHVOEB.cjs → chunk-GRGFMXNO.cjs} +31 -31
  19. package/dist/{chunk-4ZRHVOEB.cjs.map → chunk-GRGFMXNO.cjs.map} +1 -1
  20. package/dist/{chunk-46FKXLNE.js → chunk-GS4R5UQ7.js} +2 -2
  21. package/dist/{chunk-2TUOBFPK.js → chunk-HNZDQ4SH.js} +2 -2
  22. package/dist/{chunk-KMB66NYX.cjs → chunk-HSRHPF32.cjs} +7 -7
  23. package/dist/{chunk-KMB66NYX.cjs.map → chunk-HSRHPF32.cjs.map} +1 -1
  24. package/dist/{chunk-RKITTO5V.cjs → chunk-JW4BTXVH.cjs} +14 -14
  25. package/dist/{chunk-RKITTO5V.cjs.map → chunk-JW4BTXVH.cjs.map} +1 -1
  26. package/dist/{chunk-MVGL5NNJ.js → chunk-JWJQHURJ.js} +2 -2
  27. package/dist/{chunk-5U67WTAS.js → chunk-JWWZFEFI.js} +2 -2
  28. package/dist/{chunk-JDW2IL7A.cjs → chunk-JZMQQBUC.cjs} +11 -11
  29. package/dist/{chunk-JDW2IL7A.cjs.map → chunk-JZMQQBUC.cjs.map} +1 -1
  30. package/dist/{chunk-XN4JKIJY.js → chunk-QHOVACZK.js} +2 -2
  31. package/dist/{chunk-UPHN2VRZ.cjs → chunk-T4GNFVT3.cjs} +800 -979
  32. package/dist/chunk-T4GNFVT3.cjs.map +1 -0
  33. package/dist/{chunk-GNOK5J34.cjs → chunk-T743Z3KN.cjs} +37 -37
  34. package/dist/{chunk-GNOK5J34.cjs.map → chunk-T743Z3KN.cjs.map} +1 -1
  35. package/dist/{chunk-J4BE4FIR.cjs → chunk-TOWXHZF5.cjs} +4 -4
  36. package/dist/{chunk-J4BE4FIR.cjs.map → chunk-TOWXHZF5.cjs.map} +1 -1
  37. package/dist/{chunk-6SMLYZLH.js → chunk-URBYMQ4Z.js} +4 -4
  38. package/dist/{chunk-Z3NOI7X7.cjs → chunk-WL3PP4D6.cjs} +26 -26
  39. package/dist/{chunk-Z3NOI7X7.cjs.map → chunk-WL3PP4D6.cjs.map} +1 -1
  40. package/dist/{chunk-YLB6XXO6.js → chunk-WXRQYGE5.js} +2 -2
  41. package/dist/{chunk-6W3ULZ4X.js → chunk-XPKCOZQE.js} +6 -6
  42. package/dist/{chunk-OLOKQQAP.cjs → chunk-ZUVBTDLG.cjs} +5 -5
  43. package/dist/{chunk-OLOKQQAP.cjs.map → chunk-ZUVBTDLG.cjs.map} +1 -1
  44. package/dist/components/case-studies/index.cjs +8 -8
  45. package/dist/components/case-studies/index.js +2 -2
  46. package/dist/components/chat/chat-input.d.ts.map +1 -1
  47. package/dist/components/chat/index.cjs +2 -2
  48. package/dist/components/chat/index.js +1 -1
  49. package/dist/components/contact/index.cjs +3 -3
  50. package/dist/components/contact/index.js +2 -2
  51. package/dist/components/docs/index.cjs +5 -5
  52. package/dist/components/docs/index.js +4 -4
  53. package/dist/components/embeds/index.cjs +3 -3
  54. package/dist/components/embeds/index.js +2 -2
  55. package/dist/components/faq/index.cjs +3 -3
  56. package/dist/components/faq/index.js +2 -2
  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 +22 -22
  60. package/dist/components/help-center-pages/index.js +13 -13
  61. package/dist/components/index.cjs +132 -132
  62. package/dist/components/index.js +11 -11
  63. package/dist/components/meeting-scheduler/index.cjs +34 -34
  64. package/dist/components/meeting-scheduler/index.js +3 -3
  65. package/dist/components/navigation/index.cjs +2 -2
  66. package/dist/components/navigation/index.js +1 -1
  67. package/dist/components/onboarding-guides/index.cjs +5 -5
  68. package/dist/components/onboarding-guides/index.js +4 -4
  69. package/dist/components/related-content/index.cjs +3 -3
  70. package/dist/components/related-content/index.js +2 -2
  71. package/dist/components/tickets/index.cjs +6 -6
  72. package/dist/components/tickets/index.js +5 -5
  73. package/dist/components/ui/file-upload.d.ts.map +1 -1
  74. package/dist/components/ui/index.cjs +2 -2
  75. package/dist/components/ui/index.js +1 -1
  76. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -1
  77. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -1
  78. package/dist/index.cjs +2 -2
  79. package/dist/index.js +1 -1
  80. package/dist/utils/social-embed-cache.d.ts +16 -16
  81. package/dist/utils/social-embed-cache.d.ts.map +1 -1
  82. package/package.json +1 -1
  83. package/src/components/chat/chat-input.tsx +84 -32
  84. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +1 -5
  85. package/src/components/ui/file-upload.tsx +5 -1
  86. package/src/components/ui/markdown/rich/__tests__/shortcodes.test.ts +58 -0
  87. package/src/components/ui/markdown/rich/embed-overrides.tsx +13 -7
  88. package/src/components/ui/markdown/rich/shortcodes.ts +15 -0
  89. package/src/utils/social-embed-cache.ts +63 -336
  90. package/dist/chunk-LIUARTOZ.js.map +0 -1
  91. package/dist/chunk-UPHN2VRZ.cjs.map +0 -1
  92. /package/dist/{chunk-2XGHEJZK.js.map → chunk-5OPVJBHQ.js.map} +0 -0
  93. /package/dist/{chunk-4QKXNWH5.js.map → chunk-6OZWDOP5.js.map} +0 -0
  94. /package/dist/{chunk-6YXN5RHW.js.map → chunk-7TQDW3PE.js.map} +0 -0
  95. /package/dist/{chunk-U555TMPC.js.map → chunk-A5EA3YO7.js.map} +0 -0
  96. /package/dist/{chunk-LE3BJ5H2.js.map → chunk-DRJC7TTW.js.map} +0 -0
  97. /package/dist/{chunk-46FKXLNE.js.map → chunk-GS4R5UQ7.js.map} +0 -0
  98. /package/dist/{chunk-2TUOBFPK.js.map → chunk-HNZDQ4SH.js.map} +0 -0
  99. /package/dist/{chunk-MVGL5NNJ.js.map → chunk-JWJQHURJ.js.map} +0 -0
  100. /package/dist/{chunk-5U67WTAS.js.map → chunk-JWWZFEFI.js.map} +0 -0
  101. /package/dist/{chunk-XN4JKIJY.js.map → chunk-QHOVACZK.js.map} +0 -0
  102. /package/dist/{chunk-6SMLYZLH.js.map → chunk-URBYMQ4Z.js.map} +0 -0
  103. /package/dist/{chunk-YLB6XXO6.js.map → chunk-WXRQYGE5.js.map} +0 -0
  104. /package/dist/{chunk-6W3ULZ4X.js.map → chunk-XPKCOZQE.js.map} +0 -0
@@ -168,6 +168,13 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
168
168
  } = rest as typeof rest & { rows?: number }
169
169
 
170
170
  const [value, setValue] = useState('')
171
+ // Whether the editor currently holds at least one committed mention chip —
172
+ // it alone needs the tall line box (see the editor's className). Read from the
173
+ // DOM, NOT by testing the draft against `MENTION_GLOBAL`: that regex also
174
+ // matches ordinary typed text (`meet @3:00`, `@prod:us-east`), which would
175
+ // switch the editor to a 36px line box with no chip on screen. Chips are only
176
+ // ever created by `rebuildDom`, so the two writers below cover every path.
177
+ const [hasChips, setHasChips] = useState(false)
171
178
  const [focused, setFocused] = useState(false)
172
179
  const [isStopping, setIsStopping] = useState(false)
173
180
  // The ghost preview is dismissed as soon as the user edits the draft (typing
@@ -224,7 +231,12 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
224
231
  if (typeof caret === 'number') placeCaretAtOffset(el, caret)
225
232
  else placeCaretAtEnd(el)
226
233
  }
234
+ setHasChips(el.querySelector('[data-token]') !== null)
227
235
  }
236
+ // Advance the mirror HERE, not only on the next render: `fire` clears the
237
+ // draft and then reads it back within the same tick to decide whether a
238
+ // refused send may restore it.
239
+ valueRef.current = next
228
240
  setValue(next)
229
241
  }, [])
230
242
 
@@ -233,19 +245,31 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
233
245
  (message: string) => {
234
246
  const can = (message.length > 0 || allowEmptySend) && !sending && !disabled
235
247
  if (!can || !onSend) return
236
- const result = onSend(message)
237
- const done = () => {
238
- metaRef.current = new Map()
239
- applyValue('')
240
- shouldRefocusRef.current = true
241
- focusEditor()
248
+ // Clear FIRST and put it back only if the host refuses, rather than
249
+ // clearing once the send settles. The editor stays typable while `sending`
250
+ // (see `contentEditable` below), and a host whose `onSend` is async —
251
+ // `TicketReplyComposer` awaits a round trip — leaves a window in which the
252
+ // user starts their next message; a deferred clear would silently delete
253
+ // it. Restoring is guarded on the box still being empty for the same
254
+ // reason. `applyValue` advances `valueRef` synchronously, so the guard
255
+ // reads the cleared draft even when `onSend` refuses in the same tick.
256
+ const sentMeta = metaRef.current
257
+ metaRef.current = new Map()
258
+ applyValue('')
259
+ shouldRefocusRef.current = true
260
+ focusEditor()
261
+ const restore = () => {
262
+ if (valueRef.current.length > 0) return
263
+ metaRef.current = sentMeta
264
+ applyValue(message)
242
265
  }
266
+ const result = onSend(message)
243
267
  if (result instanceof Promise) {
244
268
  void result.then((ok) => {
245
- if (ok !== false) done()
269
+ if (ok === false) restore()
246
270
  })
247
- } else if (result !== false) {
248
- done()
271
+ } else if (result === false) {
272
+ restore()
249
273
  }
250
274
  },
251
275
  [allowEmptySend, sending, disabled, onSend, focusEditor, applyValue],
@@ -351,6 +375,9 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
351
375
  const syncFromDom = useCallback(() => {
352
376
  const el = editorRef.current
353
377
  if (!el) return
378
+ // Typing can DELETE a chip (they are atomic `contentEditable=false` nodes),
379
+ // so the flag has to be re-read here too, not just where chips are built.
380
+ setHasChips(el.querySelector('[data-token]') !== null)
354
381
  setValue(serialize(el))
355
382
  }, [])
356
383
 
@@ -452,14 +479,13 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
452
479
  const hasContent = value.trim().length > 0 || allowEmptySend
453
480
  const sendDisabled = sending || disabled || !hasContent
454
481
  const isEmpty = value.length === 0
455
- const isDisabled = sending || disabled
456
482
  // Non-destructive ghost preview (e.g. a hovered quick-action's full prompt).
457
483
  // Shown whenever a `previewText` is set (even over an existing draft): the
458
484
  // editor's real value is NEVER touched — it's just visually hidden behind the
459
485
  // ghost while previewing, and reappears verbatim once `previewText` clears.
460
486
  // Declarative, no `setValue` / draft save-restore. Suppressed the moment the
461
487
  // user edits the draft (`previewDismissed`) so typing is never hidden.
462
- const showPreview = !isDisabled && !!previewText && !previewDismissed
488
+ const showPreview = !sending && !disabled && !!previewText && !previewDismissed
463
489
 
464
490
  return (
465
491
  <div
@@ -493,24 +519,28 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
493
519
  }}
494
520
  className={cn(
495
521
  // `items-center` keeps the empty single-line text AND a mention chip
496
- // vertically centered the same way. `py-1.5` (6px) pairs with the
497
- // editor's `leading-9` (36px) → a single line is exactly 48px
498
- // (36 + 2*6). The 36px line box is just barely above the 32px chip
499
- // (incl. its ~3.5px vertical-align overhead), so there's almost no
500
- // slack for the chip to drift in → it reads as centered, and the row
501
- // never overflows past 48px.
522
+ // vertically centered the same way. With a chip in the box, `py-1.5`
523
+ // (6px) pairs with the editor's `leading-9` (36px) → that row is
524
+ // 36 + 2*6 = 48px. The 36px line box is just barely above the 32px
525
+ // chip (incl. its ~3.5px vertical-align overhead), so there's almost
526
+ // no slack for the chip to drift in → it reads as centered.
527
+ // A chipless row is shorter than its content box and the `min-h-*`
528
+ // here is what sets its height: 48px from `md` up, 44px below.
502
529
  "flex w-full items-center gap-2 px-3 py-1.5 min-h-11 md:min-h-12 cursor-text group transition-colors duration-200",
503
530
  !hideBorder && "rounded-[6px] border bg-ods-card border-ods-border",
504
- !hideBorder && (focused ? "border-ods-accent" : !isDisabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover"),
505
- !hideBorder && isDisabled && "!cursor-not-allowed bg-ods-bg",
531
+ !hideBorder && (focused ? "border-ods-accent" : !disabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover"),
532
+ !hideBorder && disabled && "!cursor-not-allowed bg-ods-bg",
506
533
  hideBorder && "bg-transparent",
507
534
  )}
508
535
  >
509
536
  {startIcon && <span className="flex h-6 shrink-0 items-center">{startIcon}</span>}
510
537
 
511
538
  <div className="relative flex-1 min-w-0">
539
+ {/* No chip-line override on the placeholder: it only renders while
540
+ the draft is EMPTY, so the editor is always on its plain line
541
+ box here. */}
512
542
  {isEmpty && !showPreview && (
513
- <span className="pointer-events-none absolute left-0 top-0 select-none text-h4 !leading-9 text-ods-text-secondary">
543
+ <span className="pointer-events-none absolute left-0 top-0 select-none text-h4 text-ods-text-secondary">
514
544
  {disabled ? "Connection lost. Waiting to reconnect..." : placeholder}
515
545
  </span>
516
546
  )}
@@ -525,7 +555,7 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
525
555
  text; clearing `previewText` restores the (opacity-hidden)
526
556
  editor. */}
527
557
  {showPreview && (
528
- <p className="pointer-events-none absolute inset-x-0 top-0 m-0 select-none truncate text-h4 !leading-9 text-ods-text-secondary">
558
+ <p className={cn("pointer-events-none absolute inset-x-0 top-0 m-0 select-none truncate text-h4 text-ods-text-secondary", hasChips && "!leading-9")}>
529
559
  {previewText}
530
560
  </p>
531
561
  )}
@@ -540,7 +570,21 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
540
570
  role="textbox"
541
571
  aria-multiline="true"
542
572
  aria-label={typeof placeholder === 'string' ? placeholder : 'Message'}
543
- contentEditable={!isDisabled}
573
+ // `sending` blocks SENDING, never typing. Taking
574
+ // `contentEditable` away under a focused editor blurs it, and
575
+ // iOS dismisses the keyboard on blur; restoring it re-establishes
576
+ // the input session (and the refocus-after-send effect below
577
+ // re-presents the keyboard once more), so on a phone the keyboard
578
+ // flashed shut and open again around every send — repeatedly,
579
+ // because a host's `sending` toggles once per phase of a turn
580
+ // (`chatLoading` in `embeddable-chat` is `isTyping ||
581
+ // isCompacting`, and the legacy Mingo page adds dialog creation).
582
+ // Nothing is lost by staying editable: `fire()` and the send
583
+ // button are both gated on `sending` already, so a draft typed
584
+ // while a turn streams simply waits for its turn — Enter is a
585
+ // no-op until the send button lights back up. Only `disabled` —
586
+ // the transport is gone — takes the editor out of service.
587
+ contentEditable={!disabled}
544
588
  suppressContentEditableWarning
545
589
  spellCheck
546
590
  onInput={handleInput}
@@ -558,17 +602,25 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>((allProps, ref) => {
558
602
  // see the preview comment above) and is only made transparent;
559
603
  // the absolutely-positioned single-line preview paints over it.
560
604
  showPreview && "opacity-0",
561
- // `leading-9` (36px) sits just above the 32px chip's line-box need
562
- // (~35.5px incl. vertical-align overhead): the LINE drives row
563
- // height (deterministic 48px, no overflow) AND the near-zero slack
564
- // keeps the chip centered. ~4px gap between wrapped chip rows.
565
- // `min-h-9` pins that single-line box even when EMPTY: a non-editable
566
- // (`contentEditable=false`, i.e. disabled/sending) empty div would
567
- // otherwise collapse to 0px, and `items-center` would re-center the
568
- // shorter wrapper in the row dropping the `top-0` placeholder down.
569
- "min-h-9 text-h4 text-ods-text-primary !leading-9",
605
+ // WITH chips, `leading-9` (36px) sits just above the 32px chip's
606
+ // line-box need (~35.5px incl. vertical-align overhead): the LINE
607
+ // drives row height (deterministic 48px, no overflow) AND the
608
+ // near-zero slack keeps the chip centered against the `align-top`
609
+ // `h-9` wrapper `buildChipEl` gives it. ~4px gap between wrapped
610
+ // chip rows.
611
+ // WITHOUT chips there is nothing tall to center, and forcing the
612
+ // same 36px box on 14px phone text (`text-h4`) put a blank line's
613
+ // worth of air between every wrapped row — so plain drafts keep
614
+ // `text-h4`'s own responsive line-height (20px / 24px at md).
615
+ // `min-h-*` pins the single-line box even when EMPTY: a
616
+ // non-editable (`contentEditable=false`, i.e. `disabled`) empty div
617
+ // would otherwise collapse to 0px, and `items-center` would
618
+ // re-center the shorter wrapper in the row — dropping the `top-0`
619
+ // placeholder down.
620
+ "text-h4 text-ods-text-primary",
621
+ hasChips ? "min-h-9 !leading-9" : "min-h-[var(--font-line-space-h4-body)]",
570
622
  "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-ods-border/30 hover:scrollbar-thumb-ods-text-secondary/30",
571
- isDisabled && "cursor-not-allowed",
623
+ disabled && "cursor-not-allowed",
572
624
  )}
573
625
  />
574
626
  </div>
@@ -1,9 +1,6 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
- exports[`RichMarkdownRenderer golden parity > renders fixture: audit-reddit-embed-bq 1`] = `
4
- "<div class="simple-markdown-renderer "><div class="content-wrapper max-w-none"><article class="prose prose-lg max-w-none"><div data-mock="reddit" data-url="https://www.reddit.com/r/msp/comments/abc/post/"></div>
5
- &lt;script async src="https://embed.reddit.com/widgets.js" charset="UTF-8"&gt;&lt;/script&gt;</article></div></div>"
6
- `;
3
+ exports[`RichMarkdownRenderer golden parity > renders fixture: audit-reddit-embed-bq 1`] = `"<div class="simple-markdown-renderer "><div class="content-wrapper max-w-none"><article class="prose prose-lg max-w-none"><div data-mock="reddit" data-url="https://www.reddit.com/r/msp/comments/abc/post/"></div></article></div></div>"`;
7
4
 
8
5
  exports[`RichMarkdownRenderer golden parity > renders fixture: audit-style-attrs 1`] = `
9
6
  "<div class="simple-markdown-renderer "><div class="content-wrapper max-w-none"><article class="prose prose-lg max-w-none"><div class="takeaway" style="padding: 8px;">Key point</div>
@@ -160,7 +157,6 @@ exports[`RichMarkdownRenderer golden parity > renders fixture: hostile-onerror 1
160
157
 
161
158
  exports[`RichMarkdownRenderer golden parity > renders fixture: hostile-script 1`] = `
162
159
  "<div class="simple-markdown-renderer "><div class="content-wrapper max-w-none"><article class="prose prose-lg max-w-none"><p class="first:mt-0 last:mb-0 text-ods-text-primary font-sans text-[16px] md:text-[18px] lg:text-[20px] leading-[1.6] my-4">before</p>
163
- <p class="first:mt-0 last:mb-0 text-ods-text-primary font-sans text-[16px] md:text-[18px] lg:text-[20px] leading-[1.6] my-4">&lt;script&gt;alert(1)&lt;/script&gt;</p>
164
160
  <p class="first:mt-0 last:mb-0 text-ods-text-primary font-sans text-[16px] md:text-[18px] lg:text-[20px] leading-[1.6] my-4">after</p></article></div></div>"
165
161
  `;
166
162
 
@@ -325,7 +325,11 @@ export function FileUpload<T extends FileUploadCandidate = File>({
325
325
  // already on screen, an unreadable selection), and this runs from a click
326
326
  // handler where a rejection would go unhandled.
327
327
  try {
328
- handleFiles(await pickFiles())
328
+ // Skip the empty (cancelled) case rather than routing it through
329
+ // handleFiles, whose first act is to clear a validation message the user
330
+ // may still be reading.
331
+ const picked = await pickFiles()
332
+ if (picked.length > 0) handleFiles(picked)
329
333
  } catch (err) {
330
334
  setValidationError(err instanceof Error ? err.message : "Could not open the file picker")
331
335
  }
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { processShortcodes } from '../shortcodes';
3
+
4
+ /**
5
+ * Step 1.5 of processShortcodes: pasted social embed markup ships a
6
+ * `widgets.js` loader `<script>` alongside its blockquote. Scripts never
7
+ * execute on this surface, but before the strip they leaked into the
8
+ * article body as VISIBLE escaped source text (2026-08 Reddit embed
9
+ * incident). These tests pin the strip's contract.
10
+ */
11
+ describe('processShortcodes script stripping', () => {
12
+ it('strips the Reddit widgets.js loader that accompanies pasted blockquote embeds', () => {
13
+ const input =
14
+ '<blockquote class="reddit-embed-bq" data-embed-height="316">' +
15
+ '<a href="https://www.reddit.com/r/sysadmin/comments/1op05cl/anyone_using_splashtop_as_their_main_remote/">Anyone using Splashtop?</a>' +
16
+ '</blockquote>' +
17
+ '<script async="" src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>';
18
+ const output = processShortcodes(input);
19
+ expect(output).not.toContain('<script');
20
+ expect(output).not.toContain('widgets.js');
21
+ expect(output).toContain('reddit-embed-bq');
22
+ expect(output).toContain('/comments/1op05cl/');
23
+ });
24
+
25
+ it('strips the Twitter widgets.js loader', () => {
26
+ const input =
27
+ '<blockquote class="twitter-tweet"><a href="https://twitter.com/user/status/123">tweet</a></blockquote>' +
28
+ '<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>';
29
+ const output = processShortcodes(input);
30
+ expect(output).not.toContain('<script');
31
+ expect(output).toContain('twitter-tweet');
32
+ });
33
+
34
+ it('strips inline script elements with body content', () => {
35
+ const output = processShortcodes('before <script>alert("x")</script> after');
36
+ expect(output).not.toContain('script');
37
+ expect(output).toContain('before');
38
+ expect(output).toContain('after');
39
+ });
40
+
41
+ it('preserves script tags inside fenced code blocks', () => {
42
+ const input = 'Example:\n\n```html\n<script src="https://example.com/app.js"></script>\n```\n';
43
+ const output = processShortcodes(input);
44
+ expect(output).toContain('<script src="https://example.com/app.js"></script>');
45
+ });
46
+
47
+ it('preserves script tags inside inline code', () => {
48
+ const input = 'Use `<script defer></script>` for deferred loading.';
49
+ const output = processShortcodes(input);
50
+ expect(output).toContain('`<script defer></script>`');
51
+ });
52
+
53
+ it('leaves an unclosed script opener alone (falls through to the escape pre-pass)', () => {
54
+ const input = 'An unclosed <script> opener in prose.';
55
+ const output = processShortcodes(input);
56
+ expect(output).toContain('<script>');
57
+ });
58
+ });
@@ -153,13 +153,15 @@ export function buildRichEmbedOverrides({
153
153
  return (standard.div as any)(divProps);
154
154
  },
155
155
 
156
- // Reddit's OWN embed markup lives in 58 published blog posts as
157
- // `<blockquote class="reddit-embed-bq">…<a href="post-url">` paired with
158
- // a `<script src="embed.reddit.com/widgets.js">` loader. The sanitize
159
- // stack strips the script (correctly), so the composition rehydrates the
160
- // blockquote itself: extract the post URL from the first reddit link and
161
- // render the RedditEmbedClient SSOT. Non-reddit blockquotes fall through
162
- // to the engine's base blockquote.
156
+ // Pasted-from-the-platform embed markup lives in published blog posts as
157
+ // `<blockquote class="reddit-embed-bq">…<a href="post-url">` (58 blocks
158
+ // across 9 posts) and Twitter's equivalent is
159
+ // `<blockquote class="twitter-tweet">…<a href="status-url">`. Each pair
160
+ // ships a `widgets.js` loader script, which `processShortcodes` strips
161
+ // from the source text (Step 1.5). The composition rehydrates the
162
+ // blockquote itself: extract the post URL from the first matching link
163
+ // and render the platform's embed-client SSOT. Other blockquotes fall
164
+ // through to the engine's base blockquote.
163
165
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
164
166
  blockquote: (bqProps: any) => {
165
167
  const { node, className } = bqProps;
@@ -168,6 +170,10 @@ export function buildRichEmbedOverrides({
168
170
  const postUrl = findFirstHref(node, /reddit\.com/);
169
171
  if (postUrl) return <RedditEmbedClient url={postUrl} />;
170
172
  }
173
+ if (classNames.includes('twitter-tweet')) {
174
+ const tweetUrl = findFirstHref(node, /(?:twitter\.com|x\.com)\/[^/]+\/status\//);
175
+ if (tweetUrl) return <TwitterEmbedClient url={tweetUrl} />;
176
+ }
171
177
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
172
178
  return (standard.blockquote as any)(bqProps);
173
179
  },
@@ -78,6 +78,21 @@ export const processShortcodes = (content: string): string => {
78
78
  return placeholder;
79
79
  });
80
80
 
81
+ // Step 1.5: Strip well-formed `<script …>…</script>` elements from prose.
82
+ // Pasted social embed markup ships a loader script alongside its markup
83
+ // (Reddit: `<blockquote class="reddit-embed-bq">…` + embed.reddit.com
84
+ // widgets.js; Twitter: platform.twitter.com widgets.js). Scripts NEVER
85
+ // execute on this surface — the sanitize stack strips real ones — but the
86
+ // engine's text pre-pass escapes the tag first, so it used to render as
87
+ // VISIBLE source text under every embed. Runs after Step 1 so script tags
88
+ // inside code fences / inline code stay literal sample code; an UNCLOSED
89
+ // opener is left alone (falls through to today's escape-to-text behavior,
90
+ // which is the safe degrade). Quantifiers are hard-bounded (ReDoS).
91
+ processedContent = processedContent.replace(
92
+ /<script\b[^>]{0,2000}>[\s\S]{0,20000}?<\/script\s{0,10}>/gi,
93
+ ''
94
+ );
95
+
81
96
  // Step 2: Temporarily replace markdown links to protect them
82
97
  const markdownLinks: string[] = [];
83
98
  processedContent = processedContent.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match) => {