@anthusai/papyrus 5.43.0-next.16 → 5.43.0-next.18

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.
@@ -21,6 +21,9 @@ import type { RelevanceDecisionView } from "../lib/relevance-decisions";
21
21
  import { cn } from "../lib/utils";
22
22
  import { predictionFromDecision, type ReviewChoice } from "./reference-review-card";
23
23
  import { RatingGrid } from "./review-rating-grid";
24
+ import { CaptureBadge } from "./reference-capture-panel";
25
+ import { useRatingStore, type RatingController } from "./use-reference-ratings";
26
+ import type { CaptureStatus } from "../lib/reference-capture";
24
27
 
25
28
  /**
26
29
  * Flat surfaces, no borders: the page tone, a card tone one step away from it, a footer tone, and a control tone for chips and
@@ -62,20 +65,33 @@ export type ReferenceFeedProps = {
62
65
  predictionLoading?: boolean;
63
66
  previews: ReadonlyMap<string, ReferencePreview>;
64
67
  selectedLineageId?: string;
68
+ /** Capture status per Reference lineage, for the small badge on each card. Optional. */
69
+ captures?: ReadonlyMap<string, CaptureStatus>;
70
+ /** The sections and lead, when the caller keeps the order stable (a snapshot). Without them the feed groups its own partition. */
71
+ groups?: Array<{ key: FeedGroupKey; items: FeedItem[] }>;
72
+ leadId?: string;
73
+ /** Scrolls the grid to a card, deliberately (Previous / Next). A new nonce is a new request; selecting alone never scrolls. */
74
+ scrollTarget?: { id: string; nonce: number } | null;
75
+ /** A rating store shared with the detail pane, so a vote or reason in either shows in both. Optional. */
76
+ ratings?: RatingController;
65
77
  /** True when the cards are a saved copy that is being refreshed. */
66
78
  stale?: boolean;
67
79
  tab: ReferenceFeedTabKey;
68
80
  visitRated: ReadonlySet<string>;
69
81
  };
70
82
 
71
- export const FEED_GRID = "grid gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4";
83
+ /** Columns follow the width the feed has (a container query, not the screen), so they stay put while a detail pane holds its place beside it. */
84
+ export const FEED_GRID = "grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-5";
85
+ const GRID = FEED_GRID;
86
+ /** The lead spans two columns only where there are at least two to span (two 17rem columns and the gap). */
87
+ const LEAD_SPAN = "@[36rem]:col-span-2";
72
88
 
73
89
  /** Pulsing placeholder cards: the page shell is on screen at once, with no "checking" screen in front of it. */
