@streamoid/ui 0.6.17 → 0.6.18

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 (131) hide show
  1. package/README.md +35 -18
  2. package/dist/docs/AGENTS.md +321 -0
  3. package/dist/docs/CreditWarningBanner.md +305 -0
  4. package/dist/docs/InvoiceHistoryMobile.md +222 -0
  5. package/dist/docs/ScAccess.md +259 -0
  6. package/dist/docs/ScAppCard.md +244 -0
  7. package/dist/docs/ScAppCardForCopilot.md +230 -0
  8. package/dist/docs/ScAppCardV3.md +273 -0
  9. package/dist/docs/ScAppField.md +308 -0
  10. package/dist/docs/ScAppListingCard.md +271 -0
  11. package/dist/docs/ScAppSwitchPanel.md +286 -0
  12. package/dist/docs/ScAppcardLogos.md +226 -0
  13. package/dist/docs/ScArtifaxInvite.md +262 -0
  14. package/dist/docs/ScArtifaxSidebar.md +330 -0
  15. package/dist/docs/ScAskAgentButton.md +307 -0
  16. package/dist/docs/ScBadges.md +261 -0
  17. package/dist/docs/ScBeacon.md +244 -0
  18. package/dist/docs/ScBillingHistoryHeader.md +210 -0
  19. package/dist/docs/ScBillingHistoryTableList.md +243 -0
  20. package/dist/docs/ScBillingLogsTableHeader.md +212 -0
  21. package/dist/docs/ScBillingLogsTableList.md +251 -0
  22. package/dist/docs/ScBriefCard.md +255 -0
  23. package/dist/docs/ScButton.md +251 -0
  24. package/dist/docs/ScCalendar.md +268 -0
  25. package/dist/docs/ScCalendarDateComps.md +264 -0
  26. package/dist/docs/ScCatalogixInvite.md +345 -0
  27. package/dist/docs/ScCatalogixSidebar.md +337 -0
  28. package/dist/docs/ScCatalogixStoreHeader.md +246 -0
  29. package/dist/docs/ScCatalogixStoreTableList.md +316 -0
  30. package/dist/docs/ScCheckField.md +233 -0
  31. package/dist/docs/ScCheckbox.md +272 -0
  32. package/dist/docs/ScCounter.md +235 -0
  33. package/dist/docs/ScCreditsUsageCard.md +247 -0
  34. package/dist/docs/ScCreditsUsageCardMobile.md +224 -0
  35. package/dist/docs/ScDefaultCard.md +269 -0
  36. package/dist/docs/ScDp.md +245 -0
  37. package/dist/docs/ScDrawer.md +318 -0
  38. package/dist/docs/ScFieldButton.md +255 -0
  39. package/dist/docs/ScFileField.md +268 -0
  40. package/dist/docs/ScGoogleSignIn.md +250 -0
  41. package/dist/docs/ScGuide.md +278 -0
  42. package/dist/docs/ScHDivider.md +213 -0
  43. package/dist/docs/ScHeader.md +222 -0
  44. package/dist/docs/ScImageField.md +253 -0
  45. package/dist/docs/ScInChatList.md +277 -0
  46. package/dist/docs/ScInChatMessage.md +205 -0
  47. package/dist/docs/ScInfoPopup.md +248 -0
  48. package/dist/docs/ScIntialProfileCover.md +233 -0
  49. package/dist/docs/ScInvoiceHistoryMobile.md +187 -0
  50. package/dist/docs/ScLogoUnit.md +232 -0
  51. package/dist/docs/ScMappingCard.md +241 -0
  52. package/dist/docs/ScMediaApproval.md +301 -0
  53. package/dist/docs/ScMediaSelect.md +310 -0
  54. package/dist/docs/ScMenuOptions.md +308 -0
  55. package/dist/docs/ScMobileBottomAction.md +252 -0
  56. package/dist/docs/ScMobileTopNav.md +279 -0
  57. package/dist/docs/ScModal.md +291 -0
  58. package/dist/docs/ScOnlyField.md +302 -0
  59. package/dist/docs/ScOnlyIcon.md +213 -0
  60. package/dist/docs/ScPagination.md +284 -0
  61. package/dist/docs/ScPairtext.md +287 -0
  62. package/dist/docs/ScPendingAction.md +238 -0
  63. package/dist/docs/ScPhtogenixInvite.md +275 -0
  64. package/dist/docs/ScPlanCard.md +302 -0
  65. package/dist/docs/ScPlanComparison.md +264 -0
  66. package/dist/docs/ScPlanDetailsCard.md +246 -0
  67. package/dist/docs/ScPlanDetailsCardMobile.md +240 -0
  68. package/dist/docs/ScPopUpMenu.md +224 -0
  69. package/dist/docs/ScProfile.md +234 -0
  70. package/dist/docs/ScProfileImageUpdate.md +261 -0
  71. package/dist/docs/ScProfileOptions.md +245 -0
  72. package/dist/docs/ScProfilePopup.md +396 -0
  73. package/dist/docs/ScProfileSettingsComp.md +250 -0
  74. package/dist/docs/ScProfileV2Mobile.md +216 -0
  75. package/dist/docs/ScProgressBar.md +267 -0
  76. package/dist/docs/ScQuickPrompt.md +277 -0
  77. package/dist/docs/ScRadio.md +228 -0
  78. package/dist/docs/ScReferralCardMobile.md +226 -0
  79. package/dist/docs/ScReferralTableHeader.md +260 -0
  80. package/dist/docs/ScReferralTableList.md +293 -0
  81. package/dist/docs/ScRole.md +226 -0
  82. package/dist/docs/ScRoleMobile.md +199 -0
  83. package/dist/docs/ScSelect.md +270 -0
  84. package/dist/docs/ScSelection.md +256 -0
  85. package/dist/docs/ScSelectionList.md +272 -0
  86. package/dist/docs/ScSelectionPill.md +240 -0
  87. package/dist/docs/ScSelectionPillGroup.md +302 -0
  88. package/dist/docs/ScSettingsNav.md +212 -0
  89. package/dist/docs/ScSettingsTabComp.md +260 -0
  90. package/dist/docs/ScSideBarLogoUnit.md +340 -0
  91. package/dist/docs/ScSidebar.md +243 -0
  92. package/dist/docs/ScSidebarIcons.md +232 -0
  93. package/dist/docs/ScSidebarMenu.md +283 -0
  94. package/dist/docs/ScSidebarProfile.md +231 -0
  95. package/dist/docs/ScSidebarSwitchMenu.md +258 -0
  96. package/dist/docs/ScSlider.md +194 -0
  97. package/dist/docs/ScStoreCard.md +252 -0
  98. package/dist/docs/ScStrLogo.md +253 -0
  99. package/dist/docs/ScStreamoidWordmark.md +302 -0
  100. package/dist/docs/ScSubAgent.md +226 -0
  101. package/dist/docs/ScTabComp.md +308 -0
  102. package/dist/docs/ScTabField.md +258 -0
  103. package/dist/docs/ScTabSwitcher.md +307 -0
  104. package/dist/docs/ScTableHeader.md +261 -0
  105. package/dist/docs/ScTableList.md +301 -0
  106. package/dist/docs/ScTableListMobile.md +282 -0
  107. package/dist/docs/ScTabs.md +268 -0
  108. package/dist/docs/ScTaxonomyPill.md +263 -0
  109. package/dist/docs/ScTextArea.md +259 -0
  110. package/dist/docs/ScTextField.md +324 -0
  111. package/dist/docs/ScThinkingStepIcon.md +249 -0
  112. package/dist/docs/ScTodoList.md +288 -0
  113. package/dist/docs/ScToggleSwitch.md +229 -0
  114. package/dist/docs/ScUsageHistoryMobile.md +194 -0
  115. package/dist/docs/ScVDivider.md +215 -0
  116. package/dist/docs/ScValueMappingL1.md +256 -0
  117. package/dist/docs/ScVersion.md +251 -0
  118. package/dist/docs/ScWorkspace.md +233 -0
  119. package/dist/docs/ScWorkspaceCard.md +234 -0
  120. package/dist/docs/ScWorkspaceSettingsMobile.md +265 -0
  121. package/dist/docs/ScWorkspaceSwitchCard.md +314 -0
  122. package/dist/docs/ScWorkspaceSwitchMobile.md +241 -0
  123. package/dist/docs/ScWorkspaceSwitchMobileV2.md +278 -0
  124. package/dist/docs/StreamoidSidebar.md +403 -0
  125. package/dist/docs/StreamoidWorkspaceSwitcher.md +307 -0
  126. package/dist/docs/UsageHistoryMobile.md +235 -0
  127. package/dist/docs/components.json +4849 -0
  128. package/dist/index.css +36 -36
  129. package/dist/index.d.mts +10 -0
  130. package/dist/index.d.ts +10 -0
  131. package/package.json +3 -2
