@anthusai/papyrus 5.45.0 → 5.47.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 +5 -2
  11. package/components/newsroom-references-view.tsx +120 -8
  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 +94 -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 +31 -15
  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,49 @@ 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)] px-4 pb-4 pt-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" }}
306
+ statusOverlay
287
307
  />
288
- {rate.phase === "reason" ? (
289
- <div className="space-y-2" data-review-reasons>
308
+ {rating.askReason ? (
309
+ // Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
310
+ <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
311
  <p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
291
312
  <div className="flex flex-wrap gap-1.5">
292
313
  {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">
314
+ <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
315
  {REASON_LABELS[code]}
295
316
  </button>
296
317
  ))}
297
- <button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => setRate({ phase: "idle" })} type="button">Cancel</button>
318
+ <button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => store.cancelReason(item.lineageId)} type="button">Cancel</button>
298
319
  </div>
299
320
  </div>
300
321
  ) : null}
@@ -364,7 +385,7 @@ function RankingPopover({ freshness }: { freshness: FeedFreshness }) {
364
385
  );
365
386
  }
366
387
 
367
- function SectionHeading({ children, id }: { children: ReactNode; id: string }) {
388
+ export function SectionHeading({ children, id }: { children: ReactNode; id: string }) {
368
389
  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
390
  }
370
391
 
@@ -380,6 +401,11 @@ export function ReferenceFeed({
380
401
  predictionLoading = false,
381
402
  previews,
382
403
  selectedLineageId = "",
404
+ captures,
405
+ groups: stableGroups,
406
+ leadId: stableLeadId,
407
+ ratings,
408
+ scrollTarget = null,
383
409
  stale = false,
384
410
  tab,
385
411
  visitRated,
@@ -388,6 +414,7 @@ export function ReferenceFeed({
388
414
 
389
415
  const renderCard = (item: FeedItem, lead: boolean) => (
390
416
  <FeedCard
417
+ capture={captures?.get(item.lineageId)}
391
418
  decision={item.decision}
392
419
  disabledReason={disabledReason}
393
420
  item={item}
@@ -398,6 +425,7 @@ export function ReferenceFeed({
398
425
  onRate={async (reference, choice, reasonCode) => { onVisitRated(item.lineageId); await onRate(reference, choice, reasonCode); }}
399
426
  predictionLoading={predictionLoading}
400
427
  preview={previews.get(item.lineageId)}
428
+ ratings={ratings}
401
429
  selected={selectedLineageId === item.lineageId}
402
430
  visitRated={visitRated.has(item.lineageId)}
403
431
  />
@@ -407,12 +435,18 @@ export function ReferenceFeed({
407
435
  <div className="rounded-[20px] bg-[var(--feed-card)] p-10 text-center text-sm text-muted-foreground" data-feed-empty>{label}</div>
408
436
  );
409
437
 
410
- const groups = groupFeedItems(partition.feed);
438
+ // Previous / Next in the detail pane scrolls the grid to the same card, smoothly. Selecting a card by clicking it never scrolls.
439
+ useEffect(() => {
440
+ if (!scrollTarget || typeof document === "undefined") return;
441
+ document.querySelector(`[data-feed-card="${CSS.escape(scrollTarget.id)}"]`)?.scrollIntoView({ behavior: "smooth", block: "nearest" });
442
+ }, [scrollTarget]);
443
+
444
+ const groups = stableGroups ?? groupFeedItems(partition.feed);
411
445
  const headings = groups.length > 1;
412
- const leadId = groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined;
446
+ const leadId = stableGroups ? stableLeadId : groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined;
413
447
 
414
448
  return (
415
- <div className="space-y-5" data-reference-feed data-feed-tab={tab} {...FEED_SURFACE_PROPS}>
449
+ <div className="@container space-y-5" data-reference-feed data-feed-tab={tab} {...FEED_SURFACE_PROPS}>
416
450
  <div className="flex items-center justify-between gap-2">
417
451
  <div aria-label="Feed sections" className="inline-flex rounded-full bg-[var(--feed-card)] p-1" role="tablist">
418
452
  {(Object.keys(TAB_LABELS) as ReferenceFeedTabKey[]).map((key) => (
@@ -448,20 +482,20 @@ export function ReferenceFeed({
448
482
  {groups.length ? groups.map((group) => (
449
483
  <section aria-labelledby={headings ? `feed-group-${group.key}` : undefined} data-feed-group={group.key} key={group.key}>
450
484
  {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>
485
+ <div className={FEED_GRID} data-feed-grid>{group.items.map((item) => renderCard(item, item.lineageId === leadId))}</div>
452
486
  </section>
453
487
  )) : empty(counts.rated || counts.earlier ? "You are all caught up. Check Earlier and Rated." : "No references yet.")}
454
488
  {partition.earlier.length ? (
455
489
  <section aria-labelledby="feed-earlier-heading" data-feed-earlier>
456
490
  <SectionHeading id="feed-earlier-heading">Earlier</SectionHeading>
457
491
  <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>
492
+ <div className={FEED_GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div>
459
493
  </section>
460
494
  ) : null}
461
495
  </div>
462
496
  ) : 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}
497
+ {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}
498
+ {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
499
  </div>
466
500
  )}
467
501
  </div>