@anthusai/papyrus 5.44.0 → 5.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) 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/amplify/functions/model-attachment-upload/handler.ts +1 -0
  7. package/amplify/functions/monitoring-view/schema.js +8 -0
  8. package/amplify/functions/monitoring-view/schema.ts +8 -0
  9. package/bin/papyrus-app.mjs +12 -1
  10. package/components/monitoring-overview-view.tsx +52 -6
  11. package/components/monitoring-panel.tsx +61 -3
  12. package/components/news-desk-client-provider.tsx +16 -1
  13. package/components/news-desk-page.tsx +2 -1
  14. package/components/newsroom-access-gate.tsx +7 -5
  15. package/components/newsroom-client-shell.tsx +2 -1
  16. package/components/newsroom-ops-overview.tsx +63 -60
  17. package/components/newsroom-ops-shell.tsx +4 -2
  18. package/components/newsroom-organizer.css +49 -2
  19. package/components/newsroom-references-view.tsx +296 -23
  20. package/components/notification-cards.tsx +364 -0
  21. package/components/notification-center.tsx +319 -331
  22. package/components/reference-capture-client.ts +110 -0
  23. package/components/reference-capture-panel.tsx +180 -0
  24. package/components/reference-feed.tsx +502 -0
  25. package/components/reference-review-card.tsx +32 -80
  26. package/components/references-page-loading.tsx +61 -0
  27. package/components/references-prefetch.ts +52 -0
  28. package/components/review-rating-grid.tsx +177 -0
  29. package/components/topic-steering-workspace.tsx +43 -9
  30. package/components/use-reference-ratings.ts +132 -0
  31. package/lib/category-repository.ts +6 -1
  32. package/lib/content-actions-client.ts +18 -0
  33. package/lib/local-preview-feed-fixture.ts +63 -0
  34. package/lib/local-preview-synthetic-rejects.ts +41 -0
  35. package/lib/monitoring-client.ts +16 -2
  36. package/lib/monitoring-overview.ts +20 -1
  37. package/lib/monitoring-paging.ts +58 -0
  38. package/lib/newsroom-nav.ts +1 -1
  39. package/lib/newsroom-organizer-nav.ts +1 -1
  40. package/lib/notification-feed.ts +250 -0
  41. package/lib/reference-capture.ts +293 -0
  42. package/lib/reference-feed-cache.ts +170 -0
  43. package/lib/reference-feed-ranking.ts +235 -0
  44. package/lib/reference-preview.ts +112 -0
  45. package/lib/review-drafts.ts +8 -3
  46. package/lib/site-brand.ts +2 -0
  47. package/package.json +17 -1
  48. package/routes.manifest.json +1 -1
