@flamingo-stack/openframe-frontend-core 0.0.397 → 0.0.398-snapshot.20260710082611

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 (156) hide show
  1. package/dist/{chunk-IVXOR2IA.js → chunk-2L6ALD2Q.js} +3 -3
  2. package/dist/{chunk-CK5UZLIN.js → chunk-2XVOYDRF.js} +3418 -3312
  3. package/dist/chunk-2XVOYDRF.js.map +1 -0
  4. package/dist/{chunk-VTR4QDL2.js → chunk-4H2ICJYB.js} +4 -4
  5. package/dist/{chunk-WCDM226S.js → chunk-4K5XJ5EJ.js} +3 -3
  6. package/dist/{chunk-WWHPTP4B.js → chunk-4KWIQEZT.js} +2 -2
  7. package/dist/{chunk-OAENYOKC.cjs → chunk-4PEC5KU6.cjs} +12 -12
  8. package/dist/{chunk-OAENYOKC.cjs.map → chunk-4PEC5KU6.cjs.map} +1 -1
  9. package/dist/{chunk-ZUKBC2ZX.cjs → chunk-7OKNKZBW.cjs} +763 -657
  10. package/dist/chunk-7OKNKZBW.cjs.map +1 -0
  11. package/dist/{chunk-J6TAK6HE.js → chunk-A6KGHQRF.js} +6 -6
  12. package/dist/{chunk-JNU3YPLP.js → chunk-BSJO5VFV.js} +6 -6
  13. package/dist/{chunk-D4F7ZLJG.js → chunk-CRRH7RJX.js} +5 -5
  14. package/dist/{chunk-M6NPDNVB.cjs → chunk-D5IHYUBY.cjs} +33 -33
  15. package/dist/{chunk-M6NPDNVB.cjs.map → chunk-D5IHYUBY.cjs.map} +1 -1
  16. package/dist/{chunk-K5FRWPWF.js → chunk-FXWURWDU.js} +3 -3
  17. package/dist/{chunk-3E4BDAHJ.js → chunk-G3A7QHCX.js} +3 -3
  18. package/dist/{chunk-55SNCMFS.cjs → chunk-G5NDKTX6.cjs} +40 -40
  19. package/dist/{chunk-55SNCMFS.cjs.map → chunk-G5NDKTX6.cjs.map} +1 -1
  20. package/dist/{chunk-NYIZVI4I.cjs → chunk-GA5AN24T.cjs} +10 -3
  21. package/dist/chunk-GA5AN24T.cjs.map +1 -0
  22. package/dist/{chunk-X2FPWS3P.js → chunk-HGKWTKW7.js} +10 -3
  23. package/dist/chunk-HGKWTKW7.js.map +1 -0
  24. package/dist/{chunk-DB6CUWTL.cjs → chunk-JBLBFJ4N.cjs} +32 -32
  25. package/dist/{chunk-DB6CUWTL.cjs.map → chunk-JBLBFJ4N.cjs.map} +1 -1
  26. package/dist/{chunk-743FCO64.cjs → chunk-JWBRDNYH.cjs} +31 -31
  27. package/dist/{chunk-743FCO64.cjs.map → chunk-JWBRDNYH.cjs.map} +1 -1
  28. package/dist/{chunk-LWNPMLIH.cjs → chunk-LPH2JDOW.cjs} +56 -2
  29. package/dist/chunk-LPH2JDOW.cjs.map +1 -0
  30. package/dist/{chunk-LQHMXPOJ.js → chunk-N7YBNHVT.js} +563 -509
  31. package/dist/chunk-N7YBNHVT.js.map +1 -0
  32. package/dist/{chunk-BB7TZ6KS.cjs → chunk-NCW22SII.cjs} +75 -75
  33. package/dist/{chunk-BB7TZ6KS.cjs.map → chunk-NCW22SII.cjs.map} +1 -1
  34. package/dist/{chunk-XNAYZHPK.cjs → chunk-O5NA3ULU.cjs} +93 -93
  35. package/dist/{chunk-XNAYZHPK.cjs.map → chunk-O5NA3ULU.cjs.map} +1 -1
  36. package/dist/{chunk-Z6PAD2UN.cjs → chunk-QNYNXSYQ.cjs} +12 -12
  37. package/dist/{chunk-Z6PAD2UN.cjs.map → chunk-QNYNXSYQ.cjs.map} +1 -1
  38. package/dist/{chunk-T5F54MN4.cjs → chunk-RIC4TCPD.cjs} +19 -19
  39. package/dist/{chunk-T5F54MN4.cjs.map → chunk-RIC4TCPD.cjs.map} +1 -1
  40. package/dist/{chunk-HPQKX5IW.cjs → chunk-VOHOA3WV.cjs} +19 -19
  41. package/dist/{chunk-HPQKX5IW.cjs.map → chunk-VOHOA3WV.cjs.map} +1 -1
  42. package/dist/{chunk-TTHTO5H5.js → chunk-VVKWNVKE.js} +7 -7
  43. package/dist/{chunk-2U3YPWHG.cjs → chunk-WBLRUO3F.cjs} +3 -3
  44. package/dist/{chunk-2U3YPWHG.cjs.map → chunk-WBLRUO3F.cjs.map} +1 -1
  45. package/dist/{chunk-2OOZASQE.js → chunk-XBCAY6NE.js} +4 -4
  46. package/dist/{chunk-SBWTMJPQ.cjs → chunk-YWCCKB4J.cjs} +5 -5
  47. package/dist/{chunk-SBWTMJPQ.cjs.map → chunk-YWCCKB4J.cjs.map} +1 -1
  48. package/dist/{chunk-ZFK4HHRX.js → chunk-Z5ERSGP2.js} +2 -2
  49. package/dist/{chunk-43C5ECNT.cjs → chunk-ZDT2MBKE.cjs} +40 -40
  50. package/dist/{chunk-43C5ECNT.cjs.map → chunk-ZDT2MBKE.cjs.map} +1 -1
  51. package/dist/{chunk-KQESCGVG.js → chunk-ZSZRJWY3.js} +2 -2
  52. package/dist/components/case-studies/index.cjs +15 -15
  53. package/dist/components/case-studies/index.js +5 -5
  54. package/dist/components/chat/index.cjs +5 -5
  55. package/dist/components/chat/index.js +4 -4
  56. package/dist/components/contact/index.cjs +6 -6
  57. package/dist/components/contact/index.js +5 -5
  58. package/dist/components/docs/index.cjs +8 -8
  59. package/dist/components/docs/index.js +7 -7
  60. package/dist/components/embeds/index.cjs +6 -6
  61. package/dist/components/embeds/index.js +5 -5
  62. package/dist/components/faq/index.cjs +7 -7
  63. package/dist/components/faq/index.js +6 -6
  64. package/dist/components/features/auth/complete-account-form.d.ts.map +1 -1
  65. package/dist/components/features/auth/create-organization-form.d.ts.map +1 -1
  66. package/dist/components/features/auth/openframe-sso-signup-form.d.ts.map +1 -1
  67. package/dist/components/features/index.cjs +9 -5
  68. package/dist/components/features/index.cjs.map +1 -1
  69. package/dist/components/features/index.d.ts +1 -0
  70. package/dist/components/features/index.d.ts.map +1 -1
  71. package/dist/components/features/index.js +8 -4
  72. package/dist/components/features/video-bites-strip.d.ts.map +1 -1
  73. package/dist/components/features/video-center-badge.d.ts +40 -0
  74. package/dist/components/features/video-center-badge.d.ts.map +1 -0
  75. package/dist/components/features/video.d.ts +7 -4
  76. package/dist/components/features/video.d.ts.map +1 -1
  77. package/dist/components/help-center-pages/index.cjs +24 -24
  78. package/dist/components/help-center-pages/index.js +15 -15
  79. package/dist/components/icons/index.cjs +6 -2
  80. package/dist/components/icons/index.cjs.map +1 -1
  81. package/dist/components/icons/index.d.ts +2 -0
  82. package/dist/components/icons/index.d.ts.map +1 -1
  83. package/dist/components/icons/index.js +5 -1
  84. package/dist/components/icons/play-filled-icon.d.ts +15 -0
  85. package/dist/components/icons/play-filled-icon.d.ts.map +1 -0
  86. package/dist/components/icons/volume-off-filled-icon.d.ts +14 -0
  87. package/dist/components/icons/volume-off-filled-icon.d.ts.map +1 -0
  88. package/dist/components/index.cjs +176 -166
  89. package/dist/components/index.cjs.map +1 -1
  90. package/dist/components/index.js +23 -13
  91. package/dist/components/index.js.map +1 -1
  92. package/dist/components/media-carousel.d.ts.map +1 -1
  93. package/dist/components/navigation/index.cjs +5 -5
  94. package/dist/components/navigation/index.js +4 -4
  95. package/dist/components/onboarding-guides/index.cjs +9 -9
  96. package/dist/components/onboarding-guides/index.js +8 -8
  97. package/dist/components/related-content/index.cjs +7 -7
  98. package/dist/components/related-content/index.js +6 -6
  99. package/dist/components/tickets/index.cjs +8 -8
  100. package/dist/components/tickets/index.js +7 -7
  101. package/dist/components/ui/crossfade-panels.d.ts +52 -0
  102. package/dist/components/ui/crossfade-panels.d.ts.map +1 -0
  103. package/dist/components/ui/field-wrapper.d.ts +1 -1
  104. package/dist/components/ui/field-wrapper.d.ts.map +1 -1
  105. package/dist/components/ui/file-manager/index.cjs +50 -50
  106. package/dist/components/ui/file-manager/index.js +1 -1
  107. package/dist/components/ui/index.cjs +7 -5
  108. package/dist/components/ui/index.cjs.map +1 -1
  109. package/dist/components/ui/index.d.ts +1 -0
  110. package/dist/components/ui/index.d.ts.map +1 -1
  111. package/dist/components/ui/index.js +6 -4
  112. package/dist/components/ui/tab-navigation.d.ts +6 -2
  113. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  114. package/dist/hooks/index.cjs +3 -3
  115. package/dist/hooks/index.js +2 -2
  116. package/dist/index.cjs +15 -5
  117. package/dist/index.cjs.map +1 -1
  118. package/dist/index.js +14 -4
  119. package/package.json +4 -4
  120. package/src/components/features/auth/complete-account-form.tsx +37 -51
  121. package/src/components/features/auth/create-organization-form.tsx +22 -29
  122. package/src/components/features/auth/openframe-sso-signup-form.tsx +37 -51
  123. package/src/components/features/index.ts +1 -0
  124. package/src/components/features/video-bites-strip.tsx +73 -15
  125. package/src/components/features/video-center-badge.tsx +77 -0
  126. package/src/components/features/video.tsx +51 -28
  127. package/src/components/icons/index.ts +2 -0
  128. package/src/components/icons/play-filled-icon.tsx +36 -0
  129. package/src/components/icons/volume-off-filled-icon.tsx +38 -0
  130. package/src/components/media-carousel.tsx +2 -4
  131. package/src/components/ui/crossfade-panels.tsx +88 -0
  132. package/src/components/ui/field-wrapper.tsx +6 -3
  133. package/src/components/ui/index.ts +1 -0
  134. package/src/components/ui/tab-navigation.tsx +31 -14
  135. package/src/stories/Input.stories.tsx +44 -0
  136. package/src/stories/auth/CreateOrganization.stories.tsx +19 -0
  137. package/src/styles/app-globals.css +23 -0
  138. package/dist/chunk-CK5UZLIN.js.map +0 -1
  139. package/dist/chunk-LQHMXPOJ.js.map +0 -1
  140. package/dist/chunk-LWNPMLIH.cjs.map +0 -1
  141. package/dist/chunk-NYIZVI4I.cjs.map +0 -1
  142. package/dist/chunk-X2FPWS3P.js.map +0 -1
  143. package/dist/chunk-ZUKBC2ZX.cjs.map +0 -1
  144. /package/dist/{chunk-IVXOR2IA.js.map → chunk-2L6ALD2Q.js.map} +0 -0
  145. /package/dist/{chunk-VTR4QDL2.js.map → chunk-4H2ICJYB.js.map} +0 -0
  146. /package/dist/{chunk-WCDM226S.js.map → chunk-4K5XJ5EJ.js.map} +0 -0
  147. /package/dist/{chunk-WWHPTP4B.js.map → chunk-4KWIQEZT.js.map} +0 -0
  148. /package/dist/{chunk-J6TAK6HE.js.map → chunk-A6KGHQRF.js.map} +0 -0
  149. /package/dist/{chunk-JNU3YPLP.js.map → chunk-BSJO5VFV.js.map} +0 -0
  150. /package/dist/{chunk-D4F7ZLJG.js.map → chunk-CRRH7RJX.js.map} +0 -0
  151. /package/dist/{chunk-K5FRWPWF.js.map → chunk-FXWURWDU.js.map} +0 -0
  152. /package/dist/{chunk-3E4BDAHJ.js.map → chunk-G3A7QHCX.js.map} +0 -0
  153. /package/dist/{chunk-TTHTO5H5.js.map → chunk-VVKWNVKE.js.map} +0 -0
  154. /package/dist/{chunk-2OOZASQE.js.map → chunk-XBCAY6NE.js.map} +0 -0
  155. /package/dist/{chunk-ZFK4HHRX.js.map → chunk-Z5ERSGP2.js.map} +0 -0
  156. /package/dist/{chunk-KQESCGVG.js.map → chunk-ZSZRJWY3.js.map} +0 -0
