@anthusai/papyrus 5.47.0 → 5.49.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,77 @@
1
+ "use client";
2
+
3
+ import { referenceDisplayTitle, referenceLineageId, formatReferenceListDate } from "../lib/newsroom-references";
4
+ import { referenceMetadataField } from "../lib/reference-display";
5
+ import { DEFAULT_TEASER_WORDS } from "../lib/reference-capture";
6
+ import { referenceReasonCode, normalizeReferenceCurationStatus } from "../lib/reference-policy";
7
+ import type { ReferenceRecord } from "../lib/category-repository";
8
+ import type { NeedsMeEntry } from "../lib/needs-me-items";
9
+ import { DefaultOutcome, type DetailProps } from "./needs-me-bodies";
10
+ import { decidedChoiceOf } from "./needs-me-shared";
11
+ import { predictionFromDecision, ReviewCard, type CardPrediction } from "./reference-review-card";
12
+ import { ReferenceCapturePanel } from "./reference-capture-panel";
13
+ import { useReferenceCapture } from "./reference-capture-client";
14
+ import { ReferenceSourcePreview } from "./reference-source-preview";
15
+ import { ReferenceSuggestionProvenance } from "./reference-cyclotron-suggestion";
16
+ import { useReferencePreviewAttachments } from "./use-reference-preview-attachments";
17
+
18
+ function cardPrediction(item: NeedsMeEntry, predictionLoading: boolean): CardPrediction {
19
+ if (item.payload.kind === "reference" && item.payload.decision) return predictionFromDecision(item.payload.decision);
20
+ return predictionLoading ? { state: "loading" } : { state: "none" };
21
+ }
22
+
23
+ /** A Reference, or a Reference whose capture may be cut off: the References detail's own pieces, in the same order. */
24
+ export function ReferenceBody(props: DetailProps & { payload: Extract<NeedsMeEntry["payload"], { kind: "reference" }> }) {
25
+ const { item, payload, ratings, disabledReason, attachments, captureTeaserLine = DEFAULT_TEASER_WORDS, onCorrectCapture, predictionLoading } = props;
26
+ const reference: ReferenceRecord = payload.reference;
27
+ const lineageId = referenceLineageId(reference);
28
+ const status = normalizeReferenceCurationStatus(reference.curationStatus);
29
+ const previewAttachments = useReferencePreviewAttachments(reference, attachments);
30
+ const capture = useReferenceCapture(reference, previewAttachments, captureTeaserLine, onCorrectCapture);
31
+ const url = reference.sourceUri?.trim() && /^https?:\/\//i.test(reference.sourceUri.trim()) ? reference.sourceUri.trim() : null;
32
+ const summary = referenceMetadataField(null, reference.metadata, "summary");
33
+ const incomplete = item.type === "incomplete_capture";
34
+ const capturePanel = <ReferenceCapturePanel capture={capture} startEditing={incomplete} />;
35
+ return (
36
+ <div className="space-y-4" data-reference-curation-status={status} data-news-desk-reference-detail={lineageId}>
37
+ {incomplete ? (
38
+ <div className="space-y-2" data-needs-me-capture-first>
39
+ <p className="text-sm text-muted-foreground">The captured text looks cut off or missing. Paste the full text below and say whether the capture was complete, or rate the Reference as it is.</p>
40
+ {capturePanel}
41
+ </div>
42
+ ) : null}
43
+ <ReviewCard
44
+ decided={decidedChoiceOf(item)}
45
+ disabledReason={disabledReason}
46
+ initialNote={status === "pending" ? "" : reference.curationStatusReason ?? ""}
47
+ initialReason={referenceReasonCode(reference) ?? null}
48
+ itemId={item.id}
49
+ onDecide={async () => undefined}
50
+ prediction={cardPrediction(item, predictionLoading)}
51
+ ratings={ratings}
52
+ summary={summary}
53
+ title={referenceDisplayTitle(reference)}
54
+ titleHref={url}
55
+ >
56
+ {payload.decision ? <div data-reference-relevance-decision={lineageId}><ReferenceSuggestionProvenance decision={payload.decision} /></div> : null}
57
+ </ReviewCard>
58
+ <DefaultOutcome item={item} />
59
+ {incomplete ? null : capturePanel}
60
+ <section aria-label="Source preview" className="space-y-2">
61
+ <h3 className="text-sm font-medium text-foreground">Source preview</h3>
62
+ <ReferenceSourcePreview attachments={previewAttachments} sourceUri={reference.sourceUri} />
63
+ </section>
64
+ <dl className="grid grid-cols-2 gap-3 text-sm">
65
+ <div>
66
+ <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Imported</dt>
67
+ <dd className="mt-1 text-foreground">{formatReferenceListDate(reference)}</dd>
68
+ </div>
69
+ <div>
70
+ <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Media</dt>
71
+ <dd className="mt-1 text-foreground">{reference.mediaType ?? "unknown"}</dd>
72
+ </div>
73
+ </dl>
74
+ </div>
75
+ );
76
+ }
77
+
@@ -0,0 +1,48 @@
1
+ "use client";
2
+
3
+ import { CheckIcon, CircleCheckIcon, Loader2Icon } from "lucide-react";
4
+ import { cn } from "../lib/utils";
5
+ import type { NeedsMeEntry } from "../lib/needs-me-items";
6
+ import type { ReviewChoice } from "./reference-review-card";
7
+
8
+ /** References and captures are rated like the References feed: a reason code is asked for on Reject. */
9
+ export function isReferenceLike(item: Pick<NeedsMeEntry, "type">): boolean {
10
+ return item.type === "reference" || item.type === "incomplete_capture";
11
+ }
12
+
13
+ export function decidedChoiceOf(item: Pick<NeedsMeEntry, "decisionState">): ReviewChoice | null {
14
+ return item.decisionState === "accepted" ? "accept" : item.decisionState === "rejected" ? "reject" : null;
15
+ }
16
+
17
+ /** What was recorded when a non-Reference item was decided: its Why, and whether it was rejected as "not this kind". Shown again whenever the item is reopened. */
18
+ export function recordedAnswer(item: NeedsMeEntry): { why: string | null; notThisKind: boolean } {
19
+ const { payload } = item;
20
+ if (payload.kind === "draft") return { why: payload.item.decision?.why ?? null, notThisKind: Boolean(payload.item.decision?.notThisKind) };
21
+ if (payload.kind === "request") return { why: payload.request.answer?.feedback ?? null, notThisKind: payload.request.answer?.steer === "not_this_kind" };
22
+ if (payload.kind === "email") return { why: payload.summary.quarantine?.decision?.reason || null, notThisKind: false };
23
+ return { why: null, notThisKind: false };
24
+ }
25
+
26
+ /** A flag is information, not a question: one Acknowledge button in the footer's own tone. */
27
+ export function FlagFooter({ busy, disabledReason, done, error, onAcknowledge }: { busy: boolean; disabledReason: string | null; done: boolean; error: string | null; onAcknowledge: () => void }) {
28
+ return (
29
+ <div className="space-y-2" data-flag-footer>
30
+ {done ? (
31
+ <p className="inline-flex min-h-12 items-center gap-1.5 text-sm font-medium" data-flag-acknowledged><CircleCheckIcon aria-hidden="true" className="size-4" />Acknowledged</p>
32
+ ) : (
33
+ <button
34
+ aria-disabled={busy || Boolean(disabledReason) || undefined}
35
+ className={cn("inline-flex min-h-12 w-full items-center justify-center gap-1.5 rounded-[16px] bg-[var(--feed-control)] px-3 text-sm font-semibold text-foreground outline-none transition-colors hover:bg-[var(--feed-control)]/70 focus-visible:ring-2 focus-visible:ring-ring", (busy || disabledReason) && "opacity-60")}
36
+ data-flag-acknowledge
37
+ onClick={() => { if (!busy && !disabledReason) onAcknowledge(); }}
38
+ type="button"
39
+ >
40
+ {busy ? <Loader2Icon aria-hidden="true" className="size-4 animate-spin" /> : <CheckIcon aria-hidden="true" className="size-4" />}
41
+ {busy ? "Saving…" : "Acknowledge"}
42
+ </button>
43
+ )}
44
+ {disabledReason && !done ? <p className="text-xs text-muted-foreground" data-review-disabled-reason>{disabledReason}</p> : null}
45
+ {error ? <p className="text-xs text-destructive" role="alert">{error}</p> : null}
46
+ </div>
47
+ );
48
+ }
@@ -1,9 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import Link from "next/link";
4
- import { usePathname, useRouter } from "next/navigation";
4
+ import { usePathname } from "next/navigation";
5
5
  import { useCallback, useMemo } from "react";