@@ -0,0 +1,502 @@
1
+ "use client";
2
+
3
+ import { InfoIcon, Loader2Icon } from "lucide-react";
4
+ import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
5
+ import type { ReferenceRecord } from "../lib/category-repository";
6
+ import { REFERENCE_REJECTION_REASON_CODES, normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
7
+ import {
8
+ referenceDisplayTime,
9
+ groupFeedItems,
10
+ EARLIER_AFTER_TAUS,
11
+ MINIMUM_FRESHNESS_EXAMPLES,
12
+ type FeedGroupKey,
13
+ type FeedItem,
14
+ type FeedFreshness,
15
+ type ReferenceFeedPartition,
16
+ type ReferenceFeedTabKey,
17
+ } from "../lib/reference-feed-ranking";
18
+ import { dekAddsInformation, isCardStyleImage, referenceDek, referenceSourceHost, formatFeedAge, type ReferencePreview } from "../lib/reference-preview";
19
+ import { referenceDisplayTitle } from "../lib/newsroom-references";
20
+ import type { RelevanceDecisionView } from "../lib/relevance-decisions";
21
+ import { cn } from "../lib/utils";
22
+ import { predictionFromDecision, type ReviewChoice } from "./reference-review-card";
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";
27
+
28
+ /**
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
30
+ * pills. The tones (`--feed-card`, `--feed-footer`, `--feed-control`) are mixed from the publication theme's own paper and ink in
31
+ * newsroom-organizer.css, per light or dark mode, so they follow the brand.
32
+ */
33
+ export const FEED_SURFACE_STYLE = {} as CSSProperties;
34
+
35
+ /** Spread on every feed surface: the tone variables, and the hook the newsroom stylesheet uses to allow round corners here. */
36
+ export const FEED_SURFACE_PROPS = { "data-feed-surface": "", style: FEED_SURFACE_STYLE } as const;
37
+
38
+ const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
39
+ out_of_scope: "Out of scope",
40
+ policy_exclusion: "Policy exclusion",
41
+ duplicate: "Duplicate",
42
+ low_quality: "Low quality",
43
+ unavailable: "Unavailable",
44
+ provenance: "Provenance",
45
+ other: "Other",
46
+ };
47
+
48
+ const TAB_LABELS: Record<ReferenceFeedTabKey, string> = { feed: "Feed", rated: "Rated", earlier: "Earlier" };
49
+ const GROUP_HEADINGS: Record<FeedGroupKey, string> = { top: "Top predictions", more: "More for you", least: "Least likely" };
50
+
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";
52
+
53
+ export type ReferenceFeedProps = {
54
+ /** Read-only reason shown in words when answering is not possible yet (for example while the session is confirmed). */
55
+ disabledReason?: string | null;
56
+ /** True while the first read is in flight: cards are skeletons. */
57
+ loading?: boolean;
58
+ now: number;
59
+ onOpen: (lineageId: string) => void;
60
+ onRate: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => Promise<void> | void;
61
+ onVisitRated: (lineageId: string) => void;
62
+ onTabChange: (tab: ReferenceFeedTabKey) => void;
63
+ /** The ranked tabs, from `partitionReferenceFeed`. */
64
+ partition: ReferenceFeedPartition;
65
+ predictionLoading?: boolean;
66
+ previews: ReadonlyMap<string, ReferencePreview>;
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;
77
+ /** True when the cards are a saved copy that is being refreshed. */
78
+ stale?: boolean;
79
+ tab: ReferenceFeedTabKey;
80
+ visitRated: ReadonlySet<string>;
81
+ };
82
+
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";
88
+
89
+ /** Pulsing placeholder cards: the page shell is on screen at once, with no "checking" screen in front of it. */
90
+ export function ReferenceFeedSkeleton({ count = 6 }: { count?: number }) {
91
+ return (
92
+ <div aria-busy="true" aria-label="Loading references" className={cn("@container", GRID)} data-reference-feed-skeleton role="status" {...FEED_SURFACE_PROPS}>
93
+ {Array.from({ length: count }, (_, index) => (
94
+ <div className={cn("overflow-hidden rounded-[20px] bg-[var(--feed-card)]", index === 0 && LEAD_SPAN)} key={index}>
95
+ <div className={cn("animate-pulse bg-[var(--feed-control)]", index === 0 ? "aspect-[2/1]" : "aspect-[16/10]")} />
96
+ <div className="space-y-3 p-5">
97
+ <div className="h-3 w-1/3 animate-pulse rounded-full bg-[var(--feed-control)]" />
98
+ <div className="h-5 w-11/12 animate-pulse rounded-full bg-[var(--feed-control)]" />
99
+ <div className="h-5 w-2/3 animate-pulse rounded-full bg-[var(--feed-control)]" />
100
+ <div className="h-3 w-1/4 animate-pulse rounded-full bg-[var(--feed-control)]" />
101
+ </div>
102
+ <div className="flex gap-2 bg-[var(--feed-footer)] p-3">
103
+ <div className="h-10 flex-1 animate-pulse rounded-full bg-[var(--feed-control)]" />
104
+ <div className="h-10 flex-1 animate-pulse rounded-full bg-[var(--feed-control)]" />
105
+ </div>
106
+ </div>
107
+ ))}
108
+ </div>
109
+ );
110
+ }
111
+
112
+ function hueOf(name: string): number {
113
+ let hash = 5381;
114
+ for (const character of name) hash = ((hash << 5) + hash + character.charCodeAt(0)) | 0;
115
+ return Math.abs(hash) % 360;
116
+ }
117
+
118
+ /** A small mark for a source: its own favicon on a light tile when it loads, else its first letter. */
119
+ export function SourceMark({ host, size = "sm" }: { host: string | null; size?: "sm" | "lg" }) {
120
+ const [failed, setFailed] = useState(false);
121
+ const box = size === "lg" ? "size-16 rounded-2xl text-3xl" : "size-5 rounded-md text-[0.65rem]";
122
+ const letter = (host ?? "?")[0];
123
+ return (
124
+ <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
+ {host && !failed ? (
126
+ // 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`} />
128
+ ) : letter}
129
+ </span>
130
+ );
131
+ }
132
+
133
+ /** For a page with no usable preview picture: a soft gradient from the source's name, its mark large and centred, its name beneath. */
134
+ function FallbackTile({ host, name }: { host: string | null; name: string }) {
135
+ const hue = hueOf(host ?? name);
136
+ return (
137
+ <div
138
+ className="flex size-full flex-col items-center justify-center gap-3 p-4 text-white"
139
+ data-feed-image-fallback
140
+ style={{ background: `linear-gradient(135deg, oklch(0.6 0.1 ${hue}), oklch(0.42 0.11 ${(hue + 45) % 360}))` }}
141
+ >
142
+ <SourceMark host={host} size="lg" />
143
+ <span className="max-w-full truncate text-sm font-semibold tracking-wide opacity-90">{name}</span>
144
+ </div>
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Full-bleed lazy image in a fixed frame: a soft pulse until it loads, the fallback tile when it never does.
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
151
+ * the picture's own ratio and the picture is contained, so no words are cut off at the edges.
152
+ */
153
+ export function FeedImage({ host, lead, name, src }: { host: string | null; lead: boolean; name: string; src: string | null }) {
154
+ const [state, setState] = useState<"loading" | "loaded" | "failed">(src ? "loading" : "failed");
155
+ const [ratio, setRatio] = useState<number | null>(null);
156
+ const ref = useRef<HTMLImageElement | null>(null);
157
+ const measure = (element: HTMLImageElement) => { if (element.naturalWidth > 0 && element.naturalHeight > 0) setRatio(element.naturalWidth / element.naturalHeight); };
158
+ useEffect(() => { setState(src ? "loading" : "failed"); setRatio(null); }, [src]);
159
+ useEffect(() => {
160
+ // A cached image can finish before React attaches onLoad.
161
+ const element = ref.current;
162
+ if (element?.complete && element.naturalWidth > 0) { setState("loaded"); measure(element); }
163
+ }, [src]);
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);
168
+ return (
169
+ <div
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]"))}
171
+ data-feed-image
172
+ data-feed-image-fit={card ? "contain" : "cover"}
173
+ data-feed-image-state={state}
174
+ style={hostCard ? { aspectRatio: "2" } : undefined}
175
+ >
176
+ {state === "loading" ? <div aria-hidden="true" className="absolute inset-0 animate-pulse bg-[var(--feed-footer)] blur-sm" data-feed-image-placeholder /> : null}
177
+ {src && state !== "failed" ? (
178
+ // The source site chose this picture; an <img> keeps it out of our image optimizer and out of its referrer log.
179
+ // eslint-disable-next-line @next/next/no-img-element
180
+ <img
181
+ alt=""
182
+ className={cn("absolute inset-0 size-full transition-opacity duration-500", card ? "object-contain" : "object-cover", state === "loaded" ? "opacity-100" : "opacity-0")}
183
+ decoding="async"
184
+ loading="lazy"
185
+ onError={() => setState("failed")}
186
+ onLoad={(event) => { setState("loaded"); measure(event.currentTarget); }}
187
+ ref={ref}
188
+ referrerPolicy="no-referrer"
189
+ src={src}
190
+ />
191
+ ) : null}
192
+ {state === "failed" ? <FallbackTile host={host} name={name} /> : null}
193
+ </div>
194
+ );
195
+ }
196
+
197
+ export function FeedCard({
198
+ capture,
199
+ decision,
200
+ disabledReason,
201
+ item,
202
+ lead,
203
+ now,
204
+ onOpen,
205
+ onRate,
206
+ predictionLoading,
207
+ preview,
208
+ ratings,
209
+ selected,
210
+ visitRated,
211
+ }: {
212
+ /** Optional: the capture badge. */
213
+ capture?: CaptureStatus | undefined;
214
+ decision: RelevanceDecisionView | undefined;
215
+ disabledReason: string | null;
216
+ item: FeedItem;
217
+ lead: boolean;
218
+ now: number;
219
+ onOpen: () => void;
220
+ onRate: ReferenceFeedProps["onRate"];
221
+ predictionLoading: boolean;
222
+ preview: ReferencePreview | undefined;
223
+ /** Optional: a shared rating store. Without it the card keeps its own. */
224
+ ratings?: RatingController | undefined;
225
+ selected: boolean;
226
+ visitRated: boolean;
227
+ }) {
228
+ const { reference } = item;
229
+ const titleId = useId();
230
+ const status = normalizeReferenceCurationStatus(reference.curationStatus);
231
+ const answer: ReviewChoice | null = status === "accepted" ? "accept" : status === "rejected" ? "reject" : null;
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;
241
+ const host = referenceSourceHost(reference);
242
+ const site = preview?.siteName ?? host ?? "Unknown source";
243
+ const title = referenceDisplayTitle(reference);
244
+ const dek = referenceDek(reference, preview);
245
+ const showDek = dekAddsInformation(title, dek, preview?.imageUrl ?? null);
246
+ const time = referenceDisplayTime(reference);
247
+ const age = formatFeedAge(time, now);
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;
250
+
251
+ return (
252
+ <article
253
+ aria-labelledby={titleId}
254
+ aria-current={selected ? "true" : undefined}
255
+ className={cn(
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,
259
+ )}
260
+ data-feed-card={item.lineageId}
261
+ data-feed-lead={lead || undefined}
262
+ data-feed-rated={answer ?? undefined}
263
+ data-feed-selected={selected || undefined}
264
+ data-feed-visit-rated={visitRated || undefined}
265
+ data-newsroom-card-id={item.lineageId}
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}
268
+ <button
269
+ aria-label={`Open: ${title}`}
270
+ className="flex flex-1 flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
271
+ data-feed-open
272
+ onClick={onOpen}
273
+ type="button"
274
+ >
275
+ <FeedImage host={host} lead={lead} name={site} src={preview?.imageUrl ?? null} />
276
+ <span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5 @[36rem]:p-7" : "p-5")}>
277
+ <span className="flex items-center gap-2 text-xs font-medium text-muted-foreground" data-feed-source>
278
+ <SourceMark host={host} />
279
+ <span className="truncate">{site}</span>
280
+ </span>
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}>
282
+ {title}
283
+ </span>
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}
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}
292
+ </span>
293
+ </span>
294
+ </button>
295
+
296
+ <div className="relative bg-[var(--feed-footer)] p-3" data-feed-actions>
297
+ <RatingGrid
298
+ answer={chosen}
299
+ decided={answer}
300
+ disabled={Boolean(disabledReason)}
301
+ disabledReason={disabledReason}
302
+ feedback={feedback}
303
+ note={reasonLabel ? `Reason: ${reasonLabel}` : null}
304
+ onChoose={(choice) => { if (!disabledReason) store.choose(item.lineageId, choice); }}
305
+ prediction={decision ? predictionFromDecision(decision) : predictionLoading ? { state: "loading" } : { state: "none" }}
306
+ />
307
+ {rating.askReason ? (
308
+ // Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
309
+ <div className="absolute inset-0 z-10 flex flex-col justify-center space-y-2 bg-[var(--feed-footer)] p-3" data-review-reasons>
310
+ <p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
311
+ <div className="flex flex-wrap gap-1.5">
312
+ {REFERENCE_REJECTION_REASON_CODES.map((code) => (
313
+ <button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => store.setReason(item.lineageId, code)} type="button">
314
+ {REASON_LABELS[code]}
315
+ </button>
316
+ ))}
317
+ <button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => store.cancelReason(item.lineageId)} type="button">Cancel</button>
318
+ </div>
319
+ </div>
320
+ ) : null}
321
+ </div>
322
+ </article>
323
+ );
324
+ }
325
+
326
+ function plainDays(days: number): string {
327
+ if (days < 1) return `${Math.max(1, Math.round(days * 24))} hours`;
328
+ return `${days < 10 ? days.toFixed(1).replace(/\.0$/, "") : Math.round(days)} days`;
329
+ }
330
+
331
+ /** The plain-words recipe, with this site's learned numbers in it. */
332
+ function RankingPopover({ freshness }: { freshness: FeedFreshness }) {
333
+ const [open, setOpen] = useState(false);
334
+ const panelId = useId();
335
+ const root = useRef<HTMLDivElement | null>(null);
336
+ useEffect(() => {
337
+ if (!open) return;
338
+ const close = (event: MouseEvent | KeyboardEvent) => {
339
+ if (event instanceof KeyboardEvent) { if (event.key === "Escape") setOpen(false); return; }
340
+ if (root.current && !root.current.contains(event.target as Node)) setOpen(false);
341
+ };
342
+ document.addEventListener("mousedown", close);
343
+ document.addEventListener("keydown", close);
344
+ return () => { document.removeEventListener("mousedown", close); document.removeEventListener("keydown", close); };
345
+ }, [open]);
346
+ return (
347
+ <div className="relative" ref={root}>
348
+ <button
349
+ aria-controls={panelId}
350
+ aria-expanded={open}
351
+ aria-label="How this feed is ranked"
352
+ className={cn(PILL, "text-muted-foreground hover:text-foreground", open && "bg-[var(--feed-card)] text-foreground")}
353
+ data-feed-ranking-info
354
+ onClick={() => setOpen((current) => !current)}
355
+ type="button"
356
+ >
357
+ <InfoIcon aria-hidden="true" className="size-4" />
358
+ <span className="hidden sm:inline">How this feed is ranked</span>
359
+ <span className="sr-only sm:hidden">Ranking</span>
360
+ </button>
361
+ {open ? (
362
+ <div
363
+ aria-label="How this feed is ranked"
364
+ className="fixed inset-x-4 bottom-24 z-30 max-h-[70dvh] space-y-3 overflow-y-auto rounded-3xl bg-popover p-6 text-sm text-popover-foreground shadow-2xl sm:absolute sm:inset-x-auto sm:bottom-auto sm:right-0 sm:mt-2 sm:w-[24rem]"
365
+ data-feed-ranking-panel
366
+ id={panelId}
367
+ role="dialog"
368
+ >
369
+ <p className="text-base font-bold">A feed run by algorithms you control</p>
370
+ <ol className="list-decimal space-y-2.5 pl-5">
371
+ <li><strong>Best bets first.</strong> The Cyclotron predicts Accept or Reject and how sure it is. The items it expects you to Accept most surely come first. The ones it expects you to Reject most surely come last. Items with no prediction sit in the middle.</li>
372
+ <li><strong>Confidence counts.</strong> An Accept at 90% ranks above an Accept at 60%.</li>
373
+ <li><strong>Age fades a prediction toward &ldquo;not sure&rdquo;.</strong> Older items drift toward the middle, never to the bottom. {freshness.tauDays !== null && freshness.medianAgeDays !== null
374
+ ? <>A prediction loses half its pull every {plainDays(freshness.medianAgeDays)}, the typical age of the {freshness.examples} items you accepted.</>
375
+ : <>Age does not change the order yet. The pace is learned from the items you accept, and so far there are {freshness.examples} of the {MINIMUM_FRESHNESS_EXAMPLES} needed.</>}</li>
376
+ <li><strong>Earlier.</strong> {freshness.tauDays !== null
377
+ ? <>Unrated items older than {plainDays(freshness.tauDays * EARLIER_AFTER_TAUS)} move to Earlier at the bottom. You can still rate them. Nothing is deleted.</>
378
+ : <>Once the pace is learned, unrated items past three times that pace move to Earlier at the bottom. Nothing is deleted.</>}</li>
379
+ <li><strong>Your answers stay put.</strong> A card you rate stays where it is for this visit. Next time it is under Rated.</li>
380
+ </ol>
381
+ </div>
382
+ ) : null}
383
+ </div>
384
+ );
385
+ }
386
+
387
+ export function SectionHeading({ children, id }: { children: ReactNode; id: string }) {
388
+ return <h3 className="mb-5 mt-10 text-3xl font-bold tracking-tight text-primary first:mt-2 sm:mt-12 sm:text-4xl" id={id}>{children}</h3>;
389
+ }
390
+
391
+ export function ReferenceFeed({
392
+ disabledReason = null,
393
+ loading = false,
394
+ now,
395
+ onOpen,
396
+ onRate,
397
+ onTabChange,
398
+ onVisitRated,
399
+ partition,
400
+ predictionLoading = false,
401
+ previews,
402
+ selectedLineageId = "",
403
+ captures,
404
+ groups: stableGroups,
405
+ leadId: stableLeadId,
406
+ ratings,
407
+ scrollTarget = null,
408
+ stale = false,
409
+ tab,
410
+ visitRated,
411
+ }: ReferenceFeedProps) {
412
+ const counts: Record<ReferenceFeedTabKey, number> = { feed: partition.feed.length, rated: partition.rated.length, earlier: partition.earlier.length };
413
+
414
+ const renderCard = (item: FeedItem, lead: boolean) => (
415
+ <FeedCard
416
+ capture={captures?.get(item.lineageId)}
417
+ decision={item.decision}
418
+ disabledReason={disabledReason}
419
+ item={item}
420
+ key={item.lineageId}
421
+ lead={lead}
422
+ now={now}
423
+ onOpen={() => onOpen(item.lineageId)}
424
+ onRate={async (reference, choice, reasonCode) => { onVisitRated(item.lineageId); await onRate(reference, choice, reasonCode); }}
425
+ predictionLoading={predictionLoading}
426
+ preview={previews.get(item.lineageId)}
427
+ ratings={ratings}
428
+ selected={selectedLineageId === item.lineageId}
429
+ visitRated={visitRated.has(item.lineageId)}
430
+ />
431
+ );
432
+
433
+ const empty = (label: string) => (
434
+ <div className="rounded-[20px] bg-[var(--feed-card)] p-10 text-center text-sm text-muted-foreground" data-feed-empty>{label}</div>
435
+ );
436
+
437
+ // Previous / Next in the detail pane scrolls the grid to the same card, smoothly. Selecting a card by clicking it never scrolls.
438
+ useEffect(() => {
439
+ if (!scrollTarget || typeof document === "undefined") return;
440
+ document.querySelector(`[data-feed-card="${CSS.escape(scrollTarget.id)}"]`)?.scrollIntoView({ behavior: "smooth", block: "nearest" });
441
+ }, [scrollTarget]);
442
+
443
+ const groups = stableGroups ?? groupFeedItems(partition.feed);
444
+ const headings = groups.length > 1;
445
+ const leadId = stableGroups ? stableLeadId : groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined;
446
+
447
+ return (
448
+ <div className="@container space-y-5" data-reference-feed data-feed-tab={tab} {...FEED_SURFACE_PROPS}>
449
+ <div className="flex items-center justify-between gap-2">
450
+ <div aria-label="Feed sections" className="inline-flex rounded-full bg-[var(--feed-card)] p-1" role="tablist">
451
+ {(Object.keys(TAB_LABELS) as ReferenceFeedTabKey[]).map((key) => (
452
+ <button
453
+ aria-selected={tab === key}
454
+ className={cn(PILL, "min-h-9 gap-1.5 px-3.5", tab === key ? "bg-[var(--feed-control)] text-foreground" : "text-muted-foreground hover:text-foreground")}
455
+ data-feed-tab-button={key}
456
+ key={key}
457
+ onClick={() => onTabChange(key)}
458
+ role="tab"
459
+ type="button"
460
+ >
461
+ {TAB_LABELS[key]}
462
+ <span className="text-xs tabular-nums opacity-70" data-feed-tab-count>{counts[key]}</span>
463
+ </button>
464
+ ))}
465
+ </div>
466
+ <div className="flex items-center gap-2">
467
+ {stale ? (
468
+ <span className="hidden items-center gap-1 text-xs text-muted-foreground sm:inline-flex" data-feed-stale role="status">
469
+ <Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Showing your last view while it updates
470
+ </span>
471
+ ) : null}
472
+ <RankingPopover freshness={partition.freshness} />
473
+ </div>
474
+ </div>
475
+ {stale ? <p className="text-xs text-muted-foreground sm:hidden" role="status"><Loader2Icon aria-hidden="true" className="mr-1 inline size-3.5 animate-spin" />Showing your last view while it updates</p> : null}
476
+
477
+ {loading ? <ReferenceFeedSkeleton /> : (
478
+ <div role="tabpanel">
479
+ {tab === "feed" ? (
480
+ <div>
481
+ {groups.length ? groups.map((group) => (
482
+ <section aria-labelledby={headings ? `feed-group-${group.key}` : undefined} data-feed-group={group.key} key={group.key}>
483
+ {headings ? <SectionHeading id={`feed-group-${group.key}`}>{GROUP_HEADINGS[group.key]}</SectionHeading> : null}
484
+ <div className={FEED_GRID} data-feed-grid>{group.items.map((item) => renderCard(item, item.lineageId === leadId))}</div>
485
+ </section>
486
+ )) : empty(counts.rated || counts.earlier ? "You are all caught up. Check Earlier and Rated." : "No references yet.")}
487
+ {partition.earlier.length ? (
488
+ <section aria-labelledby="feed-earlier-heading" data-feed-earlier>
489
+ <SectionHeading id="feed-earlier-heading">Earlier</SectionHeading>
490
+ <p className="-mt-3 mb-5 text-sm text-muted-foreground">Unrated and past their freshness. Still yours to rate.</p>
491
+ <div className={FEED_GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div>
492
+ </section>
493
+ ) : null}
494
+ </div>
495
+ ) : null}
496
+ {tab === "rated" ? (partition.rated.length ? <div className={FEED_GRID} data-feed-grid>{partition.rated.map((item) => renderCard(item, false))}</div> : empty("Nothing rated yet. Your Accepts and Rejects collect here, newest first.")) : null}
497
+ {tab === "earlier" ? (partition.earlier.length ? <div className={FEED_GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div> : empty("Nothing has aged out. Unrated items that fall past their freshness collect here.")) : null}
498
+ </div>
499
+ )}
500
+ </div>
501
+ );
502
+ }
@@ -12,6 +12,8 @@ import {
12
12
  import { useEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
13
13
  import { Button } from "./ui/button";
14
14
  import { cn } from "../lib/utils";
15
+ import { RatingGrid } from "./review-rating-grid";
16
+ import { useRatingStore, type RatingController } from "./use-reference-ratings";
15
17
  import { REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
16
18
 
17
19
  export type ReviewChoice = "accept" | "reject";
@@ -140,8 +142,11 @@ export function ReviewCard({
140
142
  summary,
141
143
  title,
142
144
  titleHref,
145
+ ratings,
143
146
  }: {
144
147
  children?: ReactNode;
148
+ /** A shared rating store, so the grid card and this card stay in step. Optional: the card works alone without it. */
149
+ ratings?: RatingController;
145
150
  /** The saved human answer, or null while the item is undecided. */
146
151
  decided: ReviewChoice | null;
147
152
  /** When set, the controls explain in words why they cannot change anything. */
@@ -156,70 +161,21 @@ export function ReviewCard({
156
161
  title: string;
157
162
  titleHref?: string | null;
158
163
  }) {
159
- const [draft, setDraft] = useState<ReviewChoice | null>(null);
160
- const [note, setNote] = useState(initialNote);
161
- const [reason, setReason] = useState<ReferenceRejectionReasonCode | null>(initialReason);
162
- const [saving, setSaving] = useState(false);
163
- const [saved, setSaved] = useState(false);
164
- const [error, setError] = useState<string | null>(null);
165
- const inFlight = useRef(false);
166
- const noteTouched = useRef(false);
167
- const reasonTouched = useRef(false);
168
- const savedNote = useRef(initialNote);
169
-
170
- // Saved values from outside (another editor, a reload) refresh whatever the human has not touched.
171
- useEffect(() => {
172
- savedNote.current = initialNote;
173
- if (!noteTouched.current) setNote(initialNote);
174
- }, [initialNote]);
175
- useEffect(() => {
176
- if (!reasonTouched.current) setReason(initialReason);
177
- }, [initialReason]);
178
-
179
- const chosen = draft ?? decided;
180
-
181
- async function save(choice: ReviewChoice, nextReason: ReferenceRejectionReasonCode | null, nextNote: string) {
182
- if (disabledReason || inFlight.current) return;
183
- if (choice === "reject" && !nextReason) return;
184
- inFlight.current = true;
185
- setSaving(true);
186
- setSaved(false);
187
- setError(null);
188
- try {
189
- await onDecide(choice, { note: nextNote.trim(), reasonCode: choice === "reject" ? nextReason : null });
190
- savedNote.current = nextNote.trim();
191
- noteTouched.current = false;
192
- reasonTouched.current = false;
193
- setDraft(null);
194
- setSaved(true);
195
- } catch (failure) {
196
- setDraft(null);
197
- setError(failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry.");
198
- } finally {
199
- inFlight.current = false;
200
- setSaving(false);
201
- }
202
- }
203
-
204
- function choose(choice: ReviewChoice) {
205
- if (disabledReason) return;
206
- if (chosen === choice && draft === null) return; // the selected answer never unselects itself
207
- setError(null);
208
- setDraft(choice);
209
- void save(choice, reason, note);
210
- }
211
-
212
- function chooseReason(code: ReferenceRejectionReasonCode) {
213
- if (disabledReason) return;
214
- setReason(code);
215
- reasonTouched.current = true;
216
- if (chosen === "reject") void save("reject", code, note);
217
- }
218
-
219
- function commitNote() {
220
- if (!decided || disabledReason || note.trim() === savedNote.current.trim()) return;
221
- void save(decided, reason, note);
222
- }
164
+ // The rating state lives in a store. Given one (`ratings`), the grid card and this card share it, so a vote or a reason typed in
165
+ // either shows in both at once; without one the card keeps a private store, exactly as before.
166
+ const local = useRatingStore({
167
+ save: (_id, choice, detail) => onDecide(choice, detail),
168
+ saved: () => decided,
169
+ seed: () => ({ note: initialNote, reasonCode: initialReason }),
170
+ });
171
+ const store = ratings ?? local;
172
+ const item = store.get(itemId);
173
+ const note = item.note ?? initialNote;
174
+ const reason = item.reasonCode !== undefined ? item.reasonCode : initialReason;
175
+ const chosen = item.pending ?? decided;
176
+ const choose = (choice: ReviewChoice) => { if (!disabledReason) store.choose(itemId, choice); };
177
+ const chooseReason = (code: ReferenceRejectionReasonCode) => { if (!disabledReason) store.setReason(itemId, code); };
178
+ const commitNote = () => { if (!disabledReason) store.commitNote(itemId); };
223
179
 
224
180
  // Desktop shortcuts: A accepts, R rejects. Never while typing.
225
181
  useEffect(() => {
@@ -247,21 +203,17 @@ export function ReviewCard({
247
203
  {summary ? <p className="text-sm text-muted-foreground">{summary}</p> : null}
248
204
  </header>
249
205
 
250
- <CyclotronRow decided={decided} prediction={prediction} />
251
-
252
- <div className="space-y-2" data-review-row="you">
253
- <div className="flex items-center gap-3">
254
- <span className="w-20 shrink-0 text-sm font-semibold">You</span>
255
- <ChoiceButtons chosen={chosen} disabled={Boolean(disabledReason)} onChoose={choose} />
256
- </div>
257
- <div className="min-h-5 pl-[5.75rem] text-sm" role="status">
258
- {saving ? "Saving…" : saved ? (
259
- <span className="inline-flex items-center gap-1" data-review-saved><CheckIcon aria-hidden="true" className="size-4" />Saved</span>
260
- ) : null}
261
- </div>
262
- {disabledReason ? <p className="text-sm text-muted-foreground" data-review-disabled-reason>{disabledReason}</p> : null}
263
- {error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
264
- </div>
206
+ <RatingGrid
207
+ answer={chosen}
208
+ decided={decided}
209
+ disabled={Boolean(disabledReason)}
210
+ disabledReason={disabledReason}
211
+ feedback={item.feedback}
212
+ onChoose={choose}
213
+ prediction={prediction}
214
+ size="large"
215
+ surface="card"
216
+ />
265
217
 
266
218
  <label className="block text-sm font-medium">
267
219
  Why? (optional)
@@ -269,7 +221,7 @@ export function ReviewCard({
269
221
  aria-label="Why? (optional)"
270
222
  className="mt-1 block min-h-24 w-full rounded-md border bg-background p-3 text-base font-normal"
271
223
  onBlur={commitNote}
272
- onChange={(event) => { setNote(event.target.value); noteTouched.current = true; setSaved(false); }}
224
+ onChange={(event) => store.setNote(itemId, event.target.value)}
273
225
  placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
274
226
  value={note}
275
227
  />