@chai-ui/react 0.1.0

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 (66) hide show
  1. package/DESIGN.md +207 -0
  2. package/LICENSE +21 -0
  3. package/README.md +265 -0
  4. package/dist/ChaiProvider.d.ts +49 -0
  5. package/dist/ChaiProvider.d.ts.map +1 -0
  6. package/dist/ChaiProvider.js +54 -0
  7. package/dist/ChaiProvider.js.map +1 -0
  8. package/dist/Composer.d.ts +169 -0
  9. package/dist/Composer.d.ts.map +1 -0
  10. package/dist/Composer.js +389 -0
  11. package/dist/Composer.js.map +1 -0
  12. package/dist/MediaAnalyzer.d.ts +102 -0
  13. package/dist/MediaAnalyzer.d.ts.map +1 -0
  14. package/dist/MediaAnalyzer.js +250 -0
  15. package/dist/MediaAnalyzer.js.map +1 -0
  16. package/dist/ResultCard.d.ts +55 -0
  17. package/dist/ResultCard.d.ts.map +1 -0
  18. package/dist/ResultCard.js +409 -0
  19. package/dist/ResultCard.js.map +1 -0
  20. package/dist/index.d.ts +10 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +11 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/primitives/Pagination.d.ts +19 -0
  25. package/dist/primitives/Pagination.d.ts.map +1 -0
  26. package/dist/primitives/Pagination.js +13 -0
  27. package/dist/primitives/Pagination.js.map +1 -0
  28. package/dist/primitives/SearchMenu.d.ts +69 -0
  29. package/dist/primitives/SearchMenu.d.ts.map +1 -0
  30. package/dist/primitives/SearchMenu.js +88 -0
  31. package/dist/primitives/SearchMenu.js.map +1 -0
  32. package/dist/primitives/Toggle.d.ts +9 -0
  33. package/dist/primitives/Toggle.d.ts.map +1 -0
  34. package/dist/primitives/Toggle.js +28 -0
  35. package/dist/primitives/Toggle.js.map +1 -0
  36. package/dist/primitives/index.d.ts +4 -0
  37. package/dist/primitives/index.d.ts.map +1 -0
  38. package/dist/primitives/index.js +4 -0
  39. package/dist/primitives/index.js.map +1 -0
  40. package/dist/style.css +1860 -0
  41. package/dist/use-latest.d.ts +7 -0
  42. package/dist/use-latest.d.ts.map +1 -0
  43. package/dist/use-latest.js +17 -0
  44. package/dist/use-latest.js.map +1 -0
  45. package/dist/useComposer.d.ts +75 -0
  46. package/dist/useComposer.d.ts.map +1 -0
  47. package/dist/useComposer.js +191 -0
  48. package/dist/useComposer.js.map +1 -0
  49. package/dist/useMediaAnalyzer.d.ts +37 -0
  50. package/dist/useMediaAnalyzer.d.ts.map +1 -0
  51. package/dist/useMediaAnalyzer.js +114 -0
  52. package/dist/useMediaAnalyzer.js.map +1 -0
  53. package/package.json +88 -0
  54. package/src/ChaiProvider.tsx +88 -0
  55. package/src/Composer.tsx +961 -0
  56. package/src/MediaAnalyzer.tsx +527 -0
  57. package/src/ResultCard.tsx +980 -0
  58. package/src/index.ts +36 -0
  59. package/src/primitives/Pagination.tsx +41 -0
  60. package/src/primitives/SearchMenu.tsx +284 -0
  61. package/src/primitives/Toggle.tsx +43 -0
  62. package/src/primitives/index.ts +3 -0
  63. package/src/style.css +1860 -0
  64. package/src/use-latest.ts +18 -0
  65. package/src/useComposer.ts +289 -0
  66. package/src/useMediaAnalyzer.ts +157 -0
