@chai-ui/react 0.5.0 → 0.6.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.
package/src/Composer.tsx CHANGED
@@ -1,7 +1,9 @@
1
+ import { StopIcon } from "./icons.js";
1
2
  import { useEffect, useRef, useState, type ChangeEvent } from "react";
2
3
  import { createComponent } from "@lit/react";
3
4
  import * as React from "react";
4
5
  import { MdFab } from "@material/web/fab/fab.js";
6
+ import { deriveGenerationUseCase, suggestedModels } from "@chai-ui/core";
5
7
  import type { DroppedMedia, EditRegion, ModelOption, ParameterOption, MediaKind } from "@chai-ui/core";
6
8
  import { SearchMenu } from "./primitives/SearchMenu.js";
7
9
  import { regionColor } from "./regions.js";
@@ -134,7 +136,7 @@ export interface ComposerProps {
134
136
  multiSelectUseCases?: boolean;
135
137
  useCases?: ComposerUseCase[];
136
138
  onUseCasesChange?: (next: ComposerUseCase[]) => void;
137
- /** Models per use-case kind, for `multiSelectUseCases`. */
139
+ /** Models per use-case kind, for `multiSelectUseCases`. A kind left out gets Chai's suggested models (see `models`). */
138
140
  modelsByKind?: Partial<Record<MediaKind, ModelOption[]>>;
139
141
  /**
140
142
  * Builder opt-in, off by default: the end user can pick several models,
@@ -145,7 +147,12 @@ export interface ComposerProps {
145
147
  multiSelectModels?: boolean;
146
148
  modelIds?: string[];
147
149
  onModelIdsChange?: (ids: string[]) => void;
148
- /** Models valid for the current use case. The Model menu only renders when this is non-empty. */
150
+ /**
151
+ * Models for the current use case. Leave it out and Composer offers Chai's
152
+ * suggested Fal models for the use case (`suggestedModels`), with the
153
+ * first one picked until the person picks another. Pass a list to use
154
+ * your own; pass `[]` for no Model menu.
155
+ */
149
156
  models?: ModelOption[];
150
157
  modelId?: string | null;
151
158
  /** `null` clears the pick: Composer does this when auto-select turns on, until routing picks a model. */
@@ -311,19 +318,31 @@ export function Composer({
311
318
  const showStop = submitting && Boolean(onAbort);
312
319
  const multiModel = multiSelectModels || multiSelectUseCases;
313
320
  const rawPickedIds = multiModel ? modelIds : modelId ? [modelId] : [];
321
+ // The app's list for a use case, or Chai's suggested models when it gave none.
322
+ const rosterFor = (u: ComposerUseCase): { options: ModelOption[]; suggested: boolean } => {
323
+ const given = multiSelectUseCases ? modelsByKind?.[u.kind] : models;
324
+ if (given) return { options: given, suggested: false };
325
+ return { options: suggestedModels(u.edit ? "image-edit" : deriveGenerationUseCase(u, attachments)), suggested: true };
326
+ };
314
327
  // One menu section per use case with multi-use-case select; otherwise one flat roster.
315
- const modelSections = multiSelectUseCases
316
- ? activeUseCases
317
- .map((u) => ({ label: u.label, kind: u.kind, options: modelsByKind?.[u.kind] ?? [] }))
318
- .filter((s) => s.options.length > 0)
319
- : [{ label: "", kind: activeUseCases[0]!.kind, options: models ?? [] }].filter((s) => s.options.length > 0);
328
+ const modelSections = (multiSelectUseCases ? activeUseCases : activeUseCases.slice(0, 1))
329
+ .map((u) => ({ label: multiSelectUseCases ? u.label : "", kind: u.kind, ...rosterFor(u) }))
330
+ .filter((s) => s.options.length > 0);
320
331
  const allModels = modelSections.flatMap((s) => s.options);
321
332
  // A pick that isn't in the current list (e.g. the edit model after leaving
322
333
  // edit mode) is shown as unpicked and never submitted. With no list at
323
- // all, the app's pick is passed through as given.
324
- const pickedModelIds =
334
+ // all, the app's pick is passed through as given. With nothing picked
335
+ // from a suggested list (and auto-select off), its first model is the pick.
336
+ const validPicks =
325
337
  allModels.length > 0 ? rawPickedIds.filter((id) => allModels.some((m) => m.id === id)) : rawPickedIds;
338
+ const pickedModelIds =
339
+ validPicks.length > 0 || autoSelectModel
340
+ ? validPicks
341
+ : modelSections.filter((s) => s.suggested).map((s) => s.options[0]!.id);
326
342
  const pickedModels = allModels.filter((m) => pickedModelIds.includes(m.id));
343
+ // No menu that can't change anything: without its handler, the Model menu
344
+ // hides (the default pick still runs).
345
+ const canPickModel = multiModel ? Boolean(onModelIdsChange) : Boolean(onModelChange);
327
346
  const modelTriggerLabel =
328
347
  pickedModels.length > 1
329
348
  ? `${pickedModels.length} models`
@@ -354,9 +373,9 @@ export function Composer({
354
373
  function buildSelections(): ComposerSelection[] {
355
374
  return activeUseCases.map((u) => ({
356
375
  useCase: u,
357
- models: multiSelectUseCases ? (modelsByKind?.[u.kind] ?? []) : (models ?? []),
376
+ models: rosterFor(u).options,
358
377
  modelIds: multiSelectUseCases
359
- ? pickedModelIds.filter((id) => modelsByKind?.[u.kind]?.some((m) => m.id === id))
378
+ ? pickedModelIds.filter((id) => rosterFor(u).options.some((m) => m.id === id))
360
379
  : pickedModelIds,
361
380
  }));
362
381
  }
@@ -610,7 +629,7 @@ export function Composer({
610
629
  trigger stays enabled whenever the switch is shown — it's the
611
630
  only way back to the switch. Rows are disabled by `SearchMenu`
612
631
  itself while auto-select is on. */}
613
- {modelSections.length > 0 && (
632
+ {modelSections.length > 0 && canPickModel && (
614
633
  <SearchMenu
615
634
  {...(multiSelectUseCases
616
635
  ? { sections: modelSections.map((s) => ({ label: s.label, options: s.options })) }
@@ -636,7 +655,7 @@ export function Composer({
636
655
  />
637
656
  )}
638
657
 
639
- {showAspectRatio && aspectOptions.length > 0 && (
658
+ {showAspectRatio && aspectOptions.length > 0 && onAspectRatioChange && (
640
659
  <SearchMenu
641
660
  options={aspectOptions.map((o) => ({ id: o.value, label: o.label }))}
642
661
  value={offeredAspect}
@@ -1046,20 +1065,6 @@ function UndoIcon() {
1046
1065
  );
1047
1066
  }
1048
1067
 
1049
- // Real Material Symbols "stop" glyph, the filled variant specifically
1050
- // (`stop-fill`, not the base `stop` — that one traces a hollow square-
1051
- // outline shape even filled with `currentColor`, which read as a thin
1052
- // frame rather than a real stop icon at 24px; design review caught
1053
- // this live). Viewbox `0 -960 960 960` — the submit FAB's in-flight state,
1054
- // its own doc comment above where it's rendered.
1055
- function StopIcon() {
1056
- return (
1057
- <svg viewBox="0 -960 960 960" aria-hidden="true">
1058
- <path d="M240-240v-480h480v480H240Z" fill="currentColor" />
1059
- </svg>
1060
- );
1061
- }
1062
-
1063
1068
  function AttachFileIcon() {
1064
1069
  return (
1065
1070
  <svg viewBox="0 -960 960 960" aria-hidden="true">
package/src/EditCard.tsx CHANGED
@@ -168,6 +168,7 @@ export function EditCard({
168
168
  // until the next version's image loads, so the card doesn't jump between.
169
169
  const [natural, setNatural] = useState<{ width: number; height: number } | null>(null);
170
170
  const [expanded, setExpanded] = useState(false);
171
+ const [linkCopied, setLinkCopied] = useState(false);
171
172
  const drag = useRef<Drag | null>(null);
172
173
  const viewportRef = useRef<HTMLDivElement>(null);
173
174
  const fieldRef = useRef<HTMLTextAreaElement>(null);
@@ -371,7 +372,11 @@ export function EditCard({
371
372
 
372
373
  const handleShare = async () => {
373
374
  if (!version?.src) return;
374
- await shareMedia(version.src);
375
+ // With no share sheet the link is copied; say so, or the click looks like nothing happened.
376
+ if ((await shareMedia(version.src)) === "copied") {
377
+ setLinkCopied(true);
378
+ setTimeout(() => setLinkCopied(false), 1500);
379
+ }
375
380
  onAction?.("share", version);
376
381
  };
377
382
 
@@ -509,8 +514,15 @@ export function EditCard({
509
514
  <button type="button" className="chai-card-top-btn" aria-label="Download" title="Download" disabled={disabled || !ready} onClick={handleDownload}>
510
515
  <DownloadIcon />
511
516
  </button>
512
- <button type="button" className="chai-card-top-btn" aria-label="Share" title="Share" disabled={disabled || !ready} onClick={handleShare}>
513
- <ShareIcon />
517
+ <button
518
+ type="button"
519
+ className="chai-card-top-btn"
520
+ aria-label={linkCopied ? "Link copied" : "Share"}
521
+ title={linkCopied ? "Link copied" : "Share"}
522
+ disabled={disabled || !ready}
523
+ onClick={handleShare}
524
+ >
525
+ {linkCopied ? <CheckSymbolIcon /> : <ShareIcon />}
514
526
  </button>
515
527
  <button type="button" className="chai-card-top-btn" aria-label="Expand" title="Expand" disabled={!ready} onClick={() => setExpanded(true)}>
516
528
  <ExpandIcon />
@@ -2,9 +2,11 @@ import { createComponent } from "@lit/react";
2
2
  import * as React from "react";
3
3
  import { useEffect, useRef, useState, type ChangeEvent, type DragEvent } from "react";
4
4
  import { MdFab } from "@material/web/fab/fab.js";
5
+ import { SUGGESTED_ANALYSIS_MODELS } from "@chai-ui/core";
5
6
  import type { DroppedMedia, ModelOption, MediaKind } from "@chai-ui/core";
6
7
  import type { MediaAnalysisPromptLength } from "@chai-ui/core";
7
8
  import { SearchMenu, type SearchMenuSection } from "./primitives/SearchMenu.js";
9
+ import { StopIcon } from "./icons.js";
8
10
 
9
11
  /** See primitives/Toggle.tsx for why `createComponent` is used instead of raw JSX on custom-element tags. */
10
12
  const MdFabElement = createComponent({
@@ -113,10 +115,15 @@ export interface MediaAnalyzerProps {
113
115
  onAttachmentsChange: (next: MediaAnalyzerAttachment[]) => void;
114
116
  onUnsupportedFile?: (fileName: string) => void;
115
117
  maxAttachmentsByKind?: Record<MediaKind, number>;
116
- /** Flat fallback roster, shown (ungrouped) before any media's kind is known. */
117
- models: ModelOption[];
118
- /** Per-kind rosters — once a kind is known, only that kind's models show. Before that, all three show as separate sections (reference: Figma "06 Composer from Media Analysis"). */
119
- modelsByKind: Partial<Record<MediaKind, ModelOption[]>>;
118
+ /** Fallback roster for a kind `modelsByKind` doesn't list. Optional. */
119
+ models?: ModelOption[];
120
+ /**
121
+ * Per-kind rosters — once a kind is known, only that kind's models show.
122
+ * Before that, all three show as separate sections (reference: Figma "06
123
+ * Composer from Media Analysis"). Defaults to Chai's suggested OpenRouter
124
+ * models (`SUGGESTED_ANALYSIS_MODELS`); pass your own to replace them.
125
+ */
126
+ modelsByKind?: Partial<Record<MediaKind, ModelOption[]>>;
120
127
  /**
121
128
  * The picked model. While `autoSelectModel` is on it's `null` until a
122
129
  * submit routes, then the routed model, shown by name on the trigger.
@@ -154,6 +161,8 @@ export interface MediaAnalyzerProps {
154
161
  submitting?: boolean;
155
162
  submitError?: string | null;
156
163
  onSubmit: (payload: MediaAnalyzerSubmitPayload) => void;
164
+ /** Fires when the stop button is pressed while `submitting`. With it, submit turns into stop during an analysis (wire it to `useMediaAnalyzer`'s `cancel`); without it, submit is just disabled. */
165
+ onAbort?: () => void;
157
166
  }
158
167
 
159
168
  const KIND_LABEL: Record<MediaKind, string> = {
@@ -175,8 +184,8 @@ export function MediaAnalyzer({
175
184
  onAttachmentsChange,
176
185
  onUnsupportedFile,
177
186
  maxAttachmentsByKind = DEFAULT_MAX_ATTACHMENTS_BY_KIND,
178
- models,
179
- modelsByKind,
187
+ models = [],
188
+ modelsByKind = SUGGESTED_ANALYSIS_MODELS,
180
189
  modelId,
181
190
  onModelChange,
182
191
  autoSelectModel,
@@ -188,6 +197,7 @@ export function MediaAnalyzer({
188
197
  submitting,
189
198
  submitError,
190
199
  onSubmit,
200
+ onAbort,
191
201
  }: MediaAnalyzerProps) {
192
202
  const inputRef = useRef<HTMLInputElement>(null);
193
203
  const kind = attachments[0]?.kind;
@@ -199,6 +209,9 @@ export function MediaAnalyzer({
199
209
  // children. Active for as long as this is > 0; both drag handlers below
200
210
  // clamp at 0 so a stray extra `dragleave` can't go negative.
201
211
  const [dragDepth, setDragDepth] = useState(0);
212
+ // What the last attach replaced (one kind per analysis), shown until the
213
+ // attachments change some other way.
214
+ const [replaced, setReplaced] = useState<{ note: string; for: MediaAnalyzerAttachment[] } | null>(null);
202
215
  const isDragActive = dragDepth > 0;
203
216
 
204
217
  /**
@@ -221,9 +234,14 @@ export function MediaAnalyzer({
221
234
  const settle = () => {
222
235
  pending -= 1;
223
236
  if (pending === 0 && resolved.length > 0) {
224
- onAttachmentsChange(
225
- resolved.reduce((acc, media) => nextMediaAnalyzerAttachments(acc, media, maxAttachmentsByKind), attachments)
237
+ const next = resolved.reduce(
238
+ (acc, media) => nextMediaAnalyzerAttachments(acc, media, maxAttachmentsByKind),
239
+ attachments
226
240
  );
241
+ const removed = attachments.filter((a) => !next.some((n) => n.id === a.id));
242
+ const added = next.filter((n) => !attachments.some((a) => a.id === n.id));
243
+ setReplaced(removed.length > 0 && added.length > 0 ? { note: replacedNote(removed, added), for: next } : null);
244
+ onAttachmentsChange(next);
227
245
  }
228
246
  };
229
247
  files.forEach((file) => {
@@ -278,6 +296,7 @@ export function MediaAnalyzer({
278
296
  const selectedLength = promptLengthOptions.find((o) => o.value === promptLength);
279
297
  const hasContent = attachments.length > 0;
280
298
  const isSubmitDisabled = disabled || !hasContent;
299
+ const showStop = Boolean(submitting && onAbort);
281
300
 
282
301
  return (
283
302
  <div className="chai-media-analyzer">
@@ -338,6 +357,12 @@ export function MediaAnalyzer({
338
357
  </div>
339
358
  )}
340
359
 
360
+ {replaced && replaced.for === attachments && (
361
+ <p className="chai-media-analyzer__note" role="status">
362
+ {replaced.note}
363
+ </p>
364
+ )}
365
+
341
366
  {submitError && (
342
367
  <p className="chai-media-analyzer__error" role="alert">
343
368
  {submitError}
@@ -390,14 +415,16 @@ export function MediaAnalyzer({
390
415
  size="small"
391
416
  variant="secondary"
392
417
  label=""
393
- data-ready={!isSubmitDisabled || undefined}
418
+ data-ready={showStop || !isSubmitDisabled || undefined}
394
419
  data-error={Boolean(submitError) || undefined}
420
+ data-stop={showStop || undefined}
395
421
  {...({
396
- ariaLabel: submitError ? "Retry" : "Analyze media",
397
- ariaDisabled: submitting ? "true" : isSubmitDisabled ? "true" : "false",
422
+ ariaLabel: showStop ? "Stop" : submitError ? "Retry" : "Analyze media",
423
+ ariaDisabled: showStop ? "false" : submitting ? "true" : isSubmitDisabled ? "true" : "false",
398
424
  } as Record<string, unknown>)}
399
425
  onClick={() => {
400
- if (!isSubmitDisabled && !submitting) onSubmit({
426
+ if (showStop) onAbort?.();
427
+ else if (!isSubmitDisabled && !submitting) onSubmit({
401
428
  attachments,
402
429
  modelId,
403
430
  autoSelectModel,
@@ -406,9 +433,7 @@ export function MediaAnalyzer({
406
433
  });
407
434
  }}
408
435
  >
409
- <span slot="icon">
410
- <ArrowForwardIcon />
411
- </span>
436
+ <span slot="icon">{showStop ? <StopIcon /> : <ArrowForwardIcon />}</span>
412
437
  </MdFabElement>
413
438
  </div>
414
439
  </div>
@@ -447,6 +472,14 @@ function MediaAnalyzerThumbnail({
447
472
  // Same "trace and inline as a currentColor component" convention Composer.tsx
448
473
  // documents — icons aren't shared across files yet in this codebase.
449
474
 
475
+ /** "Replaced 2 images with clip.wav." */
476
+ function replacedNote(removed: MediaAnalyzerAttachment[], added: MediaAnalyzerAttachment[]): string {
477
+ const name = (a: MediaAnalyzerAttachment) => a.name ?? KIND_LABEL[a.kind].toLowerCase();
478
+ const describe = (list: MediaAnalyzerAttachment[]) =>
479
+ list.length === 1 ? name(list[0]!) : `${list.length} ${KIND_LABEL[list[0]!.kind].toLowerCase()}s`;
480
+ return `Replaced ${describe(removed)} with ${describe(added)}.`;
481
+ }
482
+
450
483
  function ArrowForwardIcon() {
451
484
  return (
452
485
  <svg viewBox="0 -960 960 960" aria-hidden="true">
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2
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";
4
+ import { CheckSymbolIcon, DownloadIcon, ExpandIcon, ShareIcon } from "./icons.js";
5
5
  import { downloadFilename, downloadMedia, shareMedia } from "./media-actions.js";
6
6
  import { PageSlide, usePageSlide } from "./page-slide.js";
7
7
 
@@ -125,6 +125,7 @@ export function ResultCard({
125
125
  const [flipped, setFlipped] = useState(false);
126
126
  const [moreOpen, setMoreOpen] = useState(false);
127
127
  const [copied, setCopied] = useState(false);
128
+ const [linkCopied, setLinkCopied] = useState(false);
128
129
  const [mediaExpanded, setMediaExpanded] = useState(false);
129
130
  // Not part of `Result` (no engine reports generated-media dimensions
130
131
  // today) — measured client-side off the loaded `<img>`/`<video>` itself
@@ -238,7 +239,11 @@ export function ResultCard({
238
239
  };
239
240
 
240
241
  const handleShare = async () => {
241
- if (result.output) await shareMedia(result.output.src);
242
+ // With no share sheet the link is copied; say so, or the click looks like nothing happened.
243
+ if (result.output && (await shareMedia(result.output.src)) === "copied") {
244
+ setLinkCopied(true);
245
+ setTimeout(() => setLinkCopied(false), 1500);
246
+ }
242
247
  onAction("share", result);
243
248
  };
244
249
 
@@ -274,6 +279,7 @@ export function ResultCard({
274
279
  mediaExpanded={mediaExpanded}
275
280
  onDownload={handleDownload}
276
281
  onShare={handleShare}
282
+ linkCopied={linkCopied}
277
283
  onExpand={() => setMediaExpanded(true)}
278
284
  onDimensions={(w, h) => setMeasured({ id: result.id, width: w, height: h })}
279
285
  videoTimeRef={videoTimeRef}
@@ -458,6 +464,7 @@ function ResultMedia({
458
464
  mediaExpanded,
459
465
  onDownload,
460
466
  onShare,
467
+ linkCopied,
461
468
  onExpand,
462
469
  onDimensions,
463
470
  videoTimeRef,
@@ -470,6 +477,7 @@ function ResultMedia({
470
477
  mediaExpanded: boolean;
471
478
  onDownload: () => void;
472
479
  onShare: () => void;
480
+ linkCopied: boolean;
473
481
  onExpand: () => void;
474
482
  onDimensions: (width: number, height: number) => void;
475
483
  videoTimeRef: React.MutableRefObject<number>;
@@ -555,8 +563,8 @@ function ResultMedia({
555
563
  <ResultIconButton variant="top" label="Download" disabled={disabled} onClick={onDownload}>
556
564
  <DownloadIcon />
557
565
  </ResultIconButton>
558
- <ResultIconButton variant="top" label="Share" disabled={disabled} onClick={onShare}>
559
- <ShareIcon />
566
+ <ResultIconButton variant="top" label={linkCopied ? "Link copied" : "Share"} disabled={disabled} onClick={onShare}>
567
+ {linkCopied ? <CheckSymbolIcon /> : <ShareIcon />}
560
568
  </ResultIconButton>
561
569
  {(isVideo || isImage) && (
562
570
  <ResultIconButton variant="top" label="Expand" disabled={disabled} onClick={onExpand}>
package/src/icons.tsx CHANGED
@@ -113,3 +113,17 @@ export function CloseSymbolIcon() {
113
113
  </svg>
114
114
  );
115
115
  }
116
+
117
+ // Real Material Symbols "stop" glyph, the filled variant specifically
118
+ // (`stop-fill`, not the base `stop` — that one traces a hollow square-
119
+ // outline shape even filled with `currentColor`, which read as a thin
120
+ // frame rather than a real stop icon at 24px; design review caught
121
+ // this live). Viewbox `0 -960 960 960` — the submit FAB's in-flight state,
122
+ // its own doc comment above where it's rendered.
123
+ export function StopIcon() {
124
+ return (
125
+ <svg viewBox="0 -960 960 960" aria-hidden="true">
126
+ <path d="M240-240v-480h480v480H240Z" fill="currentColor" />
127
+ </svg>
128
+ );
129
+ }
package/src/index.ts CHANGED
@@ -22,6 +22,13 @@ export {
22
22
  DEFAULT_MEDIA_ANALYSIS_PROMPT_LENGTH,
23
23
  MEDIA_ANALYSIS_PROMPTS,
24
24
  MEDIA_ANALYSIS_PROMPT_LENGTHS,
25
+ SUGGESTED_IMAGE_MODELS,
26
+ SUGGESTED_TEXT_TO_VIDEO_MODELS,
27
+ SUGGESTED_IMAGE_TO_VIDEO_MODELS,
28
+ SUGGESTED_EDIT_MODELS,
29
+ SUGGESTED_ANALYSIS_MODELS,
30
+ suggestedModels,
31
+ checkSuggestedModels,
25
32
  } from "@chai-ui/core";
26
33
  export type {
27
34
  DroppedMedia,
@@ -40,5 +47,8 @@ export type {
40
47
  Result,
41
48
  ResultStatus,
42
49
  Run,
50
+ SuggestedModelProblem,
51
+ SuggestedModelsCheck,
52
+ FalInputFormat,
43
53
  Usage,
44
54
  } from "@chai-ui/core";
@@ -24,16 +24,25 @@ export async function downloadMedia(src: string, filename: string): Promise<void
24
24
  }
25
25
  }
26
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> {
27
+ /**
28
+ * The native share sheet where there is one, otherwise the media's URL
29
+ * copied to the clipboard. Says which, so the button can confirm a copy.
30
+ */
31
+ export async function shareMedia(src: string): Promise<"shared" | "copied" | "none"> {
29
32
  if (navigator.share) {
30
33
  try {
31
34
  await navigator.share({ url: src });
35
+ return "shared";
32
36
  } catch {
33
37
  // Dismissed, or not shareable: same as closing a native share sheet.
38
+ return "none";
34
39
  }
35
- } else {
36
- navigator.clipboard?.writeText(src).catch(() => {});
40
+ }
41
+ try {
42
+ await navigator.clipboard.writeText(src);
43
+ return "copied";
44
+ } catch {
45
+ return "none";
37
46
  }
38
47
  }
39
48
 
package/src/style.css CHANGED
@@ -1178,11 +1178,26 @@
1178
1178
  --md-fab-secondary-icon-color: var(--chai-media-analyzer-text);
1179
1179
  }
1180
1180
 
1181
+ /* While analyzing, with `onAbort`: submit becomes stop, same as the Composer's.
1182
+ Before `[data-error]`, so an error still wins. */
1183
+ .chai-media-analyzer__submit[data-stop] {
1184
+ --md-fab-secondary-container-color: var(--chai-color-semantic-warning-subtle);
1185
+ --md-fab-secondary-icon-color: var(--chai-color-semantic-warning);
1186
+ }
1187
+
1181
1188
  .chai-media-analyzer__submit[data-error] {
1182
1189
  --md-fab-secondary-container-color: var(--chai-color-semantic-danger-subtle);
1183
1190
  --md-fab-secondary-icon-color: var(--chai-color-semantic-danger);
1184
1191
  }
1185
1192
 
1193
+ /* What the last attach replaced (one kind per analysis). */
1194
+ .chai-media-analyzer__note {
1195
+ margin: 0;
1196
+ padding: 2px 2px 0;
1197
+ color: var(--chai-media-analyzer-text);
1198
+ font-size: var(--chai-type-size-label-large);
1199
+ }
1200
+
1186
1201
  .chai-media-analyzer__error {
1187
1202
  margin: 0;
1188
1203
  padding: 2px 2px 0;