6
- import { Badge } from "./ui/badge";
7
6
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
8
7
  import type { AssignmentRecord, NewsroomSectionRecord, ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
9
8
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
@@ -15,12 +14,14 @@ import {
15
14
  newsroomDeskKind,
16
15
  sortNewsroomSections,
17
16
  } from "../lib/newsroom-sections";
18
- import { newsroomInboxRowClassName } from "../lib/newsroom-list-selection";
17
+ import { cn } from "../lib/utils";
18
+ import { FEED_SURFACE_PROPS } from "./reference-feed";
19
19
  import type { RelevanceDecisionView } from "../lib/relevance-decisions";
20
20
  import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
21
21
  import { referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
22
22
  import type { NotificationFixture } from "./notification-center";
23
23
  import { NotificationCenter } from "./notification-center";
24
+ import type { CorrectCaptureAction } from "./reference-capture-client";
24
25
  import type { ReviewChoice } from "./reference-review-card";
25
26
 
26
27
  type NewsroomOpsOverviewProps = {
@@ -38,7 +39,9 @@ type NewsroomOpsOverviewProps = {
38
39
  predictionState?: "loading" | "ready" | "error";
39
40
  /** Words for why a rating cannot be saved right now, or null. */
40
41
  disabledReason?: string | null;
41
- onReview?: (reference: ReferenceRecord, action: ReferenceCurationAction, options?: { reasonCode?: ReferenceRejectionReasonCode | null; decisionRelationId?: string; shareable?: boolean }) => Promise<void> | void;
42
+ onReview?: (reference: ReferenceRecord, action: ReferenceCurationAction, options?: { reasonCode?: ReferenceRejectionReasonCode | null; note?: string; decisionRelationId?: string; shareable?: boolean }) => Promise<void> | void;
43
+ /** Saves a pasted or corrected capture (the `correctReferenceCapture` action). Without it the capture panel has no edit action. */
44
+ onCorrectCapture?: CorrectCaptureAction;
42
45
  /** Local real-data preview of the Notification Center (scripts/export-notification-center-fixture.py). */
43
46
  notificationFixture?: NotificationFixture | null;
44
47
  };
@@ -55,6 +58,7 @@ export function NewsroomOpsOverview({
55
58
  loading = false,
56
59
  newsroomSections,
57
60
  notificationFixture = null,
61
+ onCorrectCapture,
58
62
  onReview,
59
63
  predictionState = "ready",
60
64
  referenceAttachments = [],
@@ -62,7 +66,6 @@ export function NewsroomOpsOverview({
62
66
  savedCopy = null,
63
67
  }: NewsroomOpsOverviewProps) {
64
68
  const pathname = usePathname();
65
- const router = useRouter();
66
69
  const liveReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
67
70
  // Until the live read arrives, a saved copy of the last view fills the page. It is display only.
68
71
  const showingSavedCopy = loading && !liveReferences.length && Boolean(savedCopy?.references.length);
@@ -72,14 +75,11 @@ export function NewsroomOpsOverview({
72
75
  const live = referencePreviewsByLineage(referenceAttachments);
73
76
  return showingSavedCopy && !live.size ? new Map(savedCopy!.previews) : live;
74
77
  }, [referenceAttachments, savedCopy, showingSavedCopy]);
75
- const openReference = useCallback(
76
- (lineageId: string) => router.push(getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo)),
77
- [demo, router],
78
- );
79
- const rateReference = useCallback(async (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => {
78
+ const rateReference = useCallback(async (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null, note?: string) => {
80
79
  const decision = shownDecisions.get(referenceLineageId(reference));
81
80
  await onReview?.(reference, choice, {
82
81
  reasonCode: choice === "reject" ? reasonCode : null,
82
+ ...(note ? { note } : {}),
83
83
  ...(decision ? { decisionRelationId: decision.decisionRelationId, shareable: false } : {}),
84
84
  });
85
85
  }, [onReview, shownDecisions]);
@@ -100,29 +100,8 @@ export function NewsroomOpsOverview({
100
100
  const rotatingDesks = useMemo(() => desks.filter(desk => newsroomDeskKind(desk) === "rotating"), [desks]);
101
101
  const rateDisabled = disabledReason ?? (showingSavedCopy ? "Confirming your sign-in. You can answer in a moment." : null);
102
102
 
103
- return (
104
- <div
105
- className="space-y-12 font-sans"
106
- data-news-desk-section="overview"
107
- data-newsroom-ops-home="true"
108
- >
109
- <div className="space-y-12" data-newsroom-overview-feeds>
110
- <div data-newsroom-overview-notifications>
111
- <NotificationCenter
112
- actorLabel={actorLabel}
113
- decisions={shownDecisions}
114
- demo={demo}
115
- fixture={notificationFixture}
116
- humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
117
- onOpenReference={openReference}
118
- onRateReference={rateReference}
119
- predictionLoading={predictionState === "loading"}
120
- previews={previews}
121
- referenceDisabledReason={rateDisabled}
122
- references={canonicalReferences}
123
- referencesLoading={loading && !canonicalReferences.length}
124
- />
125
- </div>
103
+ const below = (
104
+ <div className="space-y-12 pt-6" data-newsroom-overview-below {...FEED_SURFACE_PROPS}>
126
105
  {attentionAssignments.length ? (
127
106
  <section aria-label="Open assignments" className="space-y-3">
128
107
  <h2 className="text-sm font-medium text-muted-foreground">Open assignments</h2>
@@ -133,7 +112,7 @@ export function NewsroomOpsOverview({
133
112
  return (
134
113
  <Link
135
114
  aria-current={active ? "true" : undefined}
136
- className={newsroomInboxRowClassName(active)}
115
+ className={cn("flex items-start gap-3 rounded-[16px] bg-[var(--feed-card)] p-3 text-foreground outline-none transition-colors hover:bg-[var(--feed-control)] focus-visible:ring-2 focus-visible:ring-ring", active && "bg-[var(--feed-control)]")}
137
116
  data-newsroom-inbox-item="assignment"
138
117
  data-newsroom-list-row
139
118
  data-selected={active || undefined}
@@ -144,7 +123,7 @@ export function NewsroomOpsOverview({
144
123
  <p className="truncate text-sm font-medium text-foreground">{assignment.title}</p>
145
124
  <p className="text-xs text-muted-foreground">Assignment · {assignment.assignmentTypeKey}</p>
146
125
  </div>
147
- <Badge className="mt-0.5 shrink-0" variant="secondary">{assignment.status}</Badge>
126
+ <span className="mt-0.5 shrink-0 rounded-full bg-[var(--feed-control)] px-2 py-0.5 text-xs font-medium">{assignment.status}</span>
148
127
  </Link>
149
128
  );
150
129
  })}
@@ -164,7 +143,7 @@ export function NewsroomOpsOverview({
164
143
  href={buildNewsroomSectionHref(section.id, demo)}
165
144
  key={section.id}
166
145
  >
167
- <Card className="h-full transition-colors hover:border-primary/30">
146
+ <Card className="h-full rounded-[20px] border-0 bg-[var(--feed-card)] shadow-none ring-0 transition-colors hover:bg-[var(--feed-control)]">
168
147
  <CardHeader className="p-4">
169
148
  <CardTitle className="text-base font-semibold">{section.title}</CardTitle>
170
149
  <CardDescription className="uppercase tracking-wide text-[0.65rem]">
@@ -184,7 +163,7 @@ export function NewsroomOpsOverview({
184
163
  href={buildNewsroomSectionHref(section.id, demo)}
185
164
  key={section.id}
186
165
  >
187
- <Card className="h-full transition-colors hover:border-primary/30">
166
+ <Card className="h-full rounded-[20px] border-0 bg-[var(--feed-card)] shadow-none ring-0 transition-colors hover:bg-[var(--feed-control)]">
188
167
  <CardHeader className="p-4">
189
168
  <CardTitle className="text-base font-semibold">{section.title}</CardTitle>
190
169
  <CardDescription className="uppercase tracking-wide text-[0.65rem]">
@@ -197,6 +176,34 @@ export function NewsroomOpsOverview({
197
176
  </div> : null}
198
177
  </section>
199
178
  ) : null}
179
+ </div>
180
+ );
181
+
182
+ return (
183
+ <div
184
+ className="h-full font-sans"
185
+ data-news-desk-section="overview"
186
+ data-newsroom-ops-home="true"
187
+ >
188
+ <div className="h-full" data-newsroom-overview-feeds>
189
+ <div className="h-full" data-newsroom-overview-notifications>
190
+ <NotificationCenter
191
+ below={below}
192
+ actorLabel={actorLabel}
193
+ decisions={shownDecisions}
194
+ demo={demo}
195
+ fixture={notificationFixture}
196
+ humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
197
+ onCorrectCapture={onCorrectCapture}
198
+ onRateReference={rateReference}
199
+ referenceAttachments={referenceAttachments}
200
+ predictionLoading={predictionState === "loading"}
201
+ previews={previews}
202
+ referenceDisabledReason={rateDisabled}
203
+ references={canonicalReferences}
204
+ referencesLoading={loading && !canonicalReferences.length}
205
+ />
206
+ </div>
200
207
  </div>
201
208
  </div>
202
209
  );
@@ -210,10 +210,10 @@ export function NewsroomOpsStatusBanner({
210
210
  return (
211
211
  <div
212
212
  className={cn(
213
- "mb-4 rounded-xl border px-3 py-2 text-sm",
214
- tone === "error" && "border-destructive/30 bg-destructive/10 text-destructive",
215
- tone === "ok" && "border-primary/20 bg-primary/10 text-foreground",
216
- tone === "info" && "border-border bg-muted/40 text-foreground",
213
+ // A success is announced to screen readers and never drawn: it would push the page down. Errors and notes are flat fills, no outlines.
214
+ tone === "ok" ? "sr-only" : "mb-4 rounded-lg px-3 py-2 text-sm",
215
+ tone === "error" && "bg-destructive/10 text-destructive",
216
+ tone === "info" && "bg-muted/60 text-foreground",
217
217
  )}
218
218
  role="status"
219
219
  >
@@ -16,7 +16,7 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
16
16
  .organizer-destinations a[aria-current=page]{color:var(--org-ink);box-shadow:inset 3px 0 0 var(--org-accent)}
17
17
  .newsroom-organizer :is(a,button):focus-visible,.organizer-menu :is(a,button):focus-visible{outline:2px solid currentColor;outline-offset:-3px}
18
18
  .organizer-workspace{display:flex;flex-direction:column;min-height:0;min-width:0;overflow:hidden;padding:0 16px}
19
- .organizer-topbar{height:48px;min-height:48px;display:flex;align-items:center;gap:16px;border-bottom:1px solid var(--org-line)}
19
+ .organizer-topbar{height:48px;min-height:48px;display:flex;align-items:center;gap:16px}
20
20
  .organizer-masthead{font:900 28px var(--font-wordmark),Georgia,serif;white-space:nowrap;max-width:30vw;overflow:hidden;text-overflow:ellipsis}
21
21
  .organizer-masthead-logo{display:block;height:24px;width:auto;max-width:36vw;overflow:visible}
22
22
  .organizer-topbar h1{font:400 12px var(--kb-theme-ui-font);flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0}
@@ -37,6 +37,13 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
37
37
  @media (min-width:768px){
38
38
  .organizer-main:has(>[data-category-steering]>[data-news-desk-section=references]){display:flex;flex-direction:column;overflow:hidden;padding-bottom:0}
39
39
  .organizer-main>[data-category-steering]:has(>[data-news-desk-section=references]){display:flex;flex-direction:column;flex:1;min-height:0}
40
+ .organizer-main:has(>[data-category-steering]>[data-newsroom-ops-home]){display:flex;flex-direction:column;overflow:hidden;padding-bottom:0}
41
+ .organizer-main>[data-category-steering]:has(>[data-newsroom-ops-home]){display:flex;flex-direction:column;flex:1;min-height:0}
42
+ [data-newsroom-ops-home]{display:flex;flex-direction:column;flex:1;min-height:0;overflow-y:auto}
43
+ [data-newsroom-ops-home]>[data-newsroom-overview-feeds]{display:contents}
44
+ [data-newsroom-overview-notifications]{display:flex;flex-direction:column;flex:0 0 100%;min-height:0}
45
+ [data-newsroom-overview-notifications]>[data-notification-center]{flex:1;min-height:0}
46
+ [data-newsroom-overview-feeds]>:not([data-newsroom-overview-notifications]){flex:none}
40
47
  }
41
48
  .organizer-native-board{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;gap:8px}
42
49
  .organizer-board-view-toggle{display:flex;gap:4px;flex-shrink:0}
@@ -355,15 +362,21 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
355
362
  The newsroom chrome squares every "rounded" class above; inside a [data-feed-surface] the rounded classes mean what they say.
356
363
  The repeated attribute raises specificity over the squaring rules without !important escalation. */
357
364
  .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-full"],
358
- .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-full"] { border-radius: 9999px !important; }
365
+ .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-full"] { border-radius: var(--feed-radius-pill) !important; }
359
366
  .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-[20px]"],
360
- .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-[20px]"] { border-radius: 20px !important; }
367
+ .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-[20px]"] { border-radius: var(--feed-radius-lg) !important; }
361
368
  .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-2xl"],
362
- .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-2xl"] { border-radius: 16px !important; }
369
+ .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-2xl"] { border-radius: var(--feed-radius-md) !important; }
363
370
  .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-[16px]"],
364
- .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-[16px]"] { border-radius: 16px !important; }
371
+ .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-[16px]"] { border-radius: var(--feed-radius-md) !important; }
365
372
  .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-3xl"],
366
- .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-3xl"] { border-radius: 24px !important; }
373
+ .newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-3xl"] { border-radius: var(--feed-radius-xl) !important; }
374
+
375
+ /* The mobile sheet is portaled outside .newsroom-organizer; it carries the theme attributes itself, so the same radii apply inside it. */
376
+ [data-kanbus-theme][data-feed-surface] [class*="rounded-full"] { border-radius: var(--feed-radius-pill) !important; }
377
+ [data-kanbus-theme][data-feed-surface] [class*="rounded-[20px]"] { border-radius: var(--feed-radius-lg) !important; }
378
+ [data-kanbus-theme][data-feed-surface] [class*="rounded-2xl"], [data-kanbus-theme][data-feed-surface] [class*="rounded-[16px]"] { border-radius: var(--feed-radius-md) !important; }
379
+ [data-kanbus-theme][data-feed-surface] [class*="rounded-3xl"] { border-radius: var(--feed-radius-xl) !important; }
367
380
 
368
381
  /* Feed tones. Light: cards a touch lighter than the page, footers between, controls a touch darker. Dark: each step lifts toward the ink.
369
382
  Also set on the portaled mobile sheet, which carries the theme attributes itself. */
@@ -371,6 +384,12 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
371
384
  --feed-card: color-mix(in oklab, var(--kb-theme-paper) 35%, white);
372
385
  --feed-footer: color-mix(in oklab, var(--kb-theme-paper) 70%, white);
373
386
  --feed-control: color-mix(in oklab, var(--kb-theme-paper) 90%, var(--kb-theme-ink));
387
+ /* Corner radii of the whole feed layer, in one place (Ryan, 2026-10-27: half of the first design). lg: cards and the detail pane;
388
+ md: rating cells, buttons, textareas; xl: sheets; pill: chips, filters and toggles (a rounded rectangle, not a capsule). */
389
+ --feed-radius-xl: 12px;
390
+ --feed-radius-lg: 10px;
391
+ --feed-radius-md: 8px;
392
+ --feed-radius-pill: 8px;
374
393
  }
375
394
  [data-kanbus-theme][data-color-mode="dark"] [data-feed-surface], [data-kanbus-theme][data-color-mode="dark"][data-feed-surface] {
376
395
  --feed-card: color-mix(in oklab, var(--kb-theme-paper) 94%, var(--kb-theme-ink));
@@ -385,16 +404,16 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
385
404
  [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] *:not(:focus-visible) { box-shadow: none; }
386
405
  [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is([data-slot=separator], hr) { display: none !important; }
387
406
  [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(button, [role=button], [data-slot=badge]):not([aria-pressed=true]):not([data-slot=sheet-close]):not([class*="hover:bg-muted"]) {
388
- background: var(--feed-control) !important; border-radius: 9999px !important; color: var(--foreground, inherit);
407
+ background: var(--feed-control) !important; border-radius: var(--feed-radius-pill) !important; color: var(--foreground, inherit);
389
408
  }
390
409
  [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(button, [role=button])[aria-pressed=true]:not([class*="destructive"]) {
391
- background: var(--primary) !important; color: var(--primary-foreground) !important; border-radius: 9999px !important;
410
+ background: var(--primary) !important; color: var(--primary-foreground) !important; border-radius: var(--feed-radius-pill) !important;
392
411
  }
393
- [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(button, [role=button])[aria-pressed=true][class*="destructive"] { border-radius: 9999px !important; }
412
+ [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(button, [role=button])[aria-pressed=true][class*="destructive"] { border-radius: var(--feed-radius-pill) !important; }
394
413
  [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(textarea, input:not([type=radio]):not([type=checkbox]), select, details, [data-cyclotron-choice]:not([data-predicted])) {
395
414
  background: var(--feed-control) !important; border-radius: 16px !important;
396
415
  }
397
- [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-cyclotron-choice] { border-radius: 9999px !important; }
416
+ [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-cyclotron-choice] { border-radius: var(--feed-radius-pill) !important; }
398
417
  [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-review-queue-bar] { background: var(--feed-footer) !important; }
399
418
  /* The rating grid keeps its own rounded rectangles in the borderless panes (not the pill shape the flat rules give other buttons). */
400
- [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-rating-grid] :is(button, [data-cyclotron-choice]) { border-radius: 16px !important; }
419
+ [data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-rating-grid] :is(button, [data-cyclotron-choice]) { border-radius: var(--feed-radius-md) !important; }
@@ -42,6 +42,8 @@ import {
42
42
  import { normalizeReferenceCurationStatus, referenceReasonCode, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
43
43
  import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
44
44
  import { ReferenceSourcePreview } from "./reference-source-preview";
45
+ import { useReferencePreviewAttachments } from "./use-reference-preview-attachments";
46
+ import { useMediaQuery } from "./use-media-query";
45
47
  import { FEED_SURFACE_PROPS, FEED_SURFACE_STYLE, ReferenceFeed } from "./reference-feed";
46
48
  import { ReferenceCapturePanel } from "./reference-capture-panel";
47
49
  import { useReferenceCapture, type CorrectCaptureAction } from "./reference-capture-client";
@@ -88,10 +90,6 @@ import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyc
88
90
  import type { PrimingRequestView } from "../lib/relevance-status";
89
91
  import type { CyclotronStatus } from "cyclotron/cyclotron-status";
90
92
  import { awaitsEditorReview, type RelevanceCuration, type RelevanceDecisionView } from "../lib/relevance-decisions";
91
- import {
92
- loadReferenceAttachmentsForLineageId,
93
- loadStoragePathUrl,
94
- } from "./news-desk-taxonomy-client";
95
93
 
96
94
  type NewsroomReferencesViewProps = {
97
95
  /** Present only when the host has configured optional example priming. */
@@ -154,18 +152,6 @@ const STATUS_FILTERS: Array<{ key: ReferenceListFilter; label: string }> = [
154
152
  { key: "archived", label: "Archived" },
155
153
  ];
156
154
 
157
- function useMediaQuery(query: string): boolean {
158
- const [matches, setMatches] = useState(false);
159
- useEffect(() => {
160
- const mediaQuery = window.matchMedia(query);
161
- const update = () => setMatches(mediaQuery.matches);
162
- update();
163
- mediaQuery.addEventListener("change", update);
164
- return () => mediaQuery.removeEventListener("change", update);
165
- }, [query]);
166
- return matches;
167
- }
168
-
169
155
  function buildReferenceDetailPath(lineageId: string, demo?: boolean): string {
170
156
  return getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo);
171
157
  }
@@ -177,100 +163,6 @@ function referenceUrl(reference: ReferenceRecord): string | null {
177
163
  return null;
178
164
  }
179
165
 
180
- function normalizeReferenceDetailHttpUri(value: string | null | undefined): string | null {
181
- const trimmed = value?.trim();
182
- if (!trimmed) return null;
183
- if (/^https?:\/\//i.test(trimmed)) return trimmed;
184
- return null;
185
- }
186
-
187
- function useReferencePreviewAttachments(
188
- reference: ReferenceRecord | null,
189
- referenceAttachments: ReferenceAttachmentRecord[],
190
- ) {
191
- const lineageId = reference ? referenceLineageId(reference) : "";
192
- const seededAttachments = useMemo(
193
- () => referenceAttachments.filter((attachment) => attachment.referenceLineageId === lineageId),
194
- [lineageId, referenceAttachments],
195
- );
196
- const [attachments, setAttachments] = useState<ReferenceAttachmentRecord[]>(seededAttachments);
197
- const [attachmentLinksById, setAttachmentLinksById] = useState<Record<string, string>>({});
198
-
199
- useEffect(() => {
200
- setAttachments(seededAttachments);
201
- }, [seededAttachments]);
202
-
203
- useEffect(() => {
204
- if (!lineageId) return;
205
- if (seededAttachments.length > 0) return;
206
- let active = true;
207
- void loadReferenceAttachmentsForLineageId(lineageId)
208
- .then((loaded) => {
209
- if (!active || !loaded.length) return;
210
- setAttachments(loaded);
211
- })
212
- .catch(() => undefined);
213
- return () => {
214
- active = false;
215
- };
216
- }, [lineageId, seededAttachments.length]);
217
-
218
- useEffect(() => {
219
- if (!attachments.length) {
220
- setAttachmentLinksById({});
221
- return;
222
- }
223
- let active = true;
224
- const staticLinks = new Map<string, string>();
225
- const storageLookups: Array<{ id: string; storagePath: string }> = [];
226
-
227
- for (const attachment of attachments) {
228
- if (attachment.storagePath) {
229
- storageLookups.push({ id: attachment.id, storagePath: attachment.storagePath });
230
- continue;
231
- }
232
- const sourceHref = normalizeReferenceDetailHttpUri(attachment.sourceUri);
233
- if (sourceHref) staticLinks.set(attachment.id, sourceHref);
234
- }
235
-
236
- if (!storageLookups.length) {
237
- setAttachmentLinksById(Object.fromEntries(staticLinks.entries()));
238
- return () => {
239
- active = false;
240
- };
241
- }
242
-
243
- setAttachmentLinksById(Object.fromEntries(staticLinks.entries()));
244
- void Promise.all(storageLookups.map(async (lookup) => {
245
- const result = await loadStoragePathUrl(lookup.storagePath);
246
- return { id: lookup.id, url: result.url };
247
- }))
248
- .then((resolved) => {
249
- if (!active) return;
250
- const nextLinks = Object.fromEntries(staticLinks.entries());
251
- for (const entry of resolved) {
252
- if (entry.url) nextLinks[entry.id] = entry.url;
253
- }
254
- setAttachmentLinksById(nextLinks);
255
- })
256
- .catch(() => undefined);
257
-
258
- return () => {
259
- active = false;
260
- };
261
- }, [attachments]);
262
-
263
- const previewAttachments = useMemo(
264
- () => attachments.map((attachment) => ({
265
- ...attachment,
266
- sourceUri: attachmentLinksById[attachment.id] ?? attachment.sourceUri,
267
- })),
268
- [attachmentLinksById, attachments],
269
- );
270
-
271
- return previewAttachments;
272
- }
273
-
274
166
  function needsReview(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined): boolean {
275
167
  return awaitsEditorReview(reference.curationStatus, decision);
276
168
  }
@@ -757,7 +649,7 @@ export function NewsroomReferencesView({
757
649
  </div>
758
650
  );
759
651
  const queueBar = queue.length || caughtUp ? (
760
- <ReviewQueueBar caughtUp={caughtUp} onNext={goNext} onPrevious={goPrevious} position={queuePosition} total={queue.length} />
652
+ <ReviewQueueBar anchored caughtUp={caughtUp} onNext={goNext} onPrevious={goPrevious} position={queuePosition} total={queue.length} />
761
653
  ) : null;
762
654
 
763
655
  const emptyLabel = canonicalReferences.length
@@ -981,7 +873,7 @@ export function NewsroomReferencesView({
981
873
 
982
874
  <div
983
875
  className={cn(
984
- "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)]",
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)] md:pb-16 lg:w-[min(100%,28rem)]",
985
877
  )}
986
878
  data-newsroom-list-detail-shell
987
879
  data-news-desk-section="references"
@@ -1035,7 +927,7 @@ export function NewsroomReferencesView({
1035
927
  }}
1036
928
  open={(mobileDetailOpen && Boolean(selectedReference)) || caughtUp}
1037
929
  >
1038
- <SheetContent className="gap-0 rounded-t-[24px] bg-[var(--feed-card)] p-0" data-feed-flat="" data-feed-surface="" side="bottom" {...themeAttributes}>
930
+ <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}>
1039
931
  <SheetHeader className="border-b border-border px-4 py-3">
1040
932
  <SheetTitle className="text-left text-base">Reference</SheetTitle>
1041
933
  </SheetHeader>
@@ -19,17 +19,22 @@ const CARD = "flex flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)]
19
19
  const PILL_BUTTON = cn(PILL, "min-h-9 bg-[var(--feed-control)] px-3.5 text-foreground hover:bg-[var(--feed-control)]/70");
20
20
 
21
21
  /** Pulsing placeholder cards in the shape of the real ones, on screen the moment the page opens. */
22
- export function NotificationSkeleton({ count = 3 }: { count?: number }) {
22
+ export function NotificationSkeleton({ bare = false, count = 3 }: { bare?: boolean; count?: number }) {
23
23
  return (
24
24
  <div aria-busy="true" aria-label="Loading what needs you" className="space-y-6" data-notification-skeleton role="status" {...FEED_SURFACE_PROPS}>
25
- <div className="space-y-3">
26
- <div className="h-8 w-2/3 max-w-md animate-pulse rounded-full bg-[var(--feed-control)]" />
27
- <div className="h-4 w-1/3 max-w-xs animate-pulse rounded-full bg-[var(--feed-control)]" />
28
- </div>
29
- <div className="flex gap-2">
30
- {[0, 1, 2, 3].map((index) => <div className="h-9 w-24 animate-pulse rounded-full bg-[var(--feed-control)]" key={index} />)}
31
- </div>
32
- <div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
25
+ {bare ? null : (
26
+ <>
27
+ <div className="space-y-3">
28
+ <div className="h-8 w-2/3 max-w-md animate-pulse rounded-full bg-[var(--feed-control)]" />
29
+ <div className="h-4 w-1/3 max-w-xs animate-pulse rounded-full bg-[var(--feed-control)]" />
30
+ </div>
31
+ <div className="h-11 animate-pulse rounded-[20px] bg-[var(--feed-card)]" />
32
+ <div className="flex gap-2">
33
+ {[0, 1, 2, 3, 4].map((index) => <div className="h-9 w-24 animate-pulse rounded-full bg-[var(--feed-control)]" key={index} />)}
34
+ </div>
35
+ </>
36
+ )}
37
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-5">
33
38
  {Array.from({ length: count }, (_, index) => (
34
39
  <div className={CARD} key={index}>
35
40
  <div className="aspect-[16/10] animate-pulse bg-[var(--feed-control)]" />