@anthusai/papyrus 5.50.0 → 5.52.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.
@@ -1,6 +1,5 @@
1
1
  "use client";
2
2
 
3
- import { SearchIcon } from "lucide-react";
4
3
  import { Button } from "./ui/button";
5
4
  import { Separator } from "./ui/separator";
6
5
  import { cn } from "../lib/utils";
@@ -179,27 +178,6 @@ export function NewsroomOpsShell({ activeTab, appTitle, backHref = "/", backLabe
179
178
  </div>;
180
179
  }
181
180
 
182
- export function NewsroomOpsSearchButton({
183
- disabled,
184
- onPress,
185
- }: {
186
- disabled?: boolean;
187
- onPress?: () => void;
188
- }) {
189
- return (
190
- <Button
191
- aria-label="Search knowledge base"
192
- disabled={disabled}
193
- onClick={onPress}
194
- size="icon-sm"
195
- type="button"
196
- variant="outline"
197
- >
198
- <SearchIcon className="size-4" />
199
- </Button>
200
- );
201
- }
202
-
203
181
  export function NewsroomOpsStatusBanner({
204
182
  children,
205
183
  tone = "info",
@@ -417,3 +417,22 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
417
417
  [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-review-queue-bar] { background: var(--feed-footer) !important; }
418
418
  /* The rating grid keeps its own rounded rectangles in the borderless panes (not the pill shape the flat rules give other buttons). */
419
419
  [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-rating-grid] :is(button, [data-cyclotron-choice]) { border-radius: var(--feed-radius-md) !important; }
420
+
421
+ /* Flat chrome (Ryan, 2026-10-28): the shell has no outlines or rules either. Controls are tone fills in rounded rectangles; on a phone the
422
+ page's own heading names the page, so the bar keeps one search field and the account button, nothing cut off. */
423
+ .newsroom-organizer,.organizer-menu,.organizer-account-popup{--org-control:color-mix(in oklab,var(--org-paper) 90%,var(--org-ink))}
424
+ .organizer-sidebar{border-right:0}
425
+ .organizer-search{border-bottom:0;background:var(--org-control);border-radius:8px;padding:6px 10px}
426
+ .newsroom-organizer button.organizer-toggle,.newsroom-organizer button.organizer-account{border:0;background:var(--org-control);border-radius:8px !important}
427
+ .organizer-account-popup{border:0}
428
+ .organizer-menu .organizer-destinations a{border:0;background:var(--org-control);border-radius:8px !important}
429
+ @media(max-width:900px){
430
+ .organizer-topbar h1{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
431
+ .organizer-search{flex:1;max-width:none}
432
+ .organizer-header-settings{display:none}
433
+ .organizer-bottom-nav{border-top:0;background:color-mix(in oklab,var(--org-paper) 95%,var(--org-ink))}
434
+ }
435
+
436
+ /* Captured text in the reference detail reads in the feed's own type, not the analysis view's serif and small caps. */
437
+ [data-capture-text] .reference-capture-markdown,[data-capture-text] .reference-capture-markdown :is(h1,h2,h3,h4,h5,h6,p,li,th,td){font-family:inherit;letter-spacing:normal;text-transform:none}
438
+ [data-capture-text] .reference-capture-markdown :is(h3,h4,h5){font-size:1em;font-weight:600;margin:1em 0 .25em}
@@ -9,7 +9,7 @@ import { usePathname } from "next/navigation";
9
9
  import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
10
10
  import { Badge } from "./ui/badge";
11
11
  import { Button } from "./ui/button";
12
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
12
+ import { Card, CardDescription, CardHeader, CardTitle } from "./ui/card";
13
13
  import { ScrollArea } from "./ui/scroll-area";
14
14
  import { Separator } from "./ui/separator";
15
15
  import {
@@ -661,7 +661,6 @@ export function NewsroomReferencesView({
661
661
  return (
662
662
  <div
663
663
  className="flex min-h-0 flex-1 flex-col gap-4 md:max-h-[100dvh] md:flex-row"
664
- data-detail-reserved={view === "feed" ? "true" : undefined}
665
664
  data-news-desk-section="references"
666
665
  data-detail-open={selectedReference || routeLineageId ? "true" : "false"}
667
666
  >
@@ -762,8 +761,9 @@ export function NewsroomReferencesView({
762
761
  </button>
763
762
  </div>
764
763
  ) : null}
764
+ {/* 6px of room on every side, so a selected card's 4px ring (drawn outside the card) is never cut off by the scroller. */}
765
765
  <ScrollArea className="min-h-0 flex-1 [overflow-anchor:none]" data-reference-feed-scroll>
766
- <div className="pb-6 pr-1">
766
+ <div className="px-1.5 pb-6 pt-1.5 pr-2.5">
767
767
  <ReferenceFeed
768
768
  disabledReason={feedDisabledReason}
769
769
  loading={previewSkeleton || (loading && !references.length)}
@@ -871,7 +871,9 @@ export function NewsroomReferencesView({
871
871
 
872
872
  <div
873
873
  className={cn(
874
- "hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:flex md:w-[min(100%,24rem)] md:pb-16 lg:w-[min(100%,28rem)]",
874
+ "hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:w-[min(100%,24rem)] md:pb-16 lg:w-[min(100%,28rem)]",
875
+ // The pane appears once a reference is selected (or when the queue is done); until then the feed has the whole width.
876
+ (selectedReference || caughtUp) && "md:flex",
875
877
  )}
876
878
  data-newsroom-list-detail-shell
877
879
  data-news-desk-section="references"
@@ -907,11 +909,7 @@ export function NewsroomReferencesView({
907
909
  previewAttachments={previewAttachments}
908
910
  reference={selectedReference}
909
911
  />
910
- ) : caughtUp ? caughtUpPanel : (
911
- <CardContent className="p-4 text-sm text-muted-foreground">
912
- Select a reference to inspect curation details.
913
- </CardContent>
914
- )}
912
+ ) : caughtUp ? caughtUpPanel : null}
915
913
  </ScrollArea>
916
914
  </div>
917
915
  {queueBar}
@@ -36,7 +36,9 @@ export function ReaderAuthControl({
36
36
  if (!nextPath) return;
37
37
  if (window.location.pathname !== "/" || window.location.search || window.location.hash) return;
38
38
  if (storedPath) window.sessionStorage.removeItem(POST_AUTH_PATH_KEY);
39
- window.history.replaceState(window.history.state, "", nextPath);
39
+ if (nextPath === "/") return;
40
+ // Sign-in comes back to "/"; the page asked for (a deep link such as /references/<id>) may be another route, so load it.
41
+ window.location.replace(nextPath);
40
42
  }, [postAuthPath]);
41
43
 
42
44
  const refreshAuthState = useCallback(async () => {
@@ -7,7 +7,7 @@ import {
7
7
  captureStatus, countWords, curationCardOf, grobidRows, primaryTextAttachment, structuredRows,
8
8
  type CaptureAssessment as Assessment, type CaptureStatus, type CurationCard,
9
9
  } from "../lib/reference-capture";
10
- import { loadModelPayloadsForOwner, loadStoragePathText } from "./news-desk-taxonomy-client";
10
+ import { loadModelPayloadsForOwner, loadStoragePathText, uploadModelPayloadForOwner } from "./news-desk-taxonomy-client";
11
11
 
12
12
  export type CaptureAssessment = Assessment;
13
13
 
@@ -23,8 +23,16 @@ export type CaptureView = {
23
23
  rows: Array<{ label: string; value: string }>;
24
24
  /** Saves an editor's pasted or corrected text and their verdict on the capture (the `correctReferenceCapture` action). */
25
25
  correct?: (input: { text?: string; assessment: CaptureAssessment; note?: string }) => Promise<void>;
26
+ /** True when the current text was read from a PDF the editor uploaded. */
27
+ fromPdf: boolean;
28
+ /** Stores a PDF the editor downloaded as an attachment of the Reference, then has the server read its text (`correctReferenceCapture` with `pdfAttachmentId`). */
29
+ uploadPdf?: (input: { file: File; assessment: CaptureAssessment; note?: string }, onStep?: (step: "uploading" | "reading") => void) => Promise<void>;
26
30
  };
27
31
 
32
+ /** The server reads PDFs up to this size (ideas.source_text.MAXIMUM_PDF_BYTES); say so before a slow upload that would be refused. */
33
+ export const MAXIMUM_PDF_UPLOAD_BYTES = 8_000_000;
34
+ export const PDF_UPLOAD_ROLE = "curation-upload-pdf";
35
+
28
36
  function previewText(attachment: ReferenceAttachmentRecord): string | null {
29
37
  // Local preview only: a fixture can carry the captured text inline instead of in storage.
30
38
  if (!isLocalPreviewEnabled()) return null;
@@ -42,7 +50,7 @@ type Payloads = { key: string; text: string | null; grobid: unknown; summary: st
42
50
  * Reads what the newsroom stored for one Reference. The curation card comes first (status, word count, signals, summary, GROBID fields,
43
51
  * and the `curation-text` body its `current` points at); the older `extracted_text` attachment is the fallback.
44
52
  */
45
- export type CorrectCaptureAction = (input: { referenceId: string; text?: string; assessment: CaptureAssessment; note?: string }) => Promise<unknown>;
53
+ export type CorrectCaptureAction = (input: { referenceId: string; text?: string; pdfAttachmentId?: string; assessment: CaptureAssessment; note?: string }) => Promise<unknown>;
46
54
 
47
55
  export function useReferenceCapture(reference: ReferenceRecord, attachments: readonly ReferenceAttachmentRecord[], teaserLine: number, correctAction?: CorrectCaptureAction): CaptureView {
48
56
  const card: CurationCard | null = useMemo(() => curationCardOf(attachments), [attachments]);
@@ -51,7 +59,7 @@ export function useReferenceCapture(reference: ReferenceRecord, attachments: rea
51
59
  const grobidSortKey = card?.current?.grobidSortKey ?? card?.grobid?.fieldsSortKey ?? null;
52
60
  const [payloads, setPayloads] = useState<Payloads>({ key: "", text: null, grobid: null, summary: null, loaded: false });
53
61
  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);
62
+ const [corrected, setCorrected] = useState<{ key: string; text: string; assessment: CaptureAssessment; fromPdf?: boolean } | null>(null);
55
63
  const referenceKey = `${reference.id}:${textSortKey ?? ""}:${grobidSortKey ?? ""}`;
56
64
 
57
65
  // The card's bodies and the Reference's metadata.json, in one read of the Reference's ModelAttachments.
@@ -92,6 +100,7 @@ export function useReferenceCapture(reference: ReferenceRecord, attachments: rea
92
100
 
93
101
  const current = payloads.key === referenceKey ? payloads : null;
94
102
  const mine = corrected && corrected.key === reference.id ? corrected : null;
103
+ const fromPdf = mine ? mine.fromPdf === true : card?.current?.textVia === "editor_pdf";
95
104
  const text = mine?.text ?? (textSortKey ? current?.text ?? null : fallback.key === fallbackKey ? fallback.text : null);
96
105
  const loading = mine ? false : textSortKey ? !(current?.loaded) : useFallback && fallback.key === fallbackKey ? fallback.loading : useFallback;
97
106
  const error = textSortKey || mine ? null : fallback.key === fallbackKey ? fallback.error : null;
@@ -103,8 +112,27 @@ export function useReferenceCapture(reference: ReferenceRecord, attachments: rea
103
112
 
104
113
  const correct = useCallback(async (input: { text?: string; assessment: CaptureAssessment; note?: string }) => {
105
114
  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]);
115
+ setCorrected({ key: reference.id, text: input.text ?? text ?? "", assessment: input.assessment, fromPdf: input.text === undefined && fromPdf });
116
+ }, [reference.id, text, fromPdf, correctAction]);
117
+
118
+ const uploadPdf = useCallback(async (input: { file: File; assessment: CaptureAssessment; note?: string }, onStep?: (step: "uploading" | "reading") => void) => {
119
+ if (!correctAction) throw new Error("Uploading is not available here.");
120
+ if (input.file.size > MAXIMUM_PDF_UPLOAD_BYTES) throw new Error(`That PDF is larger than we read (${MAXIMUM_PDF_UPLOAD_BYTES / 1_000_000} MB). Paste the text instead.`);
121
+ onStep?.("uploading");
122
+ const attachment = await uploadModelPayloadForOwner({
123
+ ownerKind: "reference", ownerId: reference.id, ownerLineageId: reference.lineageId ?? reference.id, ownerVersionNumber: reference.versionNumber ?? null,
124
+ ownerVersionKey: `reference#${reference.id}`, role: PDF_UPLOAD_ROLE, sortKey: `${PDF_UPLOAD_ROLE}-${Date.now().toString(36)}`,
125
+ filename: input.file.name || "upload.pdf", mediaType: "application/pdf", content: input.file,
126
+ });
127
+ onStep?.("reading");
128
+ const result = await correctAction({ referenceId: reference.id, pdfAttachmentId: attachment.id, assessment: input.assessment, ...(input.note ? { note: input.note } : {}) });
129
+ // The server read the text; fetch the new body it stored so the panel shows it.
130
+ const sortKey = (result as { edits?: Array<{ sortKey?: string; kind?: string }> } | null | undefined)?.edits?.find((edit) => edit.kind === "text")?.sortKey;
131
+ const rows = sortKey ? await loadModelPayloadsForOwner("reference", reference.id, ["metadata"]) : [];
132
+ const stored = rows.find((row) => row.attachment.sortKey === sortKey)?.text;
133
+ if (typeof stored !== "string") throw new Error("The PDF was read, but its text could not be loaded. Reload to see it.");
134
+ setCorrected({ key: reference.id, text: stored, assessment: input.assessment, fromPdf: true });
135
+ }, [reference.id, reference.lineageId, reference.versionNumber, correctAction]);
108
136
 
109
- return { status, text, loading, error, summary, summaryStale: Boolean(mine) || status.summaryStale === true, rows, correct: correctAction || isLocalPreviewEnabled() ? correct : undefined };
137
+ return { status, text, loading, error, summary, summaryStale: Boolean(mine) || status.summaryStale === true, rows, fromPdf, correct: correctAction || isLocalPreviewEnabled() ? correct : undefined, uploadPdf: correctAction ? uploadPdf : undefined };
110
138
  }
@@ -1,11 +1,12 @@
1
1
  "use client";
2
2
 
3
- import { FileCheckIcon, FileClockIcon, FileWarningIcon, FileXIcon, Loader2Icon } from "lucide-react";
4
- import { useEffect, useState } from "react";
3
+ import { FileCheckIcon, FileClockIcon, FileUpIcon, FileWarningIcon, FileXIcon, Loader2Icon } from "lucide-react";
4
+ import { useEffect, useRef, useState } from "react";
5
5
  import { captureLine, type CaptureAssessment, type CaptureState, type CaptureStatus } from "../lib/reference-capture";
6
6
  import { cn } from "../lib/utils";
7
7
  import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
8
8
  import type { CaptureView } from "./reference-capture-client";
9
+ import { MarkdownContext } from "./markdown-context";
9
10
 
10
11
  const ICON = { full: FileCheckIcon, thin: FileWarningIcon, none: FileXIcon, running: FileClockIcon } as const;
11
12
  /** Colour never stands alone: each state has its own icon and words. */
@@ -41,6 +42,87 @@ const ASSESSMENTS: Array<{ key: CaptureAssessment; label: string; hint: string }
41
42
  { key: "incorrect", label: "Incorrect", hint: "This is not the right text." },
42
43
  ];
43
44
 
45
+ /** The editor's verdict on the machine capture; asked the same way whether they paste text or upload a PDF. */
46
+ function AssessmentChoice({ value, onChange }: { value: CaptureAssessment | null; onChange: (value: CaptureAssessment) => void }) {
47
+ return (
48
+ <div aria-label="Was the capture complete?" className="flex flex-wrap gap-2" role="group">
49
+ {ASSESSMENTS.map((option) => (
50
+ <button
51
+ aria-pressed={value === option.key}
52
+ 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", value === option.key ? "bg-[var(--primary)] text-[var(--primary-foreground)]" : "bg-[var(--feed-card)]")}
53
+ data-capture-assessment={option.key}
54
+ key={option.key}
55
+ onClick={() => onChange(option.key)}
56
+ title={option.hint}
57
+ type="button"
58
+ >
59
+ {option.label}
60
+ </button>
61
+ ))}
62
+ </div>
63
+ );
64
+ }
65
+
66
+ /** "Upload a PDF": the editor's verdict, then the PDF they downloaded themselves; the server reads its text. */
67
+ function PdfUploadForm({ capture, onDone, onCancel }: { capture: CaptureView; onDone: () => void; onCancel: () => void }) {
68
+ const [assessment, setAssessment] = useState<CaptureAssessment | null>(null);
69
+ const [note, setNote] = useState("");
70
+ const [phase, setPhase] = useState<"idle" | "uploading" | "reading" | "error">("idle");
71
+ const [message, setMessage] = useState<string | null>(null);
72
+ const input = useRef<HTMLInputElement>(null);
73
+ const reason = !assessment ? "Say whether the capture was complete, incomplete or incorrect, then choose the PDF." : null;
74
+ const busy = phase === "uploading" || phase === "reading";
75
+ async function upload(file: File) {
76
+ if (!assessment || !capture.uploadPdf) return;
77
+ setMessage(null);
78
+ try {
79
+ await capture.uploadPdf({ file, assessment, ...(note.trim() ? { note: note.trim() } : {}) }, (step) => setPhase(step));
80
+ onDone();
81
+ } catch (failure) {
82
+ setPhase("error");
83
+ setMessage(failure instanceof Error ? failure.message : "The PDF could not be uploaded.");
84
+ }
85
+ }
86
+ return (
87
+ <div className="space-y-3 rounded-2xl bg-[var(--feed-control)] p-3" data-capture-upload-form>
88
+ <p className="text-sm font-medium">Upload the PDF you downloaded. Its text replaces the capture, and the capture is kept.</p>
89
+ <AssessmentChoice onChange={setAssessment} value={assessment} />
90
+ <label className="block text-sm font-medium">
91
+ Note (optional)
92
+ <AutoGrowTextarea 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} />
93
+ </label>
94
+ <input
95
+ accept="application/pdf"
96
+ aria-label="PDF file"
97
+ className="hidden"
98
+ data-capture-upload-input
99
+ onChange={(event) => {
100
+ const file = event.target.files?.[0];
101
+ event.target.value = "";
102
+ if (file) void upload(file);
103
+ }}
104
+ ref={input}
105
+ type="file"
106
+ />
107
+ {reason && !busy ? <p className="text-xs text-muted-foreground" data-capture-upload-reason>{reason}</p> : null}
108
+ {busy ? <p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" data-capture-upload-status role="status"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />{phase === "uploading" ? "Uploading the PDF…" : "Reading the text from the PDF…"}</p> : null}
109
+ {phase === "error" ? <p className="text-xs text-destructive" data-capture-upload-error role="alert">{message}</p> : null}
110
+ <div className="flex gap-2">
111
+ <button
112
+ aria-disabled={reason || busy ? true : undefined}
113
+ 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 || busy) && "opacity-60")}
114
+ data-capture-upload-choose
115
+ onClick={() => { if (!reason && !busy) input.current?.click(); }}
116
+ type="button"
117
+ >
118
+ <FileUpIcon aria-hidden="true" className="size-4" />Choose the PDF
119
+ </button>
120
+ <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" disabled={busy} onClick={onCancel} type="button">Cancel</button>
121
+ </div>
122
+ </div>
123
+ );
124
+ }
125
+
44
126
  /** "Paste or correct the text": an editor's own text and verdict on the capture, saved with `correctReferenceCapture`. */
45
127
  function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: () => void }) {
46
128
  const [text, setText] = useState(capture.text ?? "");
@@ -76,21 +158,7 @@ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: ()
76
158
  value={text}
77
159
  />
78
160
  </label>
79
- <div aria-label="Was the capture complete?" className="flex flex-wrap gap-2" role="group">
80
- {ASSESSMENTS.map((option) => (
81
- <button
82
- aria-pressed={assessment === option.key}
83
- 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)]")}
84
- data-capture-assessment={option.key}
85
- key={option.key}
86
- onClick={() => setAssessment(option.key)}
87
- title={option.hint}
88
- type="button"
89
- >
90
- {option.label}
91
- </button>
92
- ))}
93
- </div>
161
+ <AssessmentChoice onChange={setAssessment} value={assessment} />
94
162
  <label className="block text-sm font-medium">
95
163
  Note (optional)
96
164
  <AutoGrowTextarea 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} />
@@ -122,6 +190,7 @@ export function ReferenceCapturePanel({ capture, showSummary = true, startEditin
122
190
  }) {
123
191
  const [expanded, setExpanded] = useState(false);
124
192
  const [editing, setEditing] = useState(startEditing && Boolean(capture.correct));
193
+ const [uploading, setUploading] = useState(false);
125
194
  const [savedNote, setSavedNote] = useState(false);
126
195
  const line = captureLine(capture.status);
127
196
  const Icon = ICON[line.state];
@@ -144,11 +213,13 @@ export function ReferenceCapturePanel({ capture, showSummary = true, startEditin
144
213
  {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}
145
214
  {capture.error ? <p className="text-xs text-destructive" role="alert">The stored text could not be read. {capture.error}</p> : null}
146
215
  {/* While the editor is open it holds the text, so the read-only copy steps aside: the text is never shown twice. */}
216
+ {text && capture.fromPdf ? <p className="text-xs font-medium" data-capture-from-pdf>Text from a PDF you uploaded</p> : null}
147
217
  {text && !editing ? (
148
218
  <div className="space-y-2">
149
219
  {/* No scroll box of its own: on a phone a swipe here scrolls the page. "Show all" grows it in place. */}
150
- <div className="whitespace-pre-wrap rounded-2xl bg-[var(--feed-control)] p-3 text-sm leading-relaxed" data-capture-text>
151
- {expanded || !long ? text : `${text.slice(0, PREVIEW_CHARACTERS)}…`}
220
+ {/* Captured pages are stored as Markdown: render it (as React elements, never HTML) so headings and lists read as such. */}
221
+ <div className="rounded-2xl bg-[var(--feed-control)] p-3 text-sm leading-relaxed" data-capture-text>
222
+ <MarkdownContext className="reference-capture-markdown" text={expanded || !long ? text : `${text.slice(0, PREVIEW_CHARACTERS)}…`} />
152
223
  </div>
153
224
  {long ? (
154
225
  <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">
@@ -173,15 +244,24 @@ export function ReferenceCapturePanel({ capture, showSummary = true, startEditin
173
244
  ))}
174
245
  </dl>
175
246
  ) : null}
176
- {capture.correct ? (
177
- editing ? <CorrectionForm capture={capture} onDone={() => { setEditing(false); setSavedNote(true); }} /> : (
178
- <div className="flex flex-wrap items-center gap-2">
179
- <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">
180
- Paste or correct the text
181
- </button>
182
- {savedNote ? <span className="text-xs text-muted-foreground" data-capture-correction-saved role="status">Saved. Your verdict trains the completeness check.</span> : null}
183
- </div>
184
- )
247
+ {capture.correct || capture.uploadPdf ? (
248
+ editing && capture.correct ? <CorrectionForm capture={capture} onDone={() => { setEditing(false); setSavedNote(true); }} />
249
+ : uploading && capture.uploadPdf ? <PdfUploadForm capture={capture} onCancel={() => setUploading(false)} onDone={() => { setUploading(false); setSavedNote(true); }} />
250
+ : (
251
+ <div className="flex flex-wrap items-center gap-2">
252
+ {capture.correct ? (
253
+ <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">
254
+ Paste or correct the text
255
+ </button>
256
+ ) : null}
257
+ {capture.uploadPdf ? (
258
+ <button className="inline-flex min-h-9 items-center gap-1.5 rounded-full bg-[var(--feed-control)] px-4 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring" data-capture-upload onClick={() => { setSavedNote(false); setUploading(true); }} type="button">
259
+ <FileUpIcon aria-hidden="true" className="size-4" />Upload a PDF
260
+ </button>
261
+ ) : null}
262
+ {savedNote ? <span className="text-xs text-muted-foreground" data-capture-correction-saved role="status">Saved. Your verdict trains the completeness check.</span> : null}
263
+ </div>
264
+ )
185
265
  ) : null}
186
266
  </section>
187
267
  );
@@ -81,6 +81,10 @@ export type ReferenceFeedProps = {
81
81
  };
82
82
 
83
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
+ /** A selected card: a thick ring drawn outside it on every side (a box-shadow, so it takes no layout space and covers nothing inside
85
+ * the card). The grid's gap leaves room for it between cards. The fallback keeps the ring if a theme has no --primary. */
86
+ export const SELECTED_RING = { boxShadow: "0 0 0 4px var(--primary, currentColor)" } as const;
87
+
84
88
  export const FEED_GRID = "grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-5";
85
89
  const GRID = FEED_GRID;
86
90
  /** The lead spans two columns only where there are at least two to span (two 17rem columns and the gap). */
@@ -115,7 +119,7 @@ function hueOf(name: string): number {
115
119
  return Math.abs(hash) % 360;
116
120
  }
117
121
 
118
- /** A small mark for a source: its own favicon on a light tile when it loads, else its first letter. */
122
+ /** A small mark for a source: its own icon (found on the server, see lib/source-icon.ts) on a light tile when there is one, else its first letter. */
119
123
  export function SourceMark({ host, size = "sm" }: { host: string | null; size?: "sm" | "lg" }) {
120
124
  const [failed, setFailed] = useState(false);
121
125
  const box = size === "lg" ? "size-16 rounded-2xl text-3xl" : "size-5 rounded-md text-[0.65rem]";
@@ -124,7 +128,7 @@ export function SourceMark({ host, size = "sm" }: { host: string | null; size?:
124
128
  <span aria-hidden="true" className={cn("inline-flex shrink-0 items-center justify-center overflow-hidden bg-white font-bold uppercase text-neutral-700", box)} data-feed-source-mark>
125
129
  {host && !failed ? (
126
130
  // eslint-disable-next-line @next/next/no-img-element
127
- <img alt="" className={size === "lg" ? "size-10 object-contain" : "size-3.5 object-contain"} loading="lazy" onError={() => setFailed(true)} referrerPolicy="no-referrer" src={`https://${host}/favicon.ico`} />
131
+ <img alt="" className={size === "lg" ? "size-10 object-contain" : "size-3.5 object-contain"} loading="lazy" onError={() => setFailed(true)} src={`/api/newsroom/source-icon?host=${encodeURIComponent(host)}`} />
128
132
  ) : letter}
129
133
  </span>
130
134
  );
@@ -254,17 +258,17 @@ export function FeedCard({
254
258
  aria-current={selected ? "true" : undefined}
255
259
  className={cn(
256
260
  "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)]",
261
+ selected ? "bg-[var(--feed-control)]" : "bg-[var(--feed-card)]",
258
262
  lead && LEAD_SPAN,
259
263
  )}
260
264
  data-feed-card={item.lineageId}
261
265
  data-feed-lead={lead || undefined}
262
266
  data-feed-rated={answer ?? undefined}
263
267
  data-feed-selected={selected || undefined}
268
+ style={selected ? SELECTED_RING : undefined}
264
269
  data-feed-visit-rated={visitRated || undefined}
265
270
  data-newsroom-card-id={item.lineageId}
266
271
  >
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}
268
272
  <button
269
273
  aria-label={`Open: ${title}`}
270
274
  className="flex flex-1 flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"