@chai-ui/react 0.1.0 → 0.2.1

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 (55) hide show
  1. package/DESIGN.md +6 -1
  2. package/README.md +69 -6
  3. package/dist/Composer.d.ts +19 -2
  4. package/dist/Composer.d.ts.map +1 -1
  5. package/dist/Composer.js +36 -10
  6. package/dist/Composer.js.map +1 -1
  7. package/dist/EditCard.d.ts +52 -0
  8. package/dist/EditCard.d.ts.map +1 -0
  9. package/dist/EditCard.js +360 -0
  10. package/dist/EditCard.js.map +1 -0
  11. package/dist/ResultCard.d.ts +2 -1
  12. package/dist/ResultCard.d.ts.map +1 -1
  13. package/dist/ResultCard.js +19 -63
  14. package/dist/ResultCard.js.map +1 -1
  15. package/dist/icons.d.ts +27 -0
  16. package/dist/icons.d.ts.map +1 -0
  17. package/dist/icons.js +50 -0
  18. package/dist/icons.js.map +1 -0
  19. package/dist/index.d.ts +3 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/media-actions.d.ts +15 -0
  24. package/dist/media-actions.d.ts.map +1 -0
  25. package/dist/media-actions.js +47 -0
  26. package/dist/media-actions.js.map +1 -0
  27. package/dist/page-slide.d.ts +32 -0
  28. package/dist/page-slide.d.ts.map +1 -0
  29. package/dist/page-slide.js +43 -0
  30. package/dist/page-slide.js.map +1 -0
  31. package/dist/primitives/Pagination.d.ts +3 -1
  32. package/dist/primitives/Pagination.d.ts.map +1 -1
  33. package/dist/primitives/Pagination.js +2 -2
  34. package/dist/primitives/Pagination.js.map +1 -1
  35. package/dist/regions.d.ts +3 -0
  36. package/dist/regions.d.ts.map +1 -0
  37. package/dist/regions.js +7 -0
  38. package/dist/regions.js.map +1 -0
  39. package/dist/style.css +508 -82
  40. package/dist/useComposer.d.ts +22 -1
  41. package/dist/useComposer.d.ts.map +1 -1
  42. package/dist/useComposer.js +126 -24
  43. package/dist/useComposer.js.map +1 -1
  44. package/package.json +3 -3
  45. package/src/Composer.tsx +101 -13
  46. package/src/EditCard.tsx +688 -0
  47. package/src/ResultCard.tsx +60 -125
  48. package/src/icons.tsx +115 -0
  49. package/src/index.ts +8 -0
  50. package/src/media-actions.ts +45 -0
  51. package/src/page-slide.tsx +82 -0
  52. package/src/primitives/Pagination.tsx +4 -2
  53. package/src/regions.ts +7 -0
  54. package/src/style.css +508 -82
  55. package/src/useComposer.ts +147 -18
@@ -1,6 +1,9 @@
1
1
  import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2
- import type { ModelOption, Result } from "@chai-ui/core";
2
+ import { ROUTING_MODEL_ID, type ModelOption, type Result } from "@chai-ui/core";
3
3
  import { Pagination } from "./primitives/Pagination.js";
4
+ import { DownloadIcon, ExpandIcon, ShareIcon } from "./icons.js";
5
+ import { downloadFilename, downloadMedia, shareMedia } from "./media-actions.js";
6
+ import { PageSlide, usePageSlide } from "./page-slide.js";
4
7
 
