@flamingo-stack/openframe-frontend-core 0.0.473 → 0.0.474-snapshot.20260724131235

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 (119) hide show
  1. package/dist/{chunk-KXBXPOYM.cjs → chunk-3W6A7LST.cjs} +18 -18
  2. package/dist/{chunk-KXBXPOYM.cjs.map → chunk-3W6A7LST.cjs.map} +1 -1
  3. package/dist/{chunk-6WPUUGW5.js → chunk-3WBREJMR.js} +5 -5
  4. package/dist/{chunk-5LTLYPIP.cjs → chunk-5ICXBR62.cjs} +73 -73
  5. package/dist/{chunk-5LTLYPIP.cjs.map → chunk-5ICXBR62.cjs.map} +1 -1
  6. package/dist/{chunk-H6FKYKEQ.js → chunk-5IQXLG4H.js} +3 -3
  7. package/dist/{chunk-4WHDDAVP.cjs → chunk-5U7MG3CZ.cjs} +40 -40
  8. package/dist/{chunk-4WHDDAVP.cjs.map → chunk-5U7MG3CZ.cjs.map} +1 -1
  9. package/dist/{chunk-CDBQQA7V.cjs → chunk-7CU7Y3ML.cjs} +29 -29
  10. package/dist/{chunk-CDBQQA7V.cjs.map → chunk-7CU7Y3ML.cjs.map} +1 -1
  11. package/dist/{chunk-LWIVRGDN.js → chunk-A6O75SLQ.js} +2 -2
  12. package/dist/{chunk-FPOHUGEK.js → chunk-AGX565ZT.js} +2 -2
  13. package/dist/{chunk-6VEEQBVJ.cjs → chunk-AIUIIKH2.cjs} +15 -15
  14. package/dist/{chunk-6VEEQBVJ.cjs.map → chunk-AIUIIKH2.cjs.map} +1 -1
  15. package/dist/{chunk-QESV36I2.js → chunk-BQPZQQPC.js} +39 -83
  16. package/dist/chunk-BQPZQQPC.js.map +1 -0
  17. package/dist/{chunk-ZQRGJHNH.cjs → chunk-DEHW3LYM.cjs} +120 -164
  18. package/dist/chunk-DEHW3LYM.cjs.map +1 -0
  19. package/dist/{chunk-KP37QDNS.cjs → chunk-DJAPOTAY.cjs} +73 -73
  20. package/dist/{chunk-KP37QDNS.cjs.map → chunk-DJAPOTAY.cjs.map} +1 -1
  21. package/dist/{chunk-4YH4WKLC.js → chunk-E42Y3ZAL.js} +4 -4
  22. package/dist/{chunk-CH7MDKPI.cjs → chunk-E4CNWKZ3.cjs} +7 -7
  23. package/dist/{chunk-CH7MDKPI.cjs.map → chunk-E4CNWKZ3.cjs.map} +1 -1
  24. package/dist/{chunk-HQL5GJ35.cjs → chunk-FM5KWLKU.cjs} +3 -3
  25. package/dist/{chunk-HQL5GJ35.cjs.map → chunk-FM5KWLKU.cjs.map} +1 -1
  26. package/dist/{chunk-B472QGDO.cjs → chunk-HJV4VCEZ.cjs} +5 -5
  27. package/dist/{chunk-B472QGDO.cjs.map → chunk-HJV4VCEZ.cjs.map} +1 -1
  28. package/dist/{chunk-SWNZZVYG.js → chunk-LM57R7B4.js} +2 -2
  29. package/dist/{chunk-B52SBOUY.js → chunk-NJGLBKCR.js} +6 -6
  30. package/dist/{chunk-2SPAHQYE.js → chunk-PQT3NIDA.js} +5 -5
  31. package/dist/{chunk-6OGOPPTI.cjs → chunk-QM3DRG5I.cjs} +12 -12
  32. package/dist/{chunk-6OGOPPTI.cjs.map → chunk-QM3DRG5I.cjs.map} +1 -1
  33. package/dist/{chunk-UCY6VITT.js → chunk-R7JJ7PHN.js} +3 -3
  34. package/dist/{chunk-DBYUQOLS.js → chunk-S4VEPOX2.js} +2 -2
  35. package/dist/{chunk-4EU6WL5O.cjs → chunk-SZNK5Q4A.cjs} +40 -40
  36. package/dist/{chunk-4EU6WL5O.cjs.map → chunk-SZNK5Q4A.cjs.map} +1 -1
  37. package/dist/{chunk-TONMD7QX.cjs → chunk-VCOL4C2W.cjs} +86 -20
  38. package/dist/chunk-VCOL4C2W.cjs.map +1 -0
  39. package/dist/{chunk-J5S5U76A.cjs → chunk-W5UZSGJY.cjs} +32 -32
  40. package/dist/{chunk-J5S5U76A.cjs.map → chunk-W5UZSGJY.cjs.map} +1 -1
  41. package/dist/{chunk-I5L3KUTE.js → chunk-WEFPQFUO.js} +6 -6
  42. package/dist/{chunk-4Y6LX6XR.js → chunk-YRZZ7HHJ.js} +3 -3
  43. package/dist/{chunk-OTV4A6NA.js → chunk-ZPUZPJDV.js} +80 -14
  44. package/dist/chunk-ZPUZPJDV.js.map +1 -0
  45. package/dist/components/case-studies/index.cjs +9 -9
  46. package/dist/components/case-studies/index.js +3 -3
  47. package/dist/components/chat/index.cjs +3 -3
  48. package/dist/components/chat/index.js +2 -2
  49. package/dist/components/contact/index.cjs +4 -4
  50. package/dist/components/contact/index.js +3 -3
  51. package/dist/components/docs/index.cjs +6 -6
  52. package/dist/components/docs/index.js +5 -5
  53. package/dist/components/embeds/index.cjs +4 -4
  54. package/dist/components/embeds/index.js +3 -3
  55. package/dist/components/faq/index.cjs +5 -5
  56. package/dist/components/faq/index.js +4 -4
  57. package/dist/components/features/auth/auth-shell.d.ts +7 -1
  58. package/dist/components/features/auth/auth-shell.d.ts.map +1 -1
  59. package/dist/components/features/index.cjs +3 -3
  60. package/dist/components/features/index.js +2 -2
  61. package/dist/components/help-center-pages/index.cjs +22 -22
  62. package/dist/components/help-center-pages/index.js +13 -13
  63. package/dist/components/index.cjs +136 -136
  64. package/dist/components/index.js +11 -11
  65. package/dist/components/navigation/index.cjs +3 -3
  66. package/dist/components/navigation/index.js +2 -2
  67. package/dist/components/navigation/mobile-burger-menu.d.ts.map +1 -1
  68. package/dist/components/onboarding-guides/index.cjs +7 -7
  69. package/dist/components/onboarding-guides/index.js +6 -6
  70. package/dist/components/related-content/index.cjs +5 -5
  71. package/dist/components/related-content/index.js +4 -4
  72. package/dist/components/tickets/index.cjs +6 -6
  73. package/dist/components/tickets/index.js +5 -5
  74. package/dist/components/ui/entity-image.d.ts.map +1 -1
  75. package/dist/components/ui/image-uploader.d.ts.map +1 -1
  76. package/dist/components/ui/index.cjs +3 -3
  77. package/dist/components/ui/index.js +2 -2
  78. package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -1
  79. package/dist/components/ui/square-avatar.d.ts.map +1 -1
  80. package/dist/hooks/index.cjs +6 -2
  81. package/dist/hooks/index.cjs.map +1 -1
  82. package/dist/hooks/index.d.ts +1 -0
  83. package/dist/hooks/index.d.ts.map +1 -1
  84. package/dist/hooks/index.js +5 -1
  85. package/dist/hooks/use-authed-image-src.d.ts +10 -0
  86. package/dist/hooks/use-authed-image-src.d.ts.map +1 -0
  87. package/dist/index.cjs +7 -3
  88. package/dist/index.cjs.map +1 -1
  89. package/dist/index.js +6 -2
  90. package/dist/utils/embed-authed-fetch.d.ts +15 -0
  91. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  92. package/dist/utils/index.cjs.map +1 -1
  93. package/dist/utils/index.js.map +1 -1
  94. package/package.json +1 -1
  95. package/src/components/features/auth/auth-shell.tsx +8 -7
  96. package/src/components/navigation/mobile-burger-menu.tsx +14 -24
  97. package/src/components/ui/entity-image.tsx +5 -3
  98. package/src/components/ui/image-uploader.tsx +21 -12
  99. package/src/components/ui/simple-markdown-renderer.tsx +57 -58
  100. package/src/components/ui/square-avatar.tsx +5 -3
  101. package/src/hooks/index.ts +1 -0
  102. package/src/hooks/use-authed-image-src.ts +93 -0
  103. package/src/utils/embed-authed-fetch.ts +30 -0
  104. package/dist/chunk-OTV4A6NA.js.map +0 -1
  105. package/dist/chunk-QESV36I2.js.map +0 -1
  106. package/dist/chunk-TONMD7QX.cjs.map +0 -1
  107. package/dist/chunk-ZQRGJHNH.cjs.map +0 -1
  108. /package/dist/{chunk-6WPUUGW5.js.map → chunk-3WBREJMR.js.map} +0 -0
  109. /package/dist/{chunk-H6FKYKEQ.js.map → chunk-5IQXLG4H.js.map} +0 -0
  110. /package/dist/{chunk-LWIVRGDN.js.map → chunk-A6O75SLQ.js.map} +0 -0
  111. /package/dist/{chunk-FPOHUGEK.js.map → chunk-AGX565ZT.js.map} +0 -0
  112. /package/dist/{chunk-4YH4WKLC.js.map → chunk-E42Y3ZAL.js.map} +0 -0
  113. /package/dist/{chunk-SWNZZVYG.js.map → chunk-LM57R7B4.js.map} +0 -0
  114. /package/dist/{chunk-B52SBOUY.js.map → chunk-NJGLBKCR.js.map} +0 -0
  115. /package/dist/{chunk-2SPAHQYE.js.map → chunk-PQT3NIDA.js.map} +0 -0
  116. /package/dist/{chunk-UCY6VITT.js.map → chunk-R7JJ7PHN.js.map} +0 -0
  117. /package/dist/{chunk-DBYUQOLS.js.map → chunk-S4VEPOX2.js.map} +0 -0
  118. /package/dist/{chunk-I5L3KUTE.js.map → chunk-WEFPQFUO.js.map} +0 -0
  119. /package/dist/{chunk-4Y6LX6XR.js.map → chunk-YRZZ7HHJ.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.473",
3
+ "version": "0.0.474-snapshot.20260724131235",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -20,13 +20,19 @@ export interface AuthShellProps {
20
20
  /**
21
21
  * Responsive layout shell for the auth pages. Desktop shows a two-column split
22
22
  * (form left, marketing right); tablet and mobile stack into a single centered
23
- * column (logo → tabs → form → benefits → powered-by). Content is top-aligned.
23
+ * column (tabs → form → benefits → powered-by). Content is top-aligned. The
24
+ * wordmark appears only in the desktop marketing column — narrow screens go
25
+ * without it (in the native mobile shell a top logo sat under the status bar
26
+ * and was clipped inconsistently across devices).
27
+ *
28
+ * `of-auth-shell` on the root is a stable hook for shell/consumer CSS (the
29
+ * native mobile shell pads it by the top safe-area inset).
24
30
  */
25
31
  export function AuthShell({ tabs, children, benefits, footer, className }: AuthShellProps) {
26
32
  const benefitsNode = benefits ?? <AuthBenefitsPanel />
27
33
 
28
34
  return (
29
- <div className={cn('min-h-screen w-full bg-ods-bg lg:flex lg:h-screen lg:overflow-hidden', className)}>
35
+ <div className={cn('of-auth-shell min-h-screen w-full bg-ods-bg lg:flex lg:h-screen lg:overflow-hidden', className)}>
30
36
  {/* Main column — form */}
31
37
  <div className="flex min-h-screen w-full flex-col items-center p-[var(--spacing-system-l)] lg:h-full lg:min-h-0 lg:w-1/2 lg:justify-start lg:overflow-y-auto lg:px-[var(--spacing-system-xl)] lg:py-[var(--spacing-system-xxl)]">
32
38
  <div
@@ -35,11 +41,6 @@ export function AuthShell({ tabs, children, benefits, footer, className }: AuthS
35
41
  footer && 'lg:min-h-full',
36
42
  )}
37
43
  >
38
- {/* Logo — narrow screens only (desktop shows the logo in the right column) */}
39
- <div className="flex w-full flex-col items-center gap-[var(--spacing-system-l)] lg:hidden">
40
- <OpenFrameWordmark />
41
- </div>
42
-
43
44
  {/* Full width on mobile; fixed 320px from tablet up (matches desktop) */}
44
45
  {tabs && <div className="w-full md:max-w-[320px]">{tabs}</div>}
45
46
  <div className="w-full">{children}</div>
@@ -79,10 +79,11 @@ export const MobileBurgerMenu = React.memo(function MobileBurgerMenu({
79
79
  disabled={disabled}
80
80
  className={cn(
81
81
  "flex items-center gap-1 p-3 relative",
82
+ "focus:outline-none focus-visible:outline-none",
82
83
  "transition-colors duration-200",
83
84
  "bg-ods-card border border-ods-border rounded-md",
84
85
  !disabled && "hover:bg-ods-bg-hover",
85
- isGridItem ? "flex-1 min-w-0" : "w-full",
86
+ isGridItem ? "w-full min-w-0" : "w-full",
86
87
  // Active state
87
88
  isActive && !disabled && "border-ods-accent",
88
89
  // Disabled state
@@ -111,29 +112,18 @@ export const MobileBurgerMenu = React.memo(function MobileBurgerMenu({
111
112
  )
112
113
  }
113
114
 
114
- // Render grid of navigation items (2 columns)
115
- const renderNavigationGrid = (items: NavigationSidebarItem[]) => {
116
- const rows: NavigationSidebarItem[][] = []
117
- for (let i = 0; i < items.length; i += 2) {
118
- rows.push(items.slice(i, i + 2))
119
- }
120
-
121
- return (
122
- <div className="flex flex-col gap-3">
123
- {rows.map((row, rowIndex) => (
124
- <div key={rowIndex} className="flex gap-3">
125
- {row.map((item) => (
126
- <React.Fragment key={item.id}>
127
- {renderNavigationItem(item, true)}
128
- </React.Fragment>
129
- ))}
130
- {/* Fill empty space if odd number of items in last row */}
131
- {row.length === 1 && <div className="flex-1" />}
132
- </div>
133
- ))}
134
- </div>
135
- )
136
- }
115
+ // Render grid of navigation items (2 columns). CSS grid keeps a lone last-row
116
+ // item exactly one column wide (aligned with the column above) instead of
117
+ // stretching to fill the row.
118
+ const renderNavigationGrid = (items: NavigationSidebarItem[]) => (
119
+ <div className="grid grid-cols-2 gap-3">
120
+ {items.map((item) => (
121
+ <React.Fragment key={item.id}>
122
+ {renderNavigationItem(item, true)}
123
+ </React.Fragment>
124
+ ))}
125
+ </div>
126
+ )
137
127
 
138
128
  return (
139
129
  <>
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import React from 'react'
4
+ import { useAuthedImageSrc } from '../../hooks/use-authed-image-src'
4
5
  import { cn } from '../../utils/cn'
5
6
  import { getFirstLastInitials } from '../../utils/format'
6
7
 
@@ -13,13 +14,14 @@ export interface EntityImageProps {
13
14
  }
14
15
 
15
16
  export function EntityImage({ src, alt, fallbackText, className }: EntityImageProps) {
17
+ const resolvedSrc = useAuthedImageSrc(src)
16
18
  const [imageFailed, setImageFailed] = React.useState(false)
17
19
 
18
20
  React.useEffect(() => {
19
21
  setImageFailed(false)
20
- }, [src])
22
+ }, [resolvedSrc])
21
23
 
22
- const showFallback = imageFailed || !src
24
+ const showFallback = imageFailed || !resolvedSrc
23
25
  const initials = getFirstLastInitials(fallbackText ?? alt)
24
26
 
25
27
  if (showFallback) {
@@ -38,7 +40,7 @@ export function EntityImage({ src, alt, fallbackText, className }: EntityImagePr
38
40
 
39
41
  return (
40
42
  <img
41
- src={src ?? undefined}
43
+ src={resolvedSrc}
42
44
  alt={alt ?? ''}
43
45
  onError={() => setImageFailed(true)}
44
46
  className={cn(
@@ -5,6 +5,7 @@ import { Loader2 } from "lucide-react"
5
5
  import { ImagePlusIcon } from "../icons-v2-generated/audio-and-visual/image-plus-icon"
6
6
  import { Refresh02VrIcon } from "../icons-v2-generated/media-playback/refresh-02-vr-icon"
7
7
  import { TrashIcon } from "../icons-v2-generated/interface/trash-icon"
8
+ import { useAuthedImageSrc } from "../../hooks/use-authed-image-src"
8
9
  import { cn } from "../../utils/cn"
9
10
  import { FieldWrapper } from "./field-wrapper"
10
11
 
@@ -98,6 +99,12 @@ export function ImageUploader({
98
99
  const [validationError, setValidationError] = React.useState<string | null>(null)
99
100
 
100
101
  const interactive = !disabled && !loading
102
+ // Bearer-mode shells: swap a gateway URL for an authed blob URL (native
103
+ // <img> loads can't carry Authorization); pass-through everywhere else,
104
+ // including local blob/data preview URLs. `hasImage` stays keyed on the
105
+ // caller's `value` so the preview chrome doesn't flash to the empty
106
+ // state while the blob fetch is in flight.
107
+ const resolvedValue = useAuthedImageSrc(value)
101
108
  const hasImage = Boolean(value)
102
109
  const displayError = error || validationError || undefined
103
110
  const resolvedHint = hint === undefined ? `Max. size: ${formatSize(maxSize)}` : hint
@@ -196,18 +203,20 @@ export function ImageUploader({
196
203
  {hasImage ? (
197
204
  <>
198
205
  <div className="relative min-h-0 w-full flex-1 overflow-hidden rounded-md">
199
- <img
200
- src={value}
201
- alt={alt}
202
- className={cn(
203
- "pointer-events-none absolute inset-0 size-full",
204
- objectFit === "cover" && "object-cover",
205
- objectFit === "contain" && "object-contain",
206
- objectFit === "fill" && "object-fill",
207
- objectFit === "none" && "object-none",
208
- objectFit === "scale-down" && "object-scale-down",
209
- )}
210
- />
206
+ {resolvedValue && (
207
+ <img
208
+ src={resolvedValue}
209
+ alt={alt}
210
+ className={cn(
211
+ "pointer-events-none absolute inset-0 size-full",
212
+ objectFit === "cover" && "object-cover",
213
+ objectFit === "contain" && "object-contain",
214
+ objectFit === "fill" && "object-fill",
215
+ objectFit === "none" && "object-none",
216
+ objectFit === "scale-down" && "object-scale-down",
217
+ )}
218
+ />
219
+ )}
211
220
  </div>
212
221
 
213
222
  <div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-4">
@@ -9,6 +9,7 @@ import rehypeHighlight from 'rehype-highlight';
9
9
  import rehypeRaw from 'rehype-raw';
10
10
  import { visit } from 'unist-util-visit';
11
11
  import Image from '../../embed-shims/next-image';
12
+ import { useAuthedImageSrc } from '../../hooks/use-authed-image-src';
12
13
  import { AlertCircleIcon } from '../icons-v2-generated';
13
14
  import { cn } from '../../utils/cn';
14
15
  import type { ResolveLinkResult } from '../../types/doc-source';
@@ -453,6 +454,58 @@ const MermaidDiagram: React.FC<{ chart: string }> = ({ chart }) => {
453
454
  );
454
455
  };
455
456
 
457
+ // ---------------------------------------------------------------------------
458
+ // MarkdownContentImage
459
+ // ---------------------------------------------------------------------------
460
+ // Inline content image renderer. Used by blog posts, docs, AND chat
461
+ // messages (where users may attach screenshots / photos via the +
462
+ // attachment button). A standalone component (rather than inline JSX in
463
+ // the `img` renderer) because it needs `useAuthedImageSrc`: in
464
+ // bearer-mode native shells a gateway-hosted image can't load via a
465
+ // plain `<img>` (no Authorization header on native asset loads), so the
466
+ // hook swaps in an authed blob URL; everywhere else it passes `src`
467
+ // through untouched. While the blob fetch is in flight the component
468
+ // renders nothing — same as the no-src case.
469
+ //
470
+ // Sizing rules (2025-2026 best practice — Claude.ai, ChatGPT,
471
+ // iMessage, Slack, Discord inline image patterns):
472
+ //
473
+ // - CAP max-width at 400px so inline images don't blow out the
474
+ // message column on wide panels. Click-to-expand opens a
475
+ // full-resolution modal for users who need detail.
476
+ // - CAP max-height at 400px so portrait-orientation images
477
+ // don't dominate vertical space (a 1000x3000 phone screenshot
478
+ // would otherwise push the next message off-screen).
479
+ // - Small images render at NATURAL pixel size — a 64x64
480
+ // thumbnail stays 64x64, not stretched to fill the column.
481
+ // - `object-contain` preserves aspect ratio when both dimensions
482
+ // are constrained (long landscape, tall portrait).
483
+ //
484
+ // Implementation: Next.js `<Image>` — the project's canonical
485
+ // image primitive (WebP/AVIF conversion, responsive `srcset`, lazy
486
+ // loading, async decode). `width={400} height={400}` are REQUIRED by
487
+ // Next.js `<Image>` (non-`fill` mode throws without them) but they're
488
+ // effectively a CEILING here, not the display size — the CSS overrides
489
+ // (`w-auto h-auto max-w-full max-h-[400px]`) drive the actual rendered
490
+ // size, and the inline `style={{ width: 'auto', height: 'auto' }}` is
491
+ // belt-and-suspenders against the rendered `<img>`'s HTML width/height
492
+ // attributes.
493
+ const MarkdownContentImage: React.FC<{ src: string; alt?: string }> = ({ src, alt }) => {
494
+ const resolvedSrc = useAuthedImageSrc(src);
495
+ if (!resolvedSrc) return null;
496
+ return (
497
+ <Image
498
+ src={resolvedSrc}
499
+ alt={alt ?? 'No image available'}
500
+ width={400}
501
+ height={400}
502
+ sizes="(max-width: 400px) 100vw, 400px"
503
+ className="max-w-full max-h-[400px] w-auto h-auto rounded-lg object-contain"
504
+ style={{ width: 'auto', height: 'auto' }}
505
+ />
506
+ );
507
+ };
508
+
456
509
  // ---------------------------------------------------------------------------
457
510
  // Utility: extract plain text from React children
458
511
  // ---------------------------------------------------------------------------
@@ -820,66 +873,12 @@ const SimpleMarkdownRendererImpl: React.FC<SimpleMarkdownRendererProps> = ({
820
873
  },
821
874
 
822
875
  // --- images ---
823
- // Inline content image renderer. Used by blog posts, docs, AND chat
824
- // messages (where users may attach screenshots / photos via the +
825
- // attachment button).
826
- //
827
- // Sizing rules (2025-2026 best practice — Claude.ai, ChatGPT,
828
- // iMessage, Slack, Discord inline image patterns):
829
- //
830
- // - CAP max-width at 400px so inline images don't blow out the
831
- // message column on wide panels. Click-to-expand opens a
832
- // full-resolution modal for users who need detail.
833
- // - CAP max-height at 400px so portrait-orientation images
834
- // don't dominate vertical space (a 1000x3000 phone screenshot
835
- // would otherwise push the next message off-screen).
836
- // - Small images render at NATURAL pixel size — a 64x64
837
- // thumbnail stays 64x64, not stretched to fill the column.
838
- // - `object-contain` preserves aspect ratio when both dimensions
839
- // are constrained (long landscape, tall portrait).
840
- //
841
- // Implementation: Next.js `<Image>` — the project's canonical
842
- // image primitive. Gives us:
843
- // - WebP/AVIF format conversion for modern browsers (smaller
844
- // bytes for the same visual quality).
845
- // - Responsive `srcset` via the `sizes` prop (browser picks the
846
- // right variant for the viewport).
847
- // - Automatic lazy-loading (`loading="lazy"` by default, mid-
848
- // page images skipped until they near the viewport).
849
- // - Automatic `decoding="async"` so image decode doesn't block
850
- // paint.
851
- //
852
- // `width={400} height={400}` props are REQUIRED by Next.js
853
- // `<Image>` (non-`fill` mode throws without them) but they're
854
- // effectively a CEILING here, not the display size — the CSS
855
- // overrides (`w-auto h-auto max-w-full max-h-[400px]`) drive
856
- // the actual rendered size. The inline `style={{ width: 'auto',
857
- // height: 'auto' }}` is belt-and-suspenders: Next.js Image sets
858
- // matching HTML `width`/`height` attributes on the rendered
859
- // `<img>` and inline style wins over both HTML attributes AND
860
- // utility classes regardless of CSS-specificity surprises.
861
- //
862
- // Layout reservation trade-off: until image bytes arrive, the
863
- // browser may reserve a placeholder box up to 400x400 (the props'
864
- // intrinsic-ratio hint). Once loaded, the box collapses to the
865
- // natural size if smaller. This is the standard Next.js Image
866
- // behavior across the codebase — accepted for the optimizer +
867
- // responsive-srcset benefits. Chat attachments hosted in side
868
- // panels see this only on first render of a fresh attachment
869
- // (cached re-renders pop in without a perceptible shift).
876
+ // Delegates to MarkdownContentImage (module scope, above) — sizing
877
+ // rules, Next.js <Image> rationale, and the bearer-mode authed-src
878
+ // handling are documented there.
870
879
  img: ({ src, alt }: any) => {
871
880
  if (!src || typeof src !== 'string' || src.trim() === '') return null;
872
- return (
873
- <Image
874
- src={src}
875
- alt={alt ?? 'No image available'}
876
- width={400}
877
- height={400}
878
- sizes="(max-width: 400px) 100vw, 400px"
879
- className="max-w-full max-h-[400px] w-auto h-auto rounded-lg object-contain"
880
- style={{ width: 'auto', height: 'auto' }}
881
- />
882
- );
881
+ return <MarkdownContentImage src={src} alt={alt} />;
883
882
  },
884
883
 
885
884
  // --- lists ---
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import Image from "../../embed-shims/next-image";
5
+ import { useAuthedImageSrc } from "../../hooks/use-authed-image-src";
5
6
  import { cn } from "../../utils/cn";
6
7
  import { getFirstLastInitials } from "../../utils/format";
7
8
 
@@ -22,6 +23,7 @@ interface SquareAvatarProps extends React.HTMLAttributes<HTMLDivElement> {
22
23
 
23
24
  const SquareAvatar = React.memo(React.forwardRef<HTMLDivElement, SquareAvatarProps>(
24
25
  ({ className, src, alt, size = 'md', sizePx, fallback, variant = 'square', initialsClassName, style, ...props }, ref) => {
26
+ const resolvedSrc = useAuthedImageSrc(src)
25
27
  const sizeClasses = {
26
28
  sm: 'h-8 w-8',
27
29
  md: 'h-10 w-10',
@@ -66,14 +68,14 @@ const SquareAvatar = React.memo(React.forwardRef<HTMLDivElement, SquareAvatarPro
66
68
  // keeps the later class).
67
69
  'flex items-center justify-center text-xs font-medium text-[color:var(--ods-avatar-initials,var(--color-text-primary))]',
68
70
  initialsClassName,
69
- src && 'hidden'
71
+ resolvedSrc && 'hidden'
70
72
  )}>
71
73
  {getFirstLastInitials(fallback || alt) || '?'}
72
74
  </div>
73
- {src && (
75
+ {resolvedSrc && (
74
76
  <Image
75
77
  className="absolute -inset-px h-[calc(100%+2px)] w-[calc(100%+2px)] max-w-none object-cover"
76
- src={src}
78
+ src={resolvedSrc}
77
79
  alt={alt || ''}
78
80
  width={sizePx ?? sizePxBySize[size]}
79
81
  height={sizePx ?? sizePxBySize[size]}
@@ -8,6 +8,7 @@ export * from './use-copy-to-clipboard'
8
8
  // Image authentication hooks
9
9
  export * from './use-batch-images'
10
10
  export * from './use-authenticated-image'
11
+ export * from './use-authed-image-src'
11
12
 
12
13
  // URL State Management hooks
13
14
  export * from './state'
@@ -0,0 +1,93 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useReducer } from 'react'
4
+ import { embedAuthedFetch, needsBearerAssetFetch } from '../utils/embed-authed-fetch'
5
+
6
+ /**
7
+ * Resolve an image URL for hosts whose auth rides in request headers
8
+ * instead of cookies (native shells — `capacitor://localhost`,
9
+ * `tauri://localhost` — running in bearer mode). A native `<img>` load
10
+ * can't carry an `Authorization` header, so a gateway image URL that
11
+ * works on the cookie-auth web 401s in a shell. When
12
+ * `needsBearerAssetFetch` says the URL's origin is bearer-authed, this
13
+ * hook fetches it through `embedAuthedFetch` (bearer + deduped
14
+ * 401-refresh-retry + the adapter's cross-origin guard) and returns a
15
+ * blob object-URL; every other URL is returned unchanged, so on the
16
+ * cookie-auth web the hook is a pass-through.
17
+ *
18
+ * Returns `undefined` while the fetch is in flight OR after it fails, so
19
+ * callers show their existing initials/placeholder fallback instead of a
20
+ * broken image; a failed fetch retries on the next mount.
21
+ *
22
+ * Cache: module-level, session-lifetime, keyed by full URL. Gateway image
23
+ * URLs carry a `?v=<content-hash>` cache-buster, so a changed image is a
24
+ * new key; entries are never revoked (avatars are a few KB — refcount
25
+ * churn isn't worth it).
26
+ *
27
+ * Distinct from the legacy `useAuthenticatedImage`, which ALWAYS
28
+ * blob-fetches with `credentials: 'include'` and its own global config.
29
+ * This hook is pass-through-first and keys off the single auth knob the
30
+ * lib already has: the registered `EmbedAuthAdapter`.
31
+ */
32
+
33
+ const resolvedCache = new Map<string, string>()
34
+ const inFlight = new Map<string, Promise<void>>()
35
+ let cacheGeneration = 0
36
+
37
+ /**
38
+ * Drop every cached blob and revoke its object URL. Hosts call this at
39
+ * session end (logout / forced re-login) so a follow-on login as a
40
+ * different identity cannot be served blobs fetched under the previous
41
+ * one's bearer. Fetches still in flight when the clear happens are fenced
42
+ * by a generation counter — their results are revoked instead of cached.
43
+ */
44
+ export function clearAuthedImageCache(): void {
45
+ cacheGeneration++
46
+ for (const url of resolvedCache.values()) URL.revokeObjectURL(url)
47
+ resolvedCache.clear()
48
+ inFlight.clear()
49
+ }
50
+
51
+ function fetchAsBlobUrl(src: string): Promise<void> {
52
+ let pending = inFlight.get(src)
53
+ if (!pending) {
54
+ const startedGeneration = cacheGeneration
55
+ pending = embedAuthedFetch(src, { headers: { Accept: 'image/*' } })
56
+ .then(async response => {
57
+ if (!response.ok) throw new Error(`image fetch failed: ${response.status}`)
58
+ const blobUrl = URL.createObjectURL(await response.blob())
59
+ if (startedGeneration === cacheGeneration) {
60
+ resolvedCache.set(src, blobUrl)
61
+ } else {
62
+ URL.revokeObjectURL(blobUrl)
63
+ }
64
+ })
65
+ .finally(() => {
66
+ if (inFlight.get(src) === pending) inFlight.delete(src)
67
+ })
68
+ inFlight.set(src, pending)
69
+ }
70
+ return pending
71
+ }
72
+
73
+ export function useAuthedImageSrc(src?: string | null): string | undefined {
74
+ const bearerSrc = src && needsBearerAssetFetch(src) ? src : null
75
+ const [, rerender] = useReducer((c: number) => c + 1, 0)
76
+
77
+ useEffect(() => {
78
+ if (!bearerSrc || resolvedCache.has(bearerSrc)) return
79
+ let cancelled = false
80
+ fetchAsBlobUrl(bearerSrc)
81
+ .catch(() => {})
82
+ .finally(() => {
83
+ if (!cancelled) rerender()
84
+ })
85
+ return () => {
86
+ cancelled = true
87
+ }
88
+ }, [bearerSrc])
89
+
90
+ if (!src) return undefined
91
+ if (!bearerSrc) return src
92
+ return resolvedCache.get(bearerSrc)
93
+ }
@@ -156,6 +156,36 @@ export function hasEmbedAuthAdapter(): boolean {
156
156
  return getRegisteredAuthAdapter() !== null
157
157
  }
158
158
 
159
+ /**
160
+ * Whether `url` is an asset the browser CANNOT load natively because its
161
+ * auth rides in request headers: the registered adapter is currently
162
+ * supplying an `Authorization` header AND the URL's origin is one the
163
+ * adapter explicitly sanctions for that bearer (`allowedOrigins` — the
164
+ * host's gateway, cross-origin by construction in native shells).
165
+ *
166
+ * Native asset loads (`<img src>`, CSS `background-image`) can't carry
167
+ * custom headers, so a URL this returns `true` for must go through
168
+ * `embedAuthedFetch` → blob object-URL instead (see `useAuthedImageSrc`).
169
+ * Everything else — cookie-auth web (no Authorization), relative /
170
+ * same-origin URLs (cookies work), third-party origins the bearer does
171
+ * NOT belong to (public images) — loads natively, unchanged.
172
+ */
173
+ export function needsBearerAssetFetch(url: string): boolean {
174
+ if (typeof window === 'undefined') return false
175
+ const adapter = getRegisteredAuthAdapter()
176
+ if (!adapter?.allowedOrigins?.length || adapter.getHeaders?.().Authorization === undefined) {
177
+ return false
178
+ }
179
+ let target: URL
180
+ try {
181
+ target = new URL(url, window.location.href)
182
+ } catch {
183
+ return false
184
+ }
185
+ if (target.protocol !== 'http:' && target.protocol !== 'https:') return false
186
+ return adapter.allowedOrigins.includes(target.origin)
187
+ }
188
+
159
189
  /**
160
190
  * `fetch` wrapper that attaches embed-proxy bearer headers (when
161
191
  * present in sessionStorage) and forces `credentials: 'same-origin'`