@@ -0,0 +1,301 @@
1
+ ---
2
+ component: ScMediaApproval
3
+ package: "@streamoid/ui"
4
+ category: chat-agent
5
+ status: stable
6
+ renders: div
7
+ tags: [chat, agent, media, image, video, carousel, approval, feedback, review, dynamicform]
8
+ related: [ScMediaSelect, ScSelectionList, ScTextArea, ScImageField, ScFileField]
9
+ do_not_confuse_with: [ScMediaSelect, ScSelectionList, ScImageField, ScFileField, ScTodoList]
10
+ used_by: [agent]
11
+ ---
12
+
13
+ # ScMediaApproval
14
+
15
+ **A one-up media carousel with a feedback box.** A 208px-tall letterboxed frame showing
16
+ one image or video at a time (prev/next chevrons, an `n / m` counter badge and dot
17
+ pagination when there is more than one), and beneath it a labelled textarea for
18
+ "What should be changed?".
19
+
20
+ ## TL;DR for agents
21
+
22
+ - **Reach for it when:** the agent generated media and the user must review it one item
23
+ at a time and optionally say what to change.
24
+ - **Don't reach for it when:** the user must **pick** several items from a set
25
+ (→ `ScMediaSelect`), the decision is a single accept/decline on a text suggestion
26
+ (→ `ScSelectionList`), or the user is **uploading** media
27
+ (→ `ScImageField` / `ScFileField`).
28
+ - **Four things that will bite you:**
29
+ 1. Despite the name there are **no approve/reject buttons**. It is a viewer plus a
30
+ textarea; the decision buttons are yours.
31
+ 2. `feedbackValue` is a **controlled** textarea. Without `onFeedbackChange` the user
32
+ cannot type.
33
+ 3. On a video, `controls` is on **and** clicking calls `onPreview` — pressing play can
34
+ also open your lightbox.
35
+ 4. Without `mediaType`, video detection is a **filename-extension regex**. Extensionless
36
+ CDN URLs render as `<img>` and show nothing.
37
+
38
+ ---
39
+
40
+ ## 1. How to use it
41
+
42
+ ### Import
43
+
44
+ ```tsx
45
+ import { ScMediaApproval } from "@streamoid/ui";
46
+ import "@streamoid/ui/dist/index.css"; // once, at your app root
47
+ ```
48
+
49
+ ### Minimal usage
50
+
51
+ ```tsx
52
+ <ScMediaApproval
53
+ mediaUrls={urls}
54
+ mediaType="image"
55
+ feedbackValue={feedback}
56
+ onFeedbackChange={setFeedback}
57
+ />
58
+ ```
59
+
60
+ ### Props
61
+
62
+ | Prop | Type | Default | Notes |
63
+ |---|---|---|---|
64
+ | `mediaUrls` | `string[]` | `[]` | Empty ⇒ a 208px black placeholder block (not nothing). |
65
+ | `mediaType` | `"image"` \| `"video"` \| `"mixed"` | – (`undefined`) | `"video"` ⇒ every item is a `<video>`; `"image"` ⇒ every item is an `<img>`; `"mixed"` **or unset** ⇒ per-URL extension sniffing (`.mp4|.webm|.ogg|.mov|.m4v`, query string tolerated). |
66
+ | `activeIndex` | `number` | – | Pushes the internal index via `useEffect`. Semi-controlled — see Gotcha 6. |
67
+ | `onIndexChange` | `(i: number) => void` | – | Fires on every prev/next/dot click, with the **wrapped, clamped** index. |
68
+ | `onPreview` | `(url: string) => void` | – | Fires when the media element itself is clicked. Wire it to your lightbox. |
69
+ | `loading` | `boolean` | – | Forces the dark scrim + spinner overlay. ORed with the component's own load state. |
70
+ | `feedbackLabel` | `string` | `"What should be changed?"` | ⚠️ Real default, hardcoded English. Always rendered — there is no way to hide the feedback block. |
71
+ | `feedbackValue` | `string` | – | Controlled value (`feedbackValue ?? ""`). |
72
+ | `onFeedbackChange` | `(v: string) => void` | – | Required for the textarea to be usable at all. |
73
+ | `feedbackPlaceholder` | `string` | – | No default ⇒ no placeholder. |
74
+ | `className` | `string` | – | Appended after the internal class. |
75
+
76
+ ⚠️ `IScMediaApprovalProps` does **not** extend `HTMLAttributes` — no `style`, `id`,
77
+ `data-*` or root `onClick`. Wrap it.
78
+
79
+ ### What renders when
80
+
81
+ | Region | Condition |
82
+ |---|---|
83
+ | `<img>` / `<video>` | `mediaUrls.length > 0` |
84
+ | Black placeholder | `mediaUrls.length === 0` |
85
+ | Spinner overlay | `loading` **or** the internal load state (set on navigation, cleared on `load`/`loadeddata`) |
86
+ | Prev/next chevrons, `n / m` badge, dots | `mediaUrls.length > 1` only |
87
+ | Feedback label + textarea | **always** |
88
+
89
+ ### Recipes
90
+
91
+ ```tsx
92
+ // The canonical DynamicForm wiring: viewer + feedback; the form supplies the buttons
93
+ <ScMediaApproval
94
+ mediaUrls={mediaUrls}
95
+ mediaType={field.media_type ?? "mixed"}
96
+ onPreview={(url) => setMediaPreview({ url, type: resolveMediaType(url) })}
97
+ feedbackLabel={field.feedback_label ?? "What should be changed?"}
98
+ feedbackValue={value.feedback || ""}
99
+ onFeedbackChange={(v) => setValue({ ...value, feedback: v })}
100
+ feedbackPlaceholder="Describe what needs to be modified."
101
+ />
102
+ {/* the decision lives outside the component */}
103
+ <div style={{ display: "flex", gap: 8 }}>
104
+ <ScButton text="Approve" variant="mono" size="md" onClick={approve} />
105
+ <ScButton text="Request changes" variant="outline" size="md" onClick={requestChanges} />
106
+ </div>
107
+
108
+ // Tracking the visible item (e.g. to approve only the one on screen)
109
+ const [index, setIndex] = useState(0);
110
+ <ScMediaApproval
111
+ mediaUrls={urls}
112
+ mediaType="image"
113
+ activeIndex={index}
114
+ onIndexChange={setIndex}
115
+ feedbackValue={feedback}
116
+ onFeedbackChange={setFeedback}
117
+ />
118
+
119
+ // Media that is still being generated server-side
120
+ <ScMediaApproval mediaUrls={urls} loading={isGenerating} feedbackValue={fb} onFeedbackChange={setFb} />
121
+
122
+ // Extensionless signed URLs — you MUST declare the type
123
+ <ScMediaApproval mediaUrls={signedUrls} mediaType="video" feedbackValue={fb} onFeedbackChange={setFb} />
124
+ ```
125
+
126
+ ---
127
+
128
+ ## 2. Where to use it
129
+
130
+ - **The agent's in-chat DynamicForm**, `media_approval` field type — the only real
131
+ consumer. Live at `stream-agent packages/chat-components/src/DynamicForm.tsx`, which
132
+ pairs it with its own lightbox (`onPreview`) and its own submit row for the actual
133
+ approve/reject decision.
134
+ - **A generation-review step in the chat transcript**, under the `ScInChatList` steps
135
+ that produced the media.
136
+ - It is a **chat-surface** component: the root is `width: 100%` with a fixed 208px frame
137
+ and a black letterbox, tuned to sit inside a narrow panel next to `ScInChatMessage`
138
+ (600px), not to fill a full-page gallery.
139
+
140
+ ---
141
+
142
+ ## 3. When to use it
143
+
144
+ ### Use it when
145
+
146
+ - The user reviews **one item at a time** and the items are alternatives, not a set to
147
+ choose from.
148
+ - Free-text feedback ("make the background warmer") is part of the response.
149
+ - The media may be a mix of images and video.
150
+
151
+ ### Don't use it — reach for this instead
152
+
153
+ | Situation | Use instead |
154
+ |---|---|
155
+ | Pick **several** items from a grid of thumbnails | `ScMediaSelect` |
156
+ | Accept/decline one **text** suggestion | `ScSelectionList` |
157
+ | The user **uploads** an image | `ScImageField` |
158
+ | The user uploads an arbitrary file | `ScFileField` |
159
+ | A standalone feedback textarea with no media | `ScTextArea` |
160
+ | An editable task list from the agent | `ScTodoList` |
161
+ | A full-page asset gallery with filters and bulk actions | build it — this is a chat panel component |
162
+
163
+ ### Don't confuse with
164
+
165
+ | You may actually want | Not this |
166
+ |---|---|
167
+ | `ScMediaSelect` — a 4-up multi-select **grid** with select-all | This is a one-up carousel |
168
+ | A component that renders Approve/Reject | It renders neither; the name describes the *flow*, not the UI |
169
+ | `ScImageField` — an upload/preview **form field** | This never uploads; it only displays URLs you pass |
170
+ | `ScSelectionList` — ✓/✕ toggles | No toggles here |
171
+
172
+ ---
173
+
174
+ ## 4. Why to use it
175
+
176
+ - **On-media controls that survive any image, in any theme.** The chevrons, counter and
177
+ dot pill deliberately use a fixed dark scrim + fixed white glyph + a subtle light
178
+ border and blur, rather than theme tokens, so they stay legible over a white product
179
+ shot, a black letterbox, and in light mode. This is the one place in the DS where
180
+ ignoring the tokens is correct — and it is already decided for you.
181
+ - **Letterboxing is handled.** `object-fit: contain` on a black background at a fixed
182
+ 208px height means a portrait and a landscape render at the same frame size, so the
183
+ layout doesn't jump as the user navigates.
184
+ - **The spinner is self-managing.** Navigating sets the internal load state and `onLoad`
185
+ / `onLoadedData` clears it, so slow CDN fetches show a scrim without you tracking
186
+ per-URL load state.
187
+ - **Index wrapping is already modulo-safe** (`((next % total) + total) % total`), and the
188
+ render index is clamped to `total - 1`, so a shrinking `mediaUrls` array can't produce
189
+ an out-of-range read.
190
+ - **The mixed-media branch is one prop.** Sniffing per URL vs forcing a type is a
191
+ one-line decision instead of a per-call-site helper.
192
+
193
+ ---
194
+
195
+ ## Gotchas
196
+
197
+ **1. There are no approve/reject buttons.** The component is a viewer + a feedback
198
+ textarea. Every consumer must supply the decision affordance itself.
199
+
200
+ **2. The textarea is inert without `onFeedbackChange`.** It is `value={feedbackValue ?? ""}`
201
+ with an `onChange` that no-ops when the handler is missing — so the user types and
202
+ nothing appears.
203
+
204
+ ```tsx
205
+ // WRONG — read-only textarea that looks editable
206
+ <ScMediaApproval mediaUrls={urls} feedbackValue={feedback} />
207
+
208
+ // RIGHT
209
+ <ScMediaApproval mediaUrls={urls} feedbackValue={feedback} onFeedbackChange={setFeedback} />
210
+ ```
211
+
212
+ **3. Video: play and preview collide.** The `<video>` has `controls` **and**
213
+ `onClick={handlePreview}`. A click on the play button also fires `onPreview`, so your
214
+ lightbox opens over the video the user just started. Either omit `onPreview` for video,
215
+ or have your lightbox continue playback.
216
+
217
+ **4. Extension sniffing fails on extensionless URLs.** With `mediaType` unset or
218
+ `"mixed"`, `isVideoUrl` tests `/\.(mp4|webm|ogg|mov|m4v)(\?.*)?$/i`. A signed URL like
219
+ `…/asset/9f3c1?sig=…` renders as `<img>` → broken image, no error. If your URLs have no
220
+ extension, pass `mediaType` explicitly.
221
+
222
+ **5. `cursor: zoom-in` is unconditional.** The media always looks clickable, even when
223
+ you didn't pass `onPreview`. There is no way to turn that off short of a `className`
224
+ override.
225
+
226
+ **6. `activeIndex` is a *push*, not a binding.** The `useEffect` only runs when
227
+ `activeIndex` changes; internal prev/next still moves the internal index. If you pass a
228
+ constant `activeIndex` and ignore `onIndexChange`, the user can navigate away and your
229
+ state will be wrong.
230
+
231
+ ```tsx
232
+ // WRONG — thinks it pinned the view to item 0
233
+ <ScMediaApproval mediaUrls={urls} activeIndex={0} />
234
+
235
+ // RIGHT — controlled round-trip
236
+ <ScMediaApproval mediaUrls={urls} activeIndex={index} onIndexChange={setIndex} />
237
+ ```
238
+
239
+ **7. No spinner on first paint.** The internal load state starts `false` and is only set
240
+ to `true` on navigation. For the initial image you must pass `loading` yourself if you
241
+ want a scrim.
242
+
243
+ **8. Navigation wraps silently.** "Next" on the last item goes to the first. There is no
244
+ disabled end-state, so users can loop without noticing they've been round.
245
+
246
+ **9. `alt=""` is hardcoded.** Images are announced as decorative. If the media *is* the
247
+ content — which it is here — a screen-reader user gets nothing but the feedback label.
248
+
249
+ **10. The feedback block cannot be hidden.** No prop suppresses it; passing
250
+ `feedbackLabel=""` leaves an empty label div and the textarea. If you only want a
251
+ carousel, this is the wrong component.
252
+
253
+ **11. Fixed 13rem (208px) media height and hardcoded `#000` backgrounds.** Not
254
+ tokenised (deliberate, for letterboxing), and not overridable via props.
255
+
256
+ **12. The textarea's resize grip is clipped.** `.textArea` is `resize: vertical` but
257
+ `.inputContainer` is `overflow: hidden`, so the drag handle is cut off — users get a
258
+ `min-height: 4.5rem` box that scrolls rather than one they can obviously grow.
259
+
260
+ **13. No root-level props.** `IScMediaApprovalProps` doesn't extend `HTMLAttributes`, so
261
+ `style`/`id`/`data-testid` are type errors. Wrap it.
262
+
263
+ ---
264
+
265
+ ## In the wild
266
+
267
+ Rendered by the **agent runtime**, not by any of the four dashboards — the live call
268
+ site is the chat DynamicForm's `media_approval` field in the `stream-agent` repo
269
+ (`@streamoid/chat-components`):
270
+
271
+ ```tsx
272
+ // stream-agent packages/chat-components/src/DynamicForm.tsx:871
273
+ <ScMediaApproval
274
+ mediaUrls={mediaUrls}
275
+ mediaType={field.media_type ?? "mixed"}
276
+ onPreview={(url) => setMediaPreview({ url, type: resolveMediaType(url) })}
277
+ feedbackLabel={field.feedback_label ?? "What should be changed?"}
278
+ feedbackValue={mediaValue.feedback || ""}
279
+ onFeedbackChange={(v) =>
280
+ handleFieldChange(field.id, {
281
+ ...mediaValue,
282
+ feedback: v,
283
+ media_urls: normalizeMediaUrls(mediaValue.media_urls ?? mediaUrls),
284
+ media_type: mediaValue.media_type ?? field.media_type ?? "mixed",
285
+ } as MediaApprovalValue)
286
+ }
287
+ feedbackPlaceholder={
288
+ field.feedback_placeholder ?? "Describe what needs to be modified."
289
+ }
290
+ />
291
+ ```
292
+
293
+ ---
294
+
295
+ ## Related
296
+
297
+ - `ScMediaSelect` — the multi-select grid sibling; same media vocabulary, different job.
298
+ - `ScSelectionList` — accept/decline for text suggestions.
299
+ - `ScTextArea` — the standalone DS textarea if you need feedback without media.
300
+ - `ScImageField` / `ScFileField` — the upload-side fields.
301
+ - `ScButton` — what you'll use for the Approve / Request-changes pair this component omits.
@@ -0,0 +1,310 @@
1
+ ---
2
+ component: ScMediaSelect
3
+ package: "@streamoid/ui"
4
+ category: chat-agent
5
+ status: stable
6
+ renders: div
7
+ tags: [chat, agent, media, image, video, grid, multi-select, thumbnails, gallery, dynamicform]
8
+ related: [ScMediaApproval, ScCheckField, ScImageField, ScTodoList, ScSelectionList]
9
+ do_not_confuse_with: [ScMediaApproval, ScImageField, ScSelectionList, ScCheckField, ScStoreCard]
10
+ used_by: [agent]
11
+ ---
12
+
13
+ # ScMediaSelect
14
+
15
+ **A 4-up thumbnail grid for picking several generated assets.** A count line
16
+ ("3 of 12 selected (min 1)") with "Select all | Deselect all" above a bordered,
17
+ internally-scrolling square grid; selected tiles get a strong border and a white check
18
+ badge, and a hover-only maximize button opens your lightbox.
19
+
20
+ ## TL;DR for agents
21
+
22
+ - **Reach for it when:** the agent produced a batch of images/videos and the user must
23
+ choose a subset.
24
+ - **Don't reach for it when:** the user reviews one item at a time with free-text
25
+ feedback (→ `ScMediaApproval`), the choice is textual (→ `ScCheckField` /
26
+ `ScSelectionList`), or the user is **uploading** (→ `ScImageField`).
27
+ - **Four things that will bite you:**
28
+ 1. `min` and `max` are **labels only**. The component does not enforce them —
29
+ `onToggle` fires regardless.
30
+ 2. Selection identity is the **URL string**. Duplicate URLs in `items` select and
31
+ deselect together.
32
+ 3. `mediaType` defaults to `"image"`, and only the exact value `"video"` renders
33
+ `<video>`. **`"mixed"` renders `<img>` for everything**, including your `.mp4`s.
34
+ 4. Tiles are `div[role="button"] tabIndex={0}` with **no key handler**, and the preview
35
+ button is `display: none` until `:hover` — focusable but not operable, and invisible
36
+ on touch.
37
+
38
+ ---
39
+
40
+ ## 1. How to use it
41
+
42
+ ### Import
43
+
44
+ ```tsx
45
+ import { ScMediaSelect } from "@streamoid/ui";
46
+ import "@streamoid/ui/dist/index.css"; // once, at your app root
47
+ ```
48
+
49
+ ### Minimal usage
50
+
51
+ ```tsx
52
+ <ScMediaSelect items={urls} selected={picked} onToggle={toggle} />
53
+ ```
54
+
55
+ ### Props
56
+
57
+ | Prop | Type | Default | Notes |
58
+ |---|---|---|---|
59
+ | `items` | `string[]` | `[]` | Media URLs, in grid order. Empty ⇒ an empty bordered box, but the header and both link buttons still render. |
60
+ | `selected` | `string[]` | `[]` | Controlled. Compared by URL via a `Set` (memoised on the array identity — pass a new array, not a mutated one). |
61
+ | `onToggle` | `(url: string) => void` | – | Fires on any tile click. **You** add/remove and **you** enforce `max`. |
62
+ | `onSelectAll` | `() => void` | – | The "Select all" link. The component does not compute the selection for you. |
63
+ | `onDeselectAll` | `() => void` | – | The "Deselect all" link. |
64
+ | `min` | `number` | `0` | **Display only.** `> 0` appends `" (min N)"` to the count label. |
65
+ | `max` | `number` | – (⇒ `items.length`) | **Display only.** Appends `" (max N)"` **only when** the resolved max is less than `items.length`. |
66
+ | `mediaType` | `"image"` \| `"video"` \| `"mixed"` | `"image"` | ⚠️ Only `"video"` renders `<video muted playsInline>`. `"image"` **and** `"mixed"` both render `<img>`. |
67
+ | `onPreview` | `(url: string) => void` | – | **Gates the maximize button** — omit it and no preview affordance renders at all. |
68
+ | `className` | `string` | – | Appended after the internal class. |
69
+
70
+ ⚠️ `IScMediaSelectProps` does **not** extend `HTMLAttributes` — no `style`, `id`,
71
+ `data-*` or root `onClick`. Wrap it.
72
+
73
+ ### The count label, exactly
74
+
75
+ `` `${selected.size} of ${items.length} selected` `` then `" (min N)"` if `min > 0`,
76
+ then `" (max N)"` if `resolvedMax < items.length`. All hardcoded English.
77
+
78
+ ### Recipes
79
+
80
+ ```tsx
81
+ // The canonical DynamicForm wiring — note that MAX IS ENFORCED HERE, not in the DS
82
+ const toggle = (url: string) => {
83
+ const next = new Set(selectedUrls);
84
+ if (next.has(url)) next.delete(url);
85
+ else {
86
+ if (next.size >= maxSelect) return; // <- the component will not do this
87
+ next.add(url);
88
+ }
89
+ setSelectedUrls(Array.from(next));
90
+ };
91
+
92
+ <ScMediaSelect
93
+ items={allMediaUrls}
94
+ selected={selectedUrls}
95
+ min={minSelect}
96
+ max={maxSelect}
97
+ mediaType="image"
98
+ onToggle={toggle}
99
+ onSelectAll={() => setSelectedUrls(allMediaUrls.slice(0, maxSelect))}
100
+ onDeselectAll={() => setSelectedUrls([])}
101
+ onPreview={(url) => setLightbox(url)}
102
+ />
103
+
104
+ // Video thumbnails — "mixed" would render <img> and show broken tiles
105
+ <ScMediaSelect items={clipUrls} selected={picked} mediaType="video" onToggle={toggle} />
106
+
107
+ // De-duplicate before rendering: selection is keyed on the URL
108
+ <ScMediaSelect items={Array.from(new Set(urls))} selected={picked} onToggle={toggle} />
109
+
110
+ // Gate a submit on the min the component only *displays*
111
+ <ScButton
112
+ text="Use selected"
113
+ variant="mono"
114
+ size="md"
115
+ state={picked.length >= minSelect ? "default" : "disabled"}
116
+ onClick={submit}
117
+ />
118
+ ```
119
+
120
+ ---
121
+
122
+ ## 2. Where to use it
123
+
124
+ - **The agent's in-chat DynamicForm**, `media_select` field type — the only real
125
+ consumer. Live at `stream-agent packages/chat-components/src/DynamicForm.tsx`, which
126
+ owns the max enforcement, the select-all slice and the lightbox.
127
+ - **A batch-review step in the chat transcript**, after a generation run — "here are 12,
128
+ which do you want?".
129
+ - Tuned for a **narrow chat panel**: 4 columns (3 below 360px), square tiles, and an
130
+ internal `max-height: 18.75rem` scroll region rather than growing the transcript.
131
+
132
+ ---
133
+
134
+ ## 3. When to use it
135
+
136
+ ### Use it when
137
+
138
+ - The user picks **a subset** of a batch, and seeing all candidates at once matters.
139
+ - The tiles are square-croppable and the count is the feedback the user needs.
140
+ - The set is large enough that an internal scroll region is preferable to a growing panel.
141
+
142
+ ### Don't use it — reach for this instead
143
+
144
+ | Situation | Use instead |
145
+ |---|---|
146
+ | Review one item at a time, with free-text feedback | `ScMediaApproval` |
147
+ | Accept/decline one **text** suggestion | `ScSelectionList` |
148
+ | Multi-select over **labels**, not media | `ScCheckField` (or `ScCheckbox` for one) |
149
+ | The user **uploads** an image | `ScImageField` |
150
+ | Pick exactly one media item | `ScMediaApproval` (carousel + your own confirm), or this with your own max=1 |
151
+ | An editable task list | `ScTodoList` |
152
+ | A full-page asset library with filters, pagination, bulk ops | build it — this is a chat panel component |
153
+ | Cards representing entities rather than media | `ScStoreCard` / `ScDefaultCard` |
154
+
155
+ ### Don't confuse with
156
+
157
+ | You may actually want | Not this |
158
+ |---|---|
159
+ | `ScMediaApproval` — one-up carousel + feedback textarea | This is a multi-select grid with no feedback field |
160
+ | `ScImageField` — an upload/preview **form field** | This never uploads |
161
+ | `ScCheckField` — checkbox group with a field label | No labels here, only thumbnails |
162
+ | A component that enforces min/max | It only prints them |
163
+
164
+ ---
165
+
166
+ ## 4. Why to use it
167
+
168
+ - **The selected affordance is doubled.** A 2px `--alias-border-strong` frame *and* a
169
+ filled check badge, so selection survives greyscale and doesn't depend on a colour the
170
+ thumbnail might already contain.
171
+ - **The check badge is token-paired** (`--alias-fill-base-base` circle,
172
+ `--alias-text-and-icons-inverse` tick), so it inverts correctly in light mode instead
173
+ of becoming a white blob on a bright product shot.
174
+ - **The grid can't blow out the panel.** `max-height: 18.75rem` + `overflow-y: auto`
175
+ means a 200-item batch scrolls inside its own box rather than pushing the chat
176
+ transcript around, and the 4→3 column breakpoint at 360px keeps tiles tappable on a
177
+ phone.
178
+ - **Square-cropped tiles scan as a set.** `aspect-ratio: 1/1` + `object-fit: cover` on a
179
+ black background gives a uniform grid whatever the source aspect ratios are.
180
+ - **Hover-revealed preview keeps the tile clean** — the maximize button `stopPropagation`s
181
+ so previewing never toggles selection, which is the single most common bug when this is
182
+ hand-rolled.
183
+
184
+ ---
185
+
186
+ ## Gotchas
187
+
188
+ **1. `min`/`max` are not enforced.** They only decorate the count label. `onToggle` fires
189
+ on every click and the component will happily let the user select all 50 when `max={3}`.
190
+
191
+ ```tsx
192
+ // WRONG — believes max is a constraint
193
+ <ScMediaSelect items={urls} selected={picked} max={3} onToggle={(u) => setPicked([...picked, u])} />
194
+
195
+ // RIGHT — enforce it in your handler (see the first recipe)
196
+ ```
197
+
198
+ **2. `"mixed"` renders `<img>`, not a per-URL guess.** Unlike `ScMediaApproval` — which
199
+ sniffs the extension for `"mixed"` — this component branches on `mediaType === "video"`
200
+ only. Passing `field.media_type ?? "mixed"` for a mixed batch gives you broken `<img>`
201
+ tiles for the videos.
202
+
203
+ ```tsx
204
+ // WRONG for a mixed batch — videos render as broken images
205
+ <ScMediaSelect items={urls} mediaType="mixed" selected={picked} onToggle={toggle} />
206
+
207
+ // RIGHT — split the batch, or render two grids
208
+ <ScMediaSelect items={images} mediaType="image" … />
209
+ <ScMediaSelect items={clips} mediaType="video" … />
210
+ ```
211
+
212
+ **3. Selection is keyed on the URL string.** Duplicate URLs in `items` are visually
213
+ distinct tiles that select and deselect **together**, and `onToggle` cannot tell them
214
+ apart. De-duplicate before rendering. (React keys use `url + "-" + index`, so keys are
215
+ fine — the selection model is what breaks.)
216
+
217
+ **4. `selected` is memoised on array identity.** The internal `Set` is
218
+ `useMemo(..., [selected])`. Mutating the same array in place (`picked.push(url)`) will
219
+ not refresh the highlight; always pass a new array.
220
+
221
+ **5. Tiles are keyboard-focusable but not keyboard-operable.** They are
222
+ `div[role="button"] tabIndex={0} aria-pressed` with **no `onKeyDown`**. Tab lands on
223
+ them, Enter and Space do nothing. There is no way to fix this from the outside — treat it
224
+ as a DS bug for accessible surfaces.
225
+
226
+ **6. The preview button is hover-only.** `.previewButton { display: none }` → `flex` on
227
+ `.item:hover`. There is no hover on touch, so preview is unreachable on mobile.
228
+
229
+ **7. No `onPreview` ⇒ no preview button at all.** The button is conditionally rendered on
230
+ the handler's presence, which is good — but means "the maximize icon is missing" is
231
+ usually a missing prop, not a CSS problem.
232
+
233
+ **8. "Select all" / "Deselect all" compute nothing.** They just call your handlers. If
234
+ you wire `onSelectAll` to `setSelected(items)` while `max` is smaller, you have silently
235
+ broken your own limit — slice it (`items.slice(0, max)`).
236
+
237
+ **9. The header renders even with zero items.** "0 of 0 selected" plus two live link
238
+ buttons over an empty box. Guard on `items.length` and render your own empty state.
239
+
240
+ **10. Thumbnails are cropped.** `object-fit: cover` on a square tile — a tall portrait
241
+ loses its top and bottom. If the composition matters for the choice, `ScMediaApproval`
242
+ (which uses `contain`) is the better fit.
243
+
244
+ **11. Video tiles have no poster and no controls.** `<video muted playsInline>` with no
245
+ `poster` shows a black frame until metadata loads and never plays inline. Provide poster
246
+ images and use `mediaType="image"` if the first frame matters.
247
+
248
+ **12. `alt=""` is hardcoded.** Tiles are announced as decorative; a screen-reader user
249
+ gets a run of unlabelled pressed/unpressed buttons.
250
+
251
+ **13. Hardcoded English.** `"N of M selected"`, `" (min N)"`, `" (max N)"`,
252
+ `"Select all"`, `"Deselect all"`, and the `"Preview"` aria-label. No label props.
253
+
254
+ **14. No root-level props.** `IScMediaSelectProps` doesn't extend `HTMLAttributes`, so
255
+ `style`/`id`/`data-testid` are type errors. Wrap it.
256
+
257
+ ---
258
+
259
+ ## In the wild
260
+
261
+ Rendered by the **agent runtime**, not by any of the four dashboards — the live call
262
+ site is the chat DynamicForm's `media_select` field in the `stream-agent` repo
263
+ (`@streamoid/chat-components`):
264
+
265
+ ```tsx
266
+ // stream-agent packages/chat-components/src/DynamicForm.tsx:916
267
+ <ScMediaSelect
268
+ items={allMediaUrls}
269
+ selected={selectValue.selected_urls}
270
+ min={field.min_select ?? 0}
271
+ max={maxSelect}
272
+ mediaType={field.media_type ?? "mixed"}
273
+ onToggle={toggleSelect}
274
+ onSelectAll={() =>
275
+ handleFieldChange(field.id, {
276
+ ...selectValue,
277
+ selected_urls: allMediaUrls.slice(0, maxSelect),
278
+ } as MediaSelectValue)
279
+ }
280
+ onDeselectAll={() =>
281
+ handleFieldChange(field.id, {
282
+ ...selectValue,
283
+ selected_urls: [],
284
+ } as MediaSelectValue)
285
+ }
286
+ onPreview={(url) =>
287
+ setMediaPreview({
288
+ url,
289
+ type:
290
+ field.media_type && field.media_type !== "mixed"
291
+ ? field.media_type
292
+ : inferMediaTypeFromUrl(url),
293
+ })
294
+ }
295
+ />
296
+ ```
297
+
298
+ Note that this call site passes `mediaType="mixed"` — which, per Gotcha 2, renders
299
+ `<img>` for every item. That is a live bug worth fixing in the runtime (or by teaching
300
+ this component to sniff like `ScMediaApproval` does).
301
+
302
+ ---
303
+
304
+ ## Related
305
+
306
+ - `ScMediaApproval` — the one-up carousel sibling, with a feedback textarea and `contain` fit.
307
+ - `ScCheckField` / `ScCheckbox` — multi-select over labels rather than thumbnails.
308
+ - `ScSelectionList` — accept/decline one text suggestion.
309
+ - `ScImageField` — the upload-side field.
310
+ - `ScTodoList` — the other "user edits what the agent produced" in-chat component.