5
8
  /**
6
9
  * ResultCard's complement to Composer bar, and the last stop in the
@@ -47,14 +50,6 @@ function formatAspectRatio(width: number, height: number): string | undefined {
47
50
  return `${w / divisor}:${h / divisor}`;
48
51
  }
49
52
 
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
53
 
59
54
  // --- Public types ------------------------------------------------------------
60
55
 
@@ -201,11 +196,20 @@ export function ResultCard({
201
196
  const result = results[clampedIndex];
202
197
 
203
198
  const mediaDims = measured && measured.id === result?.id ? measured : null;
199
+ // Paging slides, with the last image sliding out (other kinds just slide in).
200
+ const { slide, endSlide } = usePageSlide(
201
+ result?.id,
202
+ clampedIndex,
203
+ result?.output?.kind === "image" ? result.output.src : undefined
204
+ );
204
205
 
205
206
  if (!result) return null;
206
207
 
207
208
  const vote = getVote?.(result) ?? null;
208
- const modelLabel = models?.find((m) => m.id === result.modelId)?.label ?? result.modelId;
209
+ const modelLabel =
210
+ result.modelId === ROUTING_MODEL_ID
211
+ ? "Choosing model…"
212
+ : (models?.find((m) => m.id === result.modelId)?.label ?? result.modelId);
209
213
  const duration = formatDuration(result.durationMs);
210
214
  const cost = formatCost(result.usage?.costUsd);
211
215
  const tokens = formatTokens(result.usage?.totalTokens);
@@ -228,46 +232,13 @@ export function ResultCard({
228
232
 
229
233
  const handleDownload = async () => {
230
234
  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
- }
235
+ await downloadMedia(result.output.src, downloadFilename(result.id, result.output.kind, result.output.src));
257
236
  }
258
237
  onAction("download", result);
259
238
  };
260
239
 
261
240
  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
- }
241
+ if (result.output) await shareMedia(result.output.src);
271
242
  onAction("share", result);
272
243
  };
273
244
 
@@ -286,24 +257,36 @@ export function ResultCard({
286
257
  >
287
258
  <div className="chai-result-card__inner">
288
259
  <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">
260
+ <div className="chai-result-card__media-frame">
261
+ <PageSlide slide={slide} onEnd={endSlide} className="chai-result-card__media-page">
262
+ <ResultMedia
263
+ result={result}
264
+ alt={altText?.(result) ?? prompt}
265
+ disabled={disabled}
266
+ mediaExpanded={mediaExpanded}
267
+ onDownload={handleDownload}
268
+ onShare={handleShare}
269
+ onExpand={() => setMediaExpanded(true)}
270
+ onDimensions={(w, h) => setMeasured({ id: result.id, width: w, height: h })}
271
+ videoTimeRef={videoTimeRef}
272
+ videoWasPlaying={videoWasPlaying}
273
+ onVideoPlayingChange={setVideoWasPlaying}
274
+ />
275
+ </PageSlide>
276
+ </div>
277
+
278
+ {/* Floating over the bottom of the media, as on the edit card: the
279
+ page dots above, votes and retry in one pill, details on the right. */}
280
+ <div className="chai-card-bottom">
281
+ {results.length > 1 && (
282
+ <div className="chai-card-pager">
283
+ <Pagination count={results.length} index={clampedIndex} onChange={goTo} label="Results" disabled={disabled} />
284
+ </div>
285
+ )}
286
+ <div className="chai-card-actions">
287
+ <div className="chai-card-pill">
306
288
  <ResultIconButton
289
+ variant="pill"
307
290
  label="Like"
308
291
  active={vote === "like"}
309
292
  disabled={disabled}