@@ -111,57 +111,43 @@ export function CompleteAccountForm({
111
111
  </>
112
112
  )}
113
113
 
114
- {/* First + Last name */}
115
- <div className="flex gap-[var(--spacing-system-l)]">
116
- <div className="min-w-0 flex-1">
117
- <Input
118
- label="First Name"
119
- placeholder="Enter First Name"
120
- value={firstName}
121
- error={errors?.firstName}
122
- disabled={fieldsDisabled}
123
- onChange={(event) => onFirstNameChange(event.target.value)}
124
- onKeyDown={handleKeyDown}
125
- />
126
- </div>
127
- <div className="min-w-0 flex-1">
128
- <Input
129
- label="Last Name"
130
- placeholder="Enter Last Name"
131
- value={lastName}
132
- error={errors?.lastName}
133
- disabled={fieldsDisabled}
134
- onChange={(event) => onLastNameChange(event.target.value)}
135
- onKeyDown={handleKeyDown}
136
- />
137
- </div>
138
- </div>
139
-
140
- {/* Password + Confirm */}
141
- <div className="flex gap-[var(--spacing-system-l)]">
142
- <div className="min-w-0 flex-1">
143
- <PasswordInput
144
- label="Password"
145
- placeholder="Enter Password"
146
- value={password}
147
- error={errors?.password}
148
- disabled={fieldsDisabled}
149
- onChange={(event) => onPasswordChange(event.target.value)}
150
- onKeyDown={handleKeyDown}
151
- />
152
- </div>
153
- <div className="min-w-0 flex-1">
154
- <PasswordInput
155
- label="Confirm Password"
156
- placeholder="Confirm Password"
157
- value={confirmPassword}
158
- error={errors?.confirmPassword}
159
- disabled={fieldsDisabled}
160
- onChange={(event) => onConfirmPasswordChange(event.target.value)}
161
- onKeyDown={handleKeyDown}
162
- />
163
- </div>
164
- </div>
114
+ {/* Name + password fields — single column on every breakpoint */}
115
+ <Input
116
+ label="First Name"
117
+ placeholder="Enter First Name"
118
+ value={firstName}
119
+ error={errors?.firstName}
120
+ disabled={fieldsDisabled}
121
+ onChange={(event) => onFirstNameChange(event.target.value)}
122
+ onKeyDown={handleKeyDown}
123
+ />
124
+ <Input
125
+ label="Last Name"
126
+ placeholder="Enter Last Name"
127
+ value={lastName}
128
+ error={errors?.lastName}
129
+ disabled={fieldsDisabled}
130
+ onChange={(event) => onLastNameChange(event.target.value)}
131
+ onKeyDown={handleKeyDown}
132
+ />
133
+ <PasswordInput
134
+ label="Password"
135
+ placeholder="Enter Password"
136
+ value={password}
137
+ error={errors?.password}
138
+ disabled={fieldsDisabled}
139
+ onChange={(event) => onPasswordChange(event.target.value)}
140
+ onKeyDown={handleKeyDown}
141
+ />
142
+ <PasswordInput
143
+ label="Confirm Password"
144
+ placeholder="Confirm Password"
145
+ value={confirmPassword}
146
+ error={errors?.confirmPassword}
147
+ disabled={fieldsDisabled}
148
+ onChange={(event) => onConfirmPasswordChange(event.target.value)}
149
+ onKeyDown={handleKeyDown}
150
+ />
165
151
 
