@flamingo-stack/openframe-frontend-core 0.0.741 → 0.0.743

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 (117) hide show
  1. package/dist/build/export-map.json +1 -1
  2. package/dist/{chunk-SFY4UYPY.cjs → chunk-4A6OF4HI.cjs} +44 -44
  3. package/dist/{chunk-VA7OMDZO.cjs → chunk-4FWURR3W.cjs} +50 -50
  4. package/dist/{chunk-WBANGORJ.cjs → chunk-4N7KGOBS.cjs} +3 -3
  5. package/dist/{chunk-PQ2M7P2U.cjs → chunk-5TCIRWHL.cjs} +9 -9
  6. package/dist/{chunk-FQN6STIM.cjs → chunk-6XCQXGQA.cjs} +20 -20
  7. package/dist/{chunk-BV5WZVOP.cjs → chunk-7YDF3QBN.cjs} +258 -257
  8. package/dist/{chunk-7ZFCR7UY.cjs → chunk-ABLUMGCI.cjs} +3 -3
  9. package/dist/{chunk-PX7FY655.cjs → chunk-ASMNNSUQ.cjs} +3 -3
  10. package/dist/{chunk-CWRVTQUO.cjs → chunk-BM6JZRVD.cjs} +101 -101
  11. package/dist/{chunk-LZ6NOVXX.cjs → chunk-BNTELSHZ.cjs} +103 -103
  12. package/dist/{chunk-SA7LDEVJ.cjs → chunk-CSJO7ZGN.cjs} +3 -3
  13. package/dist/{chunk-VOX4E6AX.cjs → chunk-DGFDBPO7.cjs} +7 -5
  14. package/dist/{chunk-KLI4OSRA.cjs → chunk-GAGNKWOC.cjs} +2 -2
  15. package/dist/{chunk-QEUGYHFB.cjs → chunk-IRWJKVVZ.cjs} +20 -20
  16. package/dist/{chunk-YRE3O65E.cjs → chunk-JGZEHBBD.cjs} +12 -12
  17. package/dist/{chunk-FICJS5OA.cjs → chunk-KTPR66EE.cjs} +4 -4
  18. package/dist/{chunk-YXKB5WRE.cjs → chunk-LJ46XYYP.cjs} +60 -48
  19. package/dist/{chunk-AAFW4STC.cjs → chunk-LU7TD6QS.cjs} +5 -5
  20. package/dist/{chunk-JFS6TZDX.cjs → chunk-MLZKPDPZ.cjs} +64 -64
  21. package/dist/{chunk-EKGL5K5K.cjs → chunk-MT7ELGCV.cjs} +17 -17
  22. package/dist/{chunk-HYBMADYO.cjs → chunk-NGUJVM6O.cjs} +22 -22
  23. package/dist/{chunk-3HPB4H4C.cjs → chunk-NTGIQP7O.cjs} +7 -7
  24. package/dist/{chunk-GABDE3FM.cjs → chunk-OWGLRE74.cjs} +19 -19
  25. package/dist/{chunk-IFS4AH77.cjs → chunk-QYLIHZKZ.cjs} +10 -10
  26. package/dist/{chunk-GVTDVS5U.cjs → chunk-R34JD6NP.cjs} +3 -3
  27. package/dist/{chunk-YQ37ZCD2.cjs → chunk-SQHTX5XP.cjs} +16 -16
  28. package/dist/{chunk-XJ36TEGE.cjs → chunk-TKASH5YC.cjs} +47 -47
  29. package/dist/{chunk-DSVNFRY5.cjs → chunk-U2O5YQRP.cjs} +38 -38
  30. package/dist/{chunk-5SVOGLNK.cjs → chunk-UL6ZZSL4.cjs} +6 -6
  31. package/dist/{chunk-O67V3HJR.cjs → chunk-URVIPSAJ.cjs} +24 -24
  32. package/dist/{chunk-GSWQZX23.cjs → chunk-UY6A6TEY.cjs} +2 -2
  33. package/dist/{chunk-DUVOQHQI.cjs → chunk-W2RM764D.cjs} +7 -7
  34. package/dist/{chunk-IK2RJUEA.cjs → chunk-WCY35GB5.cjs} +845 -810
  35. package/dist/{chunk-NTJE6SM6.cjs → chunk-WI2OTGKC.cjs} +2 -2
  36. package/dist/{chunk-ITPEFCQ2.cjs → chunk-XJO5ZHO6.cjs} +32 -32
  37. package/dist/{chunk-I6UE6YVM.cjs → chunk-Y2FN3E32.cjs} +6 -6
  38. package/dist/{chunk-4XL4YEZM.cjs → chunk-YDFKFLDQ.cjs} +8 -8
  39. package/dist/{chunk-HF7Z2E4W.cjs → chunk-YIOWV7LS.cjs} +2 -2
  40. package/dist/{chunk-B3XBJCOD.cjs → chunk-YJRMCFX3.cjs} +7 -7
  41. package/dist/{chunk-ZYOS7LVQ.cjs → chunk-YWMUZLU2.cjs} +89 -89
  42. package/dist/{chunk-ZJPDGVA6.cjs → chunk-ZP5DFF3Q.cjs} +28 -28
  43. package/dist/{cloud-tenants-MVJK52I2.cjs → cloud-tenants-DZC3ACG6.cjs} +24 -24
  44. package/dist/components/case-studies/index.cjs +37 -37
  45. package/dist/components/chat/entity-cards/onboarding-guide-card.d.ts.map +1 -1
  46. package/dist/components/chat/entity-cards/onboarding-guide-card.js +22 -12
  47. package/dist/components/chat/index.cjs +17 -17
  48. package/dist/components/chat/tool-execution-display.d.ts.map +1 -1
  49. package/dist/components/chat/tool-execution-display.js +5 -4
  50. package/dist/components/contact/index.cjs +25 -25
  51. package/dist/components/docs/index.cjs +28 -28
  52. package/dist/components/embeds/index.cjs +25 -25
  53. package/dist/components/faq/index.cjs +26 -26
  54. package/dist/components/features/index.cjs +24 -24
  55. package/dist/components/features/scripts/schedule-form/schedule-interval-input.d.ts +1 -1
  56. package/dist/components/features/scripts/schedule-form/schedule-interval-input.d.ts.map +1 -1
  57. package/dist/components/features/scripts/schedule-form/schedule-interval-input.js +15 -13
  58. package/dist/components/features/scripts/schedule-form/schedule-offline-fields.d.ts.map +1 -1
  59. package/dist/components/features/scripts/schedule-form/schedule-offline-fields.js +9 -7
  60. package/dist/components/help-center-pages/index.cjs +144 -144
  61. package/dist/components/index.cjs +87 -85
  62. package/dist/components/meeting-scheduler/booking-form.d.ts +11 -1
  63. package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
  64. package/dist/components/meeting-scheduler/booking-form.js +15 -3
  65. package/dist/components/meeting-scheduler/index.cjs +452 -158
  66. package/dist/components/meeting-scheduler/index.d.ts +16 -1
  67. package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
  68. package/dist/components/meeting-scheduler/index.js +157 -5
  69. package/dist/components/meeting-scheduler/modal-header.d.ts +27 -0
  70. package/dist/components/meeting-scheduler/modal-header.d.ts.map +1 -0
  71. package/dist/components/meeting-scheduler/modal-header.js +104 -0
  72. package/dist/components/meeting-scheduler/slot-picker.d.ts +13 -2
  73. package/dist/components/meeting-scheduler/slot-picker.d.ts.map +1 -1
  74. package/dist/components/meeting-scheduler/slot-picker.js +112 -73
  75. package/dist/components/navigation/index.cjs +27 -27
  76. package/dist/components/onboarding-guides/index.cjs +27 -27
  77. package/dist/components/product-demo/index.cjs +9 -9
  78. package/dist/components/related-content/index.cjs +12 -12
  79. package/dist/components/shared/product-release/product-release-card.d.ts.map +1 -1
  80. package/dist/components/shared/product-release/product-release-card.js +2 -0
  81. package/dist/components/tickets/index.cjs +30 -30
  82. package/dist/components/ui/index.cjs +26 -24
  83. package/dist/components/ui/radio-group.d.ts +6 -0
  84. package/dist/components/ui/radio-group.d.ts.map +1 -1
  85. package/dist/components/ui/radio-group.js +65 -58
  86. package/dist/components/ui/tab-navigation.d.ts +19 -1
  87. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  88. package/dist/components/ui/tab-navigation.js +32 -8
  89. package/dist/{devices-W3S7Q2PG.cjs → devices-MFGE2JQV.cjs} +25 -25
  90. package/dist/{embeddable-chat-6JKKFQEB.cjs → embeddable-chat-7ZEGCDVF.cjs} +92 -92
  91. package/dist/hooks/index.cjs +10 -10
  92. package/dist/index.cjs +32 -30
  93. package/dist/{knowledge-AIYQ4FC3.cjs → knowledge-7VPS2ZBU.cjs} +25 -25
  94. package/dist/{logs-7SHJ2ARE.cjs → logs-M3QPAUH7.cjs} +32 -32
  95. package/dist/{policies-EIZJ5SUP.cjs → policies-UAVE6X62.cjs} +28 -28
  96. package/dist/{remote-session-BZMNE6D2.cjs → remote-session-N4PJB5FW.cjs} +10 -10
  97. package/dist/{scripts-J2FWC7E4.cjs → scripts-EDQ3IQKW.cjs} +31 -31
  98. package/dist/{software-update-QAMNASVF.cjs → software-update-B2MZVNPK.cjs} +28 -28
  99. package/dist/{tickets-board-CEDHD3KB.cjs → tickets-board-O753S4ZB.cjs} +14 -14
  100. package/dist/utils/embed-authed-fetch.d.ts +33 -0
  101. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  102. package/dist/utils/embed-authed-fetch.js +3 -1
  103. package/dist/utils/index.cjs +3 -1
  104. package/dist/{video-file-player-ZH2ILAPO.cjs → video-file-player-QZ3YOLHO.cjs} +2 -2
  105. package/package.json +1 -1
  106. package/src/components/chat/entity-cards/onboarding-guide-card.tsx +8 -1
  107. package/src/components/chat/tool-execution-display.tsx +21 -10
  108. package/src/components/features/scripts/schedule-form/schedule-interval-input.tsx +20 -13
  109. package/src/components/features/scripts/schedule-form/schedule-offline-fields.tsx +9 -5
  110. package/src/components/meeting-scheduler/booking-form.tsx +29 -3
  111. package/src/components/meeting-scheduler/index.tsx +224 -3
  112. package/src/components/meeting-scheduler/modal-header.tsx +145 -0
  113. package/src/components/meeting-scheduler/slot-picker.tsx +72 -8
  114. package/src/components/shared/product-release/product-release-card.tsx +2 -0
  115. package/src/components/ui/radio-group.tsx +51 -36
  116. package/src/components/ui/tab-navigation.tsx +54 -5
  117. package/src/utils/embed-authed-fetch.ts +42 -1