@@ -0,0 +1,961 @@
1
+ import { useEffect, useRef, useState, type ChangeEvent } from "react";
2
+ import { createComponent } from "@lit/react";
3
+ import * as React from "react";
4
+ import { MdFab } from "@material/web/fab/fab.js";
5
+ import type { DroppedMedia, ModelOption, ParameterOption, MediaKind } from "@chai-ui/core";
6
+ import { SearchMenu } from "./primitives/SearchMenu.js";
7
+
8
+ /** See primitives/Toggle.tsx for why `createComponent` is used instead of raw JSX on custom-element tags. */
9
+ const MdFabElement = createComponent({
10
+ react: React,
11
+ tagName: "md-fab",
12
+ elementClass: MdFab,
13
+ events: { onClick: "click" },
14
+ });
15
+
16
+ const ACCEPTED_KINDS: MediaKind[] = ["image", "video", "audio"];
17
+
18
+ function detectKind(file: File): MediaKind | undefined {
19
+ const prefix = file.type.split("/")[0];
20
+ return ACCEPTED_KINDS.find((k) => k === prefix);
21
+ }
22
+
23
+ // Past this length the rewritten prompt just appears instantly — a
24
+ // multi-hundred-character reveal reads as sluggish, not delightful, and a
25
+ // long prompt is exactly the case where a person wants the result now, not
26
+ // a show. Picked to comfortably cover a typical Enhance rewrite (usually a
27
+ // sentence or two of added detail) while bailing out before it doesn't.
28
+ const MAX_ANIMATE_ENHANCE_LENGTH = 260;
29
+
30
+ // Time-driven (see the `requestAnimationFrame` loop that uses this), not
31
+ // count-driven — a fixed ms-per-character reveal on a slow frame would
32
+ // drift or stall instead of just finishing a beat later. Capped on both
33
+ // ends: a two-word rewrite shouldn't take as long as a five-word one to
34
+ // feel intentional, and even a near-max-length one should still resolve
35
+ // under a second.
36
+ function enhanceRevealDurationMs(charCount: number): number {
37
+ return Math.min(900, Math.max(300, charCount * 6));
38
+ }
39
+
40
+ function nextAttachmentId(): string {
41
+ return typeof crypto !== "undefined" && "randomUUID" in crypto
42
+ ? crypto.randomUUID()
43
+ : `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`;
44
+ }
45
+
46
+ export interface ComposerAttachment extends DroppedMedia {
47
+ id: string;
48
+ }
49
+
50
+ export type ComposerAttachMenuAction = "add-media" | "create-image" | "create-video" | "edit-media";
51
+
52
+ /**
53
+ * The selected generation use case, shown as a removable chip (reference:
54
+ * the mock's "Video"/"Image" chip) — deliberately independent of
55
+ * `attachments`: a use case can be picked from the attach menu with nothing
56
+ * attached yet (e.g. "Create video"). Composer never derives this from
57
+ * `attachments` itself (that's app-level policy, not a Composer opinion —
58
+ * the design). With none picked, `defaultUseCase` applies.
59
+ */
60
+ export interface ComposerUseCase {
61
+ kind: MediaKind;
62
+ label: string;
63
+ }
64
+
65
+ /** A plain text request — what a submit means when no use case is picked, unless the builder sets `defaultUseCase`. */
66
+ export const TEXT_USE_CASE: ComposerUseCase = { kind: "text", label: "Text" };
67
+
68
+ /** One use case to run and the models picked for it. Empty `modelIds` means the engine's default model. */
69
+ export interface ComposerSelection {
70
+ useCase: ComposerUseCase;
71
+ modelIds: string[];
72
+ /** The models offered for this use case: what auto-select routes among. */
73
+ models: ModelOption[];
74
+ }
75
+
76
+ /**
77
+ * Everything Composer itself tracks, assembled into one object at submit
78
+ * time — not the the design `Request` shape (no `mode`/`params`/
79
+ * `presetId`; those belong to the run store this package doesn't have yet,
80
+ * see the package README), just an honest snapshot of this component's own
81
+ * state. Handed to `onSubmit` so a builder doesn't have to re-derive it
82
+ * from five separately-controlled props they already hold — mainly a
83
+ * correctness/ergonomics win for a coding agent wiring this up from the
84
+ * type signature alone.
85
+ */
86
+ export interface ComposerSubmitPayload {
87
+ value: string;
88
+ attachments: ComposerAttachment[];
89
+ /** The use case to run — the picked one, or `defaultUseCase` when none is picked. The first one with `multiSelectUseCases`. */
90
+ useCase: ComposerUseCase;
91
+ /** The first picked model, or null for the engine's default. */
92
+ modelId: string | null;
93
+ aspectRatio: string | null;
94
+ /** Everything to run: one entry per use case with its picked models. A single entry unless `multiSelectUseCases` is on. */
95
+ selections: ComposerSelection[];
96
+ /** When true, the model is chosen at submit (`useComposer` routes among each selection's `models`), not by the picks. */
97
+ autoSelectModel: boolean;
98
+ }
99
+
100
+ export interface ComposerProps {
101
+ /** The prompt text. Controlled — Composer owns no text state of its own. */
102
+ value: string;
103
+ onChange: (value: string) => void;
104
+ placeholder?: string;
105
+ /** The prompt field's accessible name, read by screen readers. Defaults to `placeholder`, which disappears once the person types. */
106
+ promptLabel?: string;
107
+ attachments?: ComposerAttachment[];
108
+ onAttachmentsChange?: (next: ComposerAttachment[]) => void;
109
+ onUnsupportedFile?: (fileName: string) => void;
110
+ /** The use-case chip. Aspect ratio only renders for an image or video use case. */
111
+ useCase?: ComposerUseCase | null;
112
+ onClearUseCase?: () => void;
113
+ /** What a submit means with no use case picked. Defaults to `TEXT_USE_CASE`, a text request. Never shown as a chip. */
114
+ defaultUseCase?: ComposerUseCase;
115
+ /**
116
+ * Builder opt-in, off by default: several use cases at once (e.g. an image
117
+ * and a video from one prompt), at most one per kind. Uses `useCases`,
118
+ * `onUseCasesChange` and `modelsByKind` instead of `useCase` and `models`;
119
+ * the model menu shows one section per picked use case.
120
+ */
121
+ multiSelectUseCases?: boolean;
122
+ useCases?: ComposerUseCase[];
123
+ onUseCasesChange?: (next: ComposerUseCase[]) => void;
124
+ /** Models per use-case kind, for `multiSelectUseCases`. */
125
+ modelsByKind?: Partial<Record<MediaKind, ModelOption[]>>;
126
+ /**
127
+ * Builder opt-in, off by default: the end user can pick several models,
128
+ * each producing its own result (ResultCard pages through them). Uses
129
+ * `modelIds` and `onModelIdsChange` instead of `modelId` and
130
+ * `onModelChange`. Always on with `multiSelectUseCases`.
131
+ */
132
+ multiSelectModels?: boolean;
133
+ modelIds?: string[];
134
+ onModelIdsChange?: (ids: string[]) => void;
135
+ /** Models valid for the current use case. The Model menu only renders when this is non-empty. */
136
+ models?: ModelOption[];
137
+ modelId?: string | null;
138
+ /** `null` clears the pick: Composer does this when auto-select turns on, until routing picks a model. */
139
+ onModelChange?: (id: string | null) => void;
140
+ /**
141
+ * When true, the model is chosen at submit: `useComposer` asks the
142
+ * reasoning model to pick from each use case's models. The trigger reads
143
+ * "Auto-select" until a submit routes, then shows the pick.
144
+ */
145
+ autoSelectModel?: boolean;
146
+ /**
147
+ * Shows an "Auto-select model" switch at the top of the Model menu
148
+ * (same `toggleHeader` pattern as MediaAnalyzer), letting the *end user*
149
+ * flip `autoSelectModel` themselves — a builder opt-in, not the default.
150
+ * Omit (the default) to keep `autoSelectModel` a builder-only setting:
151
+ * when true, the Model trigger renders disabled with nothing the end
152
+ * user can interact with to change it.
153
+ */
154
+ showAutoSelectToggle?: boolean;
155
+ /** Fires when the end user toggles the switch — only reachable when `showAutoSelectToggle` is true. */
156
+ onAutoSelectModelChange?: (value: boolean) => void;
157
+ aspectRatios?: ParameterOption<string>[];
158
+ aspectRatio?: string | null;
159
+ onAspectRatioChange?: (value: string) => void;
160
+ /**
161
+ * The "optimize prompt" button — rewrites a
162
+ * sparse prompt into a stronger one via an LLM call. Composer owns none
163
+ * of that call itself: `onEnhance` fires, the builder's own code is what
164
+ * actually runs the rewrite (through whichever `Engine`/model they've
165
+ * configured, §5.1) and calls `onChange` with the result. Omit this prop
166
+ * to hide the button entirely — e.g. before a builder has wired an
167
+ * engine at all, there's nothing for it to do yet. Which model actually
168
+ * runs the rewrite is still an open question at the design-doc level,
169
+ * not just an implementation gap — see the design open question 5
170
+ * ("Enhance model"). Receives the current prompt text so the handler is
171
+ * self-contained — doesn't need its own closure over `value`.
172
+ */
173
+ onEnhance?: (value: string) => void;
174
+ /** True while the builder's own `onEnhance` handler is running its LLM call — shows the button's active state. */
175
+ enhancing?: boolean;
176
+ /** Fires with everything Composer tracks — see `ComposerSubmitPayload`. Also what retry calls, with the same payload, when `submitError` is set. */
177
+ onSubmit: (payload: ComposerSubmitPayload) => void;
178
+ /**
179
+ * A short, human-readable message for the last submit's failure —
180
+ * Composer never sets or clears this itself, the app does (it's the one
181
+ * that knows whether the actual generation succeeded). While set: the
182
+ * submit FAB becomes a retry action (arrow → refresh icon, danger-
183
+ * tinted) instead of disabling, and a compact inline message appears
184
+ * above the controls row — no blocking dialog. Most real generation UIs
185
+ * don't interrupt with an error dialog on a failed request; they let you
186
+ * try again from where you were, which is what this models. Clear it
187
+ * (pass `null`/`undefined`) once a retry succeeds or the prompt changes.
188
+ */
189
+ submitError?: string | null;
190
+ /** Overrides the built-in "empty prompt and no attachments" disabled check. Ignored while `submitError` is set — retry stays available regardless. */
191
+ submitDisabled?: boolean;
192
+ /**
193
+ * True while the builder's own `onSubmit` handler has a request in
194
+ * flight — swaps the submit FAB's arrow for a stop icon. Composer owns
195
+ * none of the actual cancellation, same rule `onEnhance` already follows:
196
+ * this is the affordance only, `onAbort` is what the app does with the
197
+ * click. Omit `onAbort` (even with `submitting` true) to leave the FAB
198
+ * merely disabled instead — same "presence of the callback is the opt-in"
199
+ * shape `onEnhance` uses.
200
+ */
201
+ submitting?: boolean;
202
+ /** Fires when the stop icon is clicked while `submitting` is true. See `submitting`'s own doc comment for why this — not `submitting` alone — is what actually shows the stop icon. */
203
+ onAbort?: () => void;
204
+ onAttachMenuSelect?: (action: ComposerAttachMenuAction) => void;
205
+ disabled?: boolean;
206
+ }
207
+
208
+ /**
209
+ * The `bar`-layout Composer —
210
+ * reference: the "Compose Component" design, whose four states (minimal / model+aspect select / auto-select with
211
+ * attachments / attach-menu open) are all this one controlled component,
212
+ * driven by which props are supplied, not four separate components.
213
+ *
214
+ * The container chrome (flat cool gray, 22px pill radius) comes from the
215
+ * `control-*` semantic tokens, restylable per instance through the
216
+ * `--chai-composer-*` custom properties in style.css.
217
+ */
218
+ export function Composer({
219
+ value,
220
+ onChange,
221
+ placeholder = "Describe media to create",
222
+ promptLabel,
223
+ attachments = [],
224
+ onAttachmentsChange,
225
+ onUnsupportedFile,
226
+ useCase = null,
227
+ onClearUseCase,
228
+ defaultUseCase = TEXT_USE_CASE,
229
+ multiSelectUseCases = false,
230
+ useCases = [],
231
+ onUseCasesChange,
232
+ modelsByKind,
233
+ multiSelectModels = false,
234
+ modelIds = [],
235
+ onModelIdsChange,
236
+ models,
237
+ modelId = null,
238
+ onModelChange,
239
+ autoSelectModel = false,
240
+ showAutoSelectToggle = false,
241
+ onAutoSelectModelChange,
242
+ aspectRatios,
243
+ aspectRatio = null,
244
+ onAspectRatioChange,
245
+ onEnhance,
246
+ enhancing = false,
247
+ onSubmit,
248
+ submitError = null,
249
+ submitDisabled,
250
+ submitting = false,
251
+ onAbort,
252
+ onAttachMenuSelect,
253
+ disabled = false,
254
+ }: ComposerProps) {
255
+ const hasContent = value.trim().length > 0 || attachments.length > 0;
256
+ const isSubmitDisabled = submitError ? disabled : (submitDisabled ?? (disabled || !hasContent));
257
+ // The stop icon only appears with somewhere for its click to go — without
258
+ // `onAbort`, `submitting` just disables the FAB instead (below), same as
259
+ // it would with no opt-in wired at all.
260
+ const showStop = submitting && Boolean(onAbort);
261
+ const selectedAspect = aspectRatios?.find((o) => o.value === aspectRatio) ?? null;
262
+
263
+ // What the person picked, and what actually runs: with nothing picked,
264
+ // the builder's default use case (a text request unless they chose
265
+ // otherwise), so a typed prompt always goes somewhere.
266
+ const pickedUseCases = multiSelectUseCases ? useCases : useCase ? [useCase] : [];
267
+ const activeUseCases = pickedUseCases.length > 0 ? pickedUseCases : [defaultUseCase];
268
+ const multiModel = multiSelectModels || multiSelectUseCases;
269
+ const pickedModelIds = multiModel ? modelIds : modelId ? [modelId] : [];
270
+ // One menu section per use case with multi-use-case select; otherwise one flat roster.
271
+ const modelSections = multiSelectUseCases
272
+ ? activeUseCases
273
+ .map((u) => ({ label: u.label, kind: u.kind, options: modelsByKind?.[u.kind] ?? [] }))
274
+ .filter((s) => s.options.length > 0)
275
+ : [{ label: "", kind: activeUseCases[0]!.kind, options: models ?? [] }].filter((s) => s.options.length > 0);
276
+ const allModels = modelSections.flatMap((s) => s.options);
277
+ const pickedModels = allModels.filter((m) => pickedModelIds.includes(m.id));
278
+ const modelTriggerLabel =
279
+ pickedModels.length > 1
280
+ ? `${pickedModels.length} models`
281
+ : pickedModels[0]?.label ?? (autoSelectModel ? "Auto-select" : "Select models");
282
+ const showAspectRatio = activeUseCases.some((u) => u.kind === "image" || u.kind === "video");
283
+
284
+ function toggleModel(id: string) {
285
+ if (!multiModel) return onModelChange?.(id);
286
+ onModelIdsChange?.(modelIds.includes(id) ? modelIds.filter((m) => m !== id) : [...modelIds, id]);
287
+ }
288
+
289
+ function buildSelections(): ComposerSelection[] {
290
+ return activeUseCases.map((u) => ({
291
+ useCase: u,
292
+ models: multiSelectUseCases ? (modelsByKind?.[u.kind] ?? []) : (models ?? []),
293
+ modelIds: multiSelectUseCases
294
+ ? pickedModelIds.filter((id) => modelsByKind?.[u.kind]?.some((m) => m.id === id))
295
+ : pickedModelIds,
296
+ }));
297
+ }
298
+
299
+ // Auto-select chooses at submit, from the prompt (`useComposer` routes
300
+ // with the reasoning model). Turning it on clears the current pick, so
301
+ // the trigger reads "Auto-select" until routing fills in the real model.
302
+ const wasAutoSelect = useRef(autoSelectModel);
303
+ useEffect(() => {
304
+ if (autoSelectModel && !wasAutoSelect.current) {
305
+ if (multiModel) onModelIdsChange?.([]);
306
+ else onModelChange?.(null);
307
+ }
308
+ wasAutoSelect.current = autoSelectModel;
309
+ }, [autoSelectModel, multiModel, onModelChange, onModelIdsChange]);
310
+
311
+ // Auto-grows with wrapped content instead of a fixed row count — a
312
+ // static `rows` either hides wrapped lines behind a scrollbar (too
313
+ // short) or wastes space while empty (too tall). Starts at one line
314
+ // (matching the mock's compact minimal state) and grows only as typed
315
+ // text actually wraps.
316
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
317
+ useEffect(() => {
318
+ const el = textareaRef.current;
319
+ if (!el) return;
320
+ el.style.height = "auto";
321
+ el.style.height = `${el.scrollHeight}px`;
322
+ }, [value]);
323
+
324
+ // The optimize/undo cycle (§ its own button below). `value` is already
325
+ // the final rewritten text the instant `enhancing` drops back to false —
326
+ // the reveal below is a purely cosmetic overlay on top of it, not a
327
+ // delay, so nothing here ever gates what the real `<textarea>` holds.
328
+ const [originalValue, setOriginalValue] = useState<string | null>(null);
329
+ const [enhancedValue, setEnhancedValue] = useState<string | null>(null);
330
+ const [prevEnhancing, setPrevEnhancing] = useState(enhancing);
331
+ const [animatingText, setAnimatingText] = useState<string | null>(null);
332
+ const [revealedLength, setRevealedLength] = useState(0);
333
+
334
+ // React to `enhancing` dropping back to false while rendering, not in an
335
+ // effect (the rules of React): that's the moment the rewrite has landed.
336
+ if (prevEnhancing !== enhancing) {
337
+ setPrevEnhancing(enhancing);
338
+ if (prevEnhancing && !enhancing && originalValue != null && value !== originalValue) {
339
+ setEnhancedValue(value);
340
+ // Bails on length (see the constant's own comment) or on a real
341
+ // `prefers-reduced-motion` request — the reveal is itself motion, not
342
+ // just the shimmer the CSS `@media` query above separately turns off.
343
+ const reducedMotion =
344
+ typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
345
+ if (value.length <= MAX_ANIMATE_ENHANCE_LENGTH && !reducedMotion) {
346
+ setRevealedLength(0);
347
+ setAnimatingText(value);
348
+ }
349
+ }
350
+ }
351
+
352
+ useEffect(() => {
353
+ if (animatingText == null) return;
354
+ const total = animatingText.length;
355
+ const duration = enhanceRevealDurationMs(total);
356
+ const start = performance.now();
357
+ let raf: number;
358
+ const tick = (now: number) => {
359
+ const elapsed = now - start;
360
+ const next = Math.min(total, Math.floor((elapsed / duration) * total));
361
+ setRevealedLength((prev) => (prev === next ? prev : next));
362
+ if (next < total) raf = requestAnimationFrame(tick);
363
+ else setAnimatingText(null);
364
+ };
365
+ raf = requestAnimationFrame(tick);
366
+ return () => cancelAnimationFrame(raf);
367
+ }, [animatingText]);
368
+
369
+ const animating = animatingText != null;
370
+ const isEnhanced = !animating && enhancedValue != null && value === enhancedValue;
371
+ const enhanceBusy = enhancing || animating;
372
+
373
+ function handleOptimizeClick() {
374
+ if (isEnhanced) {
375
+ const original = originalValue;
376
+ setEnhancedValue(null);
377
+ setOriginalValue(null);
378
+ if (original != null) onChange(original);
379
+ return;
380
+ }
381
+ setOriginalValue(value); // snapshot now — before the rewrite lands, for undo
382
+ onEnhance?.(value);
383
+ }
384
+
385
+ function handleFilesSelected(fileList: FileList | null) {
386
+ if (!fileList || fileList.length === 0 || !onAttachmentsChange) return;
387
+ const next = [...attachments];
388
+ let pending = fileList.length;
389
+ const settle = () => {
390
+ pending -= 1;
391
+ if (pending === 0) onAttachmentsChange(next);
392
+ };
393
+ Array.from(fileList).forEach((file) => {
394
+ const kind = detectKind(file);
395
+ if (!kind) {
396
+ onUnsupportedFile?.(file.name);
397
+ settle();
398
+ return;
399
+ }
400
+ const reader = new FileReader();
401
+ reader.onload = () => {
402
+ next.push({ id: nextAttachmentId(), src: String(reader.result), kind, name: file.name, size: file.size });
403
+ settle();
404
+ };
405
+ reader.onerror = () => {
406
+ onUnsupportedFile?.(file.name);
407
+ settle();
408
+ };
409
+ reader.readAsDataURL(file);
410
+ });
411
+ }
412
+
413
+ function handleRemoveAttachment(id: string) {
414
+ onAttachmentsChange?.(attachments.filter((a) => a.id !== id));
415
+ }
416
+
417
+ return (
418
+ <div className="chai-composer">
419
+ {attachments.length > 0 && (
420
+ <div className="chai-composer__thumbnails">
421
+ {attachments.map((a) => (
422
+ <ComposerThumbnail key={a.id} attachment={a} onRemove={() => handleRemoveAttachment(a.id)} />
423
+ ))}
424
+ </div>
425
+ )}
426
+
427
+ <div className="chai-composer__field">
428
+ <textarea
429
+ ref={textareaRef}
430
+ className={`chai-composer__input chai-composer__type${animating ? " chai-composer__input--revealing" : ""}`}
431
+ value={value}
432
+ onChange={(e) => onChange(e.target.value)}
433
+ placeholder={placeholder}
434
+ aria-label={promptLabel ?? placeholder}
435
+ rows={1}
436
+ disabled={disabled || animating}
437
+ />
438
+ {/* The optimize/undo reveal overlay — decorative only. The real
439
+ textarea above already holds the final text throughout (just
440
+ visually suppressed via `--revealing`); this div paints the
441
+ same text back in progressively with a gradient, then unmounts.
442
+ `aria-hidden` so a screen reader isn't told the text twice —
443
+ the textarea's own (already-final) value is what it reads. */}
444
+ {animating && (
445
+ <div className="chai-composer__reveal chai-composer__type" aria-hidden="true">
446
+ <span className="chai-composer__reveal-text">{animatingText!.slice(0, revealedLength)}</span>
447
+ </div>
448
+ )}
449
+ {/* "Optimize prompt" / "Revert to original prompt" — see
450
+ `onEnhance`'s own doc comment on `ComposerProps` for what the
451
+ rewrite itself actually does and why Composer doesn't run it.
452
+ Once a rewrite lands, this becomes an undo affordance instead —
453
+ an already-optimized prompt can't be re-optimized — and reverts
454
+ to a plain optimize button again the moment `value` no longer
455
+ matches that rewrite exactly, i.e. the person has since edited
456
+ it by hand. */}
457
+ {onEnhance && (
458
+ <button
459
+ type="button"
460
+ className={`chai-composer__optimize${enhanceBusy ? " chai-composer__optimize--active" : ""}`}
461
+ onClick={handleOptimizeClick}
462
+ disabled={disabled || (isEnhanced ? false : !value.trim() || enhanceBusy)}
463
+ aria-label={isEnhanced ? "Revert to original prompt" : "Optimize prompt"}
464
+ title={isEnhanced ? "Revert to original prompt" : "Optimize prompt"}
465
+ >
466
+ {isEnhanced ? <UndoIcon /> : <OptimizeIcon />}
467
+ </button>
468
+ )}
469
+ </div>
470
+
471
+ {submitError && (
472
+ <p className="chai-composer__error" role="alert">
473
+ <ErrorIcon />
474
+ {submitError}
475
+ </p>
476
+ )}
477
+
478
+ <div className="chai-composer__controls">
479
+ <div className="chai-composer__controls-start">
480
+ <ComposerAttachMenu
481
+ onSelect={onAttachMenuSelect}
482
+ onAddMediaFiles={handleFilesSelected}
483
+ disabled={disabled}
484
+ />
485
+
486
+ {pickedUseCases.map((u) => (
487
+ <UseCaseChip
488
+ key={u.kind}
489
+ useCase={u}
490
+ onRemove={
491
+ multiSelectUseCases
492
+ ? () => onUseCasesChange?.(useCases.filter((x) => x.kind !== u.kind))
493
+ : onClearUseCase
494
+ }
495
+ disabled={disabled}
496
+ />
497
+ ))}
498
+
499
+ {/* The Model menu shows whenever there are models for the current
500
+ use case(s), picked or default. The auto-select switch lives
501
+ inside it (builder opt-in, `showAutoSelectToggle`), so the
502
+ trigger stays enabled whenever the switch is shown — it's the
503
+ only way back to the switch. Rows are disabled by `SearchMenu`
504
+ itself while auto-select is on. */}
505
+ {modelSections.length > 0 && (
506
+ <SearchMenu
507
+ {...(multiSelectUseCases
508
+ ? { sections: modelSections.map((s) => ({ label: s.label, options: s.options })) }
509
+ : { options: modelSections[0]!.options })}
510
+ value={multiModel ? pickedModelIds : modelId}
511
+ multiple={multiModel}
512
+ toggleHeader={
513
+ showAutoSelectToggle
514
+ ? {
515
+ label: "Auto-select model",
516
+ value: autoSelectModel,
517
+ onChange: (v) => onAutoSelectModelChange?.(v),
518
+ }
519
+ : undefined
520
+ }
521
+ triggerLabel={modelTriggerLabel}
522
+ menuLabel={multiModel ? "Select models" : "Select model"}
523
+ onSelect={(opt) => toggleModel(opt.id)}
524
+ searchable={false}
525
+ triggerVariant="text"
526
+ disabled={disabled || (autoSelectModel && !showAutoSelectToggle)}
527
+ panelWidth={showAutoSelectToggle ? 320 : undefined}
528
+ />
529
+ )}
530
+
531
+ {showAspectRatio && aspectRatios && aspectRatios.length > 0 && (
532
+ <SearchMenu
533
+ options={aspectRatios.map((o) => ({ id: o.value, label: o.label }))}
534
+ value={aspectRatio}
535
+ triggerLabel={selectedAspect?.label ?? "Aspect ratio"}
536
+ menuLabel="Aspect ratio"
537
+ onSelect={(opt) => onAspectRatioChange?.(opt.id)}
538
+ searchable={false}
539
+ triggerVariant="text"
540
+ disabled={disabled}
541
+ />
542
+ )}
543
+ </div>
544
+
545
+ {/* MD3's `<md-fab>` has no `disabled` property of its own (FABs are
546
+ meant to always be actionable) — the disabled look/behavior here
547
+ is CHAI's own, via the `[data-ready]` CSS override below
548
+ (reference: the mock's icon-only, unfilled, shadowless submit
549
+ button that only fills brand-green once there's a valid prompt).
550
+ `label=""` rather than "Submit": any non-empty `label` switches
551
+ `<md-fab>` into its "extended" layout with a visible text label,
552
+ which the mock doesn't show — the accessible name comes from
553
+ `ariaLabel` instead (see the cast below for why camelCase, not
554
+ `aria-label`). Not `aria-disabled` for the interaction state:
555
+ `@lit/react`'s `createComponent` only routes a prop to the
556
+ element as a *property* when it matches a name already on the
557
+ element's prototype, and `MdFab`'s ARIAMixin exposes that as
558
+ camelCase `ariaDisabled` — the kebab-case `aria-disabled` prop
559
+ doesn't match, so it falls through to a plain React attribute,
560
+ and React's custom-element ARIA handling silently rewrites it to
561
+ `data-aria-disabled` rather than a real `aria-disabled`
562
+ attribute or the `ariaDisabled` property. Also tried the
563
+ "surface" variant for the not-ready look, but a green-seeded M3
564
+ theme's own surface containers still pick up a faint tint from
565
+ dynamic color, so it never actually reads neutral — overriding
566
+ the FAB's own `--md-fab-secondary-*` custom properties directly
567
+ was the only way to get a flat, unfilled rest state. `size=
568
+ "small"`, not "medium": at rest this button shows only its
569
+ 24px icon (transparent container), so "medium"'s real 56px
570
+ circle read as oversized the moment hover/focus made it
571
+ visible — "small" is a real MD3 size variant (not a custom
572
+ override) at 40px, and MD3's own default icon size is the same
573
+ 24px at both sizes, so the arrow itself doesn't change.
574
+ `data-error` swaps it to a danger-tinted retry action — see
575
+ `submitError`'s own doc comment on `ComposerProps` for why
576
+ retry replaces the button instead of a separate dialog/action.
577
+ `showStop` swaps it to a stop action instead, still `data-ready`
578
+ (same filled look — reads as "in progress," not "empty/error"),
579
+ clickable regardless of `isSubmitDisabled` since a request
580
+ already in flight isn't gated on the field having content. */}
581
+ <MdFabElement
582
+ className="chai-composer__submit"
583
+ size="small"
584
+ variant="secondary"
585
+ label=""
586
+ data-ready={showStop || !isSubmitDisabled || undefined}
587
+ data-error={Boolean(submitError) || undefined}
588
+ data-stop={showStop || undefined}
589
+ // `createComponent`'s generated prop types come from React's own
590
+ // `HTMLAttributes` (kebab-case `aria-*` only) and don't reflect
591
+ // its runtime routing rule (camelCase properties already on the
592
+ // element's prototype get set as real properties — see the
593
+ // comment above) — cast needed to reach both this way.
594
+ {...({
595
+ ariaLabel: showStop ? "Stop" : submitError ? "Retry" : "Submit",
596
+ ariaDisabled: showStop ? "false" : submitting ? "true" : isSubmitDisabled ? "true" : "false",
597
+ } as Record<string, unknown>)}
598
+ onClick={() => {
599
+ if (showStop) onAbort?.();
600
+ else if (!isSubmitDisabled && !submitting) {
601
+ const selections = buildSelections();
602
+ onSubmit({
603
+ value,
604
+ attachments,
605
+ useCase: activeUseCases[0]!,
606
+ modelId: pickedModelIds[0] ?? null,
607
+ aspectRatio,
608
+ selections,
609
+ autoSelectModel,
610
+ });
611
+ }
612
+ }}
613
+ >
614
+ <span slot="icon">
615
+ {showStop ? <StopIcon /> : submitError ? <RefreshIcon /> : <ArrowForwardIcon />}
616
+ </span>
617
+ </MdFabElement>
618
+ </div>
619
+ </div>
620
+ );
621
+ }
622
+
623
+ const ATTACH_MENU_ITEMS: {
624
+ action: ComposerAttachMenuAction;
625
+ label: string;
626
+ icon: React.ReactNode;
627
+ /** Reference: "Add media" (`MenuListItem1`) is the only item with `showDivider: true` in the source. */
628
+ divider?: boolean;
629
+ }[] = [
630
+ { action: "add-media", label: "Add media", icon: <AttachFileIcon />, divider: true },
631
+ { action: "create-image", label: "Create image", icon: <ImageIcon /> },
632
+ { action: "create-video", label: "Create video", icon: <VideocamIcon /> },
633
+ { action: "edit-media", label: "Edit media", icon: <ContentCutIcon /> },
634
+ ];
635
+
636
+ /**
637
+ * The "+" / "×" attach control (reference: Composer_01 "Menu open" state,
638
+ * Figma "Menu - Icon button Example") — hand-rolled positioned
639
+ * popover rather than `<md-menu>`, same reasoning `SearchMenu` already
640
+ * documents for its own panel (this needs to live inline under the trigger,
641
+ * not `<md-menu>`'s corner-anchored popover model).
642
+ */
643
+ function ComposerAttachMenu({
644
+ onSelect,
645
+ onAddMediaFiles,
646
+ disabled,
647
+ }: {
648
+ onSelect?: (action: ComposerAttachMenuAction) => void;
649
+ onAddMediaFiles: (files: FileList | null) => void;
650
+ disabled?: boolean;
651
+ }) {
652
+ const [open, setOpen] = useState(false);
653
+ const rootRef = useRef<HTMLDivElement>(null);
654
+ const inputRef = useRef<HTMLInputElement>(null);
655
+
656
+ useEffect(() => {
657
+ if (!open) return;
658
+ function handlePointerDown(e: PointerEvent) {
659
+ if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
660
+ }
661
+ function handleKeyDown(e: KeyboardEvent) {
662
+ if (e.key === "Escape") setOpen(false);
663
+ }
664
+ document.addEventListener("pointerdown", handlePointerDown);
665
+ document.addEventListener("keydown", handleKeyDown);
666
+ return () => {
667
+ document.removeEventListener("pointerdown", handlePointerDown);
668
+ document.removeEventListener("keydown", handleKeyDown);
669
+ };
670
+ }, [open]);
671
+
672
+ function handleItemClick(action: ComposerAttachMenuAction) {
673
+ setOpen(false);
674
+ if (action === "add-media") inputRef.current?.click();
675
+ onSelect?.(action);
676
+ }
677
+
678
+ return (
679
+ <div className="chai-composer__attach" ref={rootRef}>
680
+ <button
681
+ type="button"
682
+ className={`chai-composer__attach-toggle${open ? " chai-composer__attach-toggle--open" : ""}`}
683
+ onClick={() => setOpen((v) => !v)}
684
+ disabled={disabled}
685
+ aria-expanded={open}
686
+ aria-haspopup="menu"
687
+ aria-label={open ? "Close attach menu" : "Add media"}
688
+ >
689
+ {open ? <CloseIcon /> : <PlusIcon />}
690
+ </button>
691
+
692
+ {open && (
693
+ <ul className="chai-composer__attach-menu" role="menu">
694
+ {ATTACH_MENU_ITEMS.map((item) => (
695
+ <li key={item.action} role="none">
696
+ <button
697
+ type="button"
698
+ role="menuitem"
699
+ className="chai-composer__attach-menu-item"
700
+ onClick={() => handleItemClick(item.action)}
701
+ >
702
+ <span className="chai-composer__attach-menu-icon">{item.icon}</span>
703
+ {item.label}
704
+ </button>
705
+ {item.divider && <hr className="chai-composer__attach-menu-divider" role="separator" />}
706
+ </li>
707
+ ))}
708
+ </ul>
709
+ )}
710
+
711
+ <input
712
+ ref={inputRef}
713
+ type="file"
714
+ accept="image/*,video/*,audio/*"
715
+ multiple
716
+ hidden
717
+ disabled={disabled}
718
+ onChange={(e: ChangeEvent<HTMLInputElement>) => {
719
+ onAddMediaFiles(e.target.files);
720
+ e.target.value = "";
721
+ }}
722
+ />
723
+ </div>
724
+ );
725
+ }
726
+
727
+ function ComposerThumbnail({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove: () => void }) {
728
+ return (
729
+ <div className="chai-composer__thumb">
730
+ {attachment.kind === "image" ? (
731
+ <img className="chai-composer__thumb-img" src={attachment.src} alt="" />
732
+ ) : (
733
+ <span className="chai-composer__thumb-icon">{kindIcon(attachment.kind)}</span>
734
+ )}
735
+ <button type="button" className="chai-composer__thumb-remove" onClick={onRemove} aria-label="Remove attachment">
736
+ <CloseIcon />
737
+ </button>
738
+ </div>
739
+ );
740
+ }
741
+
742
+ /**
743
+ * The use-case chip (reference: the mock's "Video"/"Image" chip) —
744
+ * hand-rolled rather than `@material/web`'s `<md-input-chip>`: that
745
+ * component only ever renders its remove action always-visible (no
746
+ * exposed shadow part for the trailing button, confirmed against its
747
+ * source), but this design wants it hidden until hover, and a rounded-
748
+ * square shape rather than MD3 input chip's stadium default. Same
749
+ * "hand-roll when the primitive doesn't give the needed control" call
750
+ * `SearchMenu`'s own panel already documents. No leading icon — that's the
751
+ * attach menu's own affordance (each of its four items has one); the chip
752
+ * itself is label + hover-revealed remove only.
753
+ */
754
+ function UseCaseChip({
755
+ useCase,
756
+ onRemove,
757
+ disabled,
758
+ }: {
759
+ useCase: ComposerUseCase;
760
+ onRemove?: () => void;
761
+ disabled?: boolean;
762
+ }) {
763
+ return (
764
+ <span className="chai-composer__chip">
765
+ {useCase.label}
766
+ {onRemove && (
767
+ <button
768
+ type="button"
769
+ className="chai-composer__chip-remove"
770
+ onClick={onRemove}
771
+ disabled={disabled}
772
+ aria-label={`Remove ${useCase.label}`}
773
+ >
774
+ <CloseIcon />
775
+ </button>
776
+ )}
777
+ </span>
778
+ );
779
+ }
780
+
781
+ function kindIcon(kind: MediaKind) {
782
+ switch (kind) {
783
+ case "video":
784
+ return <VideocamIcon />;
785
+ case "audio":
786
+ return <AudioIcon />;
787
+ default:
788
+ return <ImageIcon />;
789
+ }
790
+ }
791
+
792
+ // --- Icons -------------------------------------------------------------
793
+ //
794
+ // Every icon below except `OptimizeIcon` is a real Material Symbols path
795
+ // (viewBox `0 -960 960 960`, the current Material Symbols coordinate
796
+ // system), copied exactly from the `@material-symbols/svg-400` package
797
+ // (Apache-2.0, installed as a devDependency purely as a source of ground-
798
+ // truth vector data — nothing here imports it at runtime, matching this
799
+ // codebase's existing "trace and inline as a currentColor component"
800
+ // convention). Fixes an earlier pass
801
+ // that hand-approximated several of these (Add media/Create image/Create
802
+ // video/Edit media's icons, plus/close/arrow) because `get_design_context`
803
+ // couldn't resolve their specific instance-swap nodes — those
804
+ // approximations read as visibly off-brand, not real MD3, correctly
805
+ // flagged and replaced with the genuine glyphs (add, close, arrow_forward,
806
+ // attach_file, image, videocam, content_cut, music_note).
807
+ //
808
+ // `OptimizeIcon` is the one exception, still traced exactly from the real
809
+ // exported design asset ("Optimize_icon") — not a Material Symbol at all, this is CHAI's own "prompt
810
+ // optimize" glyph (a pencil plus two sparkles). Same shape in both source
811
+ // assets, differing only by fill color (brand green active, gray
812
+ // inactive) — collapsed here to one currentColor path set,
813
+ // themed via the active/inactive CSS class instead of two hardcoded-color
814
+ // SVGs. See its button's own doc comment above for what it actually does.
815
+
816
+ function PlusIcon() {
817
+ return (
818
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
819
+ <path d="M450-450H200v-60h250v-250h60v250h250v60H510v250h-60v-250Z" fill="currentColor" />
820
+ </svg>
821
+ );
822
+ }
823
+
824
+ // Material Symbols "close" — reproduced here since icons aren't shared
825
+ // across files yet in this codebase.
826
+ function CloseIcon() {
827
+ return (
828
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
829
+ <path d="m249-207-42-42 231-231-231-231 42-42 231 231 231-231 42 42-231 231 231 231-42 42-231-231-231 231Z" fill="currentColor" />
830
+ </svg>
831
+ );
832
+ }
833
+
834
+ function ArrowForwardIcon() {
835
+ return (
836
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
837
+ <path d="M686-450H160v-60h526L438-758l42-42 320 320-320 320-42-42 248-248Z" fill="currentColor" />
838
+ </svg>
839
+ );
840
+ }
841
+
842
+ // Material Symbols "refresh" — the submit FAB's retry state.
843
+ function RefreshIcon() {
844
+ return (
845
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
846
+ <path
847
+ d="M480-160q-133 0-226.5-93.5T160-480q0-133 93.5-226.5T480-800q85 0 149 34.5T740-671v-129h60v254H546v-60h168q-38-60-97-97t-137-37q-109 0-184.5 75.5T220-480q0 109 75.5 184.5T480-220q83 0 152-47.5T728-393h62q-29 105-115 169t-195 64Z"
848
+ fill="currentColor"
849
+ />
850
+ </svg>
851
+ );
852
+ }
853
+
854
+ // Material Symbols "error" — the inline retry message's leading icon.
855
+ function ErrorIcon() {
856
+ return (
857
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
858
+ <path
859
+ d="M503.5-289.48q9.5-9.48 9.5-23.5t-9.48-23.52q-9.48-9.5-23.5-9.5t-23.52 9.48q-9.5 9.48-9.5 23.5t9.48 23.52q9.48 9.5 23.5 9.5t23.52-9.48ZM453-433h60v-253h-60v253Zm27.27 353q-82.74 0-155.5-31.5Q252-143 197.5-197.5t-86-127.34Q80-397.68 80-480.5t31.5-155.66Q143-709 197.5-763t127.34-85.5Q397.68-880 480.5-880t155.66 31.5Q709-817 763-763t85.5 127Q880-563 880-480.27q0 82.74-31.5 155.5Q817-252 763-197.68q-54 54.31-127 86Q563-80 480.27-80Zm.23-60Q622-140 721-239.5t99-241Q820-622 721.19-721T480-820q-141 0-240.5 98.81T140-480q0 141 99.5 240.5t241 99.5Zm-.5-340Z"
860
+ fill="currentColor"
861
+ />
862
+ </svg>
863
+ );
864
+ }
865
+
866
+ function OptimizeIcon() {
867
+ return (
868
+ <svg viewBox="0 0 25 24" fill="none" aria-hidden="true">
869
+ <path
870
+ d="M5 19H6.425L16.2 9.225L14.775 7.8L5 17.575V19ZM3 21V16.75L16.2 3.575C16.4 3.39167 16.6208 3.25 16.8625 3.15C17.1042 3.05 17.3583 3 17.625 3C17.8917 3 18.15 3.05 18.4 3.15C18.65 3.25 18.8667 3.4 19.05 3.6L20.425 5C20.625 5.18333 20.7708 5.4 20.8625 5.65C20.9542 5.9 21 6.15 21 6.4C21 6.66667 20.9542 6.92083 20.8625 7.1625C20.7708 7.40417 20.625 7.625 20.425 7.825L7.25 21H3ZM15.475 8.525L14.775 7.8L16.2 9.225L15.475 8.525Z"
871
+ fill="currentColor"
872
+ />
873
+ <path d="M20 12L21.5918 15.4082L25 17L21.5918 18.5918L20 22L18.4082 18.5918L15 17L18.4082 15.4082L20 12Z" fill="currentColor" />
874
+ <path d="M6 3L7.27345 5.72655L10 7L7.27345 8.27345L6 11L4.72655 8.27345L2 7L4.72655 5.72655L6 3Z" fill="currentColor" />
875
+ </svg>
876
+ );
877
+ }
878
+
879
+ // Real Material Symbols "undo" glyph (viewBox `0 -960 960 960`), same
880
+ // source/convention as every icon above except `OptimizeIcon` itself (see
881
+ // that block's own comment) — the optimize button becomes this once a
882
+ // rewrite has landed (its own doc comment, where it's rendered).
883
+ function UndoIcon() {
884
+ return (
885
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
886
+ <path
887
+ d="M259-200v-60h310q70 0 120.5-46.5T740-422q0-69-50.5-115.5T569-584H274l114 114-42 42-186-186 186-186 42 42-114 114h294q95 0 163.5 64T800-422q0 94-68.5 158T568-200H259Z"
888
+ fill="currentColor"
889
+ />
890
+ </svg>
891
+ );
892
+ }
893
+
894
+ // Real Material Symbols "stop" glyph, the filled variant specifically
895
+ // (`stop-fill`, not the base `stop` — that one traces a hollow square-
896
+ // outline shape even filled with `currentColor`, which read as a thin
897
+ // frame rather than a real stop icon at 24px; design review caught
898
+ // this live). Viewbox `0 -960 960 960` — the submit FAB's in-flight state,
899
+ // its own doc comment above where it's rendered.
900
+ function StopIcon() {
901
+ return (
902
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
903
+ <path d="M240-240v-480h480v480H240Z" fill="currentColor" />
904
+ </svg>
905
+ );
906
+ }
907
+
908
+ function AttachFileIcon() {
909
+ return (
910
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
911
+ <path
912
+ d="M728-326q0 103-72.18 174.5-72.17 71.5-175 71.5Q378-80 305.5-151.5T233-326v-380q0-72.5 51.5-123.25T408-880q72 0 123.5 50.75T583-706v360q0 42-30 72t-72.5 30q-42.5 0-72.5-29.67-30-29.68-30-72.33v-370h60v370q0 17 12.5 29.5t30.64 12.5q18.14 0 30-12.5T523-346v-360q0-48-33.5-81t-81.71-33q-48.21 0-81.5 33.06T293-706v380q0 78 54.97 132T481-140q77.92 0 132.46-54Q668-248 668-326v-390h60v390Z"
913
+ fill="currentColor"
914
+ />
915
+ </svg>
916
+ );
917
+ }
918
+
919
+ function ImageIcon() {
920
+ return (
921
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
922
+ <path
923
+ d="M180-120q-24 0-42-18t-18-42v-600q0-24 18-42t42-18h600q24 0 42 18t18 42v600q0 24-18 42t-42 18H180Zm0-60h600v-600H180v600Zm56-97h489L578-473 446-302l-93-127-117 152Zm-56 97v-600 600Z"
924
+ fill="currentColor"
925
+ />
926
+ </svg>
927
+ );
928
+ }
929
+
930
+ function VideocamIcon() {
931
+ return (
932
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
933
+ <path
934
+ d="M140-160q-24 0-42-18t-18-42v-520q0-24 18-42t42-18h520q24 0 42 18t18 42v215l160-160v410L720-435v215q0 24-18 42t-42 18H140Zm0-60h520v-520H140v520Zm0 0v-520 520Z"
935
+ fill="currentColor"
936
+ />
937
+ </svg>
938
+ );
939
+ }
940
+
941
+ function ContentCutIcon() {
942
+ return (
943
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
944
+ <path
945
+ d="M782-114 481-415 364-298q11 17 13.5 33t2.5 35q0 64-43 107T230-80q-64 0-107-43T80-230q0-64 43-107t107-43q18 0 35.5 5t36.5 15l116-116-118-118q-17 8-34.5 11t-35.5 3q-64 0-107-43T80-730q0-64 43-107t107-43q64 0 107 43t43 107q0 19-2.5 36T367-662l514 514v34h-99ZM599-527l-66-66 249-249h99v33L599-527ZM294-666q26-26 26-64t-26-64q-26-26-64-26t-64 26q-26 26-26 64t26 64q26 26 64 26t64-26Zm202.5 203.5Q502-468 502-476t-5.5-13.5Q491-495 483-495t-13.5 5.5Q464-484 464-476t5.5 13.5Q475-457 483-457t13.5-5.5ZM294-166q26-26 26-64t-26-64q-26-26-64-26t-64 26q-26 26-26 64t26 64q26 26 64 26t64-26Z"
946
+ fill="currentColor"
947
+ />
948
+ </svg>
949
+ );
950
+ }
951
+
952
+ function AudioIcon() {
953
+ return (
954
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
955
+ <path
956
+ d="M286.5-163.5Q243-207 243-270t43.5-106.5Q330-420 393-420q28 0 50.5 8t39.5 22v-450h234v135H543v435q0 63-43.5 106.5T393-120q-63 0-106.5-43.5Z"
957
+ fill="currentColor"
958
+ />
959
+ </svg>
960
+ );
961
+ }