166
152
  {/* Actions — optional back + submit */}
167
153
  <div className="flex items-center gap-[var(--spacing-system-l)]">
@@ -110,33 +110,27 @@ export function CreateOrganizationForm({
110
110
  <p className="text-h4 text-ods-text-secondary">Start your journey with OpenFrame.</p>
111
111
  </div>
112
112
 
113
- {/* Email + Organization Name — side by side on every breakpoint */}
114
- <div className="flex gap-[var(--spacing-system-l)]">
115
- <div className="min-w-0 flex-1">
116
- <Input
117
- type="email"
118
- label="Email"
119
- placeholder="username@mail.com"
120
- value={email}
121
- error={errors?.email ?? emailStatus?.message}
122
- errorVariant={errors?.email ? 'error' : emailStatus?.variant}
123
- disabled={fieldsDisabled}
124
- onChange={(event) => onEmailChange(event.target.value)}
125
- onKeyDown={handleKeyDown}
126
- />
127
- </div>
128
- <div className="min-w-0 flex-1">
129
- <Input
130
- label="Organization Name"
131
- placeholder="Your Company Name"
132
- value={organizationName}
133
- error={errors?.organizationName}
134
- disabled={fieldsDisabled}
135
- onChange={(event) => onOrganizationNameChange(event.target.value)}
136
- onKeyDown={handleKeyDown}
137
- />
138
- </div>
139
- </div>
113
+ {/* Email + Organization Name — single column on every breakpoint */}
114
+ <Input
115
+ type="email"
116
+ label="Email"
117
+ placeholder="username@mail.com"
118
+ value={email}
119
+ error={errors?.email ?? emailStatus?.message}
120
+ errorVariant={errors?.email ? 'error' : emailStatus?.variant}
121
+ disabled={fieldsDisabled}
122
+ onChange={(event) => onEmailChange(event.target.value)}
123
+ onKeyDown={handleKeyDown}
124
+ />
125
+ <Input
126
+ label="Organization Name"
127
+ placeholder="Your Company Name"
128
+ value={organizationName}
129
+ error={errors?.organizationName}
130
+ disabled={fieldsDisabled}
131
+ onChange={(event) => onOrganizationNameChange(event.target.value)}
132
+ onKeyDown={handleKeyDown}
133
+ />
140
134
 
141
135
  {/* Domain */}
142
136
  <div className="flex flex-col">
@@ -151,8 +145,7 @@ export function CreateOrganizationForm({
151
145
  onChange={(event) => onDomainChange(event.target.value)}
152
146
  onKeyDown={handleKeyDown}
153
147
  />
154
- {/* Top padding clears the absolutely-positioned field message */}
155
- {domainSlot && <div className="pt-[var(--spacing-system-l)]">{domainSlot}</div>}
148
+ {domainSlot && <div className="pt-[var(--spacing-system-s)]">{domainSlot}</div>}
156
149
  </div>
157
150
 
158
151
  {/* Terms & Privacy */}
@@ -102,57 +102,43 @@ export function OpenFrameSsoSignUpForm({
102
102
  onKeyDown={handleKeyDown}
103
103
  />
104
104
 
105
- {/* First + Last name */}
106
- <div className="flex gap-[var(--spacing-system-l)]">
107
- <div className="min-w-0 flex-1">
108
- <Input
109
- label="First Name"
110
- placeholder="Enter First Name"
111
- value={firstName}
112
- error={errors?.firstName}
113
- disabled={fieldsDisabled}
114
- onChange={(event) => onFirstNameChange(event.target.value)}
115
- onKeyDown={handleKeyDown}
116
- />
117
- </div>
118
- <div className="min-w-0 flex-1">
119
- <Input
120
- label="Last Name"
121
- placeholder="Enter Last Name"
122
- value={lastName}
123
- error={errors?.lastName}
124
- disabled={fieldsDisabled}
125
- onChange={(event) => onLastNameChange(event.target.value)}
126
- onKeyDown={handleKeyDown}
127
- />
128
- </div>
129
- </div>
130
-
131
- {/* Password + Confirm */}
132
- <div className="flex gap-[var(--spacing-system-l)]">
133
- <div className="min-w-0 flex-1">
134
- <PasswordInput
135
- label="Password"
136
- placeholder="Enter Password"
137
- value={password}
138
- error={errors?.password}
139
- disabled={fieldsDisabled}
140
- onChange={(event) => onPasswordChange(event.target.value)}
141
- onKeyDown={handleKeyDown}
142
- />
143
- </div>
144
- <div className="min-w-0 flex-1">
145
- <PasswordInput
146
- label="Confirm Password"
147
- placeholder="Confirm Password"
148
- value={confirmPassword}
149
- error={errors?.confirmPassword}
150
- disabled={fieldsDisabled}
151
- onChange={(event) => onConfirmPasswordChange(event.target.value)}
152
- onKeyDown={handleKeyDown}
153
- />
154
- </div>
155
- </div>
105
+ {/* Name + password fields — single column on every breakpoint */}
106
+ <Input
107
+ label="First Name"
108
+ placeholder="Enter First Name"
109
+ value={firstName}
110
+ error={errors?.firstName}
111
+ disabled={fieldsDisabled}
112
+ onChange={(event) => onFirstNameChange(event.target.value)}
113
+ onKeyDown={handleKeyDown}
114
+ />
115
+ <Input
116
+ label="Last Name"
117
+ placeholder="Enter Last Name"
118
+ value={lastName}
119
+ error={errors?.lastName}
120
+ disabled={fieldsDisabled}
121
+ onChange={(event) => onLastNameChange(event.target.value)}
122
+ onKeyDown={handleKeyDown}
123
+ />
124
+ <PasswordInput
125
+ label="Password"
126
+ placeholder="Enter Password"
127
+ value={password}
128
+ error={errors?.password}
129
+ disabled={fieldsDisabled}
130
+ onChange={(event) => onPasswordChange(event.target.value)}
131
+ onKeyDown={handleKeyDown}
132
+ />
133
+ <PasswordInput
134
+ label="Confirm Password"
135
+ placeholder="Confirm Password"
136
+ value={confirmPassword}
137
+ error={errors?.confirmPassword}
138
+ disabled={fieldsDisabled}
139
+ onChange={(event) => onConfirmPasswordChange(event.target.value)}
140
+ onKeyDown={handleKeyDown}
141
+ />
156
142
 
157
143
  {/* Forgot password + Continue */}
158
144
  <div className="flex items-center gap-[var(--spacing-system-l)]">
@@ -60,6 +60,7 @@ export * from './warning-block'
60
60
  // `video-bites-shared` is the SERVER-SAFE leaf (also its own published subpath).
61
61
  export * from './cards-strip'
62
62
  export * from './video'
63
+ export * from './video-center-badge'
63
64
  export * from './video-ratio-tabs'
64
65
  export * from './video-bites-shared'
65
66
  export * from './video-bites-strip'
@@ -36,9 +36,12 @@ import {
36
36
  sortBitesByCreatedAtDesc,
37
37
  type VideoBiteStripProfile,
38
38
  } from './video-bites-shared';
39
+ import Image from '../../embed-shims/next-image';
39
40
  import { CardsStrip, STRIP_CELL_MAX_WIDTH } from './cards-strip';
41
+ import { useCoverImageFallback } from '../chat/entity-cards/use-cover-image-fallback';
40
42
  import { UserDisplay } from '../user-display';
41
43
  import { Chevron02RightIcon } from '../icons-v2-generated/arrows/chevron-02-right-icon';
44
+ import { VideoPlayBadge } from './video-center-badge';
42
45
 
43
46
  // NOTE: the title constant / profile adapter / sort comparator live in the
44
47
  // server-safe leaf `video-bites-shared.ts` (its own published subpath). The
@@ -252,7 +255,23 @@ export function VideoBiteCard({
252
255
  io.observe(el);
253
256
  return () => io.disconnect();
254
257
  }, [gateControlled]);
255
- const showPlayer = gateControlled ? playerMounted : isNear;
258
+ const showMedia = gateControlled ? playerMounted : isNear;
259
+
260
+ // Player mounts at NEAR-VIEWPORT (not on first hover) so hover playback is
261
+ // INSTANT for any card the user can reach — mounting on activation made
262
+ // freshly-scrolled cards stall for seconds while MuxPlayer initialized and
263
+ // buffered. Always-mounted players are visually safe now that every bite
264
+ // carries a poster: the player renders with a transparent media background
265
+ // over the card's poster layer, so a not-yet-decoded player never shows as
266
+ // a black rectangle (the original wrap-seam flash came from POSTERLESS
267
+ // mounted players, not from mounting itself).
268
+ const showPlayer = showMedia;
269
+
270
+ // Poster resolution — THE shared entity-card cover fallback chain
271
+ // (use-cover-image-fallback): the bite's real thumbnail_url, dropped on
272
+ // load error. When it resolves to null (no thumbnail yet, or it failed)
273
+ // the media zone falls back to the first-frame <video> facade below.
274
+ const { src: posterSrc, onError: onPosterError } = useCoverImageFallback(bite.thumbnail_url);
256
275
 
257
276
  const navigate = () => {
258
277
  if (bite.onNavigate) bite.onNavigate();
@@ -342,26 +361,65 @@ export function VideoBiteCard({
342
361
  // same as the old single-box layout). Editor mode: width-driven.
343
362
  style={{ aspectRatio: cssAspect, ...(height !== undefined ? { height, maxWidth: STRIP_CELL_MAX_WIDTH } : {}) }}
344
363
  >
345
- {showPlayer ? (
364
+ {showMedia ? (
346
365
  // Clones: `inert` (not just the wrapper's aria-hidden) — the player's
347
366
  // shadow-DOM center control is otherwise still tab-reachable inside a
348
367
  // hidden subtree (axe aria-hidden-focus). Hover preview on clones
349
368
  // keeps working: playback is driven imperatively from the CARD's own
350
369
  // pointer handlers, never from focus/clicks on the player chrome.
351
370
  <div className="absolute inset-0" inert={isClone || undefined}>
352
- {/* CONTROLLED hover playback keyed to CARD hover (`isActive`): the
353
- detail overlay is part of the card, so moving the pointer onto
354
- it keeps playing. Sound at 50% (pre-activation: muted start +
355
- live unmute on the user's first gesture); chrome = center
356
- play/pause only (Figma card look). */}
357
- <Video
358
- kind="file"
359
- url={bite.url}
360
- poster={bite.thumbnail_url}
361
- playWhenHovered={isActive}
362
- centerControlsOnly
363
- layout="fill"
364
- />
371
+ {/* Poster layer. Preferred: the bite's REAL thumbnail (generated at
372
+ Vizard-ingestion time by vizard-persistence-utils via Shotstack
373
+ capture the provider sends no cover), resolved through the
374
+ shared cover fallback chain. Fallback when it's absent or fails
375
+ to load: the <Video> component's `firstFrameOnly` facade paints
376
+ the `#t=0.1` frame. Either way the box is never a black
377
+ rectangle, and original + clone paint the IDENTICAL image so the
378
+ marquee's wrap seam stays pixel-invisible. */}
379
+ {posterSrc ? (
380
+ <Image
381
+ src={posterSrc}
382
+ alt=""
383
+ fill
384
+ unoptimized
385
+ onError={onPosterError}
386
+ className="object-cover"
387
+ />
388
+ ) : (
389
+ <Video kind="file" url={bite.url} firstFrameOnly layout="fill" />
390
+ )}
391
+
392
+ {/* Play affordance for every non-playing state (resting poster,
393
+ facade, mounted-but-paused): <VideoPlayBadge> — a pixel replica
394
+ of MuxPlayer's pre-play center button (media-chrome's own PAUSED
395
+ center control is a different, smaller bare glyph, so the
396
+ mounted player runs chromeless and the replica keeps all states
397
+ identical to the mux button). Hidden while playing. */}
398
+ {!isActive && <VideoPlayBadge className="absolute inset-0 z-10 m-auto" />}
399
+ {showPlayer && (
400
+ // `--media-background-color: transparent` (inherited into
401
+ // media-chrome) — the freshly-mounted player must not blank the
402
+ // facade behind it with its default black fill while it loads.
403
+ <div
404
+ className="absolute inset-0"
405
+ style={{ '--media-background-color': 'transparent' } as React.CSSProperties}
406
+ >
407
+ {/* CONTROLLED hover playback keyed to CARD hover (`isActive`): the
408
+ detail overlay is part of the card, so moving the pointer onto
409
+ it keeps playing. Sound at 50% (pre-activation: muted start +
410
+ live unmute on the user's first gesture); CHROMELESS — the
411
+ card's mux-replica badge above is the play affordance for
412
+ every non-playing state. */}
413
+ <Video
414
+ kind="file"
415
+ url={bite.url}
416
+ poster={bite.thumbnail_url}
417
+ playWhenHovered={isActive}
418
+ chromeless
419
+ layout="fill"
420
+ />
421
+ </div>
422
+ )}
365
423
  </div>
366
424
  ) : (
367
425
  // Aspect-matched placeholder until the card nears the viewport (CLS-free).
@@ -0,0 +1,77 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * THE center media glyphs — the single visual identity for every play/unmute
5
+ * affordance over video, across the whole project: a SOLID WHITE GLYPH
6
+ * (`text-ods-text-primary`), NO background disc, with a soft drop-shadow for
7
+ * legibility over arbitrary frames. Accent color appears ONLY while the
8
+ * icon's own control is hovered (hosts pass `group-hover:text-ods-accent`;
9
+ * mux-player controls get the same via `::part(button):hover` in
10
+ * styles/app-globals.css).
11
+ *
12
+ * Glyphs come from THE icon system — never inline SVG paths here:
13
+ * PlayFilledIcon / VolumeOffFilledIcon (components/icons — the FILLED
14
+ * media-chrome-matching glyphs, so custom affordances are pixel-identical to
15
+ * MuxPlayer's native center button on real players).
16
+ *
17
+ * Consumers:
18
+ * - <VideoBiteCard> resting/paused posters (md)
19
+ * - <YouTubeFacade> poster (lg)
20
+ * - <MediaCarousel> video thumbnails (sm)
21
+ * - <FilePlayer>'s unmute chip (VideoUnmuteGlyph, md)
22
+ * - MuxPlayer's native center pre-play button is stripped to the same bare
23
+ * glyph language via CSS (see styles/app-globals.css).
24
+ */
25
+
26
+ import React from 'react'
27
+ import { PlayFilledIcon } from '../icons/play-filled-icon'
28
+ import { VolumeOffFilledIcon } from '../icons/volume-off-filled-icon'
29
+ import { cn } from '../../utils/cn'
30
+
31
+ export type VideoCenterBadgeSize = 'sm' | 'md' | 'lg'
32
+
33
+ const SIZES: Record<VideoCenterBadgeSize, number> = {
34
+ sm: 20, // gallery thumbnails
35
+ md: 56, // strip cards + unmute chip
36
+ lg: 64, // hero facades
37
+ }
38
+
39
+ /** Soft legibility shadow — NOT a background (per design: bare glyphs). */
40
+ const GLYPH_SHADOW = 'drop-shadow(0 1px 4px rgba(0,0,0,0.5))'
41
+
42
+ export interface VideoPlayBadgeProps {
43
+ size?: VideoCenterBadgeSize
44
+ className?: string
45
+ }
46
+
47
+ /** Bare play glyph (the icon system's PlayIcon). Decorative
48
+ * (`pointer-events-none`) — the host surface owns activation. */
49
+ export function VideoPlayBadge({ size = 'md', className }: VideoPlayBadgeProps): React.ReactElement {
50
+ return (
51
+ <div
52
+ className={cn('pointer-events-none flex items-center justify-center text-ods-text-primary transition-colors', className)}
53
+ style={{ filter: GLYPH_SHADOW }}
54
+ >
55
+ <PlayFilledIcon size={SIZES[size]} color="currentColor" />
56
+ </div>
57
+ )
58
+ }
59
+
60
+ export interface VideoUnmuteGlyphProps {
61
+ size?: VideoCenterBadgeSize
62
+ className?: string
63
+ }
64
+
65
+ /** Bare muted-volume glyph (the icon system's VolumeOffFilledIcon). Rendered
66
+ * by FilePlayer's unmute button; exported here so play + unmute share one
67
+ * identity. */
68
+ export function VideoUnmuteGlyph({ size = 'md', className }: VideoUnmuteGlyphProps): React.ReactElement {
69
+ return (
70
+ <VolumeOffFilledIcon
71
+ size={SIZES[size]}
72
+ color="currentColor"
73
+ className={className}
74
+ style={{ filter: GLYPH_SHADOW }}
75
+ />
76
+ )
77
+ }
@@ -32,8 +32,7 @@
32
32
 
33
33
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
34
34
  import MuxPlayer from '@mux/mux-player-react';
35
- import { PlayIcon } from '../icons-v2-generated/media-playback/play-icon';
36
- import { VolumeXmarkIcon } from '../icons-v2-generated/audio-and-visual/volume-xmark-icon';
35
+ import { VideoPlayBadge, VideoUnmuteGlyph } from './video-center-badge';
37
36
  import { fetchPriorityProp } from '../../utils/fetch-priority';
38
37
 
39
38
  // =============================================================================
@@ -250,9 +249,12 @@ interface VideoFileProps extends VideoCommonProps {
250
249
  * true → start hover playback, false → pause. When provided, the internal
251
250
  * pointer handlers are disabled. */
252
251
  playWhenHovered?: boolean;
253
- /** Hide the bottom control bar, keep only the CENTER play/pause control
254
- * (bite-strip cards per Figma). */
255
- centerControlsOnly?: boolean;
252
+ /** Render ONLY a lightweight first-frame preview a metadata-only element
253
+ * seeked to `#t=0.1` (media-fragment trick; paints on iOS Safari where a
254
+ * fragmentless metadata load stays blank). No chrome, no playback, no
255
+ * MuxPlayer cost — the resting facade layer under strip cards when no
256
+ * poster asset exists. All player props are ignored. */
257
+ firstFrameOnly?: boolean;
256
258
  }
257
259
 
258
260
  interface VideoYouTubeProps extends VideoCommonProps {
@@ -272,7 +274,7 @@ interface VideoAutoProps extends VideoCommonProps {
272
274
  chromeless?: boolean;
273
275
  playOnHover?: boolean;
274
276
  playWhenHovered?: boolean;
275
- centerControlsOnly?: boolean;
277
+ firstFrameOnly?: boolean;
276
278
  }
277
279
 
278
280
  export type VideoProps = VideoFileProps | VideoYouTubeProps | VideoAutoProps;
@@ -297,6 +299,8 @@ export function Video(props: VideoProps): React.ReactElement | null {
297
299
  className={props.className}
298
300
  minimalControls={props.minimalControls}
299
301
  />
302
+ ) : 'firstFrameOnly' in props && props.firstFrameOnly ? (
303
+ <FirstFramePreview url={url} className={props.className} />
300
304
  ) : (
301
305
  <FilePlayer
302
306
  url={url}
@@ -309,7 +313,6 @@ export function Video(props: VideoProps): React.ReactElement | null {
309
313
  chromeless={'chromeless' in props ? props.chromeless : undefined}
310
314
  playOnHover={'playOnHover' in props ? props.playOnHover : undefined}
311
315
  playWhenHovered={'playWhenHovered' in props ? props.playWhenHovered : undefined}
312
- centerControlsOnly={'centerControlsOnly' in props ? props.centerControlsOnly : undefined}
313
316
  className={props.className}
314
317
  />
315
318
  );
@@ -373,6 +376,35 @@ function wrapWithLayout(
373
376
  }
374
377
  }
375
378
 
379
+ // -----------------------------------------------------------------------------
380
+ // First-frame preview — the `firstFrameOnly` facade branch
381
+ // -----------------------------------------------------------------------------
382
+
383
+ /** First-frame paint through the SAME FilePlayer/MuxPlayer pipeline as
384
+ * playback (one rendering path for every file source — HLS included, which a
385
+ * plain `<video>` couldn't decode in Chromium): a chromeless, muted,
386
+ * metadata-only instance whose `#t=0.1` media fragment seeks-and-paints the
387
+ * first frame (also fixes iOS Safari, which stays blank on a fragmentless
388
+ * metadata load). The transparent media background keeps the box showing the
389
+ * card surface (never black) until the frame decodes. */
390
+ function FirstFramePreview({ url, className }: { url: string; className?: string }): React.ReactElement {
391
+ return (
392
+ <div
393
+ aria-hidden
394
+ className="h-full w-full"
395
+ style={{ '--media-background-color': 'transparent' } as React.CSSProperties}
396
+ >
397
+ <FilePlayer
398
+ url={`${url}#t=0.1`}
399
+ preload="metadata"
400
+ muted
401
+ chromeless
402
+ className={className}
403
+ />
404
+ </div>
405
+ );
406
+ }
407
+
376
408
  // -----------------------------------------------------------------------------
377
409
  // File branch — MuxPlayer (handles both .m3u8 HLS and plain .mp4)
378
410
  // -----------------------------------------------------------------------------
@@ -388,7 +420,8 @@ interface FilePlayerProps {
388
420
  chromeless?: boolean;
389
421
  playOnHover?: boolean;
390
422
  playWhenHovered?: boolean;
391
- centerControlsOnly?: boolean;
423
+ /** Media preload hint — the firstFrameOnly facade passes 'metadata'. */
424
+ preload?: 'none' | 'metadata' | 'auto';
392
425
  className?: string;
393
426
  }
394
427
 
@@ -403,12 +436,9 @@ function FilePlayer({
403
436
  chromeless,
404
437
  playOnHover,
405
438
  playWhenHovered,
406
- centerControlsOnly,
439
+ preload,
407
440
  className,
408
441
  }: FilePlayerProps): React.ReactElement {
409
- // centerControlsOnly: the center play button shows at REST only — while
410
- // playing, ALL chrome hides (no pause sign over the hover-preview).
411
- const [isPlaying, setIsPlaying] = useState(false);
412
442
  // True while hover playback is running MUTED because the browser's autoplay
413
443
  // policy blocked sound (no user activation yet). Drives the center unmute
414
444
  // control — the industry pattern (muted autoplay + explicit unmute button)
@@ -546,11 +576,10 @@ function FilePlayer({
546
576
  const player = (
547
577
  <MuxPlayer
548
578
  ref={hoverPlayerRef as React.Ref<never>}
549
- onPlay={centerControlsOnly ? () => setIsPlaying(true) : undefined}
550
- onPause={centerControlsOnly ? () => setIsPlaying(false) : undefined}
551
579
  src={url}
552
580
  poster={poster || undefined}
553
581
  streamType="on-demand"
582
+ preload={preload}
554
583
  playsInline
555
584
  muted={muted}
556
585
  preferCmcd="header"
@@ -581,15 +610,6 @@ function FilePlayer({
581
610
  width: '100%',
582
611
  height: '100%',
583
612
  ...(chromeless ? ({ '--controls': 'none' } as React.CSSProperties) : {}),
584
- ...(centerControlsOnly && !chromeless
585
- ? ({
586
- '--bottom-controls': 'none',
587
- '--top-controls': 'none',
588
- // While playing, hide the center control too — no pause sign
589
- // over the hover-preview; it reappears when playback pauses.
590
- ...(isPlaying ? { '--center-controls': 'none' } : {}),
591
- } as React.CSSProperties)
592
- : {}),
593
613
  }}
594
614
  >
595
615
  {captionsUrl ? (
@@ -616,9 +636,11 @@ function FilePlayer({
616
636
  aria-label="Unmute"
617
637
  title="Unmute"
618
638
  onClick={unmuteNow}
619
- className="absolute inset-0 z-10 m-auto flex h-14 w-14 items-center justify-center text-ods-text-primary transition-opacity hover:opacity-75"
639
+ // White at rest, ACCENT while the icon itself is hovered — the same
640
+ // hover language as every mux control icon (see app-globals.css).
641
+ className="absolute inset-0 z-10 m-auto flex h-14 w-14 items-center justify-center text-ods-text-primary transition-colors hover:text-ods-accent"
620
642
  >
621
- <VolumeXmarkIcon size={56} />
643
+ <VideoUnmuteGlyph />
622
644
  </button>
623
645
  ) : null;
624
646
 
@@ -882,9 +904,10 @@ function YouTubeFacadeInner({
882
904
  />
883
905
  </picture>
884
906
  <div className="absolute inset-0 flex items-center justify-center bg-ods-bg-inverse bg-opacity-20 transition-opacity duration-200 group-hover:bg-opacity-30">
885
- <span className="flex items-center justify-center w-16 h-16 rounded-full bg-ods-accent text-ods-text-on-accent shadow-lg transition-transform duration-200 group-hover:scale-110">
886
- <PlayIcon size={24} color="currentColor" className="ml-1" />
887
- </span>
907
+ {/* THE shared center play badge (video-center-badge.tsx) same disc
908
+ as strip cards / carousel thumbs / unmute chip; hero size + the
909
+ facade's hover-scale affordance. */}
910
+ <VideoPlayBadge size="lg" className="transition-transform duration-200 group-hover:scale-110 group-hover:text-ods-accent" />
888
911
  </div>
889
912
  </button>
890
913
  </div>
@@ -35,6 +35,8 @@ export { VendorDirectoryIcon } from './vendor-directory-icon';
35
35
  export { OpenSourceIcon } from './open-source-icon';
36
36
  export { CommunityHubIcon } from './community-hub-icon';
37
37
  export { AboutIcon } from './about-icon';
38
+ export { PlayFilledIcon } from './play-filled-icon';
39
+ export { VolumeOffFilledIcon } from './volume-off-filled-icon';
38
40
  export { UserIcon } from './user-icon';
39
41
  export { HamburgerIcon } from './hamburger-icon';
40
42
  export { MenuIcon } from './menu-icon';