@@ -47,6 +47,12 @@ interface RadioGroupBlockOption {
47
47
  disabled?: boolean;
48
48
  /** Optional trailing slot rendered at the end of the row (e.g. a discount tag) */
49
49
  trailing?: ReactNode;
50
+ /**
51
+ * Optional content under the option's row, inside the option and across its
52
+ * full width (e.g. the fields the option needs on a narrow screen). Rendered
53
+ * as given, with no wrapper, so a footer hidden at some widths leaves no gap.
54
+ */
55
+ footer?: ReactNode;
50
56
  }
51
57
 
52
58
  interface RadioGroupBlockProps extends Omit<ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>, 'children'> {
@@ -99,42 +105,9 @@ const RadioGroupBlock = forwardRef<ComponentRef<typeof RadioGroupPrimitive.Root>
99
105
  const itemId = `${props.name ?? 'radio-block'}-${option.value}`;
100
106
  const isDisabled = disabled || option.disabled;
101
107
  const isLast = index === options.length - 1;
102
- return (
103
- <label
104
- key={option.value}
105
- htmlFor={itemId}
106
- aria-disabled={isDisabled || undefined}
107
- className={cn(
108
- 'flex w-full items-center gap-3',
109
- isHorizontal && 'min-w-0 flex-1',
110
- 'transition-colors duration-200',
111
- isDisabled ? 'cursor-not-allowed' : 'cursor-pointer',
112
- // Hover / active move the BACKGROUND only, like Input and
113
- // CheckboxBlock — the border stays put so an unselected row
114
- // never flashes "selected" under the cursor.
115
- !isDisabled && 'hover:bg-ods-bg-hover active:bg-ods-bg-active',
116
- isGrouped
117
- ? cn(
118
- 'bg-ods-card px-[var(--spacing-system-sf)] py-[var(--spacing-system-xs,8px)]',
119
- !isLast && (isHorizontal ? 'border-r' : 'border-b'),
120
- error ? 'border-ods-error' : 'border-ods-border',
121
- )
122
- : cn(
123
- 'rounded-[6px] border bg-ods-card p-[var(--spacing-system-sf)]',
124
- !isDisabled &&
125
- 'has-[[data-state=checked]]:bg-[var(--ods-system-greys-soft-grey)]/30 has-[[data-state=checked]]:border-ods-accent/40',
126
- error ? 'border-ods-error' : 'border-ods-border',
127
- ),
128
- // Disabled matches a disabled field: flat `ods-bg` fill and
129
- // greyed text, not a fade of the whole row. The greying lives
130
- // on the TEXT COLUMN below, not here: as a descendant rule on
131
- // the row it also repainted whatever `trailing` renders — a
132
- // `Tag`'s label is a span too, and it went invisible against
133
- // its own fill.
134
- isDisabled && 'bg-ods-bg',
135
- itemClassName,
136
- )}
137
- >
108
+ const hasFooter = option.footer != null;
109
+ const row = (
110
+ <>
138
111
  <RadioGroupPrimitive.Item
139
112
  id={itemId}
140
113
  value={option.value}
@@ -186,6 +159,48 @@ const RadioGroupBlock = forwardRef<ComponentRef<typeof RadioGroupPrimitive.Root>
186
159
  )}
187
160
  </div>
188
161
  {option.trailing && <div className="ml-auto flex shrink-0 items-center">{option.trailing}</div>}
162
+ </>
163
+ );
164
+ return (
165
+ <label
166
+ key={option.value}
167
+ htmlFor={itemId}
168
+ aria-disabled={isDisabled || undefined}
169
+ className={cn(
170
+ 'flex w-full items-center gap-3',
171
+ isHorizontal && 'min-w-0 flex-1',
172
+ 'transition-colors duration-200',
173
+ isDisabled ? 'cursor-not-allowed' : 'cursor-pointer',
174
+ // Hover / active move the BACKGROUND only, like Input and
175
+ // CheckboxBlock — the border stays put so an unselected row
176
+ // never flashes "selected" under the cursor.
177
+ !isDisabled && 'hover:bg-ods-bg-hover active:bg-ods-bg-active',
178
+ isGrouped
179
+ ? cn(
180
+ 'bg-ods-card px-[var(--spacing-system-sf)] py-[var(--spacing-system-xs,8px)]',
181
+ !isLast && (isHorizontal ? 'border-r' : 'border-b'),
182
+ error ? 'border-ods-error' : 'border-ods-border',
183
+ )
184
+ : cn(
185
+ 'rounded-[6px] border bg-ods-card p-[var(--spacing-system-sf)]',
186
+ !isDisabled &&
187
+ 'has-[[data-state=checked]]:bg-[var(--ods-system-greys-soft-grey)]/30 has-[[data-state=checked]]:border-ods-accent/40',
188
+ error ? 'border-ods-error' : 'border-ods-border',
189
+ ),
190
+ // Disabled matches a disabled field: flat `ods-bg` fill and
191
+ // greyed text, not a fade of the whole row. The greying lives
192
+ // on the TEXT COLUMN below, not here: as a descendant rule on
193
+ // the row it also repainted whatever `trailing` renders — a
194
+ // `Tag`'s label is a span too, and it went invisible against
195
+ // its own fill.
196
+ isDisabled && 'bg-ods-bg',
197
+ // With a footer the option stacks: its row, then the footer.
198
+ hasFooter && 'flex-col items-stretch gap-[var(--spacing-system-sf)]',
199
+ itemClassName,
200
+ )}
201
+ >
202
+ {hasFooter ? <div className="flex w-full items-center gap-[var(--spacing-system-sf)]">{row}</div> : row}
203
+ {option.footer}
189
204
  </label>
190
205
  );
