@anthusai/papyrus 5.48.0 → 5.50.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.
@@ -50,7 +50,7 @@ import { useReferenceCapture, type CorrectCaptureAction } from "./reference-capt
50
50
  import { useRatingStore, type RatingController } from "./use-reference-ratings";
51
51
  import { captureStatusesByLineage } from "../lib/reference-capture";
52
52
  import { applyFeedOrderSnapshot, partitionReferenceFeed, takeFeedOrderSnapshot, type FeedOrderSnapshot, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
53
- import { referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
53
+ import { referenceDek, referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
54
54
  import { isLocalPreviewEnabled } from "../lib/local-preview";
55
55
  import { chooseSyntheticRejects, withSyntheticRejects } from "../lib/local-preview-synthetic-rejects";
56
56
  import { cn } from "../lib/utils";
@@ -262,28 +262,31 @@ function ReferenceDetailPanel({
262
262
  data-news-desk-reference-detail={lineageId}
263
263
  >
264
264
  <div className="space-y-4 p-4" data-reference-curation-status={status}>
265
- <div className="flex flex-wrap items-center gap-2">
266
- <Badge variant={referenceStatusBadgeVariant(status)}>{referenceStatusLabel(status)}</Badge>
267
- {reference.corpusId ? <Badge variant="outline">{reference.corpusId}</Badge> : null}
268
- </div>
269
-
270
265
  <ReviewCard
271
266
  decided={status === "accepted" ? "accept" : status === "rejected" ? "reject" : null}
267
+ dek={referenceDek(reference, referencePreviewsByLineage(previewAttachments).get(lineageId))}
272
268
  disabledReason={disabled ? "Editor access is required to change this reference." : null}
273
269
  initialNote={sourceNote}
274
270
  initialReason={sourceReason}
275
271
  itemId={lineageId}
272
+ meta={(
273
+ <div className="flex flex-wrap items-center gap-2">
274
+ <Badge variant={referenceStatusBadgeVariant(status)}>{referenceStatusLabel(status)}</Badge>
275
+ {reference.corpusId ? <Badge variant="secondary">{reference.corpusId}</Badge> : null}
276
+ </div>
277
+ )}
276
278
  ratings={ratings}
277
279
  onArchive={status === "archived" ? undefined : async () => { await onReview("archive"); }}
278
280
  onDecide={decide}
279
281
  prediction={cardPrediction(decision, predictionState)}
280
- summary={summaryOf(reference)}
282
+ sourcePreview={<ReferenceSourcePreview attachments={previewAttachments} sourceUri={reference.sourceUri} />}
283
+ summary={capture.summary ?? summaryOf(reference)}
281
284
  title={referenceDisplayTitle(reference)}
282
285
  titleHref={url}
283
286
  >
284
287
  {decision ? <div data-reference-relevance-decision={lineageId}><ReferenceSuggestionProvenance decision={decision} /></div> : null}
285
288
  {sectionSuggestion || ((status === "pending" || status === "accepted") && sections.length) ? (
286
- <details className="rounded-md border p-3 text-sm">
289
+ <details className="rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-control)] p-3 text-sm">
287
290
  <summary className="cursor-pointer font-semibold">Section placement (optional)</summary>
288
291
  <div className="mt-3 space-y-3">
289
292
  {sectionSuggestion ? <SectionSuggestionBlock agreement={sectionAgreement} referenceLineageId={lineageId} sections={sections} suggestion={sectionSuggestion} /> : null}
@@ -304,12 +307,7 @@ function ReferenceDetailPanel({
304
307
 
305
308
  <Separator />
306
309
 
307
- <ReferenceCapturePanel capture={capture} />
308
-
309
- <section aria-label="Source preview" className="space-y-2">
310
- <h3 className="text-sm font-medium text-foreground">Source preview</h3>
311
- <ReferenceSourcePreview attachments={previewAttachments} sourceUri={reference.sourceUri} />
312
- </section>
310
+ <ReferenceCapturePanel capture={capture} showSummary={false} />
313
311
 
314
312
  <dl className="grid gap-3 text-sm">
315
313
  <div>
@@ -649,7 +647,7 @@ export function NewsroomReferencesView({
649
647
  </div>
650
648
  );
651
649
  const queueBar = queue.length || caughtUp ? (
652
- <ReviewQueueBar caughtUp={caughtUp} onNext={goNext} onPrevious={goPrevious} position={queuePosition} total={queue.length} />
650
+ <ReviewQueueBar anchored caughtUp={caughtUp} onNext={goNext} onPrevious={goPrevious} position={queuePosition} total={queue.length} />
653
651
  ) : null;
654
652
 
655
653
  const emptyLabel = canonicalReferences.length
@@ -873,7 +871,7 @@ export function NewsroomReferencesView({
873
871
 
874
872
  <div
875
873
  className={cn(
876
- "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)]",
874
+ "hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:flex md:w-[min(100%,24rem)] md:pb-16 lg:w-[min(100%,28rem)]",
877
875
  )}
878
876
  data-newsroom-list-detail-shell
879
877
  data-news-desk-section="references"
@@ -927,7 +925,7 @@ export function NewsroomReferencesView({
927
925
  }}
928
926
  open={(mobileDetailOpen && Boolean(selectedReference)) || caughtUp}
929
927
  >
930
- <SheetContent className="gap-0 rounded-t-[24px] bg-[var(--feed-card)] p-0" data-feed-flat="" data-feed-surface="" side="bottom" {...themeAttributes}>
928
+ <SheetContent className="gap-0 rounded-t-[length:var(--feed-radius-xl)] bg-[var(--feed-card)] p-0" data-feed-flat="" data-feed-surface="" side="bottom" {...themeAttributes}>
931
929
  <SheetHeader className="border-b border-border px-4 py-3">
932
930
  <SheetTitle className="text-left text-base">Reference</SheetTitle>
933
931
  </SheetHeader>
@@ -1,13 +1,13 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
4
4
  import type { ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
5
5
  import type { EmailDecisionResult, MonitoringEmailDetails, MonitoringEmailSummary } from "../lib/monitoring-overview";
6
6
  import { proposalReceiptMessage, stateLabel, type ProposalReceipt } from "../lib/proposals-admin";
7
7
  import type { DraftLoad, ReviewLoad } from "../lib/review-client";
8
- import { acceptPath, batchAnswer, draftView, rejectPath, undecidedDraftIds, withDecision, draftDecisionUpdate, type DraftChoice, type DraftView } from "../lib/review-drafts";
8
+ import { acceptPath, batchAnswer, draftDecisionOf, draftReasonUpdate, draftView, rejectPath, undecidedDraftIds, withDecision, draftDecisionUpdate, type DraftChoice, type DraftView } from "../lib/review-drafts";
9
9
  import { previewsBySourceAddress, recentDecisions } from "../lib/notification-feed";
10
- import { answerUpdate, cyclotronAutoDecided, draftIdsOf, isDraftReview, sectionReviews, type AnswerInput, type DraftDecision, type ReviewRequest } from "../lib/review-requests";
10
+ import { answerReasonUpdate, answerUpdate, applyReviewUpdate, cyclotronAutoDecided, draftIdsOf, isDraftReview, sectionReviews, type AnswerInput, type DraftDecision, type ReviewRequest } from "../lib/review-requests";
11
11
  import type { RelevanceDecisionView } from "../lib/relevance-decisions";
12
12
  import { learnFreshness } from "../lib/reference-feed-ranking";
13
13
  import { learnConfidenceThreshold, shapingThisWeek } from "../lib/needs-me-ranking";
@@ -42,6 +42,8 @@ export type NotificationApi = {
42
42
  loadStoryTitles: (ids: string[]) => Promise<{ titles: Record<string, string>; deleted: string[]; excerpts?: Record<string, string> }>;
43
43
  answerReviewRequest: (request: ReviewRequest, input: AnswerInput, by: string) => Promise<ReviewRequest>;
44
44
  saveDraftDecision: (request: ReviewRequest, draftId: string, decision: DraftDecision) => Promise<ReviewRequest>;
45
+ /** Changes only the Why of a draft or request that is already decided (`draftReasonUpdate`, `answerReasonUpdate`). Returns the saved request. */
46
+ saveReviewReason: (request: ReviewRequest, update: Record<string, unknown>) => Promise<ReviewRequest>;
45
47
  markReviewRead: (userKey: string, requestId: string) => Promise<void>;
46
48
  transitionProposalPath: (proposalId: string, path: readonly string[]) => Promise<ProposalReceipt>;
47
49
  /** Email submissions held for review (Monitoring's own read). Optional: a site without monitoring has none. */
@@ -56,6 +58,7 @@ const liveApi: NotificationApi = {
56
58
  loadStoryTitles: async (ids) => (await import("../lib/review-client")).loadStoryTitles(ids),
57
59
  answerReviewRequest: async (request, input, by) => (await import("../lib/review-client")).answerReviewRequest(request, input, by),
58
60
  saveDraftDecision: async (request, draftId, decision) => (await import("../lib/review-client")).saveDraftDecision(request, draftId, decision),
61
+ saveReviewReason: async (request, update) => (await import("../lib/review-client")).saveReviewReason(request, update),
59
62
  markReviewRead: async (userKey, requestId) => (await import("../lib/review-client")).markReviewRead(userKey, requestId),
60
63
  transitionProposalPath: async (proposalId, path) => (await import("../lib/proposals-client")).transitionProposalPath(proposalId, path),
61
64
  loadHeldEmails: async () => {
@@ -87,6 +90,7 @@ const demoApi: NotificationApi = {
87
90
  const update = draftDecisionUpdate(request, draftId, decision);
88
91
  return { ...request, links: JSON.parse(String(update.links)), history: JSON.parse(String(update.history)), updatedAt: String(update.updatedAt) };
89
92
  },
93
+ saveReviewReason: async (request, update) => applyReviewUpdate(request, update),
90
94
  markReviewRead: async () => undefined,
91
95
  transitionProposalPath: async () => ({ status: "ok" }),
92
96
  loadHeldEmails: async () => FIXTURE_HELD_EMAILS,
@@ -112,6 +116,8 @@ type ContainerProps = {
112
116
  /** A real-data preview (see scripts/export-notification-center-fixture.py); the invented sample otherwise. */
113
117
  fixture?: NotificationFixture | null;
114
118
  api?: NotificationApi;
119
+ /** Other page sections, shown at the end of the feed's own scroll. */
120
+ below?: ReactNode;
115
121
  };
116
122
 
117
123
  const REFRESH_MILLISECONDS = 60_000;
@@ -126,6 +132,7 @@ function previewState(): "loading" | "caught-up" | "sample" | "mixed" | null {
126
132
  const EMAIL_REFUSALS: Record<string, string> = {
127
133
  forbidden: "Your session cannot decide held email.",
128
134
  bad_request: "That email could not be decided from here.",
135
+ already_applied: "This email was already queued, so its reason can no longer be changed here.",
129
136
  already_decided: "Someone already decided this email. Refresh to see what they chose.",
130
137
  not_quarantined: "This email is no longer held.",
131
138
  expired: "This email expired and can no longer be accepted.",
@@ -133,7 +140,7 @@ const EMAIL_REFUSALS: Record<string, string> = {
133
140
 
134
141
  export function NotificationCenter({
135
142
  references, decisions, humanCuratedReferenceLineageIds = NO_VISIT, previews = NO_PREVIEWS, referencesLoading = false, predictionLoading = false, referenceDisabledReason = null,
136
- onRateReference, referenceAttachments = NO_ATTACHMENTS, onCorrectCapture, actorLabel, demo, fixture = null, api: liveOverride = liveApi,
143
+ onRateReference, referenceAttachments = NO_ATTACHMENTS, onCorrectCapture, actorLabel, demo, fixture = null, api: liveOverride = liveApi, below,
137
144
  }: ContainerProps) {
138
145
  const api = demo ? demoApi : liveOverride;
139
146
  const [loadState, setLoadState] = useState<NotificationLoadState>("loading");
@@ -332,6 +339,54 @@ export function NotificationCenter({
332
339
  }
333
340
  }, [api, demo, drafts, draftsState, requests, stale]);
334
341
 
342
+ /** Accept or Reject a held email, or (the same choice again) correct the reason it was given. The reason shown is what the server recorded. */
343
+ const sendEmailDecision = useCallback(async (summary: MonitoringEmailSummary, choice: "accept" | "reject", why: string, editing = false) => {
344
+ const result = await api.decideEmail?.(summary.receiptId, choice, why);
345
+ if (!result) throw new Error("Held email cannot be decided from here.");
346
+ if (!("ok" in result)) throw new Error("error" in result ? EMAIL_REFUSALS[result.error] ?? "The decision could not be saved." : "The decision could not be saved right now. Try again in a minute.");
347
+ const plain = (text: string) => text.replace(/\s+/g, " ").trim().slice(0, 500);
348
+ const recorded = result.quarantine?.decision;
349
+ // The server keeps the first reason once it has queued the email; say so in words rather than showing a reason that was not kept.
350
+ if (editing && recorded && plain(recorded.reason) !== plain(why)) throw new Error(EMAIL_REFUSALS.already_applied);
351
+ const kept = recorded?.reason ?? plain(why);
352
+ setEmailsDecided((current) => new Map(current).set(summary.receiptId, choice));
353
+ setEmails((current) => current.map((entry) => (entry.receiptId === summary.receiptId
354
+ ? { ...entry, quarantine: { state: entry.quarantine?.state ?? "quarantined", failure: entry.quarantine?.failure ?? null, decision: { action: choice, by: recorded?.by || actorLabel, at: recorded?.at ?? new Date().toISOString(), reason: kept, viaGroup: recorded?.viaGroup } } }
355
+ : entry)));
356
+ }, [actorLabel, api]);
357
+
358
+ /**
359
+ * Changes only the Why of an item that is already decided, onto the decision that exists:
360
+ * AI draft the request's link for the draft (and, once the request has closed, its answer's drafts and feedback)
361
+ * review request the request's answer note (a hard gate, a stage review, an acknowledged flag)
362
+ * held email the same decision again with the new reason, so the Monitoring view updates the recorded reason
363
+ * A Proposal's own transitions carry only public reasons, so a private note is never written there.
364
+ */
365
+ const latest = useRef({ requests, drafts, emails, emailsDecided });
366
+ latest.current = { requests, drafts, emails, emailsDecided };
367
+ const editReason = useCallback(async (item: NeedsMeEntry, why: string) => {
368
+ const { payload } = item;
369
+ const now = new Date();
370
+ const store = (saved: ReviewRequest) => {
371
+ latest.current.requests = latest.current.requests.map((entry) => (entry.id === saved.id ? saved : entry));
372
+ setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
373
+ };
374
+ if (payload.kind === "draft") {
375
+ const request = latest.current.requests.find((entry) => entry.id === payload.item.request.id) ?? payload.item.request;
376
+ const view = latest.current.drafts[payload.item.draftId];
377
+ const decision = draftDecisionOf(request.links.find((link) => link.id === payload.item.draftId), view, { by: actorLabel, at: now.toISOString() });
378
+ if (!decision) throw new Error("This draft has not been decided yet.");
379
+ store(await api.saveReviewReason(request, draftReasonUpdate(request, payload.item.draftId, { ...decision, title: decision.title ?? view?.headline, why }, actorLabel, now)));
380
+ } else if (payload.kind === "request") {
381
+ const request = latest.current.requests.find((entry) => entry.id === payload.request.id) ?? payload.request;
382
+ store(await api.saveReviewReason(request, answerReasonUpdate(request, why, actorLabel, now)));
383
+ } else if (payload.kind === "email") {
384
+ const choice = latest.current.emailsDecided.get(payload.summary.receiptId) ?? payload.summary.quarantine?.decision?.action;
385
+ if (!choice) throw new Error("This email has not been decided yet.");
386
+ await sendEmailDecision(payload.summary, choice, why, true);
387
+ }
388
+ }, [actorLabel, api, sendEmailDecision]);
389
+
335
390
  /** One decision, whatever the type: it goes to the action that already owns that kind of thing. */
336
391
  const decide = useCallback(async (item: NeedsMeEntry, decision: NeedsMeDecision) => {
337
392
  const { payload } = item;
@@ -346,12 +401,9 @@ export function NotificationCenter({
346
401
  else if (decision.choice === "accept") await answer(request, { decision: "approve", feedback: decision.why || null });
347
402
  else await answer(request, { decision: "reject", feedback: decision.why || null, steer: decision.notThisKind ? "not_this_kind" : null });
348
403
  } else if (payload.kind === "email") {
349
- const result = await api.decideEmail?.(payload.summary.receiptId, decision.choice, decision.why);
350
- if (!result) throw new Error("Held email cannot be decided from here.");
351
- if (!("ok" in result)) throw new Error("error" in result ? EMAIL_REFUSALS[result.error] ?? "The decision could not be saved." : "The decision could not be saved right now. Try again in a minute.");
352
- setEmailsDecided((current) => new Map(current).set(payload.summary.receiptId, decision.choice));
404
+ await sendEmailDecision(payload.summary, decision.choice, decision.why);
353
405
  }
354
- }, [answer, api, decideDraft, onRateReference]);
406
+ }, [answer, decideDraft, onRateReference, sendEmailDecision]);
355
407
 
356
408
  const shownReferences = preview === "caught-up" ? references.filter((reference) => normalizeReferenceCurationStatus(reference.curationStatus) !== "pending") : references;
357
409
  const bySource = useMemo(() => previewsBySourceAddress(shownReferences, previews), [shownReferences, previews]);
@@ -383,8 +435,8 @@ export function NotificationCenter({
383
435
 
384
436
  return (
385
437
  <NeedsMeView
386
- captureTeaserLine={teaserLine} demo={demo} draftsFailed={draftsState === "failed"} entries={entries} error={error} freshness={freshness} legacyAuto={legacyAuto}
387
- loadEmail={api.loadEmail} loadState={preview === "loading" ? "loading" : loadState} missingDraftCount={missingCount} now={now} onCorrectCapture={onCorrectCapture} onDecide={decide}
438
+ below={below} captureTeaserLine={teaserLine} demo={demo} draftsFailed={draftsState === "failed"} entries={entries} error={error} freshness={freshness} legacyAuto={legacyAuto}
439
+ loadEmail={api.loadEmail} loadState={preview === "loading" ? "loading" : loadState} missingDraftCount={missingCount} now={now} onCorrectCapture={onCorrectCapture} onDecide={decide} onEditReason={editReason}
388
440
  onRetryDrafts={() => void loadDraftsFor(requests)} predictionLoading={predictionLoading} recent={recent} referenceAttachments={referenceAttachments}
389
441
  referenceDisabledReason={referenceDisabledReason} referencesLoading={referencesLoading} settled={settled} shaping={shaping} stale={stale} threshold={threshold}
390
442
  />
@@ -4,6 +4,7 @@ import { FileCheckIcon, FileClockIcon, FileWarningIcon, FileXIcon, Loader2Icon }
4
4
  import { useEffect, useState } from "react";
5
5
  import { captureLine, type CaptureAssessment, type CaptureState, type CaptureStatus } from "../lib/reference-capture";
6
6
  import { cn } from "../lib/utils";
7
+ import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
7
8
  import type { CaptureView } from "./reference-capture-client";
8
9
 
9
10
  const ICON = { full: FileCheckIcon, thin: FileWarningIcon, none: FileXIcon, running: FileClockIcon } as const;
@@ -66,7 +67,7 @@ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: ()
66
67
  <div className="space-y-3 rounded-2xl bg-[var(--feed-control)] p-3" data-capture-correction>
67
68
  <label className="block text-sm font-medium">
68
69
  The text
69
- <textarea
70
+ <AutoGrowTextarea
70
71
  aria-label="Captured text"
71
72
  className="mt-1 block min-h-40 w-full rounded-2xl bg-[var(--feed-card)] p-3 text-sm font-normal"
72
73
  data-capture-correction-text
@@ -92,7 +93,7 @@ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: ()
92
93
  </div>
93
94
  <label className="block text-sm font-medium">
94
95
  Note (optional)
95
- <textarea aria-label="Note about the capture (optional)" className="mt-1 block min-h-16 w-full rounded-2xl bg-[var(--feed-card)] p-3 text-sm font-normal" onChange={(event) => setNote(event.target.value)} value={note} />
96
+ <AutoGrowTextarea aria-label="Note about the capture (optional)" className="mt-1 block min-h-16 w-full rounded-2xl bg-[var(--feed-card)] p-3 text-sm font-normal" onChange={(event) => setNote(event.target.value)} value={note} />
96
97
  </label>
97
98
  {reason ? <p className="text-xs text-muted-foreground" data-capture-correction-reason>{reason}</p> : null}
98
99
  {phase === "error" ? <p className="text-xs text-destructive" role="alert">{message}</p> : null}
@@ -113,7 +114,12 @@ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: ()
113
114
  }
114
115
 
115
116
  /** Our captured record of a Reference: what we stored of its content, said plainly, above the live source preview. */
116
- export function ReferenceCapturePanel({ capture, startEditing = false }: { capture: CaptureView; startEditing?: boolean }) {
117
+ export function ReferenceCapturePanel({ capture, showSummary = true, startEditing = false }: {
118
+ capture: CaptureView;
119
+ /** False where the summary is already shown above the rating, with the title. */
120
+ showSummary?: boolean;
121
+ startEditing?: boolean;
122
+ }) {
117
123
  const [expanded, setExpanded] = useState(false);
118
124
  const [editing, setEditing] = useState(startEditing && Boolean(capture.correct));
119
125
  const [savedNote, setSavedNote] = useState(false);
@@ -137,9 +143,11 @@ export function ReferenceCapturePanel({ capture, startEditing = false }: { captu
137
143
  </div>
138
144
  {capture.loading ? <p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" role="status"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Reading the stored text…</p> : null}
139
145
  {capture.error ? <p className="text-xs text-destructive" role="alert">The stored text could not be read. {capture.error}</p> : null}
140
- {text ? (
146
+ {/* While the editor is open it holds the text, so the read-only copy steps aside: the text is never shown twice. */}
147
+ {text && !editing ? (
141
148
  <div className="space-y-2">
142
- <div className="max-h-80 overflow-y-auto whitespace-pre-wrap rounded-2xl bg-[var(--feed-control)] p-3 text-sm leading-relaxed" data-capture-text tabIndex={0}>
149
+ {/* No scroll box of its own: on a phone a swipe here scrolls the page. "Show all" grows it in place. */}
150
+ <div className="whitespace-pre-wrap rounded-2xl bg-[var(--feed-control)] p-3 text-sm leading-relaxed" data-capture-text>
143
151
  {expanded || !long ? text : `${text.slice(0, PREVIEW_CHARACTERS)}…`}
144
152
  </div>
145
153
  {long ? (
@@ -149,7 +157,7 @@ export function ReferenceCapturePanel({ capture, startEditing = false }: { captu
149
157
  ) : null}
150
158
  </div>
151
159
  ) : null}
152
- {capture.summary ? (
160
+ {showSummary && capture.summary ? (
153
161
  <div className="space-y-1" data-capture-summary>
154
162
  <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Summary{capture.summaryStale ? " (out of date: the text was replaced)" : ""}</h4>
155
163
  <p className="text-sm">{capture.summary}</p>
@@ -11,6 +11,7 @@ import {
11
11
  } from "lucide-react";
12
12
  import { useEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
13
13
  import { Button } from "./ui/button";
14
+ import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
14
15
  import { cn } from "../lib/utils";
15
16
  import { RatingGrid } from "./review-rating-grid";
16
17
  import { useRatingStore, type RatingController } from "./use-reference-ratings";
@@ -132,11 +133,14 @@ export function predictionFromDecision(decision: { label: string; positiveLabel:
132
133
  export function ReviewCard({
133
134
  children,
134
135
  decided,
136
+ dek,
135
137
  disabledReason,
136
138
  initialNote = "",
137
139
  initialReason = null,
138
140
  itemId,
141
+ meta,
139
142
  onArchive,
143
+ sourcePreview,
140
144
  onDecide,
141
145
  prediction,
142
146
  summary,
@@ -149,14 +153,20 @@ export function ReviewCard({
149
153
  ratings?: RatingController;
150
154
  /** The saved human answer, or null while the item is undecided. */
151
155
  decided: ReviewChoice | null;
156
+ /** The source's own subtitle or blurb, shown under the title. */
157
+ dek?: string | null;
152
158
  /** When set, the controls explain in words why they cannot change anything. */
153
159
  disabledReason?: string | null;
154
160
  initialNote?: string;
155
161
  initialReason?: ReferenceRejectionReasonCode | null;
156
162
  itemId: string;
163
+ /** Badges (type, status) shown under the title and link. */
164
+ meta?: ReactNode;
157
165
  onArchive?: () => Promise<void> | void;
158
166
  onDecide: (choice: ReviewChoice, decision: ReviewCardDecision) => Promise<void> | void;
159
167
  prediction: CardPrediction;
168
+ /** The live preview of the source, shown under its link. It renders nothing when there is none. */
169
+ sourcePreview?: ReactNode;
160
170
  summary?: string | null;
161
171
  title: string;
162
172
  titleHref?: string | null;
@@ -192,15 +202,24 @@ export function ReviewCard({
192
202
 
193
203
  return (
194
204
  <section aria-label="Review" className="space-y-4" data-review-card={itemId}>
195
- <header className="space-y-1">
205
+ {/* Title, blurb, link, badges, summary: what the item is, read top to bottom before the rating. */}
206
+ <header className="space-y-2">
196
207
  <h2 className="text-lg font-semibold leading-snug text-foreground">{title}</h2>
208
+ {dek && dek.trim() !== title.trim() ? <p className="text-base text-muted-foreground" data-review-dek>{dek}</p> : null}
197
209
  {titleHref ? (
198
210
  <a className="inline-flex max-w-full items-center gap-1 truncate text-sm text-primary underline-offset-4 hover:underline" href={titleHref} rel="noreferrer" target="_blank">
199
211
  <span className="truncate">{titleHref}</span>
200
212
  <ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" />
201
213
  </a>
202
214
  ) : null}
203
- {summary ? <p className="text-sm text-muted-foreground">{summary}</p> : null}
215
+ {sourcePreview}
216
+ {meta}
217
+ {summary ? (
218
+ <div className="space-y-1 pt-1" data-review-summary>
219
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Summary</h3>
220
+ <p className="text-sm leading-relaxed text-foreground">{summary}</p>
221
+ </div>
222
+ ) : null}
204
223
  </header>
205
224
 
206
225
  <RatingGrid
@@ -217,9 +236,9 @@ export function ReviewCard({
217
236
 
218
237
  <label className="block text-sm font-medium">
219
238
  Why? (optional)
220
- <textarea
239
+ <AutoGrowTextarea
221
240
  aria-label="Why? (optional)"
222
- className="mt-1 block min-h-24 w-full rounded-md border bg-background p-3 text-base font-normal"
241
+ className="mt-1 block min-h-20 w-full rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-control)] p-3 text-base font-normal outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
223
242
  onBlur={commitNote}
224
243
  onChange={(event) => store.setNote(itemId, event.target.value)}
225
244
  placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
@@ -232,22 +251,21 @@ export function ReviewCard({
232
251
  <p className="text-sm font-medium">Reason for rejecting</p>
233
252
  <div className="flex flex-wrap gap-2">
234
253
  {REFERENCE_REJECTION_REASON_CODES.map((code) => (
235
- <Button
254
+ <button
236
255
  aria-disabled={disabledReason ? true : undefined}
237
256
  aria-pressed={reason === code}
238
- className="min-h-10"
257
+ className={cn("inline-flex min-h-10 items-center gap-1.5 rounded-[var(--feed-radius-pill,8px)] px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", reason === code ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)]")}
239
258
  data-review-reason={code}
240
259
  key={code}
241
260
  onClick={() => chooseReason(code)}
242
261
  type="button"
243
- variant={reason === code ? "default" : "outline"}
244
262
  >
245
263
  {reason === code ? <CheckIcon aria-hidden="true" className="size-4" /> : null}
246
264
  {REASON_LABELS[code]}
247
- </Button>
265
+ </button>
248
266
  ))}
249
267
  </div>
250
- {!reason ? <p className="text-sm text-muted-foreground">Choose a reason to save your Reject.</p> : null}
268
+ {!reason && !note.trim() ? <p className="text-sm text-muted-foreground">Choose a reason, or say why above, to save your Reject.</p> : null}
251
269
  </div>
252
270
  ) : null}
253
271
 
@@ -270,7 +288,10 @@ export function ReviewQueueBar({
270
288
  onPrevious,
271
289
  position,
272
290
  total,
291
+ anchored = false,
273
292
  }: {
293
+ /** On a wide screen, pin the bar to the bottom-right corner of the viewport (the detail column's width), whatever scrolls. A sheet's own bar is not anchored. */
294
+ anchored?: boolean;
274
295
  caughtUp?: boolean;
275
296
  onNext: () => void;
276
297
  onPrevious: () => void;
@@ -282,7 +303,8 @@ export function ReviewQueueBar({
282
303
  return (
283
304
  <nav
284
305
  aria-label="Review queue"
285
- className="sticky bottom-0 z-10 flex shrink-0 items-center gap-2 border-t border-border bg-card px-3 py-2"
306
+ className={cn("sticky bottom-0 z-10 flex shrink-0 items-center gap-2 bg-[var(--feed-card)] px-3 py-2", anchored && "md:fixed md:bottom-0 md:right-0 md:w-[calc(24rem+1rem)] md:pr-7 lg:w-[calc(28rem+1rem)]")}
307
+ data-review-queue-anchored={anchored || undefined}
286
308
  data-review-queue-bar
287
309
  >
288
310
  <Button
@@ -87,6 +87,31 @@ function PdfSourcePreview({ preview }: { preview: Extract<ReferenceSourcePreview
87
87
  );
88
88
  }
89
89
 
90
+ /** Session memory of the server's answers, so reopening an item does not ask again. */
91
+ const frameableAnswers = new Map<string, boolean>();
92
+
93
+ /** Whether the page allows being shown in a frame: null while asking. A refused frame would be an empty box, so it is not shown. */
94
+ function useFrameable(href: string | null): boolean | null {
95
+ const [answer, setAnswer] = useState<boolean | null>(() => (href ? frameableAnswers.get(href) ?? null : null));
96
+ useEffect(() => {
97
+ if (!href) return undefined;
98
+ const known = frameableAnswers.get(href);
99
+ if (known !== undefined) { setAnswer(known); return undefined; }
100
+ setAnswer(null);
101
+ let active = true;
102
+ fetch(`/api/newsroom/source-frameable?url=${encodeURIComponent(href)}`)
103
+ .then((response) => (response.ok ? response.json() : { frameable: false }))
104
+ .then((body: { frameable?: unknown }) => { frameableAnswers.set(href, body.frameable === true); if (active) setAnswer(body.frameable === true); })
105
+ .catch(() => { if (active) setAnswer(false); });
106
+ return () => { active = false; };
107
+ }, [href]);
108
+ return answer;
109
+ }
110
+
111
+ /**
112
+ * The live preview of the source, or nothing at all: a YouTube video or a PDF always has one; a web page only when the page allows
113
+ * being framed (asked of the server first). It sits under the link to the original, with no heading of its own.
114
+ */
90
115
  export function ReferenceSourcePreview({
91
116
  attachments = [],
92
117
  sourceUri,
@@ -95,6 +120,7 @@ export function ReferenceSourcePreview({
95
120
  sourceUri?: string | null;
96
121
  }) {
97
122
  const preview = resolveReferenceSourcePreview(sourceUri, attachments);
123
+ const frameable = useFrameable(preview?.kind === "html" ? preview.href : null);
98
124
  const pendingPdfPreview = !preview && attachments.some(
99
125
  (attachment) => isPdfAttachment(attachment) && Boolean(attachment.storagePath),
100
126
  );
@@ -139,6 +165,7 @@ export function ReferenceSourcePreview({
139
165
  return <PdfSourcePreview preview={preview} />;
140
166
  }
141
167
 
168
+ if (frameable !== true) return null;
142
169
  return (
143
170
  <div
144
171
  className="news-desk-reference-source-preview news-desk-reference-source-preview--html"
@@ -153,11 +180,6 @@ export function ReferenceSourcePreview({
153
180
  title={preview.label}
154
181
  />
155
182
  </div>
156
- <p className="news-desk-reference-source-preview__actions">
157
- <a href={preview.href} rel="noopener noreferrer" target="_blank">
158
- {preview.label}
159
- </a>
160
- </p>
161
183
  </div>
162
184
  );
163
185
  }
@@ -13,6 +13,8 @@ import type { CardPrediction, ReviewChoice } from "./reference-review-card";
13
13
  * Feedback is never colour alone: before you answer the predicted cell is neutral-accent with a check or cross; after, it turns
14
14
  * green with a CircleCheck (Match) or red with a CircleX (Miss), and the confidence bar tints the same way. Your chosen button is
15
15
  * filled. Flat tones and rounded shapes only, no borders.
16
+ * The grid sizes itself to its own width (a container query), not the window's: on a narrow card the label column shrinks to its
17
+ * word, the padding tightens, and the confidence wraps under "Accept" instead of spilling out of its cell.
16
18
  */
17
19
 
18
20
  export type RatingFeedback = { phase: "idle" | "saving" | "saved" | "error"; message?: string };
@@ -81,9 +83,8 @@ export function RatingGrid({
81
83
  return (
82
84
  <div
83
85
  className={cn(
84
- "grid gap-x-2 gap-y-2",
85
- showPrediction ? "grid-cols-[4.5rem_minmax(0,1fr)_minmax(0,1fr)]" : "grid-cols-2",
86
- showPrediction && size === "large" && "grid-cols-[5rem_minmax(0,1fr)_minmax(0,1fr)]",
86
+ "@container/rating grid gap-x-2 gap-y-2",
87
+ showPrediction ? "grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)] @max-[17rem]/rating:gap-x-1.5" : "grid-cols-2",
87
88
  )}
88
89
  data-rating-grid
89
90
  >
@@ -95,7 +96,7 @@ export function RatingGrid({
95
96
  return (
96
97
  <span
97
98
  className={cn(
98
- "flex flex-col items-center justify-center rounded-[16px] px-2 py-1.5 text-center",
99
+ "flex min-w-0 flex-col items-center justify-center rounded-[16px] px-2 py-1.5 text-center @max-[17rem]/rating:px-1",
99
100
  cell,
100
101
  !predicted && "bg-[var(--feed-control)]/45 text-muted-foreground",
101
102
  predicted && !verdict && "bg-[var(--feed-control)] font-semibold text-[var(--primary)]",
@@ -106,12 +107,21 @@ export function RatingGrid({
106
107
  data-verdict={predicted && verdict ? verdict : undefined}
107
108
  key={choice}
108
109
  >
109
- <span className="inline-flex items-center gap-1.5">
110
- {Icon ? <Icon aria-hidden="true" className="size-4 shrink-0" /> : null}
111
- {WORD[choice]}
112
- {predicted && confidenceText ? <small className="text-xs font-normal opacity-80">{confidenceText}</small> : null}
110
+ {/* Wraps rather than overflows: when the cell is narrow, the confidence moves onto its own line. */}
111
+ <span className="flex max-w-full flex-wrap items-center justify-center gap-x-1.5 leading-tight">
112
+ <span className="inline-flex items-center gap-1">
113
+ {Icon ? <Icon aria-hidden="true" className="size-4 shrink-0" /> : null}
114
+ {WORD[choice]}
115
+ </span>
116
+ {/* The confidence and the verdict share one line, so a vote never makes the cell (or the card) taller. */}
117
+ {predicted && (confidenceText || verdict) ? (
118
+ <small className="inline-flex items-center gap-1 whitespace-nowrap text-xs">
119
+ {confidenceText ? <span className="font-normal opacity-80" data-cyclotron-confidence>{confidenceText}</span> : null}
120
+ {confidenceText && verdict ? <span aria-hidden="true" className="opacity-60">·</span> : null}
121
+ {verdict ? <span className="font-medium" data-verdict-word>{verdict === "match" ? "Match" : "Miss"}</span> : null}
122
+ </small>
123
+ ) : null}
113
124
  </span>
114
- {predicted && verdict ? <small className="text-xs font-medium" data-verdict-word>{verdict === "match" ? "Match" : "Miss"}</small> : null}
115
125
  {predicted && verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
116
126
  </span>
117
127
  );
@@ -151,7 +161,7 @@ export function RatingGrid({
151
161
  aria-disabled={disabled ? true : undefined}
152
162
  aria-pressed={on}
153
163
  className={cn(
154
- "inline-flex items-center justify-center gap-1.5 rounded-[16px] px-2 font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
164
+ "inline-flex min-w-0 items-center justify-center gap-1.5 rounded-[16px] px-2 font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring @max-[17rem]/rating:gap-1 @max-[17rem]/rating:px-1",
155
165
  cell,
156
166
  on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70",
157
167
  disabled && !on && "opacity-60",
@@ -0,0 +1,18 @@
1
+ "use client";
2
+
3
+ import { useLayoutEffect, useRef, type TextareaHTMLAttributes } from "react";
4
+
5
+ /**
6
+ * A textarea as tall as its text. It never scrolls inside itself, so on a phone a swipe over it scrolls the page instead of
7
+ * getting caught in the box. (CSS `field-sizing: content` does the same where supported; the height is set here for the rest.)
8
+ */
9
+ export function AutoGrowTextarea({ className, style, value, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
10
+ const ref = useRef<HTMLTextAreaElement>(null);
11
+ useLayoutEffect(() => {
12
+ const element = ref.current;
13
+ if (!element) return;
14
+ element.style.height = "auto";
15
+ element.style.height = `${element.scrollHeight}px`;
16
+ }, [value]);
17
+ return <textarea {...props} className={className} ref={ref} rows={props.rows ?? 2} style={{ ...style, overflow: "hidden", resize: "none" }} value={value} />;
18
+ }