@anthusai/papyrus 5.43.0-next.10 → 5.43.0-next.11

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.
@@ -41,6 +41,7 @@ const ALLOWED_ROLES = new Set([
41
41
  "item_excerpt",
42
42
  "message_body",
43
43
  "metadata",
44
+ "preview",
44
45
  "ontology_concept_profile",
45
46
  "ontology_relation_explanation",
46
47
  "published_item_body",
@@ -144,6 +144,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
144
144
  const receiptKey = receiptId ?? "";
145
145
  const quarantine = (detail && "submission" in detail ? decidedByReceipt[receiptKey] ?? detail.submission.quarantine : null);
146
146
  const accepted = emailSubmissionAccepted(quarantine);
147
+ const groupInfo = detail && "submission" in detail ? items.find((item) => item.receiptId === detail.submission.receiptId)?.group : undefined;
147
148
  const reason = reasonByReceipt[receiptKey] ?? "";
148
149
  const busyDecision = busyDecisionByReceipt[receiptKey] ?? false;
149
150
  const selected = selectedByReceipt[receiptKey] ?? {};
@@ -274,6 +275,14 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
274
275
  {items.length ? <ul className="news-desk-monitoring__list">
275
276
  {items.map((item) => <li key={item.receiptId} data-email-submission={item.receiptId}>
276
277
  <strong>{item.subject || "(no subject)"}</strong>
278
+ {item.group ? <details data-email-group={item.group.fingerprint}>
279
+ <summary>Received {item.group.count} times · latest {formatUtc(item.observedAt)}</summary>
280
+ <ol>
281
+ {item.group.copies.map((copy) => <li key={copy.receiptId} data-email-copy={copy.receiptId}>
282
+ {formatUtc(copy.observedAt)} · {copy.quarantine ? QUARANTINE_LABELS[copy.quarantine.state] : copy.status}
283
+ </li>)}
284
+ </ol>
285
+ </details> : null}
277
286
  <span>{formatUtc(item.observedAt)} · {item.linkCount} candidate links · {item.reviewRequired
278
287
  ? `${item.linkCount} await editor import` : `${item.processedCount} queued`}</span>
279
288
  {item.reviewRequired ? <span>Authentication warnings: private review required; nothing was routed automatically.</span> : null}
@@ -296,6 +305,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
296
305
  <h5>{QUARANTINE_LABELS[quarantine.state]}</h5>
297
306
  {quarantine.state === "quarantined" ? <>
298
307
  <p>This message was held privately and nothing was routed. Accept it to queue its links as if it had been accepted, or Reject it to close it. Sender identity and authentication signals never grant access; the decision is yours and is recorded.</p>
308
+ {groupInfo ? <p data-email-group-note>One decision applies to all {groupInfo.count} copies of this email.</p> : null}
299
309
  <label>Why? (optional)
300
310
  <textarea value={reason} rows={3} maxLength={500} style={{ display: "block", width: "100%" }}
301
311
  onChange={(event) => setReasonByReceipt((current) => ({ ...current, [receiptKey]: event.target.value }))} />
@@ -308,7 +318,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
308
318
  </button>
309
319
  </> : null}
310
320
  {quarantine.decision ? <p data-email-decision-record>
311
- {quarantine.decision.action === "accept" ? "Accepted" : "Rejected"}
321
+ {quarantine.decision.action === "accept" ? "Accepted" : "Rejected"}{quarantine.decision.viaGroup ? " (one decision for every copy of this email)" : ""}
312
322
  {quarantine.decision.by ? ` by ${quarantine.decision.by}` : ""}{quarantine.decision.at ? ` on ${formatUtc(quarantine.decision.at)}` : ""}
313
323
  {quarantine.decision.reason ? `. Why: ${quarantine.decision.reason}` : "."}
314
324
  </p> : null}
@@ -6,6 +6,9 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
6
6
  import type { DevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
7
7
  import type { CategorySteeringDashboard } from "../lib/category-repository";
8
8
  import { ensureDevSandboxEditorSession } from "./dev-sandbox-editor-sign-in";
9
+ import { discardReferencesHeadStart, prefetchReferencesFeed } from "./references-prefetch";
10
+ import { SITE_BRAND } from "../lib/site-brand";
11
+ import { clearSavedNewsroomDisplay, loadSessionDisplay, saveSessionDisplay } from "../lib/reference-feed-cache";
9
12
  import {
10
13
  loadEditorAssignmentsData,
11
14
  loadEditorDoctrineRecordsData,
@@ -75,7 +78,12 @@ export function NewsDeskClientProvider({
75
78
  ));
76
79
  return;
77
80
  }
78
- setShell((current) => beginAccessCheck(current));
81
+ // A recent confirmed session is shown (name, "loading" instead of "checking") while the real check runs again.
82
+ // Display only: the check below still decides access, and the backend authorizes every call.
83
+ const remembered = loadSessionDisplay(SITE_BRAND.id);
84
+ setShell((current) => (remembered && !current.dashboard
85
+ ? { ...beginAccessCheck(current), phase: "loadingDesk" as const, auth: { status: "signedIn" as const, label: remembered.label } }
86
+ : beginAccessCheck(current)));
79
87
 
80
88
  try {
81
89
  await ensureDevSandboxEditorSession(devSandboxEditorAuth);
@@ -88,6 +96,8 @@ export function NewsDeskClientProvider({
88
96
  if (bootstrapSequenceRef.current !== sequence) return;
89
97
 
90
98
  if (access.status === "signedOut") {
99
+ clearSavedNewsroomDisplay(SITE_BRAND.id);
100
+ discardReferencesHeadStart();
91
101
  setShell((current) => resolveSignedOut(current, access.auth));
92
102
  return;
93
103
  }
@@ -100,7 +110,10 @@ export function NewsDeskClientProvider({
100
110
  return;
101
111
  }
102
112
 
113
+ saveSessionDisplay(SITE_BRAND.id, { label: access.auth.label, isAdmin: access.isAdmin });
103
114
  setShell((current) => beginDeskLoad(current, access.auth));
115
+ // The References reads do not depend on the summary dashboard: start them now, side by side with it.
116
+ if (typeof window !== "undefined" && /\/newsroom\/references(\/|$)/.test(window.location.pathname)) prefetchReferencesFeed();
104
117
 
105
118
  try {
106
119
  const dashboard = await loadEditorNewsDeskDashboard({ isAdmin: access.isAdmin });
@@ -183,6 +196,8 @@ export function NewsDeskClientProvider({
183
196
  const unsubscribe = Hub.listen("auth", ({ payload }) => {
184
197
  if (payload.event === "signedOut") {
185
198
  bootstrapSequenceRef.current += 1;
199
+ clearSavedNewsroomDisplay(SITE_BRAND.id);
200
+ discardReferencesHeadStart();
186
201
  setShell((current) => resolveSignedOut(current, { status: "signedOut", label: "Signed out" }));
187
202
  return;
188
203
  }
@@ -1,3 +1,4 @@
1
+ import { withReferenceFeedFixture } from "../lib/local-preview-feed-fixture";
1
2
  import { resolveNewsDeskTab } from "../lib/newsroom-section-routing";
2
3
  import { NewsDeskWorkspace, type NewsDeskTab } from "./topic-steering-workspace";
3
4
  import {
@@ -67,7 +68,7 @@ export async function NewsDeskPage({ section: routeSection, selectionPath, searc
67
68
  const analysisProfiles = await loadAnalysisProfileSummaries(getBrandAnalysisProfilesPath(brand));
68
69
  const configuredCorpora = await loadConfiguredCorpusSummaries(getBrandSteeringConfigPath(brand));
69
70
  const dashboard = useDemoDashboard
70
- ? createDemoCategorySteeringDashboard(brand.id, getBrandNewsroomSectionsConfigPath(brand))
71
+ ? withReferenceFeedFixture(createDemoCategorySteeringDashboard(brand.id, getBrandNewsroomSectionsConfigPath(brand)))
71
72
  : await loadCategorySteeringDashboard();
72
73
  return <NewsDeskWorkspace analysisProfiles={analysisProfiles} configuredCorpora={configuredCorpora} dashboard={dashboard} initialSelection={initialSelection} initialTab={initialTab} />;
73
74
  }
@@ -9,24 +9,26 @@ import { NewsroomOpsShell } from "./newsroom-ops-shell";
9
9
  import { NewsroomOverviewSkeleton } from "./newsroom-overview-skeleton";
10
10
  import { ReaderAuthControl } from "./reader-auth-control";
11
11
  import { buttonVariants } from "./ui/button";
12
+ import { ReferencesPageLoading } from "./references-page-loading";
12
13
 
13
- export function NewsDeskAccessGate({ shell, showSectionTabs = false }: { shell: NewsDeskShellState | null; showSectionTabs?: boolean }) {
14
+ export function NewsDeskAccessGate({ initialTab, shell, showSectionTabs = false }: { initialTab?: string; shell: NewsDeskShellState | null; showSectionTabs?: boolean }) {
14
15
  const accessPhase = shell?.phase ?? "checkingAccess";
15
16
  const pending = accessPhase === "checkingAccess" || accessPhase === "loadingDesk";
17
+ const referencesPage = initialTab === "references";
16
18
 
17
19
  return (
18
20
  <NewsroomOpsShell
19
- activeTab="overview"
21
+ activeTab={referencesPage ? "references" : "overview"}
20
22
  appTitle={SITE_BRAND.appTitle}
21
23
  backHref="/"
22
24
  backLabel={SITE_BRAND.backToHomeLabel}
23
25
  headerActions={(
24
26
  <Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
25
27
  )}
26
- pageTitle={showSectionTabs ? "Notification Center" : "Newsroom"}
27
- showNavigation={showSectionTabs}
28
+ pageTitle={referencesPage ? "References" : showSectionTabs ? "Notification Center" : "Newsroom"}
29
+ showNavigation={showSectionTabs || referencesPage}
28
30
  >
29
- {pending ? (showSectionTabs ? <NewsroomOverviewSkeleton /> : (
31
+ {pending && referencesPage ? <ReferencesPageLoading /> : pending ? (showSectionTabs ? <NewsroomOverviewSkeleton /> : (
30
32
  <div aria-label="Loading newsroom" className="space-y-4" data-newsroom-page-skeleton role="status">
31
33
  <div className="h-6 w-48 rounded bg-muted" />
32
34
  <div className="h-4 w-3/4 rounded bg-muted" />
@@ -2,13 +2,14 @@ import { NewsDeskClientProvider } from "./news-desk-client-provider";
2
2
  import { createDemoCategorySteeringDashboard } from "../lib/category-repository";
3
3
  import { resolveDevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
4
4
  import { isLocalPreviewEnabled } from "../lib/local-preview";
5
+ import { withReferenceFeedFixture } from "../lib/local-preview-feed-fixture";
5
6
  import { getBrandNewsroomSectionsConfigPath, resolveActiveSiteBrand } from "../lib/site-brand-tenant";
6
7
 
7
8
  export function NewsroomClientShell({ children }: Readonly<{ children: React.ReactNode }>) {
8
9
  const devSandboxEditorAuth = resolveDevSandboxEditorAuth();
9
10
  const activeBrand = resolveActiveSiteBrand();
10
11
  const localPreviewDashboard = isLocalPreviewEnabled()
11
- ? createDemoCategorySteeringDashboard(activeBrand.id, getBrandNewsroomSectionsConfigPath(activeBrand))
12
+ ? withReferenceFeedFixture(createDemoCategorySteeringDashboard(activeBrand.id, getBrandNewsroomSectionsConfigPath(activeBrand)))
12
13
  : null;
13
14
 
14
15
  return (
@@ -165,7 +165,7 @@ export function NewsroomOpsShell({ activeTab, appTitle, backHref = "/", backLabe
165
165
  <Link className="organizer-masthead" href={backHref} title={backLabel}>{appTitle}</Link>
166
166
  <h1 data-newsroom-ops-title>{pageTitle}</h1><OrganizerSearch demo={demo} /><div className="organizer-header-actions">{headerActions}</div><Account placement="header" />
167
167
  </header>
168
- <main className="organizer-main">{demo && <p className="organizer-demo-notice">Demo workspace · synthetic examples</p>}{children}</main>
168
+ <main className="organizer-main">{demo && !session?.shell.dashboard?.realDataPreview && <p className="organizer-demo-notice">Demo workspace · synthetic examples</p>}{children}</main>
169
169
  </div>
170
170
  {showNavigation && <NewsroomMobileNavigation activeTab={activeTab} items={ORGANIZER_PRIMARY_NAV.map(item => ({ ...item, href: getNewsroomNavHref(item.path, demo) }))}
171
171
  chat={consoleState?.shouldOfferConsole ? { open: consoleState.open, onOpen: consoleState.openConsole } : undefined}
@@ -347,3 +347,47 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
347
347
  .news-desk-columns--administration .news-desk-newsletter__text{margin:0;padding:12px;max-height:280px;overflow:auto;border:1px solid var(--org-line);font:400 12px/1.5 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}
348
348
  .news-desk-columns--administration .news-desk-newsletter__filters{display:flex;flex-wrap:wrap;gap:12px 24px}
349
349
  .news-desk-columns--administration .news-desk-newsletter__filters .news-desk-doctrine-card__field{min-width:180px}
350
+
351
+ /* The References feed is rounded and borderless (Ryan's design rule): flat tones and rounded rectangles instead of lines and boxes.
352
+ The newsroom chrome squares every "rounded" class above; inside a [data-feed-surface] the rounded classes mean what they say.
353
+ The repeated attribute raises specificity over the squaring rules without !important escalation. */
354
+ .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-full"],
355
+ .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-full"] { border-radius: 9999px !important; }
356
+ .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-[20px]"],
357
+ .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-[20px]"] { border-radius: 20px !important; }
358
+ .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-2xl"],
359
+ .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-2xl"] { border-radius: 16px !important; }
360
+ .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-3xl"],
361
+ .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-3xl"] { border-radius: 24px !important; }
362
+
363
+ /* Feed tones. Light: cards a touch lighter than the page, footers between, controls a touch darker. Dark: each step lifts toward the ink.
364
+ Also set on the portaled mobile sheet, which carries the theme attributes itself. */
365
+ [data-kanbus-theme] [data-feed-surface], [data-kanbus-theme][data-feed-surface] {
366
+ --feed-card: color-mix(in oklab, var(--kb-theme-paper) 35%, white);
367
+ --feed-footer: color-mix(in oklab, var(--kb-theme-paper) 70%, white);
368
+ --feed-control: color-mix(in oklab, var(--kb-theme-paper) 90%, var(--kb-theme-ink));
369
+ }
370
+ [data-kanbus-theme][data-color-mode="dark"] [data-feed-surface], [data-kanbus-theme][data-color-mode="dark"][data-feed-surface] {
371
+ --feed-card: color-mix(in oklab, var(--kb-theme-paper) 94%, var(--kb-theme-ink));
372
+ --feed-footer: color-mix(in oklab, var(--kb-theme-paper) 90%, var(--kb-theme-ink));
373
+ --feed-control: color-mix(in oklab, var(--kb-theme-paper) 80%, var(--kb-theme-ink));
374
+ }
375
+
376
+ /* Borderless detail pane, Add panel and mobile sheet ([data-feed-flat]): no border, rule or box anywhere inside. Regions are told apart
377
+ by tone (card, footer, control) and shapes are rounded rectangles. The repeated attribute outranks the newsroom chrome's own rules. */
378
+ [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat],
379
+ [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] * { border-width: 0 !important; }
380
+ [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] *:not(:focus-visible) { box-shadow: none; }
381
+ [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is([data-slot=separator], hr) { display: none !important; }
382
+ [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(button, [role=button], [data-slot=badge]):not([aria-pressed=true]):not([data-slot=sheet-close]):not([class*="hover:bg-muted"]) {
383
+ background: var(--feed-control) !important; border-radius: 9999px !important; color: var(--foreground, inherit);
384
+ }
385
+ [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(button, [role=button])[aria-pressed=true]:not([class*="destructive"]) {
386
+ background: var(--primary) !important; color: var(--primary-foreground) !important; border-radius: 9999px !important;
387
+ }
388
+ [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(button, [role=button])[aria-pressed=true][class*="destructive"] { border-radius: 9999px !important; }
389
+ [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(textarea, input:not([type=radio]):not([type=checkbox]), select, details, [data-cyclotron-choice]:not([data-predicted])) {
390
+ background: var(--feed-control) !important; border-radius: 16px !important;
391
+ }
392
+ [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-cyclotron-choice] { border-radius: 9999px !important; }
393
+ [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; }
@@ -4,7 +4,7 @@ import type { PrimingPanelProps } from "cyclotron/components/priming-panel";
4
4
  import { ReferenceAddEntry } from "./reference-add-entry";
5
5
  import { ReferenceCurationControls, useReferenceImport } from "./reference-curation-controls";
6
6
 
7
- import { ExternalLinkIcon } from "lucide-react";
7
+ import { ExternalLinkIcon, LayoutGridIcon, ListIcon, PlusIcon } from "lucide-react";
8
8
  import { usePathname } from "next/navigation";
9
9
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10
10
  import { Badge } from "./ui/badge";
@@ -42,6 +42,14 @@ import {
42
42
  import { normalizeReferenceCurationStatus, referenceReasonCode, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
43
43
  import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
44
44
  import { ReferenceSourcePreview } from "./reference-source-preview";
45
+ import { FEED_SURFACE_PROPS, FEED_SURFACE_STYLE, ReferenceFeed } from "./reference-feed";
46
+ import { partitionReferenceFeed, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
47
+ import { referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
48
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
49
+ import { chooseSyntheticRejects, withSyntheticRejects } from "../lib/local-preview-synthetic-rejects";
50
+ import { cn } from "../lib/utils";
51
+ import { getKanbusTheme, SITE_BRAND } from "../lib/site-brand";
52
+ import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
45
53
  import {
46
54
  ReviewCard,
47
55
  predictionFromDecision,
@@ -120,8 +128,14 @@ type NewsroomReferencesViewProps = {
120
128
  onReview: (reference: ReferenceRecord, action: ReferenceCurationAction, options?: ReferenceReviewOptions) => Promise<void> | void;
121
129
  referenceAttachments?: ReferenceAttachmentRecord[];
122
130
  references: ReferenceRecord[];
131
+ /** True while the References are still being read: the feed shows card skeletons instead of an empty page. */
132
+ loading?: boolean;
133
+ /** The last view this browser saved. Shown, read only, until the live read arrives. */
134
+ savedCopy?: { references: ReferenceRecord[]; previews: Array<[string, ReferencePreview]>; decisions: Array<[string, RelevanceDecisionView]> } | null;
123
135
  };
124
136
 
137
+ type ReferenceView = "feed" | "list";
138
+
125
139
  type ReferenceListFilter = ReferenceStatusFilter | "needs_review";
126
140
 
127
141
  const NEEDS_REVIEW_FILTER: { key: ReferenceListFilter; label: string } = { key: "needs_review", label: "Needs review" };
@@ -442,7 +456,7 @@ export function NewsroomReferencesView({
442
456
  priming,
443
457
  cyclotronStatus,
444
458
  cyclotronPriming,
445
- decisions,
459
+ decisions: liveDecisions,
446
460
  humanCuratedReferenceLineageIds,
447
461
  delegatedCurations,
448
462
  shadowAgreement = null,
@@ -461,9 +475,14 @@ export function NewsroomReferencesView({
461
475
  initialReferenceLineageId = null,
462
476
  onReview,
463
477
  referenceAttachments = [],
464
- references,
478
+ references: liveReferences,
479
+ loading = false,
480
+ savedCopy = null,
465
481
  }: NewsroomReferencesViewProps) {
466
482
  const pathname = usePathname();
483
+ // The portaled mobile sheet sits outside the newsroom root, so it carries the theme attributes itself (same as useNewsroomThemeAttributes).
484
+ const themeMode = useResolvedPapyrusTheme();
485
+ const themeAttributes = { "data-kanbus-theme": getKanbusTheme(SITE_BRAND), "data-color-mode": themeMode };
467
486
  const sessionImport = useReferenceImport(disabled);
468
487
  const [importedCount, setImportedCount] = useState(0);
469
488
  const isMobileDetail = useMediaQuery("(max-width: 767px)");
@@ -473,7 +492,66 @@ export function NewsroomReferencesView({
473
492
  );
474
493
  const routeLineageId = pathname ? pathnameLineageId ?? "" : initialReferenceLineageId ?? "";
475
494
 
495
+ // Until the live read arrives, a saved copy of the last view fills the page. It is display only.
496
+ const showingSavedCopy = loading && !liveReferences.length && Boolean(savedCopy?.references.length);
497
+ const references = showingSavedCopy ? savedCopy!.references : liveReferences;
498
+ const baseDecisions = useMemo(
499
+ () => (showingSavedCopy && !liveDecisions?.size ? new Map(savedCopy!.decisions) : liveDecisions),
500
+ [liveDecisions, savedCopy, showingSavedCopy],
501
+ );
502
+ // Preview only: ?feedSynthRejects=N makes the N weakest Accept predictions into made-up Rejects (the page says so).
503
+ const synthRejectCount = useMemo(() => {
504
+ if (typeof window === "undefined" || !isLocalPreviewEnabled()) return 0;
505
+ const requested = Number(new URLSearchParams(window.location.search).get("feedSynthRejects"));
506
+ return Number.isInteger(requested) && requested > 0 ? requested : 0;
507
+ }, []);
508
+ const syntheticChoice = useRef<string[] | null>(null);
509
+ if (synthRejectCount && syntheticChoice.current === null && baseDecisions?.size && references.length) {
510
+ syntheticChoice.current = chooseSyntheticRejects(
511
+ baseDecisions,
512
+ new Set(references.filter((reference) => normalizeReferenceCurationStatus(reference.curationStatus) === "pending").map(referenceLineageId)),
513
+ synthRejectCount,
514
+ );
515
+ }
516
+ const synthetic = useMemo(
517
+ () => (synthRejectCount && syntheticChoice.current ? withSyntheticRejects(baseDecisions, syntheticChoice.current) : null),
518
+ [baseDecisions, synthRejectCount],
519
+ );
520
+ const decisions = synthetic?.decisions ?? baseDecisions;
521
+ const previews = useMemo(() => {
522
+ const live = referencePreviewsByLineage(referenceAttachments);
523
+ return showingSavedCopy && !live.size ? new Map(savedCopy!.previews) : live;
524
+ }, [referenceAttachments, savedCopy, showingSavedCopy]);
525
+ const [view, setView] = useState<ReferenceView>("feed");
526
+ const [addOpen, setAddOpen] = useState(false);
527
+ const [feedTab, setFeedTab] = useState<ReferenceFeedTabKey>("feed");
528
+ const [visitRated, setVisitRated] = useState<ReadonlySet<string>>(() => new Set());
529
+ const [mountedAt] = useState(() => Date.now());
530
+ const markVisitRated = useCallback((lineageId: string) => {
531
+ setVisitRated((current) => (current.has(lineageId) ? current : new Set(current).add(lineageId)));
532
+ }, []);
533
+ // Preview only: ?feedTau=<days> stands in for the learned freshness period, to review the Earlier section on a young corpus.
534
+ const tauDaysOverride = useMemo(() => {
535
+ if (typeof window === "undefined" || !isLocalPreviewEnabled()) return null;
536
+ const requested = Number(new URLSearchParams(window.location.search).get("feedTau"));
537
+ return Number.isFinite(requested) && requested > 0 ? requested : null;
538
+ }, []);
539
+
540
+ // Preview only: ?feedState=loading holds the page in its loading state, to review the card skeletons.
541
+ const previewSkeleton = useMemo(
542
+ () => typeof window !== "undefined" && isLocalPreviewEnabled() && new URLSearchParams(window.location.search).get("feedState") === "loading",
543
+ [],
544
+ );
545
+
476
546
  const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
547
+ const partition = useMemo(
548
+ () => partitionReferenceFeed(canonicalReferences, decisions, { now: mountedAt, visitRated, tauDaysOverride }),
549
+ [canonicalReferences, decisions, mountedAt, tauDaysOverride, visitRated],
550
+ );
551
+ const feedOrderIds = useMemo(
552
+ () => (feedTab === "feed" ? [...partition.feed, ...partition.earlier] : feedTab === "rated" ? partition.rated : partition.earlier).map((item) => item.lineageId),
553
+ [feedTab, partition],
554
+ );
477
555
  const statusCounts = useMemo(() => countReferencesByStatus(canonicalReferences), [canonicalReferences]);
478
556
 
479
557
  const [statusFilter, setStatusFilter] = useState<ReferenceListFilter>(routeLineageId ? "all" : "pending");
@@ -504,14 +582,14 @@ export function NewsroomReferencesView({
504
582
  // list, but keeps its place in the queue until they move on, so "3 of 29" does not jump under them.
505
583
  const queueRef = useRef<string[]>([]);
506
584
  const queue = useMemo(() => {
507
- const ids = filteredReferences.map(referenceLineageId);
585
+ const ids = view === "feed" ? [...feedOrderIds] : filteredReferences.map(referenceLineageId);
508
586
  const open = selectedReference ? referenceLineageId(selectedReference) : "";
509
587
  if (open && !ids.includes(open)) {
510
588
  const before = queueRef.current.indexOf(open);
511
589
  if (before >= 0) ids.splice(Math.min(before, ids.length), 0, open);
512
590
  }
513
591
  return ids;
514
- }, [filteredReferences, selectedReference]);
592
+ }, [feedOrderIds, filteredReferences, selectedReference, view]);
515
593
  useEffect(() => { queueRef.current = queue; }, [queue]);
516
594
  const [caughtUp, setCaughtUp] = useState(false);
517
595
  const selectedIndex = selectedReference ? queue.indexOf(referenceLineageId(selectedReference)) : -1;
@@ -582,9 +660,21 @@ export function NewsroomReferencesView({
582
660
 
583
661
  const runReview = (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => {
584
662
  if (!selectedReference) return;
663
+ if (action === "accept" || action === "reject") markVisitRated(referenceLineageId(selectedReference));
585
664
  return onReview(selectedReference, action, options);
586
665
  };
587
666
 
667
+ const rateFromFeed = async (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => {
668
+ const decision = decisions?.get(referenceLineageId(reference));
669
+ await onReview(reference, choice, {
670
+ reasonCode: choice === "reject" ? reasonCode : null,
671
+ ...(decision ? { decisionRelationId: decision.decisionRelationId, shareable: false } : {}),
672
+ });
673
+ };
674
+ const feedDisabledReason = disabled
675
+ ? "Editor access is required to change this reference."
676
+ : showingSavedCopy ? "Confirming your sign-in. You can answer in a moment." : null;
677
+
588
678
  const caughtUpPanel = (
589
679
  <div className="space-y-2 p-6 text-center" data-review-caught-up>
590
680
  <h3 className="text-lg font-semibold">All caught up</h3>
@@ -606,23 +696,64 @@ export function NewsroomReferencesView({
606
696
  data-detail-open={selectedReference || routeLineageId ? "true" : "false"}
607
697
  >
608
698
  <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 md:overflow-hidden">
609
- <div className="shrink-0 space-y-1">
610
- <h2 className="text-lg font-semibold tracking-tight">References</h2>
611
- <p className="text-sm text-muted-foreground">
612
- Review source intake before it becomes accepted evidence.
613
- </p>
699
+ <div className="flex shrink-0 items-center justify-between gap-3" {...FEED_SURFACE_PROPS}>
700
+ <div className="min-w-0 space-y-0.5">
701
+ <h2 className="text-2xl font-bold tracking-tight">References</h2>
702
+ <p className="hidden text-sm text-muted-foreground sm:block">
703
+ {view === "feed" ? "A feed ranked by what you are most likely to accept." : "Review source intake before it becomes accepted evidence."}
704
+ </p>
705
+ </div>
706
+ <div className="flex items-center gap-2">
707
+ <button
708
+ aria-controls="reference-add-panel"
709
+ aria-expanded={addOpen}
710
+ aria-label="Add a reference and prediction activity"
711
+ className={cn("inline-flex min-h-10 items-center justify-center gap-1.5 rounded-full px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring", addOpen ? "bg-[var(--feed-control)] text-foreground" : "bg-[var(--feed-card)] text-muted-foreground hover:text-foreground")}
712
+ data-reference-add-toggle
713
+ onClick={() => setAddOpen((open) => !open)}
714
+ title="Add a reference and prediction activity"
715
+ type="button"
716
+ >
717
+ <PlusIcon aria-hidden="true" className="size-4" />
718
+ <span className="hidden sm:inline">Add</span>
719
+ </button>
720
+ <div aria-label="Reference view" className="inline-flex rounded-full bg-[var(--feed-card)] p-1" role="group">
721
+ {([["feed", "Feed", LayoutGridIcon], ["list", "List", ListIcon]] as const).map(([key, label, Icon]) => (
722
+ <button
723
+ aria-label={label}
724
+ aria-pressed={view === key}
725
+ className={cn(
726
+ "inline-flex min-h-8 items-center justify-center gap-1.5 rounded-full px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
727
+ view === key ? "bg-[var(--feed-control)] text-foreground" : "text-muted-foreground hover:text-foreground",
728
+ )}
729
+ data-reference-view={key}
730
+ key={key}
731
+ onClick={() => { setView(key); setCaughtUp(false); }}
732
+ title={label}
733
+ type="button"
734
+ >
735
+ <Icon aria-hidden="true" className="size-4" />
736
+ <span className="hidden sm:inline">{label}</span>
737
+ </button>
738
+ ))}
739
+ </div>
740
+ </div>
614
741
  </div>
615
- {predictionState === "loading" ? <p role="status">Loading predictions…</p> : null}
742
+ {synthetic?.changed ? (
743
+ <p className="rounded-2xl bg-[var(--feed-card)] p-3 text-sm" data-preview-synthetic role="status" {...FEED_SURFACE_PROPS}>
744
+ Preview only: {synthetic.changed} of these predictions are made up to show the Reject end of the feed. Real data has none yet.
745
+ </p>
746
+ ) : null}
747
+ {predictionState === "loading" && view === "list" ? <p role="status">Loading predictions…</p> : null}
616
748
  {predictionState === "error" ? (
617
- <div role="alert" className="space-y-2 rounded-md border p-3">
749
+ <div role="alert" className="space-y-2 rounded-2xl bg-[var(--feed-card)] p-4" {...FEED_SURFACE_PROPS}>
618
750
  <p>Predictions unavailable. {predictionError}</p>
619
751
  <p className="text-sm">You can still review references. Existing suggestions may be out of date.</p>
620
752
  {onRetryPredictions ? <Button onClick={onRetryPredictions} size="sm" type="button" variant="outline">Retry predictions</Button> : null}
621
753
  </div>
622
754
  ) : null}
623
- <details className="shrink-0 overflow-y-auto rounded-md border p-3 text-sm" style={{ maxHeight: "35dvh" }}>
624
- <summary className="cursor-pointer font-semibold">Add a reference and prediction activity</summary>
625
- <div className="mt-3 space-y-4">
755
+ <div className="shrink-0 overflow-y-auto rounded-[20px] bg-[var(--feed-card)] p-4 text-sm" data-feed-flat="" data-feed-surface="" hidden={!addOpen} id="reference-add-panel" style={{ ...FEED_SURFACE_STYLE, maxHeight: "45dvh", overflowY: "auto" }}>
756
+ <div className="space-y-4">
626
757
  <ReferenceLiveAgreement agreement={shadowAgreement} />
627
758
  <ReferenceAddEntry
628
759
  importOptions={disabled ? null : priming ?? sessionImport?.panel}
@@ -639,8 +770,31 @@ export function NewsroomReferencesView({
639
770
  </div>
640
771
  ) : null}
641
772
  </div>
642
- </details>
773
+ </div>
643
774
 
775
+ {view === "feed" ? (
776
+ <ScrollArea className="min-h-0 flex-1" data-reference-feed-scroll>
777
+ <div className="pb-6 pr-1">
778
+ <ReferenceFeed
779
+ disabledReason={feedDisabledReason}
780
+ loading={previewSkeleton || (loading && !references.length)}
781
+ now={mountedAt}
782
+ onOpen={selectReference}
783
+ onRate={rateFromFeed}
784
+ onTabChange={(next) => { setFeedTab(next); setCaughtUp(false); }}
785
+ onVisitRated={markVisitRated}
786
+ partition={partition}
787
+ predictionLoading={predictionState === "loading"}
788
+ previews={previews}
789
+ selectedLineageId={selectedLineageId}
790
+ stale={showingSavedCopy || (loading && references.length > 0)}
791
+ tab={feedTab}
792
+ visitRated={visitRated}
793
+ />
794
+ </div>
795
+ </ScrollArea>
796
+ ) : (
797
+ <>
644
798
  <Tabs
645
799
  className="shrink-0"
646
800
  defaultValue="pending"
@@ -716,15 +870,22 @@ export function NewsroomReferencesView({
716
870
  )}
717
871
  </div>
718
872
  </ScrollArea>
873
+ </>
874
+ )}
719
875
  </div>
720
876
 
721
877
  <div
722
- className="hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-card md:flex md:w-[min(100%,24rem)] lg:w-[min(100%,28rem)]"
878
+ className={cn(
879
+ "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)] lg:w-[min(100%,28rem)]",
880
+ view === "feed" && !selectedReference && "md:hidden",
881
+ )}
723
882
  data-newsroom-list-detail-shell
724
883
  data-news-desk-section="references"
725
884
  data-detail-open={selectedReference ? "true" : "false"}
885
+ data-feed-flat=""
886
+ {...FEED_SURFACE_PROPS}
726
887
  >
727
- <div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
888
+ <div className="flex shrink-0 items-center justify-between gap-2 px-4 pt-3 pb-1">
728
889
  <span className="text-sm font-medium text-foreground">Detail</span>
729
890
  </div>
730
891
  <div className="flex min-h-0 flex-1 flex-col" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}>
@@ -766,12 +927,12 @@ export function NewsroomReferencesView({
766
927
  }}
767
928
  open={(mobileDetailOpen && Boolean(selectedReference)) || caughtUp}
768
929
  >
769
- <SheetContent className="gap-0 p-0" side="bottom">
930
+ <SheetContent className="gap-0 rounded-t-[24px] bg-[var(--feed-card)] p-0" data-feed-flat="" data-feed-surface="" side="bottom" {...themeAttributes}>
770
931
  <SheetHeader className="border-b border-border px-4 py-3">
771
932
  <SheetTitle className="text-left text-base">Reference</SheetTitle>
772
933
  </SheetHeader>
773
934
  {selectedReference ? (
774
- <div data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}><ScrollArea className="max-h-[70dvh]">
935
+ <div className="min-w-0" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}><ScrollArea className="max-h-[70dvh]">
775
936
  <ReferenceDetailPanel
776
937
  key={referenceLineageId(selectedReference)}
777
938
  predictionState={predictionState}