@chai-ui/react 0.4.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/EditCard.tsx CHANGED
@@ -44,8 +44,20 @@ export interface EditCardProps {
44
44
  onRegionsChange: (next: EditRegion[]) => void;
45
45
  /** The most regions the chosen model takes (`ModelOption.maxRegions`). "New region" is disabled at this many. Defaults to 6. */
46
46
  maxRegions?: number;
47
- /** Width in px. Defaults to 421. The height follows the image. */
47
+ /** A fixed starting width in px, fitted within `maxWidth` and `maxHeight`. Without it (and without `scale`), the card is as large as fits in that box. */
48
48
  width?: number;
49
+ /**
50
+ * Sizes the card from the image's own pixel width: `0.3` makes it 30% as
51
+ * wide as the image (a 5000px photo gives a 1500px card). The height
52
+ * follows the image's shape, and the card is never wider than its
53
+ * container. Each version is measured on its own, since an edit can come
54
+ * back a different size.
55
+ */
56
+ scale?: number;
57
+ /** The widest the card gets, in px. Defaults to 630. With no `width` or `scale`, the card fills `maxWidth` × `maxHeight`, keeping the image's shape. */
58
+ maxWidth?: number;
59
+ /** The tallest the card gets, in px. Defaults to 630. A tall image narrows the card to fit, keeping its shape. */
60
+ maxHeight?: number;
49
61
  /** Alt text for the image. Defaults to "Image being edited". */
50
62
  alt?: string;
51
63
  /** Fires after the card downloads or shares the version showing. */
@@ -131,6 +143,9 @@ export function EditCard({
131
143
  onRegionsChange,
132
144
  maxRegions = DEFAULT_MAX_REGIONS,
133
145
  width,
146
+ scale,
147
+ maxWidth = 630,
148
+ maxHeight = 630,
134
149
  alt = "Image being edited",
135
150
  onAction,
136
151
  disabled = false,
@@ -149,7 +164,11 @@ export function EditCard({
149
164
  const [zoom, setZoom] = useState(1);
150
165
  const [pan, setPan] = useState<Pan>({ x: 0, y: 0 });
151
166
  const [size, setSize] = useState<Size>({ width: 0, height: 0 });
167
+ // The shown image's own pixel size, for `scale` and the max height. Kept
168
+ // until the next version's image loads, so the card doesn't jump between.
169
+ const [natural, setNatural] = useState<{ width: number; height: number } | null>(null);
152
170
  const [expanded, setExpanded] = useState(false);
171
+ const [linkCopied, setLinkCopied] = useState(false);
153
172
  const drag = useRef<Drag | null>(null);
154
173
  const viewportRef = useRef<HTMLDivElement>(null);
155
174
  const fieldRef = useRef<HTMLTextAreaElement>(null);
@@ -353,7 +372,11 @@ export function EditCard({
353
372
 
354
373
  const handleShare = async () => {
355
374
  if (!version?.src) return;
356
- 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
+ }
357
380
  onAction?.("share", version);
358
381
  };
359
382
 
@@ -379,11 +402,18 @@ export function EditCard({
379
402
 
380
403
  if (!version) return null;
381
404
 
405
+ // The starting width (from `scale`, else `width`), then fitted inside
406
+ // maxWidth × maxHeight with the image's shape kept.
407
+ // With neither, the card fills the box.
408
+ const baseWidth = scale != null && natural ? natural.width * scale : (width ?? maxWidth);
409
+ const heightCap = natural && natural.height > 0 ? (maxHeight * natural.width) / natural.height : Infinity;
410
+ const cardWidth = Math.round(Math.min(baseWidth, maxWidth, heightCap));
411
+
382
412
  return (
383
413
  <>
384
414
  <div
385
415
  className="chai-edit-card"
386
- style={width != null ? ({ "--chai-edit-card-width": `${width}px` } as CSSProperties) : undefined}
416
+ style={{ "--chai-edit-card-width": `${cardWidth}px` } as CSSProperties}
387
417
  >
388
418
  <div
389
419
  ref={viewportRef}
@@ -403,7 +433,13 @@ export function EditCard({
403
433
  style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, "--chai-edit-zoom": zoom } as CSSProperties}
404
434
  >
405
435
  {version.src || version.from ? (
406
- <img className="chai-edit-card__image" src={version.src ?? version.from} alt={alt} draggable={false} />
436
+ <img
437
+ className="chai-edit-card__image"
438
+ src={version.src ?? version.from}
439
+ alt={alt}
440
+ draggable={false}
441
+ onLoad={(e) => setNatural({ width: e.currentTarget.naturalWidth, height: e.currentTarget.naturalHeight })}
442
+ />
407
443
  ) : (
408
444
  <div className="chai-edit-card__placeholder" />
409
445
  )}
@@ -478,8 +514,15 @@ export function EditCard({
478
514
  <button type="button" className="chai-card-top-btn" aria-label="Download" title="Download" disabled={disabled || !ready} onClick={handleDownload}>
479
515
  <DownloadIcon />
480
516
  </button>
481
- <button type="button" className="chai-card-top-btn" aria-label="Share" title="Share" disabled={disabled || !ready} onClick={handleShare}>
482
- <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 />}
483
526
  </button>
484
527
  <button type="button" className="chai-card-top-btn" aria-label="Expand" title="Expand" disabled={!ready} onClick={() => setExpanded(true)}>
485
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;
@@ -1825,7 +1840,7 @@
1825
1840
  regions, so both move and scale together. */
1826
1841
 
1827
1842
  .chai-edit-card {
1828
- width: var(--chai-edit-card-width, 421px);
1843
+ width: var(--chai-edit-card-width, 630px);
1829
1844
  max-width: 100%;
1830
1845
  display: flex;
1831
1846
  flex-direction: column;
@@ -128,8 +128,12 @@ export function useComposer(options: UseComposerOptions = {}) {
128
128
  const [activeVersion, setActiveVersion] = useState(0);
129
129
  // Another image starts over. Reset while rendering, not in an effect (the rules of React).
130
130
  const [editSource, setEditSource] = useState<string | null>(editImage?.src ?? null);
131
+ // Numbers each image, so its original version has its own id and EditCard
132
+ // starts it clean (no half-drawn region from the last image).
133
+ const [editImageCount, setEditImageCount] = useState(0);
131
134
  if ((editImage?.src ?? null) !== editSource) {
132
135
  setEditSource(editImage?.src ?? null);
136
+ setEditImageCount((n) => n + 1);
133
137
  setEditRuns([]);
134
138
  setRegions([]);
135
139
  setActiveVersion(0);
@@ -138,7 +142,7 @@ export function useComposer(options: UseComposerOptions = {}) {
138
142
  () =>
139
143
  editImage
140
144
  ? [
141
- { id: "original", src: editImage.src, status: "done", regions: [] },
145
+ { id: `original-${editImageCount}`, src: editImage.src, status: "done", regions: [] },
142
146
  ...editRuns.flatMap((run) =>
143
147
  run.results.map((r) => ({
144
148
  id: r.id,
@@ -151,7 +155,7 @@ export function useComposer(options: UseComposerOptions = {}) {
151
155
  ),
152
156
  ]
153
157
  : [],
154
- [editImage, editRuns]
158
+ [editImage, editRuns, editImageCount]
155
159
  );
156
160
  const editRef = useLatest({ versions, activeVersion, editRuns });
157
161
  // Each submit gets an id, so routing that settles after a newer submit is dropped.