@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,980 @@
1
+ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2
+ import type { ModelOption, Result } from "@chai-ui/core";
3
+ import { Pagination } from "./primitives/Pagination.js";
4
+
5
+ /**
6
+ * ResultCard's complement to Composer bar, and the last stop in the
7
+ * pipeline: by design there's no "use as input" action feeding a result
8
+ * back into a Composer.
9
+ *
10
+ * Renders the results from one prompt (reference: Figma "08 Result from
11
+ * Composer Bar") — a single result, or paginated through several (the
12
+ * "Image - Paginated" state) when more than one is handed in. That's a
13
+ * different thing from the still-unscoped plural `Results` grid/stack/
14
+ * carousel container — this card
15
+ * only ever shows what came back from one prompt, one candidate at a time,
16
+ * never a grid of separate prompts/history.
17
+ */
18
+
19
+ // --- Formatting ------------------------------------------------------------
20
+
21
+ function formatDuration(ms: number | undefined): string | undefined {
22
+ if (ms == null) return undefined;
23
+ return `${(ms / 1000).toFixed(2)}s`;
24
+ }
25
+
26
+ function formatCost(usd: number | undefined): string | undefined {
27
+ if (usd == null) return undefined;
28
+ return `$${usd.toFixed(2)}`;
29
+ }
30
+
31
+ function formatTokens(total: number | undefined): string | undefined {
32
+ if (total == null) return undefined;
33
+ return `${total.toLocaleString()} tokens`;
34
+ }
35
+
36
+ function gcd(a: number, b: number): number {
37
+ return b === 0 ? a : gcd(b, a % b);
38
+ }
39
+
40
+ /** A real, exactly-reduced ratio (e.g. 1024×768 → "4:3", 1920×1080 → "16:9") from the media's own measured dimensions — not a guess at the nearest "named" ratio, so an unusual crop just shows its own honest (if less familiar) reduced fraction instead of a misleading rounded one. */
41
+ function formatAspectRatio(width: number, height: number): string | undefined {
42
+ if (!width || !height) return undefined;
43
+ const w = Math.round(width);
44
+ const h = Math.round(height);
45
+ const divisor = gcd(w, h);
46
+ if (!divisor) return undefined;
47
+ return `${w / divisor}:${h / divisor}`;
48
+ }
49
+
50
+ /** A real extension from the URL itself when there is one, otherwise a sensible guess by kind — used only for the downloaded file's suggested name, not anything that affects the actual bytes. */
51
+ function guessDownloadFilename(result: Result): string {
52
+ const kind = result.output?.kind ?? "file";
53
+ const src = result.output?.src ?? "";
54
+ const match = /\.([a-zA-Z0-9]{2,5})(?:[?#]|$)/.exec(src);
55
+ const ext = match?.[1] ?? (kind === "video" ? "mp4" : kind === "audio" ? "mp3" : kind === "text" ? "txt" : "png");
56
+ return `${kind}-${result.id}.${ext}`;
57
+ }
58
+
59
+ // --- Public types ------------------------------------------------------------
60
+
61
+ /** One row in the "more" (⋯) menu, for app-specific actions (e.g. "Flag content"). ResultCard can't implement any of them — every one of these just fires `onAction` with this `id`. */
62
+ export interface ResultCardMoreAction {
63
+ id: string;
64
+ label: string;
65
+ icon?: ReactNode;
66
+ }
67
+
68
+ export type ResultCardVote = "like" | "dislike" | null;
69
+
70
+ export interface ResultCardProps {
71
+ /** The results from one prompt — `results[0]` if there's only one, paginated internally if more. */
72
+ results: Result[];
73
+ /**
74
+ * Fixed width in px — defaults to 375 (the Figma mock's own width), not
75
+ * to the displayed media's actual pixel dimensions. The card's own
76
+ * footprint has to stay predictable for whatever app it's embedded in;
77
+ * a real generated image could be anywhere from a few hundred to a few
78
+ * thousand pixels wide, and this card was never meant to render at that
79
+ * native size — that's what the "Expand" button is for. Height still
80
+ * follows the media's own aspect ratio at this width (see
81
+ * `.chai-result-card`'s own CSS comment) — only the width is fixed.
82
+ */
83
+ width?: number;
84
+ /** Max height in px of a text result before it scrolls — defaults to 400. Text results are often long; the card stays a predictable size and the text scrolls inside it. */
85
+ textMaxHeight?: number;
86
+ /** The request's source prompt — shown read-only (copiable, not editable) on the flipped info side. */
87
+ prompt: string;
88
+ /** Alt text for a generated image. Defaults to `prompt`, which describes what was asked for. */
89
+ altText?: (result: Result) => string;
90
+ /** Resolves `Result.modelId` to a human-readable label (reference: "GPT-4o Image Pro", not a raw id) — falls back to the raw id when not found. */
91
+ models?: ModelOption[];
92
+ /**
93
+ * Resolves a result's vote, if any — controlled, not tracked by
94
+ * ResultCard itself: what a vote means is the app's policy. A function, not a single flat value: pagination
95
+ * is internal to this component (not a prop the host controls — see
96
+ * `results`'s own doc comment), so a host can't know in advance which
97
+ * result is currently paginated-to in order to hand back the right one.
98
+ */
99
+ getVote?: (result: Result) => ResultCardVote;
100
+ /**
101
+ * Fires for every action this card can trigger: `"like"` / `"dislike"`
102
+ * (always fires the plain vote name, even to toggle one back off — the
103
+ * app decides what re-clicking an already-active vote means, same as it
104
+ * decides what the vote itself means), `"download"`, `"share"`,
105
+ * `"copy-prompt"`, `"retry"` (expected to re-run the exact same model
106
+ * call), or one of
107
+ * `moreActions`' own ids.
108
+ */
109
+ onAction: (action: string, result: Result) => void;
110
+ /** Configurable extra items in the "more" menu (reference: Figma "Result Card - More menu expanded") — entirely up to the host. Omit to hide the "more" button entirely. */
111
+ moreActions?: ResultCardMoreAction[];
112
+ disabled?: boolean;
113
+ }
114
+
115
+ // --- Component ------------------------------------------------------------
116
+
117
+ export function ResultCard({
118
+ results,
119
+ width,
120
+ textMaxHeight,
121
+ prompt,
122
+ altText,
123
+ models,
124
+ getVote,
125
+ onAction,
126
+ moreActions,
127
+ disabled,
128
+ }: ResultCardProps) {
129
+ const [activeIndex, setActiveIndex] = useState(0);
130
+ const [flipped, setFlipped] = useState(false);
131
+ const [moreOpen, setMoreOpen] = useState(false);
132
+ const [copied, setCopied] = useState(false);
133
+ const [mediaExpanded, setMediaExpanded] = useState(false);
134
+ // Not part of `Result` (no engine reports generated-media dimensions
135
+ // today) — measured client-side off the loaded `<img>`/`<video>` itself
136
+ // (`naturalWidth`/`naturalHeight`, `videoWidth`/`videoHeight`) purely to
137
+ // back the info side's aspect-ratio chip. Kept with the id of the result
138
+ // it was measured from, so after pagination a stale ratio from the
139
+ // *previous* result can't flash before the new media finishes loading.
140
+ const [measured, setMeasured] = useState<{ id: string; width: number; height: number } | null>(null);
141
+ const videoTimeRef = useRef(0);
142
+ // The face that's turned away is `inert`, so its buttons can't be tabbed
143
+ // to. Set as a DOM property: React 18 and 19 treat the `inert` attribute
144
+ // differently.
145
+ const frontRef = useRef<HTMLDivElement>(null);
146
+ const backRef = useRef<HTMLDivElement>(null);
147
+ useEffect(() => {
148
+ if (frontRef.current) frontRef.current.inert = flipped;
149
+ if (backRef.current) backRef.current.inert = !flipped;
150
+ });
151
+
152
+ // Flipping moves focus to the side now showing, so a keyboard user isn't
153
+ // left on a button that just turned away. Skipped on first render.
154
+ const wasFlipped = useRef(flipped);
155
+ useEffect(() => {
156
+ if (wasFlipped.current === flipped) return;
157
+ wasFlipped.current = flipped;
158
+ const target = flipped
159
+ ? backRef.current?.querySelector<HTMLElement>('[aria-label="Close details"]')
160
+ : frontRef.current?.querySelector<HTMLElement>('[aria-label="Show details"]');
161
+ target?.focus();
162
+ }, [flipped]);
163
+
164
+ // The expanded view is a modal dialog: focus moves in, Tab stays inside,
165
+ // Escape closes, and focus returns to what opened it.
166
+ const overlayRef = useRef<HTMLDivElement>(null);
167
+ useEffect(() => {
168
+ if (!mediaExpanded) return;
169
+ overlayRef.current?.querySelector<HTMLElement>(".chai-result-card__collapse")?.focus();
170
+ // On close, back to Expand, the only way in. Looked up at close time:
171
+ // the card re-renders its media buttons while expanded.
172
+ const front = frontRef.current;
173
+ return () => front?.querySelector<HTMLElement>('[aria-label="Expand"]')?.focus();
174
+ }, [mediaExpanded]);
175
+
176
+ function handleOverlayKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
177
+ if (e.key === "Escape") {
178
+ e.stopPropagation();
179
+ setMediaExpanded(false);
180
+ return;
181
+ }
182
+ if (e.key !== "Tab") return;
183
+ const focusable = [
184
+ ...e.currentTarget.querySelectorAll<HTMLElement>('button, [tabindex="0"], a[href], input, video[controls]'),
185
+ ].filter((el) => !el.hasAttribute("disabled"));
186
+ if (focusable.length === 0) return;
187
+ const first = focusable[0]!;
188
+ const last = focusable[focusable.length - 1]!;
189
+ if (e.shiftKey && document.activeElement === first) {
190
+ e.preventDefault();
191
+ last.focus();
192
+ } else if (!e.shiftKey && document.activeElement === last) {
193
+ e.preventDefault();
194
+ first.focus();
195
+ }
196
+ }
197
+ // State, not a ref: the player reads it while rendering, and it only
198
+ // changes on play/pause.
199
+ const [videoWasPlaying, setVideoWasPlaying] = useState(false);
200
+ const clampedIndex = Math.min(activeIndex, Math.max(0, results.length - 1));
201
+ const result = results[clampedIndex];
202
+
203
+ const mediaDims = measured && measured.id === result?.id ? measured : null;
204
+
205
+ if (!result) return null;
206
+
207
+ const vote = getVote?.(result) ?? null;
208
+ const modelLabel = models?.find((m) => m.id === result.modelId)?.label ?? result.modelId;
209
+ const duration = formatDuration(result.durationMs);
210
+ const cost = formatCost(result.usage?.costUsd);
211
+ const tokens = formatTokens(result.usage?.totalTokens);
212
+ const aspectRatio = mediaDims ? formatAspectRatio(mediaDims.width, mediaDims.height) : undefined;
213
+
214
+ // Arrow expressions, not function declarations — a hoisted declaration
215
+ // doesn't inherit the `if (!result) return null;` narrowing above (a
216
+ // known TS limitation, not stylistic), so every one of these closing
217
+ // over `result` needs to be an expression instead.
218
+ const goTo = (index: number) => {
219
+ setActiveIndex(Math.max(0, Math.min(results.length - 1, index)));
220
+ };
221
+
222
+ const handleCopyPrompt = () => {
223
+ navigator.clipboard?.writeText(prompt).catch(() => {});
224
+ setCopied(true);
225
+ setTimeout(() => setCopied(false), 1500);
226
+ onAction("copy-prompt", result);
227
+ };
228
+
229
+ const handleDownload = async () => {
230
+ if (result.output) {
231
+ // A plain `<a download href={remoteUrl}>` only forces a real download
232
+ // for a same-origin or `blob:`/`data:` URL — for anything else
233
+ // (a normal cross-origin CDN url, which is what a real generated
234
+ // result's `src` almost always is) browsers ignore `download` and
235
+ // just navigate/open it instead, which is exactly the "pops a tab"
236
+ // behavior this replaces. Fetching it into a blob first sidesteps
237
+ // that: a blob: URL is always same-origin, so `download` works
238
+ // reliably regardless of where the media actually lives.
239
+ try {
240
+ const response = await fetch(result.output.src);
241
+ const blob = await response.blob();
242
+ const blobUrl = URL.createObjectURL(blob);
243
+ const a = document.createElement("a");
244
+ a.href = blobUrl;
245
+ a.download = guessDownloadFilename(result);
246
+ document.body.appendChild(a);
247
+ a.click();
248
+ a.remove();
249
+ URL.revokeObjectURL(blobUrl);
250
+ } catch {
251
+ // The one case this can't force: the source not allowing
252
+ // cross-origin fetches (no CORS headers) or a real network
253
+ // failure. Opening it directly is the best fallback available —
254
+ // still better than silently doing nothing.
255
+ window.open(result.output.src, "_blank", "noopener");
256
+ }
257
+ }
258
+ onAction("download", result);
259
+ };
260
+
261
+ const handleShare = async () => {
262
+ if (result.output && navigator.share) {
263
+ try {
264
+ await navigator.share({ url: result.output.src });
265
+ } catch {
266
+ // Cancelled or unsupported for this src — fall through silently, same as a native share sheet dismissal.
267
+ }
268
+ } else if (result.output) {
269
+ navigator.clipboard?.writeText(result.output.src).catch(() => {});
270
+ }
271
+ onAction("share", result);
272
+ };
273
+
274
+ const isVideo = result.output?.kind === "video";
275
+
276
+ return (
277
+ <>
278
+ <div
279
+ className={`chai-result-card${flipped ? " chai-result-card--flipped" : ""}`}
280
+ style={
281
+ {
282
+ ...(width != null && { "--chai-result-card-width": `${width}px` }),
283
+ ...(textMaxHeight != null && { "--chai-result-card-text-max-height": `${textMaxHeight}px` }),
284
+ } as CSSProperties
285
+ }
286
+ >
287
+ <div className="chai-result-card__inner">
288
+ <div ref={frontRef} className="chai-result-card__face chai-result-card__front" aria-hidden={flipped}>
289
+ <ResultMedia
290
+ result={result}
291
+ alt={altText?.(result) ?? prompt}
292
+ disabled={disabled}
293
+ mediaExpanded={mediaExpanded}
294
+ onDownload={handleDownload}
295
+ onShare={handleShare}
296
+ onExpand={() => setMediaExpanded(true)}
297
+ onDimensions={(w, h) => setMeasured({ id: result.id, width: w, height: h })}
298
+ videoTimeRef={videoTimeRef}
299
+ videoWasPlaying={videoWasPlaying}
300
+ onVideoPlayingChange={setVideoWasPlaying}
301
+ />
302
+
303
+ <div className="chai-result-card__bar">
304
+ <div className="chai-result-card__bar-row">
305
+ <div className="chai-result-card__bar-start">
306
+ <ResultIconButton
307
+ label="Like"
308
+ active={vote === "like"}
309
+ disabled={disabled}
310
+ onClick={() => onAction("like", result)}
311
+ >
312
+ <ThumbUpIcon />
313
+ </ResultIconButton>
314
+ <ResultIconButton
315
+ label="Dislike"
316
+ active={vote === "dislike"}
317
+ disabled={disabled}
318
+ onClick={() => onAction("dislike", result)}
319
+ >
320
+ <ThumbDownIcon />
321
+ </ResultIconButton>
322
+ <ResultIconButton
323
+ label="Retry"
324
+ disabled={disabled}
325
+ onClick={() => onAction("retry", result)}
326
+ >
327
+ <RefreshIcon />
328
+ </ResultIconButton>
329
+ {moreActions && moreActions.length > 0 && (
330
+ <MoreMenu
331
+ actions={moreActions}
332
+ open={moreOpen}
333
+ onOpenChange={setMoreOpen}
334
+ disabled={disabled}
335
+ onSelect={(action) => onAction(action.id, result)}
336
+ />
337
+ )}
338
+ </div>
339
+
340
+ <ResultIconButton
341
+ label="Show details"
342
+ disabled={disabled}
343
+ onClick={() => setFlipped(true)}
344
+ >
345
+ <InfoIcon />
346
+ </ResultIconButton>
347
+ </div>
348
+
349
+ {results.length > 1 && (
350
+ <Pagination
351
+ count={results.length}
352
+ index={clampedIndex}
353
+ onChange={goTo}
354
+ label="Results"
355
+ disabled={disabled}
356
+ />
357
+ )}
358
+ </div>
359
+ </div>
360
+
361
+ <div ref={backRef} className="chai-result-card__face chai-result-card__back" aria-hidden={!flipped}>
362
+ <button
363
+ type="button"
364
+ className="chai-result-card__close"
365
+ aria-label="Close details"
366
+ onClick={() => setFlipped(false)}
367
+ >
368
+ <CloseIcon />
369
+ </button>
370
+
371
+ <div className="chai-result-card__prompt-box">
372
+ <p className="chai-result-card__prompt-text">{prompt}</p>
373
+ <button
374
+ type="button"
375
+ className="chai-result-card__copy"
376
+ aria-label={copied ? "Copied" : "Copy prompt"}
377
+ title={copied ? "Copied" : "Copy prompt"}
378
+ onClick={handleCopyPrompt}
379
+ >
380
+ {copied ? <CheckIcon /> : <CopyIcon />}
381
+ </button>
382
+ </div>
383
+
384
+ <div className="chai-result-card__chips">
385
+ <span className="chai-result-card__chip">{modelLabel}</span>
386
+ {tokens && <span className="chai-result-card__chip">{tokens}</span>}
387
+ {cost && <span className="chai-result-card__chip">{cost}</span>}
388
+ {duration && <span className="chai-result-card__chip">{duration}</span>}
389
+ {aspectRatio && <span className="chai-result-card__chip">{aspectRatio}</span>}
390
+ {/* The builder's own backend checks (reference: the mock's green
391
+ "Evaluation" chip) — pass green with a check, fail red with a
392
+ cross, so the verdict never relies on color alone. */}
393
+ {result.evaluations?.map((evaluation) => (
394
+ <span
395
+ key={evaluation.id}
396
+ className={`chai-result-card__chip chai-result-card__chip--evaluation chai-result-card__chip--${
397
+ evaluation.passed ? "passed" : "failed"
398
+ }`}
399
+ title={`${evaluation.label}: ${evaluation.passed ? "passed" : "failed"}`}
400
+ >
401
+ {evaluation.passed ? <CheckIcon /> : <CloseIcon />}
402
+ {evaluation.label}
403
+ <span className="chai-visually-hidden">{evaluation.passed ? ", passed" : ", failed"}</span>
404
+ </span>
405
+ ))}
406
+ </div>
407
+ </div>
408
+ </div>
409
+ </div>
410
+
411
+ {/*
412
+ * The expanded-media overlay (image or video — by design, keep the same explicit "expand" affordance for
413
+ * images too, rather than only video, since an image's own surface
414
+ * isn't a reliable/universal "click to enlarge" gesture on its own)
415
+ * is a sibling of `.chai-result-card` itself, not a descendant —
416
+ * `.chai-result-card` sets `perspective` for the flip animation, and
417
+ * *any* element with `perspective` (like `transform`) becomes a new
418
+ * containing block for its `position: fixed` descendants. An overlay
419
+ * nested inside it would be "fixed" relative to the card's own box,
420
+ * not the viewport — confirmed via a real screenshot during this
421
+ * build (it covered only the card's own area, sidebar and page
422
+ * content still visible around it). Escaping the subtree entirely is
423
+ * what actually fixes it, not a z-index or position tweak.
424
+ *
425
+ * This does mean the expanded `<video>` is a different DOM element
426
+ * from the inline one (remounts, doesn't just get repositioned) —
427
+ * `videoTimeRef`/`videoWasPlaying` are what carry current time and
428
+ * play state across that swap so it doesn't visibly reset. An image
429
+ * has no such state to carry, so it's just rendered twice (inline +
430
+ * overlay) with no special handling — cheap, no decode/playback cost
431
+ * the way a second `<video>` would have.
432
+ */}
433
+ {mediaExpanded && result.output && (result.output.kind === "image" || isVideo) && (
434
+ <div
435
+ ref={overlayRef}
436
+ className="chai-result-card__expand-overlay"
437
+ role="dialog"
438
+ aria-modal="true"
439
+ aria-label={isVideo ? "Expanded video" : "Expanded image"}
440
+ onKeyDown={handleOverlayKeyDown}
441
+ >
442
+ <div
443
+ className="chai-result-card__expand-scrim"
444
+ role="presentation"
445
+ onClick={() => setMediaExpanded(false)}
446
+ />
447
+ <div className="chai-result-card__expand-frame">
448
+ {isVideo ? (
449
+ <ResultVideoPlayer
450
+ src={result.output.src}
451
+ expanded
452
+ timeRef={videoTimeRef}
453
+ wasPlaying={videoWasPlaying}
454
+ onPlayingChange={setVideoWasPlaying}
455
+ />
456
+ ) : (
457
+ <img className="chai-result-card__image" src={result.output.src} alt={altText?.(result) ?? prompt} />
458
+ )}
459
+ <button
460
+ type="button"
461
+ className="chai-result-card__collapse"
462
+ aria-label="Collapse"
463
+ onClick={() => setMediaExpanded(false)}
464
+ >
465
+ <CloseIcon />
466
+ </button>
467
+ </div>
468
+ </div>
469
+ )}
470
+ </>
471
+ );
472
+ }
473
+
474
+ // --- Media (image / video / audio / text, by status) ------------------------------------------------------------
475
+
476
+ function ResultMedia({
477
+ result,
478
+ alt,
479
+ disabled,
480
+ mediaExpanded,
481
+ onDownload,
482
+ onShare,
483
+ onExpand,
484
+ onDimensions,
485
+ videoTimeRef,
486
+ videoWasPlaying,
487
+ onVideoPlayingChange,
488
+ }: {
489
+ result: Result;
490
+ alt: string;
491
+ disabled?: boolean;
492
+ mediaExpanded: boolean;
493
+ onDownload: () => void;
494
+ onShare: () => void;
495
+ onExpand: () => void;
496
+ onDimensions: (width: number, height: number) => void;
497
+ videoTimeRef: React.MutableRefObject<number>;
498
+ videoWasPlaying: boolean;
499
+ onVideoPlayingChange: (playing: boolean) => void;
500
+ }) {
501
+ const isVideo = result.output?.kind === "video";
502
+ const isImage = result.output?.kind === "image";
503
+ // "Running with output" is a text result streaming in (see `streamResult`
504
+ // in @chai-ui/core) — show the partial text, not the spinner.
505
+ const streaming = result.status === "running" && result.output?.kind === "text";
506
+
507
+ if ((result.status === "queued" || result.status === "running") && !streaming) {
508
+ return (
509
+ <div className="chai-result-card__media chai-result-card__media--pending">
510
+ <span className="chai-result-card__spinner" aria-label={result.status === "running" ? "Running" : "Queued"} />
511
+ </div>
512
+ );
513
+ }
514
+
515
+ // Stopped before any output: say so. A streamed text result that was
516
+ // stopped keeps its partial text and renders below like any other.
517
+ if (result.status === "cancelled" && !result.output) {
518
+ return (
519
+ <div className="chai-result-card__media chai-result-card__media--cancelled">
520
+ <p className="chai-result-card__cancelled-text">Stopped</p>
521
+ </div>
522
+ );
523
+ }
524
+
525
+ if (result.status === "error") {
526
+ return (
527
+ <div className="chai-result-card__media chai-result-card__media--error">
528
+ <p className="chai-result-card__error-text">{result.error?.message ?? "Something went wrong."}</p>
529
+ </div>
530
+ );
531
+ }
532
+
533
+ if (!result.output) return <div className="chai-result-card__media" />;
534
+
535
+ return (
536
+ <div
537
+ className={`chai-result-card__media${
538
+ result.output.kind === "audio" || result.output.kind === "text"
539
+ ? " chai-result-card__media--fixed-height"
540
+ : ""
541
+ }`}
542
+ >
543
+ {result.output.kind === "video" ? (
544
+ mediaExpanded ? (
545
+ // The real, playing `<video>` lives in the expand overlay (a
546
+ // sibling of the whole card — see that overlay's own comment for
547
+ // why) while it's open; this card face just shows a plain
548
+ // frozen frame in its place so the layout doesn't collapse.
549
+ <img className="chai-result-card__image" src={result.output.src} alt="" />
550
+ ) : (
551
+ <ResultVideoPlayer
552
+ src={result.output.src}
553
+ timeRef={videoTimeRef}
554
+ wasPlaying={videoWasPlaying}
555
+ onPlayingChange={onVideoPlayingChange}
556
+ onDimensions={onDimensions}
557
+ />
558
+ )
559
+ ) : result.output.kind === "audio" ? (
560
+ <audio className="chai-result-card__audio" src={result.output.src} controls />
561
+ ) : result.output.kind === "text" ? (
562
+ <ResultText text={result.output.src} streaming={streaming} />
563
+ ) : (
564
+ <img
565
+ className="chai-result-card__image"
566
+ src={result.output.src}
567
+ alt={alt}
568
+ onLoad={(e) => {
569
+ const el = e.currentTarget;
570
+ onDimensions(el.naturalWidth, el.naturalHeight);
571
+ }}
572
+ />
573
+ )}
574
+
575
+ {!mediaExpanded && !streaming && (
576
+ <div className="chai-result-card__media-actions">
577
+ <ResultIconButton label="Download" disabled={disabled} onClick={onDownload}>
578
+ <DownloadIcon />
579
+ </ResultIconButton>
580
+ <ResultIconButton label="Share" disabled={disabled} onClick={onShare}>
581
+ <ShareIcon />
582
+ </ResultIconButton>
583
+ {(isVideo || isImage) && (
584
+ <ResultIconButton label="Expand" disabled={disabled} onClick={onExpand}>
585
+ <ExpandIcon />
586
+ </ResultIconButton>
587
+ )}
588
+ </div>
589
+ )}
590
+ </div>
591
+ );
592
+ }
593
+
594
+ /**
595
+ * A text result, streaming or finished. One element for both states, so the
596
+ * scroll position survives the moment a stream completes. Past its max
597
+ * height it scrolls; while streaming it follows the newest text, unless the
598
+ * person has scrolled up to read, in which case it stays where they are.
599
+ */
600
+ function ResultText({ text, streaming }: { text: string; streaming: boolean }) {
601
+ const ref = useRef<HTMLParagraphElement>(null);
602
+ const followRef = useRef(true);
603
+
604
+ useEffect(() => {
605
+ const el = ref.current;
606
+ if (el && streaming && followRef.current) el.scrollTop = el.scrollHeight;
607
+ }, [text, streaming]);
608
+
609
+ return (
610
+ <p
611
+ ref={ref}
612
+ className={`chai-result-card__text-output${streaming ? " chai-result-card__text-output--streaming" : ""}`}
613
+ // Screen readers wait for the finished text instead of announcing every chunk.
614
+ aria-busy={streaming}
615
+ // Keyboard users can scroll a long answer.
616
+ tabIndex={0}
617
+ onScroll={(e) => {
618
+ const el = e.currentTarget;
619
+ followRef.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 24;
620
+ }}
621
+ >
622
+ {text}
623
+ </p>
624
+ );
625
+ }
626
+
627
+ /**
628
+ * A real `<video>` element plus custom play/pause + scrubber chrome built
629
+ * on its own playback API (`currentTime`, `duration`, the `timeupdate`
630
+ * event) — not the browser's native `controls` UI (reads as a different,
631
+ * heavier chrome than the mock's slim in-card scrubber) and not a
632
+ * from-scratch player (nothing here decodes or renders video itself, the
633
+ * real `<video>` element still does all of that).
634
+ *
635
+ * Used in two different mount points (inline in the card, and again in the
636
+ * expand overlay — see that overlay's own comment on why it can't just be
637
+ * the same element repositioned) — `timeRef`/`wasPlaying` are what let
638
+ * whichever one mounts pick up exactly where the other left off: seeded on
639
+ * mount, kept live on every timeupdate/play/pause so the *other* instance
640
+ * has an up-to-date value whenever it mounts next.
641
+ */
642
+ function ResultVideoPlayer({
643
+ src,
644
+ expanded,
645
+ timeRef,
646
+ wasPlaying,
647
+ onPlayingChange,
648
+ onDimensions,
649
+ }: {
650
+ src: string;
651
+ expanded?: boolean;
652
+ timeRef: React.MutableRefObject<number>;
653
+ wasPlaying: boolean;
654
+ onPlayingChange: (playing: boolean) => void;
655
+ onDimensions?: (width: number, height: number) => void;
656
+ }) {
657
+ const videoRef = useRef<HTMLVideoElement>(null);
658
+ const [playing, setPlaying] = useState(wasPlaying);
659
+ const [progress, setProgress] = useState(0);
660
+
661
+ function togglePlay() {
662
+ const el = videoRef.current;
663
+ if (!el) return;
664
+ if (el.paused) el.play();
665
+ else el.pause();
666
+ }
667
+
668
+ function handleScrub(e: React.MouseEvent<HTMLDivElement>) {
669
+ const el = videoRef.current;
670
+ if (!el || !el.duration) return;
671
+ const rect = e.currentTarget.getBoundingClientRect();
672
+ const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
673
+ el.currentTime = ratio * el.duration;
674
+ }
675
+
676
+ // Keyboard seeking, the slider pattern: arrows move 5s, Home/End jump.
677
+ function handleScrubKey(e: React.KeyboardEvent<HTMLDivElement>) {
678
+ const el = videoRef.current;
679
+ if (!el || !el.duration) return;
680
+ const step = { ArrowRight: 5, ArrowUp: 5, ArrowLeft: -5, ArrowDown: -5 }[e.key];
681
+ if (step != null) el.currentTime = Math.min(el.duration, Math.max(0, el.currentTime + step));
682
+ else if (e.key === "Home") el.currentTime = 0;
683
+ else if (e.key === "End") el.currentTime = el.duration;
684
+ else return;
685
+ e.preventDefault();
686
+ }
687
+
688
+ return (
689
+ <>
690
+ <video
691
+ ref={videoRef}
692
+ className="chai-result-card__video"
693
+ src={src}
694
+ onLoadedMetadata={(e) => {
695
+ const el = e.currentTarget;
696
+ el.currentTime = timeRef.current;
697
+ if (wasPlaying) el.play().catch(() => {});
698
+ onDimensions?.(el.videoWidth, el.videoHeight);
699
+ }}
700
+ onPlay={() => {
701
+ setPlaying(true);
702
+ onPlayingChange(true);
703
+ }}
704
+ onPause={() => {
705
+ setPlaying(false);
706
+ onPlayingChange(false);
707
+ }}
708
+ onTimeUpdate={(e) => {
709
+ const el = e.currentTarget;
710
+ timeRef.current = el.currentTime;
711
+ if (el.duration) setProgress(el.currentTime / el.duration);
712
+ }}
713
+ />
714
+ {/* One control over the whole video: click, or Tab then Space/Enter, to
715
+ play or pause. The round Play badge is just its paused look. */}
716
+ <button
717
+ type="button"
718
+ className="chai-result-card__video-toggle"
719
+ aria-label={playing ? "Pause" : "Play"}
720
+ onClick={togglePlay}
721
+ >
722
+ {!playing && (
723
+ <span className="chai-result-card__play">
724
+ <PlayIcon />
725
+ </span>
726
+ )}
727
+ </button>
728
+ <div
729
+ className={`chai-result-card__scrubber${expanded ? " chai-result-card__scrubber--expanded" : ""}`}
730
+ role="slider"
731
+ tabIndex={0}
732
+ aria-label="Seek"
733
+ aria-valuemin={0}
734
+ aria-valuemax={100}
735
+ aria-valuenow={Math.round(progress * 100)}
736
+ aria-valuetext={`${Math.round(progress * 100)}%`}
737
+ onClick={handleScrub}
738
+ onKeyDown={handleScrubKey}
739
+ >
740
+ <div className="chai-result-card__scrubber-fill" style={{ width: `${progress * 100}%` }} />
741
+ </div>
742
+ </>
743
+ );
744
+ }
745
+
746
+ // --- Shared icon button (the hover circle — reference: the design note that the "like" button's hover treatment in the mock applies to every small icon button on the card) ------------------------------------------------------------
747
+
748
+ function ResultIconButton({
749
+ children,
750
+ label,
751
+ active,
752
+ disabled,
753
+ onClick,
754
+ }: {
755
+ children: ReactNode;
756
+ label: string;
757
+ active?: boolean;
758
+ disabled?: boolean;
759
+ onClick: () => void;
760
+ }) {
761
+ return (
762
+ <button
763
+ type="button"
764
+ className={`chai-result-card__icon-btn${active ? " chai-result-card__icon-btn--active" : ""}`}
765
+ aria-label={label}
766
+ aria-pressed={active}
767
+ title={label}
768
+ disabled={disabled}
769
+ onClick={onClick}
770
+ >
771
+ {children}
772
+ </button>
773
+ );
774
+ }
775
+
776
+ // --- More menu (reference: Figma "Result Card - More menu expanded") — same hand-rolled positioned-popover pattern Composer.tsx's own ComposerAttachMenu documents, for the same reason (needs to live inline under its own trigger). ------------------------------------------------------------
777
+
778
+ function MoreMenu({
779
+ actions,
780
+ open,
781
+ onOpenChange,
782
+ disabled,
783
+ onSelect,
784
+ }: {
785
+ actions: ResultCardMoreAction[];
786
+ open: boolean;
787
+ onOpenChange: (open: boolean) => void;
788
+ disabled?: boolean;
789
+ onSelect: (action: ResultCardMoreAction) => void;
790
+ }) {
791
+ const rootRef = useRef<HTMLDivElement>(null);
792
+
793
+ useEffect(() => {
794
+ if (!open) return;
795
+ function handlePointerDown(e: PointerEvent) {
796
+ if (!rootRef.current?.contains(e.target as Node)) onOpenChange(false);
797
+ }
798
+ function handleKeyDown(e: KeyboardEvent) {
799
+ if (e.key === "Escape") onOpenChange(false);
800
+ }
801
+ document.addEventListener("pointerdown", handlePointerDown);
802
+ document.addEventListener("keydown", handleKeyDown);
803
+ return () => {
804
+ document.removeEventListener("pointerdown", handlePointerDown);
805
+ document.removeEventListener("keydown", handleKeyDown);
806
+ };
807
+ }, [open, onOpenChange]);
808
+
809
+ return (
810
+ <div className="chai-result-card__more" ref={rootRef}>
811
+ <ResultIconButton label="More" active={open} disabled={disabled} onClick={() => onOpenChange(!open)}>
812
+ <MoreHorizIcon />
813
+ </ResultIconButton>
814
+ {open && (
815
+ <ul className="chai-result-card__more-menu" role="menu">
816
+ {actions.map((action) => (
817
+ <li key={action.id} role="none">
818
+ <button
819
+ type="button"
820
+ role="menuitem"
821
+ className="chai-result-card__more-menu-item"
822
+ onClick={() => {
823
+ onOpenChange(false);
824
+ onSelect(action);
825
+ }}
826
+ >
827
+ {action.icon && <span className="chai-result-card__more-menu-icon">{action.icon}</span>}
828
+ {action.label}
829
+ </button>
830
+ </li>
831
+ ))}
832
+ </ul>
833
+ )}
834
+ </div>
835
+ );
836
+ }
837
+
838
+ // --- Icons ------------------------------------------------------------
839
+ // Traced from the design's exported assets ("Result from Composer Bar") — same "trace and inline as a
840
+ // currentColor component" convention Composer.tsx documents, icons aren't
841
+ // shared across files yet in this codebase. `PlayIcon`/`PauseIcon`/
842
+ // `ExpandIcon` aren't in the mock itself (its video state has no separate
843
+ // play-button layer — the triangle in the reference screenshot is baked
844
+ // into that frame's placeholder photo, not a real asset) — real Material
845
+ // Symbols glyphs / a simple hand-drawn corner-bracket mark instead.
846
+
847
+ function ThumbUpIcon() {
848
+ return (
849
+ <svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
850
+ <path
851
+ d="M5.25039 15.7502V6.00019L10.5004 0.750193L11.8879 2.13769L10.9129 6.00019H17.2504V9.30019L14.5129 15.7502H5.25039ZM6.75039 14.2502H13.5004L15.7504 9.00019V7.50019H9.00039L10.0129 3.37519L6.75039 6.63769V14.2502ZM1.50039 15.7502V6.00019H5.25039V7.50019H3.00039V14.2502H5.25039V15.7502H1.50039Z"
852
+ fill="currentColor"
853
+ />
854
+ </svg>
855
+ );
856
+ }
857
+
858
+ function ThumbDownIcon() {
859
+ return (
860
+ <svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
861
+ <path
862
+ d="M2.24961 12C1.84961 12 1.49961 11.85 1.19961 11.55C0.899615 11.25 0.749615 10.9 0.749615 10.5V9C0.749615 8.9125 0.762115 8.81875 0.787115 8.71875C0.812115 8.61875 0.837115 8.525 0.862115 8.4375L3.11211 3.15C3.22461 2.9 3.41211 2.6875 3.67461 2.5125C3.93711 2.3375 4.21211 2.25 4.49961 2.25H12.7496V12L8.24961 16.4625C8.06211 16.65 7.84024 16.7594 7.58399 16.7906C7.32774 16.8219 7.08086 16.775 6.84336 16.65C6.60586 16.525 6.43086 16.35 6.31836 16.125C6.20586 15.9 6.18086 15.6688 6.24336 15.4313L7.08711 12H2.24961ZM11.2496 11.3625V3.75H4.49961L2.24961 9V10.5H8.99961L7.98711 14.625L11.2496 11.3625ZM14.9996 2.25C15.4121 2.25 15.7652 2.39688 16.059 2.69063C16.3527 2.98438 16.4996 3.3375 16.4996 3.75V10.5C16.4996 10.9125 16.3527 11.2656 16.059 11.5594C15.7652 11.8531 15.4121 12 14.9996 12H12.7496V10.5H14.9996V3.75H12.7496V2.25H14.9996Z"
863
+ fill="currentColor"
864
+ />
865
+ </svg>
866
+ );
867
+ }
868
+
869
+ function RefreshIcon() {
870
+ return (
871
+ <svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
872
+ <path
873
+ d="M8.99961 15.0002C7.32461 15.0002 5.90586 14.4189 4.74336 13.2564C3.58086 12.0939 2.99961 10.6752 2.99961 9.00019C2.99961 7.32519 3.58086 5.90644 4.74336 4.74394C5.90586 3.58144 7.32461 3.00019 8.99961 3.00019C9.86211 3.00019 10.6871 3.17832 11.4746 3.53457C12.2621 3.89082 12.9371 4.40019 13.4996 5.06269V3.00019H14.9996V8.25019H9.74961V6.75019H12.8996C12.4996 6.05019 11.9527 5.50019 11.259 5.10019C10.5652 4.70019 9.81211 4.50019 8.99961 4.50019C7.74961 4.50019 6.68711 4.93769 5.81211 5.81269C4.93711 6.68769 4.49961 7.75019 4.49961 9.00019C4.49961 10.2502 4.93711 11.3127 5.81211 12.1877C6.68711 13.0627 7.74961 13.5002 8.99961 13.5002C9.96211 13.5002 10.8309 13.2252 11.6059 12.6752C12.3809 12.1252 12.9246 11.4002 13.2371 10.5002H14.8121C14.4621 11.8252 13.7496 12.9064 12.6746 13.7439C11.5996 14.5814 10.3746 15.0002 8.99961 15.0002Z"
874
+ fill="currentColor"
875
+ />
876
+ </svg>
877
+ );
878
+ }
879
+
880
+ function MoreHorizIcon() {
881
+ return (
882
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
883
+ <path
884
+ d="M6.41473 14C5.86473 14 5.3939 13.8042 5.00223 13.4125C4.61056 13.0208 4.41473 12.55 4.41473 12C4.41473 11.45 4.61056 10.9792 5.00223 10.5875C5.3939 10.1958 5.86473 10 6.41473 10C6.96473 10 7.43556 10.1958 7.82723 10.5875C8.2189 10.9792 8.41473 11.45 8.41473 12C8.41473 12.55 8.2189 13.0208 7.82723 13.4125C7.43556 13.8042 6.96473 14 6.41473 14ZM12.4147 14C11.8647 14 11.3939 13.8042 11.0022 13.4125C10.6106 13.0208 10.4147 12.55 10.4147 12C10.4147 11.45 10.6106 10.9792 11.0022 10.5875C11.3939 10.1958 11.8647 10 12.4147 10C12.9647 10 13.4356 10.1958 13.8272 10.5875C14.2189 10.9792 14.4147 11.45 14.4147 12C14.4147 12.55 14.2189 13.0208 13.8272 13.4125C13.4356 13.8042 12.9647 14 12.4147 14ZM18.4147 14C17.8647 14 17.3939 13.8042 17.0022 13.4125C16.6106 13.0208 16.4147 12.55 16.4147 12C16.4147 11.45 16.6106 10.9792 17.0022 10.5875C17.3939 10.1958 17.8647 10 18.4147 10C18.9647 10 19.4356 10.1958 19.8272 10.5875C20.2189 10.9792 20.4147 11.45 20.4147 12C20.4147 12.55 20.2189 13.0208 19.8272 13.4125C19.4356 13.8042 18.9647 14 18.4147 14Z"
885
+ fill="currentColor"
886
+ />
887
+ </svg>
888
+ );
889
+ }
890
+
891
+ function ShareIcon() {
892
+ return (
893
+ <svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
894
+ <path
895
+ d="M2.99961 16.4999V5.9999H6.74961V7.4999H4.49961V14.9999H13.4996V7.4999H11.2496V5.9999H14.9996V16.4999H2.99961ZM8.24961 11.9999V3.61865L7.04961 4.81865L5.99961 3.7499L8.99961 0.749904L11.9996 3.7499L10.9496 4.81865L9.74961 3.61865V11.9999H8.24961Z"
896
+ fill="currentColor"
897
+ />
898
+ </svg>
899
+ );
900
+ }
901
+
902
+ function DownloadIcon() {
903
+ return (
904
+ <svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
905
+ <path
906
+ d="M8.99961 11.9999L5.24961 8.2499L6.29961 7.1624L8.24961 9.1124V2.9999H9.74961V9.1124L11.6996 7.1624L12.7496 8.2499L8.99961 11.9999ZM2.99961 14.9999V11.2499H4.49961V13.4999H13.4996V11.2499H14.9996V14.9999H2.99961Z"
907
+ fill="currentColor"
908
+ />
909
+ </svg>
910
+ );
911
+ }
912
+
913
+ function InfoIcon() {
914
+ return (
915
+ <svg viewBox="0 0 22 22" fill="none" aria-hidden="true">
916
+ <path
917
+ d="M10.0833 6.41667H11.9167V8.25H10.0833V6.41667ZM10.0833 10.0833H11.9167V15.5833H10.0833V10.0833ZM11 1.83333C5.94 1.83333 1.83333 5.94 1.83333 11C1.83333 16.06 5.94 20.1667 11 20.1667C16.06 20.1667 20.1667 16.06 20.1667 11C20.1667 5.94 16.06 1.83333 11 1.83333ZM11 18.3333C6.9575 18.3333 3.66667 15.0425 3.66667 11C3.66667 6.9575 6.9575 3.66667 11 3.66667C15.0425 3.66667 18.3333 6.9575 18.3333 11C18.3333 15.0425 15.0425 18.3333 11 18.3333Z"
918
+ fill="currentColor"
919
+ />
920
+ </svg>
921
+ );
922
+ }
923
+
924
+ function CloseIcon() {
925
+ return (
926
+ <svg viewBox="0 0 14 14" fill="none" aria-hidden="true">
927
+ <path
928
+ d="M14 1.41L12.59 0L7 5.59L1.41 0L0 1.41L5.59 7L0 12.59L1.41 14L7 8.41L12.59 14L14 12.59L8.41 7L14 1.41Z"
929
+ fill="currentColor"
930
+ />
931
+ </svg>
932
+ );
933
+ }
934
+
935
+ function CopyIcon() {
936
+ return (
937
+ <svg viewBox="0 0 8.60253 11.6462" fill="none" aria-hidden="true">
938
+ <path
939
+ d="M1.01206 11.6462C0.733745 11.6462 0.495405 11.5323 0.29704 11.3044C0.0990134 11.0761 0 10.8019 0 10.4816V2.32924H1.01206V10.4816H6.57841V11.6462H1.01206ZM3.03619 9.31697C2.75787 9.31697 2.5197 9.20303 2.32167 8.97515C2.12331 8.74689 2.02413 8.47262 2.02413 8.15235V1.16462C2.02413 0.84435 2.12331 0.570082 2.32167 0.341816C2.5197 0.113939 2.75787 0 3.03619 0H7.59047C7.86879 0 8.10713 0.113939 8.30549 0.341816C8.50352 0.570082 8.60253 0.84435 8.60253 1.16462V8.15235C8.60253 8.47262 8.50352 8.74689 8.30549 8.97515C8.10713 9.20303 7.86879 9.31697 7.59047 9.31697H3.03619ZM3.03619 8.15235H7.59047V1.16462H3.03619V8.15235Z"
940
+ fill="currentColor"
941
+ />
942
+ </svg>
943
+ );
944
+ }
945
+
946
+ /** MD3 "check" symbol — same traced glyph SearchMenu.tsx uses, marking a passed evaluation. */
947
+ function CheckIcon() {
948
+ return (
949
+ <svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
950
+ <path
951
+ d="M7.1625 13.5L2.8875 9.225L3.95625 8.15625L7.1625 11.3625L14.0438 4.48125L15.1125 5.55L7.1625 13.5Z"
952
+ fill="currentColor"
953
+ />
954
+ </svg>
955
+ );
956
+ }
957
+
958
+ /** Real Material Symbols "play_arrow" (viewBox `0 -960 960 960`) — not in the mock itself, see this section's own header comment. */
959
+ function PlayIcon() {
960
+ return (
961
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
962
+ <path d="M320-200v-560l440 280-440 280Z" fill="currentColor" />
963
+ </svg>
964
+ );
965
+ }
966
+
967
+ /** Self-drawn corner-bracket mark, not a recalled/traced icon-set glyph (the mock has no "expand" asset to trace — see this section's own header comment) — four independent strokes, deliberately simple so its geometry is obviously correct rather than a compound path that's hard to eyeball. */
968
+ function ExpandIcon() {
969
+ return (
970
+ <svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
971
+ <path
972
+ d="M2 6V2h4M12 2h4v4M16 12v4h-4M6 16H2v-4"
973
+ stroke="currentColor"
974
+ strokeWidth="1.6"
975
+ strokeLinecap="round"
976
+ strokeLinejoin="round"
977
+ />
978
+ </svg>
979
+ );
980
+ }