@anthusai/papyrus 5.45.0 → 5.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/amplify/data/resource.js +6 -0
  2. package/amplify/data/resource.ts +7 -0
  3. package/amplify/data/schema.js +6 -0
  4. package/amplify/data/schema.ts +7 -0
  5. package/amplify/functions/content-actions/handler.py +8 -0
  6. package/bin/papyrus-app.mjs +12 -1
  7. package/components/news-desk-client-provider.tsx +2 -2
  8. package/components/newsroom-ops-overview.tsx +63 -60
  9. package/components/newsroom-ops-shell.tsx +3 -1
  10. package/components/newsroom-organizer.css +1 -2
  11. package/components/newsroom-references-view.tsx +119 -7
  12. package/components/notification-cards.tsx +364 -0
  13. package/components/notification-center.tsx +319 -331
  14. package/components/reference-capture-client.ts +110 -0
  15. package/components/reference-capture-panel.tsx +180 -0
  16. package/components/reference-feed.tsx +93 -60
  17. package/components/reference-review-card.tsx +21 -66
  18. package/components/references-prefetch.ts +5 -0
  19. package/components/review-rating-grid.tsx +27 -14
  20. package/components/topic-steering-workspace.tsx +12 -2
  21. package/components/use-reference-ratings.ts +132 -0
  22. package/lib/category-repository.ts +2 -0
  23. package/lib/content-actions-client.ts +18 -0
  24. package/lib/local-preview-feed-fixture.ts +20 -0
  25. package/lib/newsroom-nav.ts +1 -1
  26. package/lib/newsroom-organizer-nav.ts +1 -1
  27. package/lib/notification-feed.ts +250 -0
  28. package/lib/reference-capture.ts +293 -0
  29. package/lib/reference-feed-cache.ts +37 -0
  30. package/lib/reference-feed-ranking.ts +44 -0
  31. package/lib/review-drafts.ts +8 -3
  32. package/lib/site-brand.ts +2 -0
  33. package/package.json +7 -1
  34. package/routes.manifest.json +1 -1
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import type { ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
5
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
6
+ import {
7
+ captureStatus, countWords, curationCardOf, grobidRows, primaryTextAttachment, structuredRows,
8
+ type CaptureAssessment as Assessment, type CaptureStatus, type CurationCard,
9
+ } from "../lib/reference-capture";
10
+ import { loadModelPayloadsForOwner, loadStoragePathText } from "./news-desk-taxonomy-client";
11
+
12
+ export type CaptureAssessment = Assessment;
13
+
14
+ export type CaptureView = {
15
+ status: CaptureStatus;
16
+ /** The stored text, once read. Null while loading, or when there is none. */
17
+ text: string | null;
18
+ loading: boolean;
19
+ error: string | null;
20
+ summary: string | null;
21
+ /** True when an editor replaced the text and the summary awaits the next curation run. */
22
+ summaryStale: boolean;
23
+ rows: Array<{ label: string; value: string }>;
24
+ /** Saves an editor's pasted or corrected text and their verdict on the capture (the `correctReferenceCapture` action). */
25
+ correct?: (input: { text?: string; assessment: CaptureAssessment; note?: string }) => Promise<void>;
26
+ };
27
+
28
+ function previewText(attachment: ReferenceAttachmentRecord): string | null {
29
+ // Local preview only: a fixture can carry the captured text inline instead of in storage.
30
+ if (!isLocalPreviewEnabled()) return null;
31
+ const raw = attachment.metadata;
32
+ try {
33
+ const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
34
+ const text = (parsed as { previewText?: unknown } | null)?.previewText;
35
+ return typeof text === "string" ? text : null;
36
+ } catch { return null; }
37
+ }
38
+
39
+ type Payloads = { key: string; text: string | null; grobid: unknown; summary: string | null; loaded: boolean };
40
+
41
+ /**
42
+ * Reads what the newsroom stored for one Reference. The curation card comes first (status, word count, signals, summary, GROBID fields,
43
+ * and the `curation-text` body its `current` points at); the older `extracted_text` attachment is the fallback.
44
+ */
45
+ export type CorrectCaptureAction = (input: { referenceId: string; text?: string; assessment: CaptureAssessment; note?: string }) => Promise<unknown>;
46
+
47
+ export function useReferenceCapture(reference: ReferenceRecord, attachments: readonly ReferenceAttachmentRecord[], teaserLine: number, correctAction?: CorrectCaptureAction): CaptureView {
48
+ const card: CurationCard | null = useMemo(() => curationCardOf(attachments), [attachments]);
49
+ const primary = useMemo(() => primaryTextAttachment(attachments), [attachments]);
50
+ const textSortKey = card?.current?.textSortKey ?? card?.capture.textSortKey ?? null;
51
+ const grobidSortKey = card?.current?.grobidSortKey ?? card?.grobid?.fieldsSortKey ?? null;
52
+ const [payloads, setPayloads] = useState<Payloads>({ key: "", text: null, grobid: null, summary: null, loaded: false });
53
+ const [fallback, setFallback] = useState<{ key: string; text: string | null; error: string | null; loading: boolean }>({ key: "", text: null, error: null, loading: false });
54
+ const [corrected, setCorrected] = useState<{ key: string; text: string; assessment: CaptureAssessment } | null>(null);
55
+ const referenceKey = `${reference.id}:${textSortKey ?? ""}:${grobidSortKey ?? ""}`;
56
+
57
+ // The card's bodies and the Reference's metadata.json, in one read of the Reference's ModelAttachments.
58
+ useEffect(() => {
59
+ if (isLocalPreviewEnabled()) { setPayloads({ key: referenceKey, text: null, grobid: null, summary: null, loaded: true }); return; }
60
+ let active = true;
61
+ void loadModelPayloadsForOwner("reference", reference.id, ["metadata"])
62
+ .then((rows) => {
63
+ if (!active) return;
64
+ const bySortKey = (key: string | null) => (key ? rows.find((row) => row.attachment.sortKey === key) : undefined);
65
+ const metadata = rows.find((row) => row.attachment.sortKey === "metadata")?.json as { summary?: unknown } | null | undefined;
66
+ setPayloads({
67
+ key: referenceKey,
68
+ text: bySortKey(textSortKey)?.text ?? null,
69
+ grobid: bySortKey(grobidSortKey)?.json ?? null,
70
+ summary: typeof metadata?.summary === "string" && metadata.summary.trim() ? metadata.summary.trim() : null,
71
+ loaded: true,
72
+ });
73
+ })
74
+ .catch(() => { if (active) setPayloads({ key: referenceKey, text: null, grobid: null, summary: null, loaded: true }); });
75
+ return () => { active = false; };
76
+ }, [reference.id, referenceKey, textSortKey, grobidSortKey]);
77
+
78
+ // Fallback: the older extracted_text attachment, when the card has no text of its own.
79
+ const fallbackKey = primary ? `${primary.id}:${primary.storagePath ?? ""}` : "";
80
+ const useFallback = Boolean(primary) && !textSortKey;
81
+ useEffect(() => {
82
+ if (!useFallback || !primary) { setFallback({ key: "", text: null, error: null, loading: false }); return; }
83
+ const inline = previewText(primary);
84
+ if (inline !== null) { setFallback({ key: fallbackKey, text: inline, error: null, loading: false }); return; }
85
+ let active = true;
86
+ setFallback({ key: fallbackKey, text: null, error: null, loading: true });
87
+ void loadStoragePathText(primary.storagePath)
88
+ .then((result) => { if (active) setFallback({ key: fallbackKey, text: result.text, error: result.error, loading: false }); })
89
+ .catch((error) => { if (active) setFallback({ key: fallbackKey, text: null, error: error instanceof Error ? error.message : "Could not read the stored text.", loading: false }); });
90
+ return () => { active = false; };
91
+ }, [primary, fallbackKey, useFallback]);
92
+
93
+ const current = payloads.key === referenceKey ? payloads : null;
94
+ const mine = corrected && corrected.key === reference.id ? corrected : null;
95
+ const text = mine?.text ?? (textSortKey ? current?.text ?? null : fallback.key === fallbackKey ? fallback.text : null);
96
+ const loading = mine ? false : textSortKey ? !(current?.loaded) : useFallback && fallback.key === fallbackKey ? fallback.loading : useFallback;
97
+ const error = textSortKey || mine ? null : fallback.key === fallbackKey ? fallback.error : null;
98
+ const loadedWords = text !== null ? countWords(text) : null;
99
+ const base = captureStatus(attachments, teaserLine, loadedWords);
100
+ const status: CaptureStatus = mine ? { ...base, state: base.state === "none" || base.state === "running" ? (loadedWords !== null && loadedWords < teaserLine ? "thin" : "full") : base.state, words: loadedWords, estimated: false, assessment: mine.assessment, summaryStale: true, ...(loadedWords !== null && loadedWords < teaserLine ? { thinBy: "length" as const } : {}) } : base;
101
+ const summary = card?.summary?.status === "completed" && card.summary.text ? card.summary.text : current?.summary ?? null;
102
+ const rows = card ? grobidRows(current?.grobid, card) : structuredRows(attachments);
103
+
104
+ const correct = useCallback(async (input: { text?: string; assessment: CaptureAssessment; note?: string }) => {
105
+ if (correctAction) await correctAction({ referenceId: reference.id, ...(input.text !== undefined ? { text: input.text } : {}), assessment: input.assessment, ...(input.note ? { note: input.note } : {}) });
106
+ setCorrected({ key: reference.id, text: input.text ?? text ?? "", assessment: input.assessment });
107
+ }, [reference.id, text, correctAction]);
108
+
109
+ return { status, text, loading, error, summary, summaryStale: Boolean(mine) || status.summaryStale === true, rows, correct: correctAction || isLocalPreviewEnabled() ? correct : undefined };
110
+ }
@@ -0,0 +1,180 @@
1
+ "use client";
2
+
3
+ import { FileCheckIcon, FileClockIcon, FileWarningIcon, FileXIcon, Loader2Icon } from "lucide-react";
4
+ import { useEffect, useState } from "react";
5
+ import { captureLine, type CaptureAssessment, type CaptureState, type CaptureStatus } from "../lib/reference-capture";
6
+ import { cn } from "../lib/utils";
7
+ import type { CaptureView } from "./reference-capture-client";
8
+
9
+ const ICON = { full: FileCheckIcon, thin: FileWarningIcon, none: FileXIcon, running: FileClockIcon } as const;
10
+ /** Colour never stands alone: each state has its own icon and words. */
11
+ const TONE: Record<CaptureState, string> = {
12
+ full: "bg-emerald-600/15 text-emerald-900 dark:bg-emerald-400/15 dark:text-emerald-100",
13
+ thin: "bg-amber-500/20 text-amber-900 dark:bg-amber-300/15 dark:text-amber-100",
14
+ none: "bg-red-600/15 text-red-900 dark:bg-red-400/15 dark:text-red-100",
15
+ running: "bg-[var(--feed-control)] text-foreground",
16
+ };
17
+
18
+ /** A small badge for a grid card, so a thin or missing capture shows before the item is opened. It follows the curation status when there is one. */
19
+ export function CaptureBadge({ status }: { status: CaptureStatus }) {
20
+ const Icon = ICON[status.state];
21
+ const text = status.state === "none" ? "No text" : status.state === "running" ? "Capturing" : status.state === "thin" ? (status.words === null || status.thinBy === "signals" ? "Maybe cut off" : `${status.words.toLocaleString("en-US")} words`) : "Full text";
22
+ return (
23
+ <span
24
+ className={cn("inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.7rem] font-medium", TONE[status.state])}
25
+ data-capture-badge={status.state}
26
+ data-capture-curation={status.curation ?? undefined}
27
+ data-capture-source={status.source}
28
+ title={captureLine(status).label}
29
+ >
30
+ <Icon aria-hidden="true" className="size-3.5" />
31
+ {text}
32
+ </span>
33
+ );
34
+ }
35
+
36
+ const PREVIEW_CHARACTERS = 1600;
37
+ const ASSESSMENTS: Array<{ key: CaptureAssessment; label: string; hint: string }> = [
38
+ { key: "complete", label: "Complete", hint: "This is the whole item." },
39
+ { key: "incomplete", label: "Incomplete", hint: "Something is missing or cut off." },
40
+ { key: "incorrect", label: "Incorrect", hint: "This is not the right text." },
41
+ ];
42
+
43
+ /** "Paste or correct the text": an editor's own text and verdict on the capture, saved with `correctReferenceCapture`. */
44
+ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: () => void }) {
45
+ const [text, setText] = useState(capture.text ?? "");
46
+ const [assessment, setAssessment] = useState<CaptureAssessment | null>(null);
47
+ const [note, setNote] = useState("");
48
+ const [phase, setPhase] = useState<"idle" | "saving" | "error">("idle");
49
+ const [message, setMessage] = useState<string | null>(null);
50
+ useEffect(() => { setText((current) => current || capture.text || ""); }, [capture.text]);
51
+ const changedText = text.trim() !== (capture.text ?? "").trim() && text.trim().length > 0;
52
+ const reason = !assessment ? "Say whether the capture was complete, incomplete or incorrect to save." : null;
53
+ async function save() {
54
+ if (!assessment || !capture.correct) return;
55
+ setPhase("saving");
56
+ setMessage(null);
57
+ try {
58
+ await capture.correct({ ...(changedText ? { text: text.trim() } : {}), assessment, ...(note.trim() ? { note: note.trim() } : {}) });
59
+ onDone();
60
+ } catch (failure) {
61
+ setPhase("error");
62
+ setMessage(failure instanceof Error ? failure.message : "The correction could not be saved.");
63
+ }
64
+ }
65
+ return (
66
+ <div className="space-y-3 rounded-2xl bg-[var(--feed-control)] p-3" data-capture-correction>
67
+ <label className="block text-sm font-medium">
68
+ The text
69
+ <textarea
70
+ aria-label="Captured text"
71
+ className="mt-1 block min-h-40 w-full rounded-2xl bg-[var(--feed-card)] p-3 text-sm font-normal"
72
+ data-capture-correction-text
73
+ onChange={(event) => setText(event.target.value)}
74
+ placeholder="Paste the whole article here, or fix what was captured."
75
+ value={text}
76
+ />
77
+ </label>
78
+ <div aria-label="Was the capture complete?" className="flex flex-wrap gap-2" role="group">
79
+ {ASSESSMENTS.map((option) => (
80
+ <button
81
+ aria-pressed={assessment === option.key}
82
+ className={cn("inline-flex min-h-9 items-center rounded-full px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", assessment === option.key ? "bg-[var(--primary)] text-[var(--primary-foreground)]" : "bg-[var(--feed-card)]")}
83
+ data-capture-assessment={option.key}
84
+ key={option.key}
85
+ onClick={() => setAssessment(option.key)}
86
+ title={option.hint}
87
+ type="button"
88
+ >
89
+ {option.label}
90
+ </button>
91
+ ))}
92
+ </div>
93
+ <label className="block text-sm font-medium">
94
+ Note (optional)
95
+ <textarea aria-label="Note about the capture (optional)" className="mt-1 block min-h-16 w-full rounded-2xl bg-[var(--feed-card)] p-3 text-sm font-normal" onChange={(event) => setNote(event.target.value)} value={note} />
96
+ </label>
97
+ {reason ? <p className="text-xs text-muted-foreground" data-capture-correction-reason>{reason}</p> : null}
98
+ {phase === "error" ? <p className="text-xs text-destructive" role="alert">{message}</p> : null}
99
+ <div className="flex gap-2">
100
+ <button
101
+ aria-disabled={reason ? true : undefined}
102
+ className={cn("inline-flex min-h-9 items-center gap-1.5 rounded-full bg-[var(--primary)] px-4 text-sm font-semibold text-[var(--primary-foreground)] outline-none focus-visible:ring-2 focus-visible:ring-ring", reason && "opacity-60")}
103
+ data-capture-correction-save
104
+ onClick={() => { if (!reason && phase !== "saving") void save(); }}
105
+ type="button"
106
+ >
107
+ {phase === "saving" ? <><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Saving…</> : changedText ? "Save the text and my verdict" : "Save my verdict"}
108
+ </button>
109
+ <button className="inline-flex min-h-9 items-center rounded-full bg-[var(--feed-card)] px-4 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={onDone} type="button">Cancel</button>
110
+ </div>
111
+ </div>
112
+ );
113
+ }
114
+
115
+ /** Our captured record of a Reference: what we stored of its content, said plainly, above the live source preview. */
116
+ export function ReferenceCapturePanel({ capture }: { capture: CaptureView }) {
117
+ const [expanded, setExpanded] = useState(false);
118
+ const [editing, setEditing] = useState(false);
119
+ const [savedNote, setSavedNote] = useState(false);
120
+ const line = captureLine(capture.status);
121
+ const Icon = ICON[line.state];
122
+ const text = capture.text ?? "";
123
+ const long = text.length > PREVIEW_CHARACTERS;
124
+ return (
125
+ <section aria-label="Our captured record" className="space-y-3" data-capture-panel={line.state}>
126
+ <h3 className="text-sm font-semibold text-foreground">Our captured record</h3>
127
+ <div className={cn("flex items-start gap-3 rounded-2xl p-3", TONE[line.state])} data-capture-status={line.state} data-capture-source={capture.status.source}>
128
+ <Icon aria-hidden="true" className="mt-0.5 size-5 shrink-0" />
129
+ <div className="min-w-0">
130
+ <p className="text-sm font-semibold" data-capture-label>{line.label}</p>
131
+ {line.state === "full" ? null : <p className="text-xs opacity-90" data-capture-detail>{line.detail}</p>}
132
+ {capture.status.words !== null ? <p className="text-xs font-medium" data-capture-words>{capture.status.estimated ? "about " : ""}{capture.status.words.toLocaleString("en-US")} words</p> : null}
133
+ {line.state === "full" && capture.status.flags?.length ? <p className="text-xs opacity-90" data-capture-flags>{capture.status.flags.join(" ")}</p> : null}
134
+ {capture.status.curation === "degraded" ? <p className="text-xs opacity-90" data-capture-curation-note>Curation finished all but one stage (usually the summary).</p> : null}
135
+ {capture.status.assessment ? <p className="text-xs font-medium" data-capture-assessment-note>An editor marked this capture {capture.status.assessment}.</p> : null}
136
+ </div>
137
+ </div>
138
+ {capture.loading ? <p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" role="status"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Reading the stored text…</p> : null}
139
+ {capture.error ? <p className="text-xs text-destructive" role="alert">The stored text could not be read. {capture.error}</p> : null}
140
+ {text ? (
141
+ <div className="space-y-2">
142
+ <div className="max-h-80 overflow-y-auto whitespace-pre-wrap rounded-2xl bg-[var(--feed-control)] p-3 text-sm leading-relaxed" data-capture-text tabIndex={0}>
143
+ {expanded || !long ? text : `${text.slice(0, PREVIEW_CHARACTERS)}…`}
144
+ </div>
145
+ {long ? (
146
+ <button className="rounded-full bg-[var(--feed-control)] px-3 py-1.5 text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring" data-capture-toggle onClick={() => setExpanded((value) => !value)} type="button">
147
+ {expanded ? "Show less" : "Show all of the captured text"}
148
+ </button>
149
+ ) : null}
150
+ </div>
151
+ ) : null}
152
+ {capture.summary ? (
153
+ <div className="space-y-1" data-capture-summary>
154
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Summary{capture.summaryStale ? " (out of date: the text was replaced)" : ""}</h4>
155
+ <p className="text-sm">{capture.summary}</p>
156
+ </div>
157
+ ) : capture.summaryStale ? <p className="text-xs text-muted-foreground" data-capture-summary-stale>The summary will be rewritten from the new text on the next curation run.</p> : null}
158
+ {capture.rows.length ? (
159
+ <dl className="grid gap-2 rounded-2xl bg-[var(--feed-control)] p-3 text-sm" data-capture-structured>
160
+ {capture.rows.map((row) => (
161
+ <div key={`${row.label}:${row.value}`}>
162
+ <dt className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{row.label}</dt>
163
+ <dd className="mt-0.5">{row.value}</dd>
164
+ </div>
165
+ ))}
166
+ </dl>
167
+ ) : null}
168
+ {capture.correct ? (
169
+ editing ? <CorrectionForm capture={capture} onDone={() => { setEditing(false); setSavedNote(true); }} /> : (
170
+ <div className="flex flex-wrap items-center gap-2">
171
+ <button className="inline-flex min-h-9 items-center rounded-full bg-[var(--feed-control)] px-4 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring" data-capture-correct onClick={() => { setSavedNote(false); setEditing(true); }} type="button">
172
+ Paste or correct the text
173
+ </button>
174
+ {savedNote ? <span className="text-xs text-muted-foreground" data-capture-correction-saved role="status">Saved. Your verdict trains the completeness check.</span> : null}
175
+ </div>
176
+ )
177
+ ) : null}
178
+ </section>
179
+ );
180
+ }
@@ -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
@@ -45,7 +48,7 @@ const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
45
48
  const TAB_LABELS: Record<ReferenceFeedTabKey, string> = { feed: "Feed", rated: "Rated", earlier: "Earlier" };
46
49
  const GROUP_HEADINGS: Record<FeedGroupKey, string> = { top: "Top predictions", more: "More for you", least: "Least likely" };
47
50
 
48
- const PILL = "inline-flex min-h-9 items-center justify-center gap-1.5 rounded-full px-3.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring";
51
+ export const PILL = "inline-flex min-h-9 items-center justify-center gap-1.5 rounded-full px-3.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring";
49
52
 
50
53
  export type ReferenceFeedProps = {
51
54
  /** Read-only reason shown in words when answering is not possible yet (for example while the session is confirmed). */
@@ -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
- const 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={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)]" />
@@ -100,7 +116,7 @@ function hueOf(name: string): number {
100
116
  }
101
117
 
102
118
  /** A small mark for a source: its own favicon on a light tile when it loads, else its first letter. */
103
- function SourceMark({ host, size = "sm" }: { host: string | null; size?: "sm" | "lg" }) {
119
+ export function SourceMark({ host, size = "sm" }: { host: string | null; size?: "sm" | "lg" }) {
104
120
  const [failed, setFailed] = useState(false);
105
121
  const box = size === "lg" ? "size-16 rounded-2xl text-3xl" : "size-5 rounded-md text-[0.65rem]";
106
122
  const letter = (host ?? "?")[0];
@@ -134,7 +150,7 @@ function FallbackTile({ host, name }: { host: string | null; name: string }) {
134
150
  * Photos fill the frame (cover). A text card (a repository social card, or a 2:1 picture) is shown whole instead: the frame takes
135
151
  * the picture's own ratio and the picture is contained, so no words are cut off at the edges.
136
152
  */
137
- function FeedImage({ host, lead, name, src }: { host: string | null; lead: boolean; name: string; src: string | null }) {
153
+ export function FeedImage({ host, lead, name, src }: { host: string | null; lead: boolean; name: string; src: string | null }) {
138
154
  const [state, setState] = useState<"loading" | "loaded" | "failed">(src ? "loading" : "failed");
139
155
  const [ratio, setRatio] = useState<number | null>(null);
140
156
  const ref = useRef<HTMLImageElement | null>(null);
@@ -145,14 +161,17 @@ function FeedImage({ host, lead, name, src }: { host: string | null; lead: boole
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 @@ function FeedImage({ host, lead, name, src }: { host: string | null; lead: boole
175
194
  );
176
195
  }
177
196
 
178
- type RateState = { phase: "idle" | "saving" | "saved" | "error" | "reason"; message?: string };
179
-
180
- function FeedCard({
197
+ export function FeedCard({
198
+ capture,
181
199
  decision,
182
200
  disabledReason,
183
201
  item,
@@ -187,9 +205,12 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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}
@@ -364,7 +384,7 @@ function RankingPopover({ freshness }: { freshness: FeedFreshness }) {
364
384
  );
365
385
  }
366
386
 
367
- function SectionHeading({ children, id }: { children: ReactNode; id: string }) {
387
+ export function SectionHeading({ children, id }: { children: ReactNode; id: string }) {
368
388
  return <h3 className="mb-5 mt-10 text-3xl font-bold tracking-tight text-primary first:mt-2 sm:mt-12 sm:text-4xl" id={id}>{children}</h3>;
369
389
  }
370
390
 
@@ -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) => (
@@ -448,20 +481,20 @@ export function ReferenceFeed({
448
481
  {groups.length ? groups.map((group) => (
449
482
  <section aria-labelledby={headings ? `feed-group-${group.key}` : undefined} data-feed-group={group.key} key={group.key}>
450
483
  {headings ? <SectionHeading id={`feed-group-${group.key}`}>{GROUP_HEADINGS[group.key]}</SectionHeading> : null}
451
- <div className={GRID} data-feed-grid>{group.items.map((item) => renderCard(item, item.lineageId === leadId))}</div>
484
+ <div className={FEED_GRID} data-feed-grid>{group.items.map((item) => renderCard(item, item.lineageId === leadId))}</div>
452
485
  </section>
453
486
  )) : empty(counts.rated || counts.earlier ? "You are all caught up. Check Earlier and Rated." : "No references yet.")}
454
487
  {partition.earlier.length ? (
455
488
  <section aria-labelledby="feed-earlier-heading" data-feed-earlier>
456
489
  <SectionHeading id="feed-earlier-heading">Earlier</SectionHeading>
457
490
  <p className="-mt-3 mb-5 text-sm text-muted-foreground">Unrated and past their freshness. Still yours to rate.</p>
458
- <div className={GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div>
491
+ <div className={FEED_GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div>
459
492
  </section>
460
493
  ) : null}
461
494
  </div>
462
495
  ) : null}
463
- {tab === "rated" ? (partition.rated.length ? <div className={GRID} data-feed-grid>{partition.rated.map((item) => renderCard(item, false))}</div> : empty("Nothing rated yet. Your Accepts and Rejects collect here, newest first.")) : null}
464
- {tab === "earlier" ? (partition.earlier.length ? <div className={GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div> : empty("Nothing has aged out. Unrated items that fall past their freshness collect here.")) : null}
496
+ {tab === "rated" ? (partition.rated.length ? <div className={FEED_GRID} data-feed-grid>{partition.rated.map((item) => renderCard(item, false))}</div> : empty("Nothing rated yet. Your Accepts and Rejects collect here, newest first.")) : null}
497
+ {tab === "earlier" ? (partition.earlier.length ? <div className={FEED_GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div> : empty("Nothing has aged out. Unrated items that fall past their freshness collect here.")) : null}
465
498
  </div>
466
499
  )}
467
500
  </div>