@anthusai/papyrus 5.44.0 → 5.45.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.
@@ -0,0 +1,469 @@
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
+
25
+ /**
26
+ * 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
27
+ * pills. The tones (`--feed-card`, `--feed-footer`, `--feed-control`) are mixed from the publication theme's own paper and ink in
28
+ * newsroom-organizer.css, per light or dark mode, so they follow the brand.
29
+ */
30
+ export const FEED_SURFACE_STYLE = {} as CSSProperties;
31
+
32
+ /** Spread on every feed surface: the tone variables, and the hook the newsroom stylesheet uses to allow round corners here. */
33
+ export const FEED_SURFACE_PROPS = { "data-feed-surface": "", style: FEED_SURFACE_STYLE } as const;
34
+
35
+ const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
36
+ out_of_scope: "Out of scope",
37
+ policy_exclusion: "Policy exclusion",
38
+ duplicate: "Duplicate",
39
+ low_quality: "Low quality",
40
+ unavailable: "Unavailable",
41
+ provenance: "Provenance",
42
+ other: "Other",
43
+ };
44
+
45
+ const TAB_LABELS: Record<ReferenceFeedTabKey, string> = { feed: "Feed", rated: "Rated", earlier: "Earlier" };
46
+ const GROUP_HEADINGS: Record<FeedGroupKey, string> = { top: "Top predictions", more: "More for you", least: "Least likely" };
47
+
48
+ const PILL = "inline-flex min-h-9 items-center justify-center gap-1.5 rounded-full px-3.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring";
49
+
50
+ export type ReferenceFeedProps = {
51
+ /** Read-only reason shown in words when answering is not possible yet (for example while the session is confirmed). */
52
+ disabledReason?: string | null;
53
+ /** True while the first read is in flight: cards are skeletons. */
54
+ loading?: boolean;
55
+ now: number;
56
+ onOpen: (lineageId: string) => void;
57
+ onRate: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => Promise<void> | void;
58
+ onVisitRated: (lineageId: string) => void;
59
+ onTabChange: (tab: ReferenceFeedTabKey) => void;
60
+ /** The ranked tabs, from `partitionReferenceFeed`. */
61
+ partition: ReferenceFeedPartition;
62
+ predictionLoading?: boolean;
63
+ previews: ReadonlyMap<string, ReferencePreview>;
64
+ selectedLineageId?: string;
65
+ /** True when the cards are a saved copy that is being refreshed. */
66
+ stale?: boolean;
67
+ tab: ReferenceFeedTabKey;
68
+ visitRated: ReadonlySet<string>;
69
+ };
70
+
71
+ const GRID = "grid gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4";
72
+
73
+ /** Pulsing placeholder cards: the page shell is on screen at once, with no "checking" screen in front of it. */
74
+ export function ReferenceFeedSkeleton({ count = 6 }: { count?: number }) {
75
+ return (
76
+ <div aria-busy="true" aria-label="Loading references" className={GRID} data-reference-feed-skeleton role="status" {...FEED_SURFACE_PROPS}>
77
+ {Array.from({ length: count }, (_, index) => (
78
+ <div className={cn("overflow-hidden rounded-[20px] bg-[var(--feed-card)]", index === 0 && "sm:col-span-2")} key={index}>
79
+ <div className={cn("animate-pulse bg-[var(--feed-control)]", index === 0 ? "aspect-[2/1]" : "aspect-[16/10]")} />
80
+ <div className="space-y-3 p-5">
81
+ <div className="h-3 w-1/3 animate-pulse rounded-full bg-[var(--feed-control)]" />
82
+ <div className="h-5 w-11/12 animate-pulse rounded-full bg-[var(--feed-control)]" />
83
+ <div className="h-5 w-2/3 animate-pulse rounded-full bg-[var(--feed-control)]" />
84
+ <div className="h-3 w-1/4 animate-pulse rounded-full bg-[var(--feed-control)]" />
85
+ </div>
86
+ <div className="flex gap-2 bg-[var(--feed-footer)] p-3">
87
+ <div className="h-10 flex-1 animate-pulse rounded-full bg-[var(--feed-control)]" />
88
+ <div className="h-10 flex-1 animate-pulse rounded-full bg-[var(--feed-control)]" />
89
+ </div>
90
+ </div>
91
+ ))}
92
+ </div>
93
+ );
94
+ }
95
+
96
+ function hueOf(name: string): number {
97
+ let hash = 5381;
98
+ for (const character of name) hash = ((hash << 5) + hash + character.charCodeAt(0)) | 0;
99
+ return Math.abs(hash) % 360;
100
+ }
101
+
102
+ /** A small mark for a source: its own favicon on a light tile when it loads, else its first letter. */
103
+ function SourceMark({ host, size = "sm" }: { host: string | null; size?: "sm" | "lg" }) {
104
+ const [failed, setFailed] = useState(false);
105
+ const box = size === "lg" ? "size-16 rounded-2xl text-3xl" : "size-5 rounded-md text-[0.65rem]";
106
+ const letter = (host ?? "?")[0];
107
+ return (
108
+ <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>
109
+ {host && !failed ? (
110
+ // eslint-disable-next-line @next/next/no-img-element
111
+ <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`} />
112
+ ) : letter}
113
+ </span>
114
+ );
115
+ }
116
+
117
+ /** For a page with no usable preview picture: a soft gradient from the source's name, its mark large and centred, its name beneath. */
118
+ function FallbackTile({ host, name }: { host: string | null; name: string }) {
119
+ const hue = hueOf(host ?? name);
120
+ return (
121
+ <div
122
+ className="flex size-full flex-col items-center justify-center gap-3 p-4 text-white"
123
+ data-feed-image-fallback
124
+ style={{ background: `linear-gradient(135deg, oklch(0.6 0.1 ${hue}), oklch(0.42 0.11 ${(hue + 45) % 360}))` }}
125
+ >
126
+ <SourceMark host={host} size="lg" />
127
+ <span className="max-w-full truncate text-sm font-semibold tracking-wide opacity-90">{name}</span>
128
+ </div>
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Full-bleed lazy image in a fixed frame: a soft pulse until it loads, the fallback tile when it never does.
134
+ * Photos fill the frame (cover). A text card (a repository social card, or a 2:1 picture) is shown whole instead: the frame takes
135
+ * the picture's own ratio and the picture is contained, so no words are cut off at the edges.
136
+ */
137
+ function FeedImage({ host, lead, name, src }: { host: string | null; lead: boolean; name: string; src: string | null }) {
138
+ const [state, setState] = useState<"loading" | "loaded" | "failed">(src ? "loading" : "failed");
139
+ const [ratio, setRatio] = useState<number | null>(null);
140
+ const ref = useRef<HTMLImageElement | null>(null);
141
+ const measure = (element: HTMLImageElement) => { if (element.naturalWidth > 0 && element.naturalHeight > 0) setRatio(element.naturalWidth / element.naturalHeight); };
142
+ useEffect(() => { setState(src ? "loading" : "failed"); setRatio(null); }, [src]);
143
+ useEffect(() => {
144
+ // A cached image can finish before React attaches onLoad.
145
+ const element = ref.current;
146
+ if (element?.complete && element.naturalWidth > 0) { setState("loaded"); measure(element); }
147
+ }, [src]);
148
+ const card = isCardStyleImage(src, ratio);
149
+ return (
150
+ <div
151
+ className={cn("relative w-full overflow-hidden bg-[var(--feed-control)]", !card && (lead ? "aspect-[16/10] sm:aspect-[2/1] xl:aspect-[21/9]" : "aspect-[16/10]"))}
152
+ data-feed-image
153
+ data-feed-image-fit={card ? "contain" : "cover"}
154
+ data-feed-image-state={state}
155
+ style={card ? { aspectRatio: String(ratio ?? 2) } : undefined}
156
+ >
157
+ {state === "loading" ? <div aria-hidden="true" className="absolute inset-0 animate-pulse bg-[var(--feed-footer)] blur-sm" data-feed-image-placeholder /> : null}
158
+ {src && state !== "failed" ? (
159
+ // The source site chose this picture; an <img> keeps it out of our image optimizer and out of its referrer log.
160
+ // eslint-disable-next-line @next/next/no-img-element
161
+ <img
162
+ alt=""
163
+ className={cn("absolute inset-0 size-full transition-opacity duration-500", card ? "object-contain" : "object-cover", state === "loaded" ? "opacity-100" : "opacity-0")}
164
+ decoding="async"
165
+ loading="lazy"
166
+ onError={() => setState("failed")}
167
+ onLoad={(event) => { setState("loaded"); measure(event.currentTarget); }}
168
+ ref={ref}
169
+ referrerPolicy="no-referrer"
170
+ src={src}
171
+ />
172
+ ) : null}
173
+ {state === "failed" ? <FallbackTile host={host} name={name} /> : null}
174
+ </div>
175
+ );
176
+ }
177
+
178
+ type RateState = { phase: "idle" | "saving" | "saved" | "error" | "reason"; message?: string };
179
+
180
+ function FeedCard({
181
+ decision,
182
+ disabledReason,
183
+ item,
184
+ lead,
185
+ now,
186
+ onOpen,
187
+ onRate,
188
+ predictionLoading,
189
+ preview,
190
+ selected,
191
+ visitRated,
192
+ }: {
193
+ decision: RelevanceDecisionView | undefined;
194
+ disabledReason: string | null;
195
+ item: FeedItem;
196
+ lead: boolean;
197
+ now: number;
198
+ onOpen: () => void;
199
+ onRate: ReferenceFeedProps["onRate"];
200
+ predictionLoading: boolean;
201
+ preview: ReferencePreview | undefined;
202
+ selected: boolean;
203
+ visitRated: boolean;
204
+ }) {
205
+ const { reference } = item;
206
+ const titleId = useId();
207
+ const status = normalizeReferenceCurationStatus(reference.curationStatus);
208
+ const answer: ReviewChoice | null = status === "accepted" ? "accept" : status === "rejected" ? "reject" : null;
209
+ const [rate, setRate] = useState<RateState>({ phase: "idle" });
210
+ const inFlight = useRef(false);
211
+ const host = referenceSourceHost(reference);
212
+ const site = preview?.siteName ?? host ?? "Unknown source";
213
+ const title = referenceDisplayTitle(reference);
214
+ const dek = referenceDek(reference, preview);
215
+ const showDek = dekAddsInformation(title, dek, preview?.imageUrl ?? null);
216
+ const time = referenceDisplayTime(reference);
217
+ const age = formatFeedAge(time, now);
218
+
219
+ async function save(choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) {
220
+ if (disabledReason || inFlight.current) return;
221
+ inFlight.current = true;
222
+ setRate({ phase: "saving" });
223
+ try {
224
+ await onRate(reference, choice, reasonCode);
225
+ setRate({ phase: "saved" });
226
+ } catch (failure) {
227
+ setRate({ phase: "error", message: failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry." });
228
+ } finally {
229
+ inFlight.current = false;
230
+ }
231
+ }
232
+
233
+ function choose(choice: ReviewChoice) {
234
+ if (disabledReason) return;
235
+ if (answer === choice) return; // the chosen answer never unselects itself; the other choice is the undo
236
+ if (choice === "reject") { setRate({ phase: "reason" }); return; }
237
+ void save("accept", null);
238
+ }
239
+
240
+ return (
241
+ <article
242
+ aria-labelledby={titleId}
243
+ className={cn(
244
+ "flex flex-col overflow-hidden rounded-[20px] text-card-foreground",
245
+ selected ? "bg-[var(--feed-control)]" : "bg-[var(--feed-card)]",
246
+ lead && "sm:col-span-2",
247
+ )}
248
+ data-feed-card={item.lineageId}
249
+ data-feed-lead={lead || undefined}
250
+ data-feed-rated={answer ?? undefined}
251
+ data-feed-visit-rated={visitRated || undefined}
252
+ data-newsroom-card-id={item.lineageId}
253
+ >
254
+ <button
255
+ aria-label={`Open: ${title}`}
256
+ className="flex flex-1 flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
257
+ data-feed-open
258
+ onClick={onOpen}
259
+ type="button"
260
+ >
261
+ <FeedImage host={host} lead={lead} name={site} src={preview?.imageUrl ?? null} />
262
+ <span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5 sm:p-7" : "p-5")}>
263
+ <span className="flex items-center gap-2 text-xs font-medium text-muted-foreground" data-feed-source>
264
+ <SourceMark host={host} />
265
+ <span className="truncate">{site}</span>
266
+ </span>
267
+ <span className={cn("font-bold leading-[1.15] tracking-tight text-foreground", lead ? "line-clamp-3 text-2xl sm:text-3xl xl:text-4xl" : "line-clamp-3 text-xl")} data-feed-title id={titleId}>
268
+ {title}
269
+ </span>
270
+ {showDek ? <span className={cn("text-muted-foreground", lead ? "line-clamp-3 text-base" : "line-clamp-2 text-sm")} data-feed-dek>{dek}</span> : null}
271
+ <span className="mt-auto pt-1 text-xs text-muted-foreground">
272
+ {age ? <time dateTime={new Date(time ?? now).toISOString()} title={new Date(time ?? now).toLocaleString()}>{age}</time> : null}
273
+ {age && host ? <span aria-hidden="true"> · </span> : null}
274
+ {host}
275
+ </span>
276
+ </span>
277
+ </button>
278
+
279
+ <div className="space-y-2.5 bg-[var(--feed-footer)] p-3" data-feed-actions>
280
+ <RatingGrid
281
+ answer={answer}
282
+ disabled={Boolean(disabledReason)}
283
+ disabledReason={disabledReason}
284
+ feedback={rate.phase === "saving" ? { phase: "saving" } : rate.phase === "error" ? { phase: "error", message: rate.message } : rate.phase === "saved" || (visitRated && rate.phase === "idle" && answer) ? { phase: "saved" } : { phase: "idle" }}
285
+ onChoose={choose}
286
+ prediction={decision ? predictionFromDecision(decision) : predictionLoading ? { state: "loading" } : { state: "none" }}
287
+ />
288
+ {rate.phase === "reason" ? (
289
+ <div className="space-y-2" data-review-reasons>
290
+ <p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
291
+ <div className="flex flex-wrap gap-1.5">
292
+ {REFERENCE_REJECTION_REASON_CODES.map((code) => (
293
+ <button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => void save("reject", code)} type="button">
294
+ {REASON_LABELS[code]}
295
+ </button>
296
+ ))}
297
+ <button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => setRate({ phase: "idle" })} type="button">Cancel</button>
298
+ </div>
299
+ </div>
300
+ ) : null}
301
+ </div>
302
+ </article>
303
+ );
304
+ }
305
+
306
+ function plainDays(days: number): string {
307
+ if (days < 1) return `${Math.max(1, Math.round(days * 24))} hours`;
308
+ return `${days < 10 ? days.toFixed(1).replace(/\.0$/, "") : Math.round(days)} days`;
309
+ }
310
+
311
+ /** The plain-words recipe, with this site's learned numbers in it. */
312
+ function RankingPopover({ freshness }: { freshness: FeedFreshness }) {
313
+ const [open, setOpen] = useState(false);
314
+ const panelId = useId();
315
+ const root = useRef<HTMLDivElement | null>(null);
316
+ useEffect(() => {
317
+ if (!open) return;
318
+ const close = (event: MouseEvent | KeyboardEvent) => {
319
+ if (event instanceof KeyboardEvent) { if (event.key === "Escape") setOpen(false); return; }
320
+ if (root.current && !root.current.contains(event.target as Node)) setOpen(false);
321
+ };
322
+ document.addEventListener("mousedown", close);
323
+ document.addEventListener("keydown", close);
324
+ return () => { document.removeEventListener("mousedown", close); document.removeEventListener("keydown", close); };
325
+ }, [open]);
326
+ return (
327
+ <div className="relative" ref={root}>
328
+ <button
329
+ aria-controls={panelId}
330
+ aria-expanded={open}
331
+ aria-label="How this feed is ranked"
332
+ className={cn(PILL, "text-muted-foreground hover:text-foreground", open && "bg-[var(--feed-card)] text-foreground")}
333
+ data-feed-ranking-info
334
+ onClick={() => setOpen((current) => !current)}
335
+ type="button"
336
+ >
337
+ <InfoIcon aria-hidden="true" className="size-4" />
338
+ <span className="hidden sm:inline">How this feed is ranked</span>
339
+ <span className="sr-only sm:hidden">Ranking</span>
340
+ </button>
341
+ {open ? (
342
+ <div
343
+ aria-label="How this feed is ranked"
344
+ 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]"
345
+ data-feed-ranking-panel
346
+ id={panelId}
347
+ role="dialog"
348
+ >
349
+ <p className="text-base font-bold">A feed run by algorithms you control</p>
350
+ <ol className="list-decimal space-y-2.5 pl-5">
351
+ <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>
352
+ <li><strong>Confidence counts.</strong> An Accept at 90% ranks above an Accept at 60%.</li>
353
+ <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
354
+ ? <>A prediction loses half its pull every {plainDays(freshness.medianAgeDays)}, the typical age of the {freshness.examples} items you accepted.</>
355
+ : <>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>
356
+ <li><strong>Earlier.</strong> {freshness.tauDays !== null
357
+ ? <>Unrated items older than {plainDays(freshness.tauDays * EARLIER_AFTER_TAUS)} move to Earlier at the bottom. You can still rate them. Nothing is deleted.</>
358
+ : <>Once the pace is learned, unrated items past three times that pace move to Earlier at the bottom. Nothing is deleted.</>}</li>
359
+ <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>
360
+ </ol>
361
+ </div>
362
+ ) : null}
363
+ </div>
364
+ );
365
+ }
366
+
367
+ function SectionHeading({ children, id }: { children: ReactNode; id: string }) {
368
+ return <h3 className="mb-5 mt-10 text-3xl font-bold tracking-tight text-primary first:mt-2 sm:mt-12 sm:text-4xl" id={id}>{children}</h3>;
369
+ }
370
+
371
+ export function ReferenceFeed({
372
+ disabledReason = null,
373
+ loading = false,
374
+ now,
375
+ onOpen,
376
+ onRate,
377
+ onTabChange,
378
+ onVisitRated,
379
+ partition,
380
+ predictionLoading = false,
381
+ previews,
382
+ selectedLineageId = "",
383
+ stale = false,
384
+ tab,
385
+ visitRated,
386
+ }: ReferenceFeedProps) {
387
+ const counts: Record<ReferenceFeedTabKey, number> = { feed: partition.feed.length, rated: partition.rated.length, earlier: partition.earlier.length };
388
+
389
+ const renderCard = (item: FeedItem, lead: boolean) => (
390
+ <FeedCard
391
+ decision={item.decision}
392
+ disabledReason={disabledReason}
393
+ item={item}
394
+ key={item.lineageId}
395
+ lead={lead}
396
+ now={now}
397
+ onOpen={() => onOpen(item.lineageId)}
398
+ onRate={async (reference, choice, reasonCode) => { onVisitRated(item.lineageId); await onRate(reference, choice, reasonCode); }}
399
+ predictionLoading={predictionLoading}
400
+ preview={previews.get(item.lineageId)}
401
+ selected={selectedLineageId === item.lineageId}
402
+ visitRated={visitRated.has(item.lineageId)}
403
+ />
404
+ );
405
+
406
+ const empty = (label: string) => (
407
+ <div className="rounded-[20px] bg-[var(--feed-card)] p-10 text-center text-sm text-muted-foreground" data-feed-empty>{label}</div>
408
+ );
409
+
410
+ const groups = groupFeedItems(partition.feed);
411
+ const headings = groups.length > 1;
412
+ const leadId = groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined;
413
+
414
+ return (
415
+ <div className="space-y-5" data-reference-feed data-feed-tab={tab} {...FEED_SURFACE_PROPS}>
416
+ <div className="flex items-center justify-between gap-2">
417
+ <div aria-label="Feed sections" className="inline-flex rounded-full bg-[var(--feed-card)] p-1" role="tablist">
418
+ {(Object.keys(TAB_LABELS) as ReferenceFeedTabKey[]).map((key) => (
419
+ <button
420
+ aria-selected={tab === key}
421
+ 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")}
422
+ data-feed-tab-button={key}
423
+ key={key}
424
+ onClick={() => onTabChange(key)}
425
+ role="tab"
426
+ type="button"
427
+ >
428
+ {TAB_LABELS[key]}
429
+ <span className="text-xs tabular-nums opacity-70" data-feed-tab-count>{counts[key]}</span>
430
+ </button>
431
+ ))}
432
+ </div>
433
+ <div className="flex items-center gap-2">
434
+ {stale ? (
435
+ <span className="hidden items-center gap-1 text-xs text-muted-foreground sm:inline-flex" data-feed-stale role="status">
436
+ <Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Showing your last view while it updates
437
+ </span>
438
+ ) : null}
439
+ <RankingPopover freshness={partition.freshness} />
440
+ </div>
441
+ </div>
442
+ {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}
443
+
444
+ {loading ? <ReferenceFeedSkeleton /> : (
445
+ <div role="tabpanel">
446
+ {tab === "feed" ? (
447
+ <div>
448
+ {groups.length ? groups.map((group) => (
449
+ <section aria-labelledby={headings ? `feed-group-${group.key}` : undefined} data-feed-group={group.key} key={group.key}>
450
+ {headings ? <SectionHeading id={`feed-group-${group.key}`}>{GROUP_HEADINGS[group.key]}</SectionHeading> : null}
451
+ <div className={GRID} data-feed-grid>{group.items.map((item) => renderCard(item, item.lineageId === leadId))}</div>
452
+ </section>
453
+ )) : empty(counts.rated || counts.earlier ? "You are all caught up. Check Earlier and Rated." : "No references yet.")}
454
+ {partition.earlier.length ? (
455
+ <section aria-labelledby="feed-earlier-heading" data-feed-earlier>
456
+ <SectionHeading id="feed-earlier-heading">Earlier</SectionHeading>
457
+ <p className="-mt-3 mb-5 text-sm text-muted-foreground">Unrated and past their freshness. Still yours to rate.</p>
458
+ <div className={GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div>
459
+ </section>
460
+ ) : null}
461
+ </div>
462
+ ) : null}
463
+ {tab === "rated" ? (partition.rated.length ? <div className={GRID} data-feed-grid>{partition.rated.map((item) => renderCard(item, false))}</div> : empty("Nothing rated yet. Your Accepts and Rejects collect here, newest first.")) : null}
464
+ {tab === "earlier" ? (partition.earlier.length ? <div className={GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div> : empty("Nothing has aged out. Unrated items that fall past their freshness collect here.")) : null}
465
+ </div>
466
+ )}
467
+ </div>
468
+ );
469
+ }
@@ -12,6 +12,7 @@ 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";
15
16
  import { REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
16
17
 
17
18
  export type ReviewChoice = "accept" | "reject";
@@ -247,21 +248,17 @@ export function ReviewCard({
247
248
  {summary ? <p className="text-sm text-muted-foreground">{summary}</p> : null}
248
249
  </header>
249
250
 
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>
251
+ <RatingGrid
252
+ answer={chosen}
253
+ decided={decided}
254
+ disabled={Boolean(disabledReason)}
255
+ disabledReason={disabledReason}
256
+ feedback={saving ? { phase: "saving" } : error ? { phase: "error", message: error } : saved ? { phase: "saved" } : { phase: "idle" }}
257
+ onChoose={choose}
258
+ prediction={prediction}
259
+ size="large"
260
+ surface="card"
261
+ />
265
262
 
266
263
  <label className="block text-sm font-medium">
267
264
  Why? (optional)
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import { LoaderIcon } from "lucide-react";
5
+ import { SITE_BRAND } from "../lib/site-brand";
6
+ import { loadFeedSnapshot, type FeedSnapshot } from "../lib/reference-feed-cache";
7
+ import { partitionReferenceFeed, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
8
+ import { selectCanonicalReferenceRecords } from "../lib/newsroom-references";
9
+ import { ReferenceFeed, ReferenceFeedSkeleton } from "./reference-feed";
10
+
11
+ /**
12
+ * What the References page shows while the session and the first reads are still resolving: the real page header at once,
13
+ * then this browser's last saved view (read only, answering switches on when the session is confirmed), or card skeletons
14
+ * when there is none. Never a full-screen "checking" screen.
15
+ */
16
+ export function ReferencesPageLoading() {
17
+ const [snapshot, setSnapshot] = useState<FeedSnapshot | null>(null);
18
+ const [checked, setChecked] = useState(false);
19
+ const [tab, setTab] = useState<ReferenceFeedTabKey>("feed");
20
+ const [note, setNote] = useState<string | null>(null);
21
+ const [now] = useState(() => Date.now());
22
+ useEffect(() => {
23
+ setSnapshot(loadFeedSnapshot(SITE_BRAND.id));
24
+ setChecked(true);
25
+ }, []);
26
+ const references = useMemo(() => selectCanonicalReferenceRecords(snapshot?.references ?? []), [snapshot]);
27
+ const decisions = useMemo(() => new Map(snapshot?.decisions ?? []), [snapshot]);
28
+ const previews = useMemo(() => new Map(snapshot?.previews ?? []), [snapshot]);
29
+ const partition = useMemo(() => partitionReferenceFeed(references, decisions, { now }), [references, decisions, now]);
30
+ const noVisitRated = useMemo(() => new Set<string>(), []);
31
+
32
+ return (
33
+ <div className="space-y-4" data-references-page-loading>
34
+ <div className="space-y-1">
35
+ <h2 className="text-2xl font-bold tracking-tight">References</h2>
36
+ <p className="text-sm text-muted-foreground">A feed ranked by what you are most likely to accept.</p>
37
+ </div>
38
+ {note ? <p className="text-sm text-muted-foreground" role="status">{note}</p> : null}
39
+ {checked && references.length ? (
40
+ <ReferenceFeed
41
+ disabledReason="Confirming your sign-in. You can answer in a moment."
42
+ now={now}
43
+ onOpen={() => setNote("Opening a reference is available in a moment, once your sign-in is confirmed.")}
44
+ onRate={() => undefined}
45
+ onTabChange={setTab}
46
+ onVisitRated={() => undefined}
47
+ partition={partition}
48
+ previews={previews}
49
+ stale
50
+ tab={tab}
51
+ visitRated={noVisitRated}
52
+ />
53
+ ) : (
54
+ <>
55
+ <p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" role="status"><LoaderIcon aria-hidden="true" className="size-3.5 animate-spin" />Loading your references</p>
56
+ <ReferenceFeedSkeleton />
57
+ </>
58
+ )}
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Starts the References reads as soon as the editor session is confirmed, in parallel with the summary dashboard, instead of after it.
3
+ * The page that needs them takes the in-flight request once; anything later, or a retry, makes a fresh read. This is a head start
4
+ * on the same reads, not a cache: the backend authorizes each call exactly as before.
5
+ */
6
+ import {
7
+ loadEditorMessagesData,
8
+ loadEditorReferencesData,
9
+ loadEditorSemanticRelationsData,
10
+ } from "./news-desk-taxonomy-client";
11
+
12
+ const HEAD_START_MS = 60_000;
13
+ type Started = { startedAt: number; promise: Promise<unknown> };
14
+ const started = new Map<string, Started>();
15
+
16
+ const LOADERS = {
17
+ references: loadEditorReferencesData,
18
+ relations: loadEditorSemanticRelationsData,
19
+ messages: loadEditorMessagesData,
20
+ } as const;
21
+ type Key = keyof typeof LOADERS;
22
+
23
+ /** Begin all three reads. Safe to call twice; a failure is reported to whoever takes the read, never as an unhandled rejection. */
24
+ export function prefetchReferencesFeed(now = Date.now()) {
25
+ for (const key of Object.keys(LOADERS) as Key[]) {
26
+ if (started.has(key)) continue;
27
+ const promise = LOADERS[key]();
28
+ promise.catch(() => undefined);
29
+ started.set(key, { startedAt: now, promise });
30
+ }
31
+ }
32
+
33
+ function take<Key2 extends Key>(key: Key2, now = Date.now()): ReturnType<(typeof LOADERS)[Key2]> {
34
+ const head = started.get(key);
35
+ started.delete(key);
36
+ if (head && now - head.startedAt <= HEAD_START_MS) return head.promise as ReturnType<(typeof LOADERS)[Key2]>;
37
+ return LOADERS[key]() as ReturnType<(typeof LOADERS)[Key2]>;
38
+ }
39
+
40
+ export const loadReferencesWithHeadStart = () => take("references");
41
+ export const loadRelationsWithHeadStart = () => take("relations");
42
+ export const loadMessagesWithHeadStart = () => take("messages");
43
+
44
+ /** Forget any unclaimed head start (sign-out). */
45
+ export function discardReferencesHeadStart() {
46
+ started.clear();
47
+ }