@@ -312,6 +295,7 @@ export function ResultCard({
312
295
  <ThumbUpIcon />
313
296
  </ResultIconButton>
314
297
  <ResultIconButton
298
+ variant="pill"
315
299
  label="Dislike"
316
300
  active={vote === "dislike"}
317
301
  disabled={disabled}
@@ -319,11 +303,7 @@ export function ResultCard({
319
303
  >
320
304
  <ThumbDownIcon />
321
305
  </ResultIconButton>
322
- <ResultIconButton
323
- label="Retry"
324
- disabled={disabled}
325
- onClick={() => onAction("retry", result)}
326
- >
306
+ <ResultIconButton variant="pill" label="Retry" disabled={disabled} onClick={() => onAction("retry", result)}>
327
307
  <RefreshIcon />
328
308
  </ResultIconButton>
329
309
  {moreActions && moreActions.length > 0 && (
@@ -336,25 +316,10 @@ export function ResultCard({
336
316
  />
337
317
  )}
338
318
  </div>
339
-
340
- <ResultIconButton
341
- label="Show details"
342
- disabled={disabled}
343
- onClick={() => setFlipped(true)}
344
- >
319
+ <ResultIconButton variant="round" label="Show details" disabled={disabled} onClick={() => setFlipped(true)}>
345
320
  <InfoIcon />
346
321
  </ResultIconButton>
347
322
  </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
323
  </div>
359
324
  </div>
360
325
 
@@ -507,7 +472,7 @@ function ResultMedia({
507
472
  if ((result.status === "queued" || result.status === "running") && !streaming) {
508
473
  return (
509
474
  <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"} />
475
+ <span className="chai-result-card__spinner" role="img" aria-label={result.status === "running" ? "Running" : "Queued"} />
511
476
  </div>
512
477
  );
513
478
  }
@@ -573,15 +538,15 @@ function ResultMedia({
573
538
  )}
574
539
 
575
540
  {!mediaExpanded && !streaming && (
576
- <div className="chai-result-card__media-actions">
577
- <ResultIconButton label="Download" disabled={disabled} onClick={onDownload}>
541
+ <div className="chai-card-top chai-card-top--end">
542
+ <ResultIconButton variant="top" label="Download" disabled={disabled} onClick={onDownload}>
578
543
  <DownloadIcon />
579
544
  </ResultIconButton>
580
- <ResultIconButton label="Share" disabled={disabled} onClick={onShare}>
545
+ <ResultIconButton variant="top" label="Share" disabled={disabled} onClick={onShare}>
581
546
  <ShareIcon />
582
547
  </ResultIconButton>
583
548
  {(isVideo || isImage) && (
584
- <ResultIconButton label="Expand" disabled={disabled} onClick={onExpand}>
549
+ <ResultIconButton variant="top" label="Expand" disabled={disabled} onClick={onExpand}>
585
550
  <ExpandIcon />
586
551
  </ResultIconButton>
587
552
  )}
@@ -745,14 +710,19 @@ function ResultVideoPlayer({
745
710
 
746
711
  // --- 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
712
 
713
+ const ICON_BUTTON_CLASS = { top: "chai-card-top-btn", pill: "chai-card-pill-btn", round: "chai-card-round" } as const;
714
+
748
715
  function ResultIconButton({
749
716
  children,
717
+ variant,
750
718
  label,
751
719
  active,
752
720
  disabled,
753
721
  onClick,
754
722
  }: {
755
723
  children: ReactNode;
724
+ /** Where it floats: in a dark group along the top, in the light pill, or the round button on the right. */
725
+ variant: "top" | "pill" | "round";
756
726
  label: string;
757
727
  active?: boolean;
758
728
  disabled?: boolean;
@@ -761,7 +731,7 @@ function ResultIconButton({
761
731
  return (
762
732
  <button
763
733
  type="button"
764
- className={`chai-result-card__icon-btn${active ? " chai-result-card__icon-btn--active" : ""}`}
734
+ className={`${ICON_BUTTON_CLASS[variant]}${active ? " chai-card-pill-btn--active" : ""}`}
765
735
  aria-label={label}
766
736
  aria-pressed={active}
767
737
  title={label}
@@ -808,7 +778,7 @@ function MoreMenu({
808
778
 
809
779
  return (
810
780
  <div className="chai-result-card__more" ref={rootRef}>
811
- <ResultIconButton label="More" active={open} disabled={disabled} onClick={() => onOpenChange(!open)}>
781
+ <ResultIconButton variant="pill" label="More" active={open} disabled={disabled} onClick={() => onOpenChange(!open)}>
812
782
  <MoreHorizIcon />
813
783
  </ResultIconButton>
814
784
  {open && (
@@ -888,28 +858,6 @@ function MoreHorizIcon() {
888
858
  );
889
859
  }
890
860
 
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
861
  function InfoIcon() {
914
862
  return (
915
863
  <svg viewBox="0 0 22 22" fill="none" aria-hidden="true">
@@ -965,16 +913,3 @@ function PlayIcon() {
965
913
  }
966
914
 
967
915
  /** 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
- }
package/src/icons.tsx ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Icons shared by more than one component. Each is an inline
3
+ * `currentColor` SVG, the convention every component file follows.
4
+ * The media-action icons are traced from the result card design; the rest
5
+ * are real Material Symbols paths (viewBox `0 -960 960 960`) copied from
6
+ * `@material-symbols/svg-400`.
7
+ */
8
+
9
+ export function DownloadIcon() {
10
+ return (
11
+ <svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
12
+ <path
13
+ 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"
14
+ fill="currentColor"
15
+ />
16
+ </svg>
17
+ );
18
+ }
19
+
20
+ export function ShareIcon() {
21
+ return (
22
+ <svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
23
+ <path
24
+ 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"
25
+ fill="currentColor"
26
+ />
27
+ </svg>
28
+ );
29
+ }
30
+
31
+ export function ExpandIcon() {
32
+ return (
33
+ <svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
34
+ <path
35
+ d="M2 6V2h4M12 2h4v4M16 12v4h-4M6 16H2v-4"
36
+ stroke="currentColor"
37
+ strokeWidth="1.6"
38
+ strokeLinecap="round"
39
+ strokeLinejoin="round"
40
+ />
41
+ </svg>
42
+ );
43
+ }
44
+
45
+ /** Material Symbols "zoom_in". */
46
+ export function ZoomInIcon() {
47
+ return (
48
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
49
+ <path d="M796-121 533-384q-30 26-69.96 40.5Q423.08-329 378-329q-108.16 0-183.08-75Q120-479 120-585t75-181q75-75 181.5-75t181 75Q632-691 632-584.85 632-542 618-502q-14 40-42 75l264 262-44 44ZM377-389q81.25 0 138.13-57.5Q572-504 572-585t-56.87-138.5Q458.25-781 377-781q-82.08 0-139.54 57.5Q180-666 180-585t57.46 138.5Q294.92-389 377-389Zm-31-85v-82h-82v-60h82v-81h60v81h81v60h-81v82h-60Z" fill="currentColor" />
50
+ </svg>
51
+ );
52
+ }
53
+
54
+ /** Material Symbols "zoom_out". */
55
+ export function ZoomOutIcon() {
56
+ return (
57
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
58
+ <path d="M796-121 533-384q-30 26-70 40.5T378-329q-108 0-183-75t-75-181q0-106 75-181t182-75q106 0 180.5 75T632-585q0 43-14 83t-42 75l264 262-44 44ZM377-389q81 0 138-57.5T572-585q0-81-57-138.5T377-781q-82 0-139.5 57.5T180-585q0 81 57.5 138.5T377-389ZM275-556v-60h201v60H275Z" fill="currentColor" />
59
+ </svg>
60
+ );
61
+ }
62
+
63
+ /** Material Symbols "add". */
64
+ export function AddIcon() {
65
+ return (
66
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
67
+ <path d="M450-450H200v-60h250v-250h60v250h250v60H510v250h-60v-250Z" fill="currentColor" />
68
+ </svg>
69
+ );
70
+ }
71
+
72
+ /** Material Symbols "delete". */
73
+ export function DeleteIcon() {
74
+ return (
75
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
76
+ <path d="M261-120q-24.75 0-42.37-17.63Q201-155.25 201-180v-570h-41v-60h188v-30h264v30h188v60h-41v570q0 24-18 42t-42 18H261Zm438-630H261v570h438v-570ZM367-266h60v-399h-60v399Zm166 0h60v-399h-60v399ZM261-750v570-570Z" fill="currentColor" />
77
+ </svg>
78
+ );
79
+ }
80
+
81
+ /** Material Symbols "visibility". */
82
+ export function VisibilityIcon() {
83
+ return (
84
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
85
+ <path d="M600.5-379.5Q650-429 650-500t-49.5-120.5Q551-670 480-670t-120.5 49.5Q310-571 310-500t49.5 120.5Q409-330 480-330t120.5-49.5Zm-200-41Q368-453 368-500t32.5-79.5Q433-612 480-612t79.5 32.5Q592-547 592-500t-32.5 79.5Q527-388 480-388t-79.5-32.5ZM216-283Q98-366 40-500q58-134 176-217t264-83q146 0 264 83t176 217q-58 134-176 217t-264 83q-146 0-264-83Zm264-217Zm222.5 174.5Q804-391 857-500q-53-109-154.5-174.5T480-740q-121 0-222.5 65.5T102-500q54 109 155.5 174.5T480-260q121 0 222.5-65.5Z" fill="currentColor" />
86
+ </svg>
87
+ );
88
+ }
89
+
90
+ /** Material Symbols "visibility_off". */
91
+ export function VisibilityOffIcon() {
92
+ return (
93
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
94
+ <path d="m629-419-44-44q26-71-27-118t-115-24l-44-44q17-11 38-16t43-5q71 0 120.5 49.5T650-500q0 22-5.5 43.5T629-419Zm129 129-40-40q49-36 85.5-80.5T857-500q-50-111-150-175.5T490-740q-42 0-86 8t-69 19l-46-47q35-16 89.5-28T485-800q143 0 261.5 81.5T920-500q-26 64-67 117t-95 93Zm58 226L648-229q-35 14-79 21.5t-89 7.5q-146 0-265-81.5T40-500q20-52 55.5-101.5T182-696L56-822l42-43 757 757-39 44ZM223-654q-37 27-71.5 71T102-500q51 111 153.5 175.5T488-260q33 0 65-4t48-12l-64-64q-11 5-27 7.5t-30 2.5q-70 0-120-49t-50-121q0-15 2.5-30t7.5-27l-97-97Zm305 142Zm-116 58Z" fill="currentColor" />
95
+ </svg>
96
+ );
97
+ }
98
+
99
+ /** Material Symbols "check". */
100
+ export function CheckSymbolIcon() {
101
+ return (
102
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
103
+ <path d="M378-246 154-470l43-43 181 181 384-384 43 43-427 427Z" fill="currentColor" />
104
+ </svg>
105
+ );
106
+ }
107
+
108
+ /** Material Symbols "close". */
109
+ export function CloseSymbolIcon() {
110
+ return (
111
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
112
+ <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" />
113
+ </svg>
114
+ );
115
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export * from "./primitives/index.js";
2
2
  export * from "./Composer.js";
3
3
  export * from "./MediaAnalyzer.js";
4
4
  export * from "./ResultCard.js";
5
+ export * from "./EditCard.js";
5
6
  export * from "./useComposer.js";
6
7
  export * from "./ChaiProvider.js";
7
8
  export * from "./useMediaAnalyzer.js";
@@ -13,6 +14,10 @@ export {
13
14
  createOpenRouterEngine,
14
15
  createMockEngine,
15
16
  mockEngine,
17
+ buildRegionEditPrompt,
18
+ DEFAULT_MAX_REGIONS,
19
+ PRECISE_EDIT_MODELS,
20
+ ROUTING_MODEL_ID,
16
21
  GenerationError,
17
22
  DEFAULT_MEDIA_ANALYSIS_PROMPT_LENGTH,
18
23
  MEDIA_ANALYSIS_PROMPTS,
@@ -20,6 +25,7 @@ export {
20
25
  } from "@chai-ui/core";
21
26
  export type {
22
27
  DroppedMedia,
28
+ EditRegion,
23
29
  Evaluation,
24
30
  GenerationEngine,
25
31
  GenerationUseCase,
@@ -28,6 +34,8 @@ export type {
28
34
  MediaKind,
29
35
  ModelOption,
30
36
  ParameterOption,
37
+ RegionBox,
38
+ RegionFormat,
31
39
  Request,
32
40
  Result,
33
41
  ResultStatus,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Download and share for a piece of generated media, shared by the cards.
3
+ *
4
+ * A plain `<a download href={remoteUrl}>` only forces a download for a
5
+ * same-origin or `blob:`/`data:` URL; for a cross-origin CDN URL (what a
6
+ * generated result usually is) browsers ignore `download` and open it
7
+ * instead. Fetching it into a blob first works wherever the source allows
8
+ * cross-origin fetches. When it doesn't, opening the media is the fallback.
9
+ */
10
+ export async function downloadMedia(src: string, filename: string): Promise<void> {
11
+ try {
12
+ const response = await fetch(src);
13
+ const blob = await response.blob();
14
+ const blobUrl = URL.createObjectURL(blob);
15
+ const a = document.createElement("a");
16
+ a.href = blobUrl;
17
+ a.download = filename;
18
+ document.body.appendChild(a);
19
+ a.click();
20
+ a.remove();
21
+ URL.revokeObjectURL(blobUrl);
22
+ } catch {
23
+ window.open(src, "_blank", "noopener");
24
+ }
25
+ }
26
+
27
+ /** The native share sheet where there is one, otherwise the media's URL copied to the clipboard. */
28
+ export async function shareMedia(src: string): Promise<void> {
29
+ if (navigator.share) {
30
+ try {
31
+ await navigator.share({ url: src });
32
+ } catch {
33
+ // Dismissed, or not shareable: same as closing a native share sheet.
34
+ }
35
+ } else {
36
+ navigator.clipboard?.writeText(src).catch(() => {});
37
+ }
38
+ }
39
+
40
+ /** A file name for a download: the URL's own extension when it has one, otherwise a guess by kind. */
41
+ export function downloadFilename(id: string, kind: string, src: string): string {
42
+ const match = /\.([a-zA-Z0-9]{2,5})(?:[?#]|$)/.exec(src);
43
+ const ext = match?.[1] ?? (kind === "video" ? "mp4" : kind === "audio" ? "mp3" : kind === "text" ? "txt" : "png");
44
+ return `${kind}-${id}.${ext}`;
45
+ }
@@ -0,0 +1,82 @@
1
+ import { useState, type ReactNode } from "react";
2
+
3
+ interface Slide {
4
+ /** The image paged away from, shown sliding out. Absent when it wasn't an image. */
5
+ from?: string;
6
+ forward: boolean;
7
+ key: number;
8
+ active: boolean;
9
+ }
10
+
11
+ /**
12
+ * The slide between pages of a card: the new page comes in from the side it
13
+ * was paged from while the last one leaves the other way. `id` is the page
14
+ * showing and `image` its image, if it has one (what slides out next time).
15
+ */
16
+ export function usePageSlide(id: string | undefined, index: number, image: string | undefined) {
17
+ const [shown, setShown] = useState({ id, index, image });
18
+ const [slide, setSlide] = useState<Slide | null>(null);
19
+ // Tracked while rendering, not in an effect (the rules of React).
20
+ if (id !== shown.id) {
21
+ setShown({ id, index, image });
22
+ // No slide for someone who has asked their system for less motion.
23
+ const reduceMotion =
24
+ typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
25
+ if (shown.id !== undefined && id !== undefined && !reduceMotion) {
26
+ setSlide({ from: shown.image, forward: index > shown.index, key: (slide?.key ?? 0) + 1, active: true });
27
+ }
28
+ } else if (image !== shown.image) {
29
+ // The same page getting its image (a result arriving) doesn't slide.
30
+ setShown({ ...shown, image });
31
+ }
32
+ return { slide, endSlide: () => setSlide((s) => s && { ...s, active: false, from: undefined }) };
33
+ }
34
+
35
+ /**
36
+ * Renders a page with its slide. The outgoing image stays in the layout
37
+ * while it slides away, so the card holds its height even while the new
38
+ * image is still loading, and the new page slides in over that space. The
39
+ * page keeps its key once the slide ends, so it isn't remounted (no
40
+ * reloaded image, no restarted video).
41
+ */
42
+ export function PageSlide({
43
+ slide,
44
+ onEnd,
45
+ className,
46
+ children,
47
+ }: {
48
+ slide: Slide | null;
49
+ onEnd: () => void;
50
+ className?: string;
51
+ children: ReactNode;
52
+ }) {
53
+ const side = slide?.forward ? "left" : "right";
54
+ const outgoing = slide?.active && slide.from;
55
+ return (
56
+ <>
57
+ {outgoing && (
58
+ <img
59
+ key={`out-${slide.key}`}
60
+ className={`chai-slide-out chai-slide-out--${side}`}
61
+ src={slide.from}
62
+ alt=""
63
+ aria-hidden="true"
64
+ draggable={false}
65
+ />
66
+ )}
67
+ <div
68
+ key={`in-${slide?.key ?? 0}`}
69
+ className={
70
+ [className, slide?.active && `chai-slide-in chai-slide-in--${side}`, outgoing && "chai-slide-in--over"]
71
+ .filter(Boolean)
72
+ .join(" ") || undefined
73
+ }
74
+ onAnimationEnd={(e) => {
75
+ if (e.target === e.currentTarget) onEnd();
76
+ }}
77
+ >
78
+ {children}
79
+ </div>
80
+ </>
81
+ );
82
+ }
@@ -6,6 +6,8 @@ interface PaginationProps {
6
6
  onChange: (index: number) => void;
7
7
  /** Accessible name for the nav landmark, e.g. "Results". */
8
8
  label: string;
9
+ /** What one page is, for each dot's name ("Result 2 of 3"). Defaults to "Result". */
10
+ itemLabel?: string;
9
11
  disabled?: boolean;
10
12
  }
11
13
 
@@ -17,7 +19,7 @@ interface PaginationProps {
17
19
  * any one component, so every paginated surface renders the same pager
18
20
  * rather than each growing its own.
19
21
  */
20
- export function Pagination({ count, index, onChange, label, disabled }: PaginationProps) {
22
+ export function Pagination({ count, index, onChange, label, itemLabel = "Result", disabled }: PaginationProps) {
21
23
  return (
22
24
  <nav
23
25
  className="chai-pagination"
@@ -31,7 +33,7 @@ export function Pagination({ count, index, onChange, label, disabled }: Paginati
31
33
  type="button"
32
34
  className={`chai-pagination__dot${i <= index ? " chai-pagination__dot--filled" : ""}`}
33
35
  aria-current={i === index}
34
- aria-label={`Result ${i + 1} of ${count}`}
36
+ aria-label={`${itemLabel} ${i + 1} of ${count}`}
35
37
  disabled={disabled}
36
38
  onClick={() => onChange(i)}
37
39
  />
package/src/regions.ts ADDED
@@ -0,0 +1,7 @@
1
+ /** How many region colors the tokens define (`--chai-color-semantic-region-1` to `-6`). */
2
+ const REGION_COLOR_COUNT = 6;
3
+
4
+ /** The color token for a region number. Numbers past the last color start over at the first. */
5
+ export function regionColor(number: number): string {
6
+ return `var(--chai-color-semantic-region-${((Math.max(1, number) - 1) % REGION_COLOR_COUNT) + 1})`;
7
+ }