191
206
  })}
@@ -2,6 +2,8 @@
2
2
 
3
3
  import type React from 'react';
4
4
  import {
5
+ createContext,
6
+ useContext,
5
7
  useState,
6
8
  useEffect,
7
9
  useLayoutEffect,
@@ -71,6 +73,31 @@ export interface TabItem {
71
73
  accent?: string;
72
74
  }
73
75
 
76
+ /**
77
+ * How a strip marks its tabs.
78
+ *
79
+ * - `default`: the tabs touch, and the open tab is marked by the underline alone.
80
+ * - `admin`: the product admin's tab bar. A gap between tabs, and the open and the
81
+ * hovered tab carry an accent fade above the underline.
82
+ */
83
+ export type TabNavigationVariant = 'default' | 'admin';
84
+
85
+ const TabNavigationVariantContext = createContext<TabNavigationVariant>('default');
86
+
87
+ /**
88
+ * Sets the variant of every `TabNavigation` below it that does not pass its own
89
+ * `variant`. An app whose tab bars all share one look mounts it once in its layout.
90
+ */
91
+ export function TabNavigationVariantProvider({
92
+ variant,
93
+ children,
94
+ }: {
95
+ variant: TabNavigationVariant;
96
+ children: React.ReactNode;
97
+ }) {
98
+ return <TabNavigationVariantContext.Provider value={variant}>{children}</TabNavigationVariantContext.Provider>;
99
+ }
100
+
74
101
  export interface TabNavigationUrlSyncOptions {
75
102
  paramName?: string; // Default: 'tab'
76
103
  replaceState?: boolean; // Default: true (use replace instead of push)
@@ -99,6 +126,8 @@ interface TabNavigationProps {
99
126
  tabAlign?: 'center' | 'start';
100
127
  /** How long the underline takes to reach a new `TabItem.progress` value. Default 200. */
101
128
  progressTransitionMs?: number;
129
+ /** How the strip marks its tabs. Omitted: the nearest `TabNavigationVariantProvider`'s, else `default`. */
130
+ variant?: TabNavigationVariant;
102
131
 
103
132
  // URL sync mode
104
133
  urlSync?: boolean | TabNavigationUrlSyncOptions;
@@ -145,6 +174,7 @@ interface TabBarProps {
145
174
  stretchTabs: boolean;
146
175
  tabAlign: 'center' | 'start';
147
176
  progressTransitionMs: number;
177
+ variant: TabNavigationVariant;
148
178
  }
149
179
 
150
180
  /**
@@ -170,7 +200,9 @@ const TabBar = memo(function TabBarImpl({
170
200
  stretchTabs,
171
201
  tabAlign,
172
202
  progressTransitionMs,
203
+ variant,
173
204
  }: TabBarProps) {
205
+ const isAdmin = variant === 'admin';
174
206
  const scrollRef = useRef<HTMLDivElement>(null);
175
207
  const activeTabRef = useRef<HTMLButtonElement>(null);
176
208
  const isFirstActiveScrollRef = useRef(true);
@@ -396,7 +428,10 @@ const TabBar = memo(function TabBarImpl({
396
428
  `relative` makes this the offsetParent the underline is measured against. */}
397
429
  <div
398
430
  ref={scrollRef}
399
- className="scrollbar-hide relative flex h-full items-center justify-start overflow-x-auto overflow-y-hidden"
431
+ className={cn(
432
+ 'scrollbar-hide relative flex h-full items-center justify-start overflow-x-auto overflow-y-hidden',
433
+ isAdmin && 'gap-[var(--spacing-system-xxs)]',
434
+ )}
400
435
  >
401
436
  {tabs.map(tab => {
402
437
  const isActive = activeTab === tab.id;
@@ -417,11 +452,19 @@ const TabBar = memo(function TabBarImpl({
417
452
  // `outline-none` above drops the browser's own focus ring, so
418
453
  // put one back or the strip is un-navigable by keyboard — you
419
454
  // can tab through it with nothing to show where you are. Inset,
420
- // so it stays within the button: the tabs touch.
455
+ // so it stays within the button and never overlaps a neighbour.
421
456
  'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-focus',
422
457
  stretchTabs && 'flex-1',
423
458
  tab.avatar && 'gap-[var(--spacing-system-xsf)]',
424
- // The open tab is marked by the underline alone: no surface of its own.
459
+ // `default`: the open tab is marked by the underline alone, no surface
460
+ // of its own. `admin`: the open and the hovered tab carry an accent
461
+ // fade. ODS colour vars hold hex values, so Tailwind alpha modifiers
462
+ // (to-ods-accent/10) silently produce no CSS; color-mix() accepts
463
+ // hex, so the 10%-alpha stop is derived from the token.
464
+ isAdmin &&
465
+ (isActive
466
+ ? 'bg-gradient-to-b from-transparent to-[color-mix(in_srgb,var(--color-accent-primary)_10%,transparent)]'
467
+ : 'hover:bg-gradient-to-b hover:from-transparent hover:to-[color-mix(in_srgb,var(--color-accent-primary)_10%,transparent)]'),
425
468
  )}
426
469
  >
427
470
  {tab.avatar ? (
@@ -436,7 +479,7 @@ const TabBar = memo(function TabBarImpl({
436
479
  'h-4 w-4 transition-colors content-md:h-6 content-md:w-6',
437
480
  isActive
438
481
  ? !tab.accent && 'text-ods-accent'
439
- : 'text-ods-text-secondary group-hover:text-ods-text-primary',
482
+ : cn('text-ods-text-secondary', !isAdmin && 'group-hover:text-ods-text-primary'),
440
483
  )}
441
484
  style={isActive && tab.accent ? { color: tab.accent } : undefined}
442
485
  />
@@ -453,7 +496,9 @@ const TabBar = memo(function TabBarImpl({
453
496
  <span
454
497
  className={cn(
455
498
  'whitespace-nowrap transition-colors text-h4',
456
- isActive ? 'text-ods-text-primary' : 'text-ods-text-secondary group-hover:text-ods-text-primary',
499
+ isActive
500
+ ? 'text-ods-text-primary'
501
+ : cn('text-ods-text-secondary', !isAdmin && 'group-hover:text-ods-text-primary'),
457
502
  )}
458
503
  >
459
504
  {tab.label}
@@ -542,10 +587,13 @@ export function TabNavigation({
542
587
  stretchTabs = false,
543
588
  tabAlign = 'center',
544
589
  progressTransitionMs = 200,
590
+ variant: variantProp,
545
591
  urlSync = false,
546
592
  defaultTab,
547
593
  children,
548
594
  }: TabNavigationProps) {
595
+ const providedVariant = useContext(TabNavigationVariantContext);
596
+ const variant = variantProp ?? providedVariant;
549
597
  const router = useRouter();
550
598
  const pathname = usePathname();
551
599
  const searchParams = useSearchParams();
@@ -717,6 +765,7 @@ export function TabNavigation({
717
765
  stretchTabs={stretchTabs}
718
766
  tabAlign={tabAlign}
719
767
  progressTransitionMs={progressTransitionMs}
768
+ variant={variant}
720
769
  />
721
770
 
722
771
  {/* Render children with active tab if provided */}
@@ -86,6 +86,34 @@ export interface EmbedAuthAdapter {
86
86
  * host's own auth layer, not to this fetch wrapper.
87
87
  */
88
88
  refresh?: () => Promise<boolean>;
89
+ /**
90
+ * Optional guard, run before every request goes out — the first attempt
91
+ * and the retry after a `refresh`. Throw — or reject — to refuse the
92
+ * request: `embedAuthedFetch` rejects with that error and nothing reaches
93
+ * the network.
94
+ *
95
+ * For a host-wide state that makes every request pointless. openframe's
96
+ * mobile force-update is the case it exists for: once the gateway has
97
+ * refused this client build, every later request is refused locally, so no
98
+ * surface's retry or reconnect loop keeps hitting the gateway. Runs inside
99
+ * the returned promise (after the synchronous same-origin guard), so
100
+ * callers see a rejection, never a synchronous throw.
101
+ */
102
+ beforeRequest?: () => void | Promise<void>;
103
+ /**
104
+ * Optional response hook, run on every response (the post-refresh retry's
105
+ * too) BEFORE the 401 self-heal. Throw — or reject — to fail the request
106
+ * with that error: `embedAuthedFetch` rejects with it, and neither
107
+ * `refresh` nor a retry runs. Return to let the response continue through
108
+ * the wrapper unchanged.
109
+ *
110
+ * For a status the host handles app-wide rather than per surface — e.g.
111
+ * openframe's gateway answering `426 Upgrade Required` to a too-old mobile
112
+ * build, which swaps the whole app for an "update required" screen and
113
+ * must never be retried or read as an auth failure. Read the body from
114
+ * `response.clone()`: once the hook returns, the caller reads the original.
115
+ */
116
+ onResponse?: (response: Response) => void | Promise<void>;
89
117
  /**
90
118
  * Exact extra origins (`scheme://host[:port]`, as produced by `URL.origin`)
91
119
  * the same-origin guard additionally accepts. The guard normally rejects
@@ -253,6 +281,11 @@ export function needsBearerAssetFetch(url: string): boolean {
253
281
  * no longer need a host-side `window.fetch` monkey-patch to survive an
254
282
  * expired access token mid-chat. With no adapter (or no `refresh`), the
255
283
  * 401 passes straight through unchanged.
284
+ *
285
+ * **Host hooks:** an adapter's `beforeRequest` may refuse a request before it
286
+ * is sent, and its `onResponse` sees every response ahead of the 401
287
+ * self-heal and may fail the request instead (see their docs). Without them
288
+ * the wrapper behaves exactly as described above.
256
289
  */
257
290
  export function embedAuthedFetch(url: string, init: RequestInit = {}): Promise<Response> {
258
291
  // Same-origin guard runs SYNCHRONOUSLY (not awaited inside the async
@@ -354,6 +387,11 @@ async function fetchWithRefresh(
354
387
  baseHeaders: Record<string, string>,
355
388
  isRetry: boolean,
356
389
  ): Promise<Response> {
390
+ const adapter = getRegisteredAuthAdapter();
391
+ // Per attempt, so a retry after `refresh` is refused too when the host's
392
+ // state changed while the refresh was in flight.
393
+ await adapter?.beforeRequest?.();
394
+
357
395
  // Re-run the merge each attempt: `applyProxyAuth` reads the latest stored
358
396
  // proxy creds and `getHeaders()` reads the latest bearer, so a retry after
359
397
  // refresh carries the rotated token rather than the stale one. `{...baseHeaders}`
@@ -365,7 +403,6 @@ async function fetchWithRefresh(
365
403
  // is the host's explicit "this is my auth model" override, intentionally
366
404
  // last-writer-wins. When no adapter is registered, this is a zero-cost
367
405
  // no-op (object spread of `{}`).
368
- const adapter = getRegisteredAuthAdapter();
369
406
  if (adapter?.getHeaders) {
370
407
  // Filter `undefined` values — the adapter type allows them so consumers
371
408
  // don't have to narrow `{ Authorization: token ? '…' : undefined }`-shaped
@@ -387,6 +424,10 @@ async function fetchWithRefresh(
387
424
  credentials,
388
425
  });
389
426
 
427
+ // Ahead of the 401 self-heal: a status the host claims by throwing must
428
+ // never reach `refresh` or a retry.
429
+ await adapter?.onResponse?.(response);
430
+
390
431
  // 401 self-heal: refresh the token once and retry. Only when an adapter
391
432
  // opted into `refresh`, and only on the first attempt — a 401 on the
392
433
  // retry means the fresh token is also unauthorized, so surface it.