74
90
  export function ReferenceFeedSkeleton({ count = 6 }: { count?: number }) {
75
91
  return (
76
- <div aria-busy="true" aria-label="Loading references" className={FEED_GRID} data-reference-feed-skeleton role="status" {...FEED_SURFACE_PROPS}>
92
+ <div aria-busy="true" aria-label="Loading references" className={cn("@container", GRID)} data-reference-feed-skeleton role="status" {...FEED_SURFACE_PROPS}>
77
93
  {Array.from({ length: count }, (_, index) => (
78
- <div className={cn("overflow-hidden rounded-[20px] bg-[var(--feed-card)]", index === 0 && "sm:col-span-2")} key={index}>
94
+ <div className={cn("overflow-hidden rounded-[20px] bg-[var(--feed-card)]", index === 0 && LEAD_SPAN)} key={index}>
79
95
  <div className={cn("animate-pulse bg-[var(--feed-control)]", index === 0 ? "aspect-[2/1]" : "aspect-[16/10]")} />
80
96
  <div className="space-y-3 p-5">
81
97
  <div className="h-3 w-1/3 animate-pulse rounded-full bg-[var(--feed-control)]" />
@@ -145,14 +161,17 @@ export function FeedImage({ host, lead, name, src }: { host: string | null; lead
145
161
  const element = ref.current;
146
162
  if (element?.complete && element.naturalWidth > 0) { setState("loaded"); measure(element); }
147
163
  }, [src]);
148
- const card = isCardStyleImage(src, ratio);
164
+ // The frame is decided before the picture loads (from its address), so loading never moves the cards around. A picture found to be
165
+ // a 2:1 text card afterwards is contained inside the same frame instead of changing it.
166
+ const hostCard = isCardStyleImage(src, null);
167
+ const card = hostCard || isCardStyleImage(src, ratio);
149
168
  return (
150
169
  <div
151
- className={cn("relative w-full overflow-hidden bg-[var(--feed-control)]", !card && (lead ? "aspect-[16/10] sm:aspect-[2/1] xl:aspect-[21/9]" : "aspect-[16/10]"))}
170
+ className={cn("relative w-full overflow-hidden bg-[var(--feed-control)]", !hostCard && (lead ? "aspect-[16/10] @[36rem]:aspect-[2/1] @[60rem]:aspect-[21/9]" : "aspect-[16/10]"))}
152
171
  data-feed-image
153
172
  data-feed-image-fit={card ? "contain" : "cover"}
154
173
  data-feed-image-state={state}
155
- style={card ? { aspectRatio: String(ratio ?? 2) } : undefined}
174
+ style={hostCard ? { aspectRatio: "2" } : undefined}
156
175
  >
157
176
  {state === "loading" ? <div aria-hidden="true" className="absolute inset-0 animate-pulse bg-[var(--feed-footer)] blur-sm" data-feed-image-placeholder /> : null}
158
177
  {src && state !== "failed" ? (
@@ -175,9 +194,8 @@ export function FeedImage({ host, lead, name, src }: { host: string | null; lead
175
194
  );
176
195
  }
177
196
 
178
- type RateState = { phase: "idle" | "saving" | "saved" | "error" | "reason"; message?: string };
179
-
180
197
  export function FeedCard({
198
+ capture,
181
199
  decision,
182
200
  disabledReason,
183
201
  item,
@@ -187,9 +205,12 @@ export function FeedCard({
187
205
  onRate,
188
206
  predictionLoading,
189
207
  preview,
208
+ ratings,
190
209
  selected,
191
210
  visitRated,
192
211
  }: {
212
+ /** Optional: the capture badge. */
213
+ capture?: CaptureStatus | undefined;
193
214
  decision: RelevanceDecisionView | undefined;
194
215
  disabledReason: string | null;
195
216
  item: FeedItem;
@@ -199,6 +220,8 @@ export function FeedCard({
199
220
  onRate: ReferenceFeedProps["onRate"];
200
221
  predictionLoading: boolean;
201
222
  preview: ReferencePreview | undefined;
223
+ /** Optional: a shared rating store. Without it the card keeps its own. */
224
+ ratings?: RatingController | undefined;
202
225
  selected: boolean;
203
226
  visitRated: boolean;
204
227
  }) {
@@ -206,8 +229,15 @@ export function FeedCard({
206
229
  const titleId = useId();
207
230
  const status = normalizeReferenceCurationStatus(reference.curationStatus);
208
231
  const answer: ReviewChoice | null = status === "accepted" ? "accept" : status === "rejected" ? "reject" : null;
209
- const [rate, setRate] = useState<RateState>({ phase: "idle" });
210
- const inFlight = useRef(false);
232
+ // Rating state lives in a store: the shared one when given (so the detail pane and this card stay in step), else a private one.
233
+ const local = useRatingStore({
234
+ save: async (_id, choice, detail) => { await onRate(reference, choice, detail.reasonCode); },
235
+ saved: () => answer,
236
+ seed: () => ({ note: "", reasonCode: null }),
237
+ });
238
+ const store = ratings ?? local;
239
+ const rating = store.get(item.lineageId);
240
+ const chosen = rating.pending ?? answer;
211
241
  const host = referenceSourceHost(reference);
212
242
  const site = preview?.siteName ?? host ?? "Unknown source";
213
243
  const title = referenceDisplayTitle(reference);
@@ -215,42 +245,26 @@ export function FeedCard({
215
245
  const showDek = dekAddsInformation(title, dek, preview?.imageUrl ?? null);
216
246
  const time = referenceDisplayTime(reference);
217
247
  const age = formatFeedAge(time, now);
218
-
219
- async function save(choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) {
220
- if (disabledReason || inFlight.current) return;
221
- inFlight.current = true;
222
- setRate({ phase: "saving" });
223
- try {
224
- await onRate(reference, choice, reasonCode);
225
- setRate({ phase: "saved" });
226
- } catch (failure) {
227
- setRate({ phase: "error", message: failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry." });
228
- } finally {
229
- inFlight.current = false;
230
- }
231
- }
232
-
233
- function choose(choice: ReviewChoice) {
234
- if (disabledReason) return;
235
- if (answer === choice) return; // the chosen answer never unselects itself; the other choice is the undo
236
- if (choice === "reject") { setRate({ phase: "reason" }); return; }
237
- void save("accept", null);
238
- }
248
+ const feedback = rating.feedback.phase === "idle" && visitRated && answer ? { phase: "saved" as const } : rating.feedback;
249
+ const reasonLabel = answer === "reject" && rating.reasonCode ? REASON_LABELS[rating.reasonCode] : null;
239
250
 
240
251
  return (
241
252
  <article
242
253
  aria-labelledby={titleId}
254
+ aria-current={selected ? "true" : undefined}
243
255
  className={cn(
244
- "flex flex-col overflow-hidden rounded-[20px] text-card-foreground",
245
- selected ? "bg-[var(--feed-control)]" : "bg-[var(--feed-card)]",
246
- lead && "sm:col-span-2",
256
+ "relative flex flex-col overflow-hidden rounded-[20px] text-card-foreground transition-[background-color,box-shadow] duration-200",
257
+ selected ? "bg-[var(--feed-control)] shadow-xl" : "bg-[var(--feed-card)]",
258
+ lead && LEAD_SPAN,
247
259
  )}
248
260
  data-feed-card={item.lineageId}
249
261
  data-feed-lead={lead || undefined}
250
262
  data-feed-rated={answer ?? undefined}
263
+ data-feed-selected={selected || undefined}
251
264
  data-feed-visit-rated={visitRated || undefined}
252
265
  data-newsroom-card-id={item.lineageId}
253
266
  >
267
+ {selected ? <span aria-hidden="true" className="absolute inset-y-0 left-0 z-10 w-1.5 bg-[var(--primary)]" data-feed-selected-bar /> : null}
254
268
  <button
255
269
  aria-label={`Open: ${title}`}
256
270
  className="flex flex-1 flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
@@ -259,42 +273,48 @@ export function FeedCard({
259
273
  type="button"
260
274
  >
261
275
  <FeedImage host={host} lead={lead} name={site} src={preview?.imageUrl ?? null} />
262
- <span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5 sm:p-7" : "p-5")}>
276
+ <span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5 @[36rem]:p-7" : "p-5")}>
263
277
  <span className="flex items-center gap-2 text-xs font-medium text-muted-foreground" data-feed-source>
264
278
  <SourceMark host={host} />
265
279
  <span className="truncate">{site}</span>
266
280
  </span>
267
- <span className={cn("font-bold leading-[1.15] tracking-tight text-foreground", lead ? "line-clamp-3 text-2xl sm:text-3xl xl:text-4xl" : "line-clamp-3 text-xl")} data-feed-title id={titleId}>
281
+ <span className={cn("font-bold leading-[1.15] tracking-tight text-foreground", lead ? "line-clamp-3 text-2xl @[36rem]:text-3xl @[60rem]:text-4xl" : "line-clamp-3 text-xl")} data-feed-title id={titleId}>
268
282
  {title}
269
283
  </span>
270
284
  {showDek ? <span className={cn("text-muted-foreground", lead ? "line-clamp-3 text-base" : "line-clamp-2 text-sm")} data-feed-dek>{dek}</span> : null}
271
- <span className="mt-auto pt-1 text-xs text-muted-foreground">
272
- {age ? <time dateTime={new Date(time ?? now).toISOString()} title={new Date(time ?? now).toLocaleString()}>{age}</time> : null}
273
- {age && host ? <span aria-hidden="true"> · </span> : null}
274
- {host}
285
+ <span className="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 pt-1 text-xs text-muted-foreground">
286
+ <span>
287
+ {age ? <time dateTime={new Date(time ?? now).toISOString()} title={new Date(time ?? now).toLocaleString()}>{age}</time> : null}
288
+ {age && host ? <span aria-hidden="true"> · </span> : null}
289
+ {host}
290
+ </span>
291
+ {capture ? <CaptureBadge status={capture} /> : null}
275
292
  </span>
276
293
  </span>
277
294
  </button>
278
295
 
279
- <div className="space-y-2.5 bg-[var(--feed-footer)] p-3" data-feed-actions>
296
+ <div className="relative bg-[var(--feed-footer)] p-3" data-feed-actions>
280
297
  <RatingGrid
281
- answer={answer}
298
+ answer={chosen}
299
+ decided={answer}
282
300
  disabled={Boolean(disabledReason)}
283
301
  disabledReason={disabledReason}
284
- feedback={rate.phase === "saving" ? { phase: "saving" } : rate.phase === "error" ? { phase: "error", message: rate.message } : rate.phase === "saved" || (visitRated && rate.phase === "idle" && answer) ? { phase: "saved" } : { phase: "idle" }}
285
- onChoose={choose}
302
+ feedback={feedback}
303
+ note={reasonLabel ? `Reason: ${reasonLabel}` : null}
304
+ onChoose={(choice) => { if (!disabledReason) store.choose(item.lineageId, choice); }}
286
305
  prediction={decision ? predictionFromDecision(decision) : predictionLoading ? { state: "loading" } : { state: "none" }}
287
306
  />
288
- {rate.phase === "reason" ? (
289
- <div className="space-y-2" data-review-reasons>
307
+ {rating.askReason ? (
308
+ // Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
309
+ <div className="absolute inset-0 z-10 flex flex-col justify-center space-y-2 bg-[var(--feed-footer)] p-3" data-review-reasons>
290
310
  <p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
291
311
  <div className="flex flex-wrap gap-1.5">
292
312
  {REFERENCE_REJECTION_REASON_CODES.map((code) => (
293
- <button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => void save("reject", code)} type="button">
313
+ <button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => store.setReason(item.lineageId, code)} type="button">
294
314
  {REASON_LABELS[code]}
295
315
  </button>
296
316
  ))}
297
- <button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => setRate({ phase: "idle" })} type="button">Cancel</button>
317
+ <button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => store.cancelReason(item.lineageId)} type="button">Cancel</button>
298
318
  </div>
299
319
  </div>
300
320
  ) : null}
@@ -380,6 +400,11 @@ export function ReferenceFeed({
380
400
  predictionLoading = false,
381
401
  previews,
382
402
  selectedLineageId = "",
403
+ captures,
404
+ groups: stableGroups,
405
+ leadId: stableLeadId,
406
+ ratings,
407
+ scrollTarget = null,
383
408
  stale = false,
384
409
  tab,
385
410
  visitRated,
@@ -388,6 +413,7 @@ export function ReferenceFeed({
388
413
 
389
414
  const renderCard = (item: FeedItem, lead: boolean) => (
390
415
  <FeedCard
416
+ capture={captures?.get(item.lineageId)}
391
417
  decision={item.decision}
392
418
  disabledReason={disabledReason}
393
419
  item={item}
@@ -398,6 +424,7 @@ export function ReferenceFeed({
398
424
  onRate={async (reference, choice, reasonCode) => { onVisitRated(item.lineageId); await onRate(reference, choice, reasonCode); }}
399
425
  predictionLoading={predictionLoading}
400
426
  preview={previews.get(item.lineageId)}
427
+ ratings={ratings}
401
428
  selected={selectedLineageId === item.lineageId}
402
429
  visitRated={visitRated.has(item.lineageId)}
403
430
  />
@@ -407,12 +434,18 @@ export function ReferenceFeed({
407
434
  <div className="rounded-[20px] bg-[var(--feed-card)] p-10 text-center text-sm text-muted-foreground" data-feed-empty>{label}</div>
408
435
  );
409
436
 
410
- const groups = groupFeedItems(partition.feed);
437
+ // Previous / Next in the detail pane scrolls the grid to the same card, smoothly. Selecting a card by clicking it never scrolls.
438
+ useEffect(() => {
439
+ if (!scrollTarget || typeof document === "undefined") return;
440
+ document.querySelector(`[data-feed-card="${CSS.escape(scrollTarget.id)}"]`)?.scrollIntoView({ behavior: "smooth", block: "nearest" });
441
+ }, [scrollTarget]);
442
+
443
+ const groups = stableGroups ?? groupFeedItems(partition.feed);
411
444
  const headings = groups.length > 1;
412
- const leadId = groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined;
445
+ const leadId = stableGroups ? stableLeadId : groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined;
413
446
 
414
447
  return (
415
- <div className="space-y-5" data-reference-feed data-feed-tab={tab} {...FEED_SURFACE_PROPS}>
448
+ <div className="@container space-y-5" data-reference-feed data-feed-tab={tab} {...FEED_SURFACE_PROPS}>
416
449
  <div className="flex items-center justify-between gap-2">
417
450
  <div aria-label="Feed sections" className="inline-flex rounded-full bg-[var(--feed-card)] p-1" role="tablist">
418
451
  {(Object.keys(TAB_LABELS) as ReferenceFeedTabKey[]).map((key) => (
@@ -13,6 +13,7 @@ import { useEffect, useRef, useState, type ReactNode, type PointerEvent as React
13
13
  import { Button } from "./ui/button";
14
14
  import { cn } from "../lib/utils";
15
15
  import { RatingGrid } from "./review-rating-grid";
16
+ import { useRatingStore, type RatingController } from "./use-reference-ratings";
16
17
  import { REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
17
18
 
18
19
  export type ReviewChoice = "accept" | "reject";
@@ -141,8 +142,11 @@ export function ReviewCard({
141
142
  summary,
142
143
  title,
143
144
  titleHref,
145
+ ratings,
144
146
  }: {
145
147
  children?: ReactNode;
148
+ /** A shared rating store, so the grid card and this card stay in step. Optional: the card works alone without it. */
149
+ ratings?: RatingController;
146
150
  /** The saved human answer, or null while the item is undecided. */
147
151
  decided: ReviewChoice | null;
148
152
  /** When set, the controls explain in words why they cannot change anything. */
@@ -157,70 +161,21 @@ export function ReviewCard({
157
161
  title: string;
158
162
  titleHref?: string | null;
159
163
  }) {
160
- const [draft, setDraft] = useState<ReviewChoice | null>(null);
161
- const [note, setNote] = useState(initialNote);
162
- const [reason, setReason] = useState<ReferenceRejectionReasonCode | null>(initialReason);
163
- const [saving, setSaving] = useState(false);
164
- const [saved, setSaved] = useState(false);
165
- const [error, setError] = useState<string | null>(null);
166
- const inFlight = useRef(false);
167
- const noteTouched = useRef(false);
168
- const reasonTouched = useRef(false);
169
- const savedNote = useRef(initialNote);
170
-
171
- // Saved values from outside (another editor, a reload) refresh whatever the human has not touched.
172
- useEffect(() => {
173
- savedNote.current = initialNote;
174
- if (!noteTouched.current) setNote(initialNote);
175
- }, [initialNote]);
176
- useEffect(() => {
177
- if (!reasonTouched.current) setReason(initialReason);
178
- }, [initialReason]);
179
-
180
- const chosen = draft ?? decided;
181
-
182
- async function save(choice: ReviewChoice, nextReason: ReferenceRejectionReasonCode | null, nextNote: string) {
183
- if (disabledReason || inFlight.current) return;
184
- if (choice === "reject" && !nextReason) return;
185
- inFlight.current = true;
186
- setSaving(true);
187
- setSaved(false);
188
- setError(null);
189
- try {
190
- await onDecide(choice, { note: nextNote.trim(), reasonCode: choice === "reject" ? nextReason : null });
191
- savedNote.current = nextNote.trim();
192
- noteTouched.current = false;
193
- reasonTouched.current = false;
194
- setDraft(null);
195
- setSaved(true);
196
- } catch (failure) {
197
- setDraft(null);
198
- setError(failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry.");
199
- } finally {
200
- inFlight.current = false;
201
- setSaving(false);
202
- }
203
- }
204
-
205
- function choose(choice: ReviewChoice) {
206
- if (disabledReason) return;
207
- if (chosen === choice && draft === null) return; // the selected answer never unselects itself
208
- setError(null);
209
- setDraft(choice);
210
- void save(choice, reason, note);
211
- }
212
-
213
- function chooseReason(code: ReferenceRejectionReasonCode) {
214
- if (disabledReason) return;
215
- setReason(code);
216
- reasonTouched.current = true;
217
- if (chosen === "reject") void save("reject", code, note);
218
- }
219
-
220
- function commitNote() {
221
- if (!decided || disabledReason || note.trim() === savedNote.current.trim()) return;
222
- void save(decided, reason, note);
223
- }
164
+ // The rating state lives in a store. Given one (`ratings`), the grid card and this card share it, so a vote or a reason typed in
165
+ // either shows in both at once; without one the card keeps a private store, exactly as before.
166
+ const local = useRatingStore({
167
+ save: (_id, choice, detail) => onDecide(choice, detail),
168
+ saved: () => decided,
169
+ seed: () => ({ note: initialNote, reasonCode: initialReason }),
170
+ });
171
+ const store = ratings ?? local;
172
+ const item = store.get(itemId);
173
+ const note = item.note ?? initialNote;
174
+ const reason = item.reasonCode !== undefined ? item.reasonCode : initialReason;
175
+ const chosen = item.pending ?? decided;
176
+ const choose = (choice: ReviewChoice) => { if (!disabledReason) store.choose(itemId, choice); };
177
+ const chooseReason = (code: ReferenceRejectionReasonCode) => { if (!disabledReason) store.setReason(itemId, code); };
178
+ const commitNote = () => { if (!disabledReason) store.commitNote(itemId); };
224
179
 
225
180
  // Desktop shortcuts: A accepts, R rejects. Never while typing.
226
181
  useEffect(() => {
@@ -253,7 +208,7 @@ export function ReviewCard({
253
208
  decided={decided}
254
209
  disabled={Boolean(disabledReason)}
255
210
  disabledReason={disabledReason}
256
- feedback={saving ? { phase: "saving" } : error ? { phase: "error", message: error } : saved ? { phase: "saved" } : { phase: "idle" }}
211
+ feedback={item.feedback}
257
212
  onChoose={choose}
258
213
  prediction={prediction}
259
214
  size="large"
@@ -266,7 +221,7 @@ export function ReviewCard({
266
221
  aria-label="Why? (optional)"
267
222
  className="mt-1 block min-h-24 w-full rounded-md border bg-background p-3 text-base font-normal"
268
223
  onBlur={commitNote}
269
- onChange={(event) => { setNote(event.target.value); noteTouched.current = true; setSaved(false); }}
224
+ onChange={(event) => store.setNote(itemId, event.target.value)}
270
225
  placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
271
226
  value={note}
272
227
  />
@@ -42,6 +42,7 @@ export function RatingGrid({
42
42
  disabled = false,
43
43
  disabledReason = null,
44
44
  feedback = { phase: "idle" },
45
+ note = null,
45
46
  onChoose,
46
47
  prediction,
47
48
  showPrediction = true,
@@ -55,6 +56,8 @@ export function RatingGrid({
55
56
  disabled?: boolean;
56
57
  disabledReason?: string | null;
57
58
  feedback?: RatingFeedback;
59
+ /** A short line shown in the status row beside the Saved tick (for example the reason). It never adds a row. */
60
+ note?: string | null;
58
61
  onChoose: (choice: ReviewChoice) => void;
59
62
  prediction: CardPrediction;
60
63
  /** False for a card with nothing to predict (an AI draft): the Cyclotron row and the "You" label are left out and only the two buttons remain. */
@@ -128,11 +131,10 @@ export function RatingGrid({
128
131
  ) : null}
129
132
  <span aria-hidden="true" className={cn("absolute inset-y-0 left-1/2 w-1 -translate-x-1/2", surface === "footer" ? "bg-[var(--feed-footer)]" : "bg-[var(--feed-card)]")} />
130
133
  </div>
131
- {prediction.state === "ready" ? null : (
132
- <small className="text-center text-xs text-muted-foreground" data-cyclotron-state={prediction.state}>
133
- {prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
134
- </small>
135
- )}
134
+ {/* The line is always there, so a prediction arriving later never changes the card's height. */}
135
+ <small className="min-h-4 text-center text-xs text-muted-foreground" data-cyclotron-state={prediction.state === "ready" ? undefined : prediction.state}>
136
+ {prediction.state === "ready" ? "\u00a0" : prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
137
+ </small>
136
138
  </div>
137
139
  </div> : null}
138
140
 
@@ -163,7 +165,8 @@ export function RatingGrid({
163
165
  })}
164
166
  </div>
165
167
 
166
- <div className={cn("min-h-5 text-xs text-muted-foreground", showPrediction ? "col-span-3" : "col-span-2")} data-feed-saved role="status">
168
+ <div className={cn("flex min-h-5 flex-wrap items-center gap-x-3 text-xs text-muted-foreground", showPrediction ? "col-span-3" : "col-span-2")} data-feed-saved role="status">
169
+ {note ? <span data-feed-reason>{note}</span> : null}
167
170
  {feedback.phase === "saving" ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Saving…</span> : null}
168
171
  {feedback.phase === "saved" ? <span className="inline-flex items-center gap-1" data-review-saved><CheckIcon aria-hidden="true" className="size-3.5" />Saved</span> : null}
169
172
  </div>
@@ -94,6 +94,7 @@ import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
94
94
  import { ReferenceSourcePreview } from "./reference-source-preview";
95
95
  import type { ReaderAuthSnapshot } from "./reader-auth-state";
96
96
  import { NewsDeskAccessGate } from "./newsroom-access-gate";
97
+ import { correctReferenceCapture } from "../lib/content-actions-client";
97
98
  import { loadMessagesWithHeadStart, loadReferencesWithHeadStart, loadRelationsWithHeadStart } from "./references-prefetch";
98
99
  import { loadFeedSnapshot, saveFeedSnapshot, type FeedSnapshot } from "../lib/reference-feed-cache";
99
100
  import { referencePreviewsByLineage } from "../lib/reference-preview";
@@ -3213,6 +3214,7 @@ function NewsDeskDashboard({
3213
3214
  referenceAttachments={referenceAttachments}
3214
3215
  references={references}
3215
3216
  loading={!dashboard.isDemo && !hasHydratedReferences}
3217
+ onCorrectCapture={dashboard.isDemo ? undefined : (input) => correctReferenceCapture(input)}
3216
3218
  savedCopy={savedFeedCopy}
3217
3219
  />
3218
3220
  </NewsroomCurationBootstrap>
@@ -0,0 +1,132 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
5
+ import type { ReviewChoice } from "./reference-review-card";
6
+ import type { RatingFeedback } from "./review-rating-grid";
7
+
8
+ /** How long after the last keystroke a typed reason is saved on its own. */
9
+ export const NOTE_AUTOSAVE_MS = 1200;
10
+
11
+ export type ItemRating = {
12
+ /** The reason as typed, else as saved. In the store itself it stays undefined until touched, so an untouched field follows the record. */
13
+ note?: string;
14
+ reasonCode?: ReferenceRejectionReasonCode | null;
15
+ /** A vote that is waiting for a reason (Reject needs one) or is still saving. */
16
+ pending: ReviewChoice | null;
17
+ /** True while a Reject is waiting for its reason. */
18
+ askReason: boolean;
19
+ feedback: RatingFeedback;
20
+ };
21
+
22
+ const EMPTY: ItemRating = { pending: null, askReason: false, feedback: { phase: "idle" } };
23
+
24
+ export type RatingSave = (id: string, choice: ReviewChoice, detail: { note: string; reasonCode: ReferenceRejectionReasonCode | null }) => Promise<void> | void;
25
+
26
+ export type RatingController = {
27
+ get: (id: string) => ItemRating;
28
+ choose: (id: string, choice: ReviewChoice) => void;
29
+ setReason: (id: string, code: ReferenceRejectionReasonCode) => void;
30
+ setNote: (id: string, text: string) => void;
31
+ /** Save a typed reason now (on blur). */
32
+ commitNote: (id: string) => void;
33
+ cancelReason: (id: string) => void;
34
+ };
35
+
36
+ /**
37
+ * One store for the rating state of every item, shared by the grid card and the detail pane so a vote or a reason typed in either
38
+ * shows in both at once. `saved(id)` is the saved human answer; `seed(id)` the saved note and reason code.
39
+ * vote, then reason -> the reason is saved onto the existing decision (on blur, or after a short pause)
40
+ * reason, then vote -> the vote carries the reason
41
+ * A Reject needs a reason code (the backend refuses one without), so it waits as `pending` until one is chosen; a typed reason is
42
+ * kept in the store until it is saved and is never dropped by a failed save.
43
+ */
44
+ export function useRatingStore({ save, saved, seed }: {
45
+ save: RatingSave;
46
+ saved: (id: string) => ReviewChoice | null;
47
+ seed: (id: string) => { note: string; reasonCode: ReferenceRejectionReasonCode | null };
48
+ }): RatingController {
49
+ const [items, setItems] = useState<Record<string, ItemRating>>({});
50
+ const itemsRef = useRef(items);
51
+ const latest = useRef({ save, saved, seed });
52
+ latest.current = { save, saved, seed };
53
+ const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
54
+ const inFlight = useRef<Set<string>>(new Set());
55
+ const dirty = useRef<Set<string>>(new Set());
56
+
57
+ const patch = useCallback((id: string, change: Partial<ItemRating>) => {
58
+ const next = { ...itemsRef.current, [id]: { ...(itemsRef.current[id] ?? EMPTY), ...change } };
59
+ itemsRef.current = next;
60
+ setItems(next);
61
+ }, []);
62
+
63
+ // What a reader of the store sees: the touched value, else what is saved. (A saved reason therefore shows on the card too.)
64
+ const get = useCallback((id: string): ItemRating => {
65
+ const item = items[id] ?? EMPTY;
66
+ const seeded = latest.current.seed(id);
67
+ return { ...item, note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
68
+ }, [items]);
69
+
70
+ const values = (id: string) => {
71
+ const item = itemsRef.current[id] ?? EMPTY;
72
+ const seeded = latest.current.seed(id);
73
+ return { note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
74
+ };
75
+
76
+ const commit = useCallback(async (id: string, choice: ReviewChoice) => {
77
+ const { note, reasonCode } = values(id);
78
+ if (choice === "reject" && !reasonCode) { patch(id, { pending: "reject", askReason: true }); return; }
79
+ if (inFlight.current.has(id)) { dirty.current.add(id); return; }
80
+ inFlight.current.add(id);
81
+ dirty.current.delete(id);
82
+ const started = itemsRef.current[id] ?? EMPTY;
83
+ patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
84
+ try {
85
+ await latest.current.save(id, choice, { note: note.trim(), reasonCode: choice === "reject" ? reasonCode : null });
86
+ // What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept.
87
+ const now = itemsRef.current[id] ?? EMPTY;
88
+ patch(id, { pending: null, feedback: { phase: "saved" }, ...(now.note === started.note ? { note: undefined } : {}), ...(now.reasonCode === started.reasonCode ? { reasonCode: undefined } : {}) });
89
+ } catch (failure) {
90
+ // The typed reason stays in the store; only the vote is undone.
91
+ patch(id, { pending: null, feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry." } });
92
+ } finally {
93
+ inFlight.current.delete(id);
94
+ }
95
+ if (dirty.current.has(id)) { dirty.current.delete(id); const again = itemsRef.current[id]?.pending ?? latest.current.saved(id); if (again) void commit(id, again); }
96
+ }, [patch]);
97
+
98
+ const effective = (id: string): ReviewChoice | null => itemsRef.current[id]?.pending ?? latest.current.saved(id);
99
+
100
+ const choose = useCallback((id: string, choice: ReviewChoice) => {
101
+ if (effective(id) === choice && !itemsRef.current[id]?.askReason) return; // the chosen answer never unselects itself
102
+ void commit(id, choice);
103
+ }, [commit]);
104
+
105
+ const setReason = useCallback((id: string, code: ReferenceRejectionReasonCode) => {
106
+ patch(id, { reasonCode: code });
107
+ // Vote then reason: onto the existing (or waiting) decision. Reason then vote: stored, and the vote carries it.
108
+ if (effective(id) === "reject") void commit(id, "reject");
109
+ }, [commit, patch]);
110
+
111
+ const setNote = useCallback((id: string, text: string) => {
112
+ patch(id, { note: text, feedback: { phase: "idle" } });
113
+ dirty.current.add(`note:${id}`);
114
+ clearTimeout(timers.current[id]);
115
+ timers.current[id] = setTimeout(() => {
116
+ const choice = effective(id);
117
+ if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
118
+ }, NOTE_AUTOSAVE_MS);
119
+ }, [commit, patch]);
120
+
121
+ const commitNote = useCallback((id: string) => {
122
+ clearTimeout(timers.current[id]);
123
+ const choice = effective(id);
124
+ if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
125
+ }, [commit]);
126
+
127
+ const cancelReason = useCallback((id: string) => patch(id, { pending: null, askReason: false }), [patch]);
128
+
129
+ useEffect(() => () => { for (const timer of Object.values(timers.current)) clearTimeout(timer); }, []);
130
+
131
+ return { get, choose, setReason, setNote, commitNote, cancelReason };
132
+ }
@@ -30,6 +30,18 @@ export type SaveItemDraftResult = { ok: true; item: SavedItemSummary };
30
30
  export type PublishItemResult = { ok: true; changed: boolean; publishedId: string; versionNumber: number | null };
31
31
  export type UnpublishItemResult = { ok: true; changed: boolean };
32
32
 
33
+ export type CaptureAssessment = "complete" | "incomplete" | "incorrect";
34
+ export type CorrectReferenceCaptureInput = {
35
+ referenceId: string;
36
+ /** The full corrected or pasted text. */
37
+ text?: string;
38
+ grobid?: Record<string, unknown>;
39
+ /** Required with text; alone it is just a label on the capture. */
40
+ assessment?: CaptureAssessment;
41
+ note?: string;
42
+ };
43
+ export type CorrectReferenceCaptureResult = { ok: true; referenceId: string; status: string; edits: unknown[]; label: unknown; summaryStale: boolean };
44
+
33
45
  export class ContentActionFailure extends Error {
34
46
  readonly errors: ContentActionError[];
35
47
 
@@ -48,6 +60,7 @@ export type ContentActionsTransport = {
48
60
  saveItemDraft: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
49
61
  publishItem: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
50
62
  unpublishItem: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
63
+ correctReferenceCapture: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
51
64
  };
52
65
  };
53
66
 
@@ -80,6 +93,9 @@ export function createContentActionsClient(transport: ContentActionsTransport) {
80
93
  async publishItem(id: string): Promise<PublishItemResult> {
81
94
  return parseActionResponse(await transport.mutations.publishItem({ input: JSON.stringify({ id }) }, USER_POOL));
82
95
  },
96
+ async correctReferenceCapture(input: CorrectReferenceCaptureInput): Promise<CorrectReferenceCaptureResult> {
97
+ return parseActionResponse(await transport.mutations.correctReferenceCapture({ input: JSON.stringify(input) }, USER_POOL));
98
+ },
83
99
  async unpublishItem(id: string): Promise<UnpublishItemResult> {
84
100
  return parseActionResponse(await transport.mutations.unpublishItem({ input: JSON.stringify({ id }) }, USER_POOL));
85
101
  },
@@ -99,3 +115,5 @@ export const deriveMarkus = (input: DeriveMarkusInput) => contentActionsClient()
99
115
  export const saveItemDraft = (input: SaveItemDraftInput) => contentActionsClient().saveItemDraft(input);
100
116
  export const publishItem = (id: string) => contentActionsClient().publishItem(id);
101
117
  export const unpublishItem = (id: string) => contentActionsClient().unpublishItem(id);
118
+
119
+ export const correctReferenceCapture = (input: CorrectReferenceCaptureInput) => contentActionsClient().correctReferenceCapture(input);