@anthusai/papyrus 1.0.0-next.4 → 1.0.0-next.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/amplify/auth/redirect-urls.js +54 -0
  2. package/amplify/auth/redirect-urls.ts +66 -0
  3. package/amplify/auth/resource.js +20 -5
  4. package/amplify/auth/resource.ts +27 -5
  5. package/amplify/data/resource.js +28 -70
  6. package/amplify/data/resource.ts +29 -70
  7. package/amplify/data/schema.js +33 -58
  8. package/amplify/data/schema.ts +34 -58
  9. package/amplify/functions/category-action/handler.ts +5 -3
  10. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  11. package/amplify/functions/console-chat-responder/resource.js +0 -7
  12. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  13. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  14. package/amplify/functions/content-actions/handler.py +45 -3
  15. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  16. package/amplify/inbound-email/ses-intake.js +86 -0
  17. package/amplify/inbound-email/ses-intake.ts +110 -0
  18. package/amplify/rebuild-trigger-settings.js +22 -0
  19. package/amplify/rebuild-trigger-settings.ts +42 -0
  20. package/amplify/resolver-waves.js +26 -0
  21. package/amplify/resolver-waves.ts +34 -0
  22. package/amplify/site-backend-config.ts +36 -1
  23. package/amplify/site-backend-names.js +103 -0
  24. package/amplify/site-backend-names.ts +166 -0
  25. package/amplify/site-backend.js +93 -170
  26. package/amplify/site-backend.ts +126 -196
  27. package/amplify/storage/resource.js +3 -0
  28. package/amplify/storage/resource.ts +3 -0
  29. package/amplify/storage-backups/construct.js +31 -0
  30. package/amplify/storage-backups/construct.ts +47 -0
  31. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  32. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -0
  33. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  34. package/app/api/media/[...path]/route.ts +5 -16
  35. package/app/api/revalidate/route.ts +8 -7
  36. package/app/articles/[slug]/page.tsx +6 -4
  37. package/app/blog-rhythm.css +278 -0
  38. package/app/globals.css +21 -0
  39. package/app/layout.tsx +16 -0
  40. package/app/newsroom/[section]/[...selection]/page.tsx +6 -5
  41. package/app/newsroom/[section]/page.tsx +4 -3
  42. package/app/newsroom/articles/[id]/page.tsx +15 -0
  43. package/app/newsroom/articles/page.tsx +13 -0
  44. package/app/newsroom/layout.tsx +4 -9
  45. package/app/page.tsx +18 -5
  46. package/app/robots.ts +2 -5
  47. package/bin/papyrus-app.mjs +12 -2
  48. package/bin/papyrus-infra.mjs +29 -3
  49. package/components/amplify-client-provider.tsx +4 -22
  50. package/components/archive-shell.tsx +2 -22
  51. package/components/article-video.tsx +247 -3
  52. package/components/blog-page-background.tsx +23 -6
  53. package/components/markus-ir-preview.tsx +300 -0
  54. package/components/news-desk-taxonomy-client.ts +51 -0
  55. package/components/newspaper.tsx +6 -2
  56. package/components/newsroom-access-gate.tsx +67 -0
  57. package/components/newsroom-article-editor.tsx +741 -0
  58. package/components/newsroom-articles-view.tsx +129 -0
  59. package/components/newsroom-client-shell.tsx +12 -0
  60. package/components/newsroom-ops-overview.tsx +28 -11
  61. package/components/newsroom-ops-shell.tsx +3 -1
  62. package/components/newsroom-references-view.tsx +83 -8
  63. package/components/papyrus-console-shell.tsx +4 -2
  64. package/components/pictogram-figure.tsx +9 -2
  65. package/components/presentation-header.tsx +135 -0
  66. package/components/presentation-rhythm-hrule.tsx +17 -0
  67. package/components/reference-cyclotron-status.tsx +109 -0
  68. package/components/reference-relevance-review.tsx +75 -0
  69. package/components/settings-page.tsx +2 -1
  70. package/components/topic-steering-workspace.tsx +54 -64
  71. package/components/use-blog-header-obstacles.ts +81 -0
  72. package/components/use-brand-fonts-ready.ts +28 -0
  73. package/components/use-rhythm-overlay.ts +29 -0
  74. package/infra/amplify-app-shell.js +212 -50
  75. package/infra/amplify-app-shell.ts +240 -53
  76. package/infra/build-specs.js +14 -5
  77. package/infra/build-specs.ts +17 -7
  78. package/infra/github-oidc-provider.js +24 -0
  79. package/infra/github-oidc-provider.ts +30 -0
  80. package/infra/index.js +1 -0
  81. package/infra/site-config.js +167 -10
  82. package/infra/site-config.ts +196 -14
  83. package/lib/amplify-outputs-path.ts +5 -1
  84. package/lib/blog-feature-solver.ts +459 -0
  85. package/lib/blog-rhythm.ts +134 -0
  86. package/lib/brand-fonts-watch.ts +43 -0
  87. package/lib/brand-fonts.ts +112 -0
  88. package/lib/cached-content-repository.ts +12 -0
  89. package/lib/category-repository.ts +42 -93
  90. package/lib/content-actions-demo.ts +190 -0
  91. package/lib/content-repository.ts +3 -0
  92. package/lib/content-source-context.ts +12 -2
  93. package/lib/content-types.ts +4 -0
  94. package/lib/edition-routes.ts +31 -29
  95. package/lib/graphql-content-repository.ts +61 -9
  96. package/lib/media-route-response.ts +45 -0
  97. package/lib/newspaper-layout.ts +3 -2
  98. package/lib/newsroom-articles.ts +325 -0
  99. package/lib/newsroom-base-path.ts +12 -3
  100. package/lib/newsroom-category-drilldown.ts +3 -2
  101. package/lib/newsroom-demo-articles.ts +90 -0
  102. package/lib/newsroom-forum-routes.ts +6 -4
  103. package/lib/newsroom-index-filters.ts +14 -10
  104. package/lib/newsroom-media-backend.ts +142 -0
  105. package/lib/newsroom-media.ts +182 -0
  106. package/lib/newsroom-nav.ts +3 -0
  107. package/lib/oauth-redirect-priority.ts +40 -0
  108. package/lib/papyrus-web-locations.ts +7 -6
  109. package/lib/presentation-footer.ts +3 -2
  110. package/lib/pretext-layout.ts +12 -3
  111. package/lib/reader-base-path.ts +24 -0
  112. package/lib/reader-static-params.ts +13 -0
  113. package/lib/reference-policy.ts +5 -0
  114. package/lib/relevance-decisions.ts +103 -0
  115. package/lib/relevance-status.ts +63 -0
  116. package/lib/renderer.ts +7 -0
  117. package/lib/revalidate-secret.ts +59 -0
  118. package/lib/robots-policy.ts +14 -0
  119. package/lib/root-route-redirect.ts +16 -0
  120. package/lib/semantic-graph.ts +7 -0
  121. package/lib/site-brand.ts +51 -0
  122. package/lib/staging-gated-path.ts +23 -0
  123. package/lib/staging-preview-object.ts +114 -0
  124. package/lib/video-mode.ts +42 -0
  125. package/lib/video-script.ts +77 -0
  126. package/middleware.ts +23 -18
  127. package/package.json +45 -1
  128. package/renderers/pretext/article-page.tsx +39 -3
  129. package/renderers/pretext/presentation-shared.tsx +113 -0
  130. package/renderers/pretext/presentation-shell.tsx +15 -102
  131. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  132. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  133. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  134. package/routes.manifest.json +45 -2
  135. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  136. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  137. package/scripts/videoml/bundle-common.mjs +129 -0
  138. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  139. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  140. package/scripts/videoml/preview/preview-timing.ts +42 -0
  141. package/scripts/videoml/preview.html +77 -0
  142. package/scripts/videoml/standard-entry.tsx +46 -0
  143. package/src/sync.mjs +30 -6
  144. package/src/with-papyrus.mjs +19 -2
  145. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  146. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  147. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
@@ -0,0 +1,129 @@
1
+ "use client";
2
+
3
+ import { PlusIcon } from "lucide-react";
4
+ import Link from "next/link";
5
+ import { useEffect, useMemo, useState } from "react";
6
+ import { Badge } from "./ui/badge";
7
+ import { buttonVariants } from "./ui/button";
8
+ import { Spinner } from "./ui/spinner";
9
+ import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
10
+ import { SITE_BRAND } from "../lib/site-brand";
11
+ import { getNewsroomNavHref } from "../lib/newsroom-nav";
12
+ import { newsroomHref } from "../lib/newsroom-base-path";
13
+ import {
14
+ resolveArticlesBackend,
15
+ rowStatusLabel,
16
+ type NewsroomArticleRow,
17
+ } from "../lib/newsroom-articles";
18
+ import { cn } from "../lib/utils";
19
+ import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
20
+ import { NewsDeskAccessGate } from "./newsroom-access-gate";
21
+ import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
22
+
23
+ type ArticleFilter = "all" | "drafts" | "published";
24
+
25
+ export function articlesAccessBlocked(phase: string | undefined): boolean {
26
+ return phase === undefined || phase === "checkingAccess" || phase === "signedOut" || phase === "forbidden" || phase === "error";
27
+ }
28
+
29
+ export function NewsroomArticlesView({ demo }: { demo: boolean }) {
30
+ const session = useOptionalNewsDeskClient();
31
+ const blocked = !demo && articlesAccessBlocked(session?.shell.phase);
32
+ const [rows, setRows] = useState<NewsroomArticleRow[] | null>(null);
33
+ const [loadError, setLoadError] = useState<string | null>(null);
34
+ const [filter, setFilter] = useState<ArticleFilter>("all");
35
+
36
+ useEffect(() => {
37
+ if (blocked) return;
38
+ let cancelled = false;
39
+ resolveArticlesBackend(demo)
40
+ .listRows()
41
+ .then((loaded) => {
42
+ if (!cancelled) setRows(loaded);
43
+ })
44
+ .catch((error: unknown) => {
45
+ if (!cancelled) setLoadError(error instanceof Error ? error.message : "Could not load articles.");
46
+ });
47
+ return () => {
48
+ cancelled = true;
49
+ };
50
+ }, [blocked, demo]);
51
+
52
+ const visibleRows = useMemo(() => {
53
+ if (!rows) return [];
54
+ if (filter === "drafts") return rows.filter((row) => row.status === "draft");
55
+ if (filter === "published") return rows.filter((row) => row.status === "published");
56
+ return rows;
57
+ }, [filter, rows]);
58
+
59
+ if (blocked) return <NewsDeskAccessGate shell={session?.shell ?? null} />;
60
+
61
+ return (
62
+ <NewsroomOpsShell
63
+ activeTab="articles"
64
+ appTitle={SITE_BRAND.appTitle}
65
+ backHref="/"
66
+ backLabel={SITE_BRAND.backToHomeLabel}
67
+ demo={demo}
68
+ headerActions={(
69
+ <>
70
+ {demo ? <Badge variant="outline">Demo</Badge> : null}
71
+ <Link
72
+ className={cn(buttonVariants({ size: "sm" }))}
73
+ data-newsroom-article-new
74
+ href={getNewsroomNavHref(newsroomHref("articles", "new"), demo)}
75
+ >
76
+ <PlusIcon aria-hidden="true" />
77
+ New article
78
+ </Link>
79
+ </>
80
+ )}
81
+ pageTitle="Articles"
82
+ >
83
+ <div className="space-y-4" data-newsroom-articles>
84
+ <Tabs defaultValue="all" onValueChange={(value) => setFilter(value as ArticleFilter)} value={filter}>
85
+ <TabsList>
86
+ <TabsTrigger className="min-w-[5.5rem]" value="all">All</TabsTrigger>
87
+ <TabsTrigger className="min-w-[5.5rem]" value="drafts">Drafts</TabsTrigger>
88
+ <TabsTrigger className="min-w-[5.5rem]" value="published">Published</TabsTrigger>
89
+ </TabsList>
90
+ </Tabs>
91
+ {loadError ? <NewsroomOpsStatusBanner tone="error">{loadError}</NewsroomOpsStatusBanner> : null}
92
+ {rows === null && !loadError ? (
93
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
94
+ <Spinner /> Loading articles…
95
+ </div>
96
+ ) : null}
97
+ {rows !== null && visibleRows.length === 0 ? (
98
+ <p className="text-sm text-muted-foreground">Nothing here yet.</p>
99
+ ) : null}
100
+ <ul className="divide-y divide-border rounded-xl border border-border bg-card" data-newsroom-articles-list>
101
+ {visibleRows.map((row) => (
102
+ <li key={row.id}>
103
+ <Link
104
+ className="flex flex-col gap-1 px-4 py-3 hover:bg-muted/40 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
105
+ data-newsroom-article-row={row.slug}
106
+ href={getNewsroomNavHref(newsroomHref("articles", row.id), demo)}
107
+ >
108
+ <span className="min-w-0">
109
+ <span className="block truncate text-sm font-medium">{row.title}</span>
110
+ <span className="block truncate text-xs text-muted-foreground">
111
+ {row.slug}
112
+ {row.section ? ` · ${row.section}` : ""}
113
+ {row.type !== "article" ? ` · ${row.type}` : ""}
114
+ </span>
115
+ </span>
116
+ <Badge
117
+ data-newsroom-article-status
118
+ variant={row.status === "draft" ? "muted" : row.pending ? "outline" : "secondary"}
119
+ >
120
+ {rowStatusLabel(row)}
121
+ </Badge>
122
+ </Link>
123
+ </li>
124
+ ))}
125
+ </ul>
126
+ </div>
127
+ </NewsroomOpsShell>
128
+ );
129
+ }
@@ -0,0 +1,12 @@
1
+ import { NewsDeskClientProvider } from "./news-desk-client-provider";
2
+ import { resolveDevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
3
+
4
+ export function NewsroomClientShell({ children }: Readonly<{ children: React.ReactNode }>) {
5
+ const devSandboxEditorAuth = resolveDevSandboxEditorAuth();
6
+
7
+ return (
8
+ <NewsDeskClientProvider devSandboxEditorAuth={devSandboxEditorAuth}>
9
+ {children}
10
+ </NewsDeskClientProvider>
11
+ );
12
+ }
@@ -3,8 +3,13 @@
3
3
  import {
4
4
  BookOpenIcon,
5
5
  ClipboardListIcon,
6
+ FileTextIcon,
6
7
  LayersIcon,
8
+ LightbulbIcon,
7
9
  MessageSquareIcon,
10
+ NetworkIcon,
11
+ SearchIcon,
12
+ SettingsIcon,
8
13
  } from "lucide-react";
9
14
  import Link from "next/link";
10
15
  import { usePathname } from "next/navigation";
@@ -12,7 +17,7 @@ import { useMemo } from "react";
12
17
  import { Badge } from "./ui/badge";
13
18
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
14
19
  import type { AssignmentRecord, MessageRecord, NewsroomSectionRecord, ReferenceRecord } from "../lib/category-repository";
15
- import { getNewsroomNavHref } from "../lib/newsroom-nav";
20
+ import { getNewsroomNavHref, NEWSROOM_OPS_NAV, type NewsroomNavTabId } from "../lib/newsroom-nav";
16
21
  import {
17
22
  countReferencesByStatus,
18
23
  filterReferencesByStatus,
@@ -37,15 +42,28 @@ type NewsroomOpsOverviewProps = {
37
42
  references: ReferenceRecord[];
38
43
  };
39
44
 
40
- type DestinationKey = "references" | "assignments" | "messages" | "topics";
45
+ type DestinationKey = Exclude<NewsroomNavTabId, "overview" | "desks">;
41
46
 
42
- const DESTINATION_META: Record<DestinationKey, { label: string; detail: string; href: string; icon: typeof BookOpenIcon }> = {
43
- references: { label: "References", detail: "Source intake", href: "/newsroom/references", icon: BookOpenIcon },
44
- assignments: { label: "Assignments", detail: "Work queue", href: "/newsroom/assignments", icon: ClipboardListIcon },
45
- messages: { label: "Messages", detail: "Team threads", href: "/newsroom/messages", icon: MessageSquareIcon },
46
- topics: { label: "Topics", detail: "Taxonomy", href: "/newsroom/topics", icon: LayersIcon },
47
+ const DESTINATION_ICONS: Record<DestinationKey, typeof BookOpenIcon> = {
48
+ articles: FileTextIcon,
49
+ assignments: ClipboardListIcon,
50
+ references: BookOpenIcon,
51
+ messages: MessageSquareIcon,
52
+ insights: LightbulbIcon,
53
+ topics: LayersIcon,
54
+ concepts: NetworkIcon,
55
+ administration: SettingsIcon,
56
+ search: SearchIcon,
47
57
  };
48
58
 
59
+ const DESTINATIONS = NEWSROOM_OPS_NAV.filter((item) => item.id !== "overview").map((item) => ({
60
+ key: item.id as DestinationKey,
61
+ label: item.label,
62
+ detail: item.detail,
63
+ href: item.href,
64
+ icon: DESTINATION_ICONS[item.id as DestinationKey],
65
+ }));
66
+
49
67
  function openAssignmentCount(assignments: AssignmentRecord[]): number {
50
68
  return assignments.filter((assignment) => {
51
69
  const status = assignment.status?.trim().toLowerCase() ?? "";
@@ -85,11 +103,10 @@ export function NewsroomOpsOverview({
85
103
  [newsroomSections],
86
104
  );
87
105
 
88
- const destinationCounts: Record<DestinationKey, number | null> = {
106
+ const destinationCounts: Partial<Record<DestinationKey, number | null>> = {
89
107
  references: referenceCounts.pending ?? 0,
90
108
  assignments: openAssignmentCount(assignments),
91
109
  messages: recentMessageCount(messages),
92
- topics: null,
93
110
  };
94
111
 
95
112
  return (
@@ -102,8 +119,8 @@ export function NewsroomOpsOverview({
102
119
  <section aria-label="Destinations" className="space-y-3">
103
120
  <h2 className="text-sm font-medium text-muted-foreground">Go to</h2>
104
121
  <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
105
- {(Object.keys(DESTINATION_META) as DestinationKey[]).map((key) => {
106
- const meta = DESTINATION_META[key];
122
+ {DESTINATIONS.map((meta) => {
123
+ const key = meta.key;
107
124
  const Icon = meta.icon;
108
125
  const count = destinationCounts[key];
109
126
  const href = getNewsroomNavHref(meta.href, demo);
@@ -3,6 +3,7 @@
3
3
  import {
4
4
  BookOpenIcon,
5
5
  ClipboardListIcon,
6
+ FileTextIcon,
6
7
  LayoutGridIcon,
7
8
  MenuIcon,
8
9
  SearchIcon,
@@ -55,6 +56,7 @@ type NewsroomOpsShellProps = {
55
56
 
56
57
  const MOBILE_TAB_ICONS: Record<string, typeof LayoutGridIcon> = {
57
58
  overview: LayoutGridIcon,
59
+ articles: FileTextIcon,
58
60
  assignments: ClipboardListIcon,
59
61
  references: BookOpenIcon,
60
62
  topics: TagsIcon,
@@ -198,7 +200,7 @@ export function NewsroomOpsShell({
198
200
  className="shrink-0 border-t border-border bg-card/95 pb-[max(env(safe-area-inset-bottom),0.35rem)] pt-1 md:hidden"
199
201
  data-newsroom-ops-bottom-nav
200
202
  >
201
- <ul className="grid grid-cols-5 gap-1 px-2">
203
+ <ul className="grid grid-cols-6 gap-1 px-1">
202
204
  {mobilePrimary.map((item) => {
203
205
  const Icon = MOBILE_TAB_ICONS[item.id] ?? LayoutGridIcon;
204
206
  const active = resolvedActiveTab === item.id;
@@ -44,21 +44,36 @@ import {
44
44
  import { normalizeReferenceCurationStatus } from "../lib/reference-policy";
45
45
  import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
46
46
  import { ReferenceSourcePreview } from "./reference-source-preview";
47
+ import { ReferenceRelevanceReview } from "./reference-relevance-review";
48
+ import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
49
+ import type { CyclotronStatus } from "cyclotron/cyclotron-status";
50
+ import type { RelevanceCuration, RelevanceDecisionView } from "../lib/relevance-decisions";
47
51
  import {
48
52
  loadReferenceAttachmentsForLineageId,
49
53
  loadStoragePathUrl,
50
54
  } from "./news-desk-taxonomy-client";
51
55
 
52
56
  type NewsroomReferencesViewProps = {
57
+ /** The relevance cyclotron's status snapshot, shown above the list. */
58
+ cyclotronStatus?: CyclotronStatus | null;
59
+ /** Present for editors who may ask for a manual review rate. */
60
+ onRequestReviewRate?: (choice: ReviewRateChoice) => Promise<void> | void;
53
61
  demo?: boolean;
62
+ /** The relevance cyclotron's current decisions, keyed by reference lineage. */
63
+ decisions?: Map<string, RelevanceDecisionView>;
54
64
  disabled?: boolean;
65
+ onRelevanceReview?: (reference: ReferenceRecord, curation: RelevanceCuration) => void;
55
66
  initialReferenceLineageId?: string | null;
56
67
  onReview: (reference: ReferenceRecord, action: ReferenceCurationAction) => void;
57
68
  referenceAttachments?: ReferenceAttachmentRecord[];
58
69
  references: ReferenceRecord[];
59
70
  };
60
71
 
61
- const STATUS_FILTERS: Array<{ key: ReferenceStatusFilter; label: string }> = [
72
+ type ReferenceListFilter = ReferenceStatusFilter | "needs_review";
73
+
74
+ const NEEDS_REVIEW_FILTER: { key: ReferenceListFilter; label: string } = { key: "needs_review", label: "Needs review" };
75
+
76
+ const STATUS_FILTERS: Array<{ key: ReferenceListFilter; label: string }> = [
62
77
  { key: "all", label: "All" },
63
78
  { key: "pending", label: "Pending" },
64
79
  { key: "accepted", label: "Accepted" },
@@ -183,16 +198,30 @@ function useReferencePreviewAttachments(
183
198
  return previewAttachments;
184
199
  }
185
200
 
201
+ function decisionSummary(decision: RelevanceDecisionView): string {
202
+ const sure = decision.confidence == null ? "confidence unknown" : `${Math.round(decision.confidence * 100)}% sure`;
203
+ return `${decision.label} · ${sure}${decision.version != null ? ` · version ${decision.version}` : ""}`;
204
+ }
205
+
206
+ function needsReview(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined): boolean {
207
+ return Boolean(decision?.reviewRecommended)
208
+ && normalizeReferenceCurationStatus(reference.curationStatus) === "pending";
209
+ }
210
+
186
211
  function ReferenceDetailPanel({
212
+ decision,
187
213
  demo,
188
214
  disabled,
215
+ onRelevanceReview,
189
216
  onReview,
190
217
  previewAttachments,
191
218
  reference,
192
219
  onClose,
193
220
  }: {
221
+ decision?: RelevanceDecisionView;
194
222
  demo?: boolean;
195
223
  disabled?: boolean;
224
+ onRelevanceReview?: (curation: RelevanceCuration) => void;
196
225
  onReview: (action: ReferenceCurationAction) => void;
197
226
  previewAttachments: ReferenceAttachmentRecord[];
198
227
  reference: ReferenceRecord;
@@ -224,11 +253,25 @@ function ReferenceDetailPanel({
224
253
  ) : null}
225
254
  </div>
226
255
 
256
+ {decision && status === "pending" && onRelevanceReview ? (
257
+ <ReferenceRelevanceReview
258
+ decision={decision}
259
+ disabled={disabled}
260
+ onCurate={onRelevanceReview}
261
+ referenceLineageId={lineageId}
262
+ />
263
+ ) : decision ? (
264
+ <p className="text-sm text-muted-foreground" data-reference-relevance-decision={lineageId}>
265
+ Cyclotron decision: {decisionSummary(decision)}
266
+ </p>
267
+ ) : null}
268
+
227
269
  <div
228
270
  className="flex flex-wrap items-center gap-2"
229
271
  data-news-desk-reference-curation-cluster
230
272
  data-reference-curation-status={status}
231
273
  >
274
+ {decision && status === "pending" && onRelevanceReview ? null : <>
232
275
  <Button
233
276
  aria-label="Accept reference"
234
277
  aria-pressed={status === "accepted"}
@@ -255,6 +298,7 @@ function ReferenceDetailPanel({
255
298
  <ThumbsDownIcon className="size-4" />
256
299
  Reject
257
300
  </Button>
301
+ </>}
258
302
  <Button
259
303
  aria-label="Archive reference"
260
304
  disabled={archiveDisabled}
@@ -347,8 +391,12 @@ function ReferenceDetailPanel({
347
391
  }
348
392
 
349
393
  export function NewsroomReferencesView({
394
+ cyclotronStatus,
395
+ decisions,
396
+ onRequestReviewRate,
350
397
  demo = false,
351
398
  disabled = false,
399
+ onRelevanceReview,
352
400
  initialReferenceLineageId = null,
353
401
  onReview,
354
402
  referenceAttachments = [],
@@ -365,13 +413,20 @@ export function NewsroomReferencesView({
365
413
  const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
366
414
  const statusCounts = useMemo(() => countReferencesByStatus(canonicalReferences), [canonicalReferences]);
367
415
 
368
- const [statusFilter, setStatusFilter] = useState<ReferenceStatusFilter>("all");
416
+ const [statusFilter, setStatusFilter] = useState<ReferenceListFilter>("all");
417
+ const needsReviewCount = useMemo(
418
+ () => canonicalReferences.filter((reference) => needsReview(reference, decisions?.get(referenceLineageId(reference)))).length,
419
+ [canonicalReferences, decisions],
420
+ );
421
+ const filters = decisions?.size ? [NEEDS_REVIEW_FILTER, ...STATUS_FILTERS] : STATUS_FILTERS;
369
422
  const [selectedLineageId, setSelectedLineageId] = useState(routeLineageId);
370
423
  const [mobileDetailOpen, setMobileDetailOpen] = useState(Boolean(routeLineageId) && isMobileDetail);
371
424
 
372
425
  const filteredReferences = useMemo(
373
- () => filterReferencesByStatus(canonicalReferences, statusFilter),
374
- [canonicalReferences, statusFilter],
426
+ () => statusFilter === "needs_review"
427
+ ? canonicalReferences.filter((reference) => needsReview(reference, decisions?.get(referenceLineageId(reference))))
428
+ : filterReferencesByStatus(canonicalReferences, statusFilter),
429
+ [canonicalReferences, decisions, statusFilter],
375
430
  );
376
431
 
377
432
  const selectedReference = useMemo(
@@ -390,7 +445,7 @@ export function NewsroomReferencesView({
390
445
  ? filteredReferences[selectedIndex + 1]
391
446
  : null;
392
447
 
393
- const syncStatusUrl = useCallback((nextStatus: ReferenceStatusFilter) => {
448
+ const syncStatusUrl = useCallback((nextStatus: ReferenceListFilter) => {
394
449
  if (demo) return;
395
450
  syncBrowserNewsroomIndexUrl("references", {
396
451
  status: nextStatus === "all" ? "" : nextStatus,
@@ -452,22 +507,29 @@ export function NewsroomReferencesView({
452
507
  Review source intake before it becomes accepted evidence.
453
508
  </p>
454
509
  </div>
510
+ {cyclotronStatus ? (
511
+ <div className="shrink-0">
512
+ <ReferenceCyclotronStatus onRequestRate={disabled ? undefined : onRequestReviewRate} status={cyclotronStatus} />
513
+ </div>
514
+ ) : null}
455
515
 
456
516
  <Tabs
457
517
  className="shrink-0"
458
518
  defaultValue="all"
459
519
  onValueChange={(value) => {
460
- const next = value as ReferenceStatusFilter;
520
+ const next = value as ReferenceListFilter;
461
521
  setStatusFilter(next);
462
522
  syncStatusUrl(next);
463
523
  }}
464
524
  value={statusFilter}
465
525
  >
466
526
  <TabsList className="h-auto w-full flex-wrap justify-start gap-1 bg-muted/40 p-1">
467
- {STATUS_FILTERS.map((filter) => {
527
+ {filters.map((filter) => {
468
528
  const count = filter.key === "all"
469
529
  ? canonicalReferences.length
470
- : statusCounts[filter.key] ?? 0;
530
+ : filter.key === "needs_review"
531
+ ? needsReviewCount
532
+ : statusCounts[filter.key] ?? 0;
471
533
  return (
472
534
  <TabsTrigger className="text-xs sm:text-sm" key={filter.key} value={filter.key}>
473
535
  {filter.label}
@@ -484,6 +546,7 @@ export function NewsroomReferencesView({
484
546
  const lineageId = referenceLineageId(reference);
485
547
  const status = normalizeReferenceCurationStatus(reference.curationStatus);
486
548
  const active = selectedLineageId === lineageId;
549
+ const decision = decisions?.get(lineageId);
487
550
  return (
488
551
  <button
489
552
  aria-current={active ? "true" : undefined}
@@ -504,6 +567,14 @@ export function NewsroomReferencesView({
504
567
  <Badge variant={referenceStatusBadgeVariant(status)}>{referenceStatusLabel(status)}</Badge>
505
568
  </div>
506
569
  <p className="mt-2 text-xs text-muted-foreground">{formatReferenceListDate(reference)}</p>
570
+ {decision ? (
571
+ <p className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground" data-reference-relevance-row={lineageId}>
572
+ <span>Decision: {decisionSummary(decision)}</span>
573
+ {needsReview(reference, decision) ? (
574
+ <Badge variant="outline"><span aria-hidden="true">●</span> Needs review</Badge>
575
+ ) : null}
576
+ </p>
577
+ ) : null}
507
578
  </button>
508
579
  );
509
580
  }) : (
@@ -552,8 +623,10 @@ export function NewsroomReferencesView({
552
623
  <ScrollArea className="min-h-0 flex-1">
553
624
  {selectedReference ? (
554
625
  <ReferenceDetailPanel
626
+ decision={decisions?.get(referenceLineageId(selectedReference))}
555
627
  demo={demo}
556
628
  disabled={disabled}
629
+ onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
557
630
  onReview={runReview}
558
631
  previewAttachments={previewAttachments}
559
632
  reference={selectedReference}
@@ -581,9 +654,11 @@ export function NewsroomReferencesView({
581
654
  {selectedReference ? (
582
655
  <ScrollArea className="max-h-[75dvh]">
583
656
  <ReferenceDetailPanel
657
+ decision={decisions?.get(referenceLineageId(selectedReference))}
584
658
  demo={demo}
585
659
  disabled={disabled}
586
660
  onClose={closeDetail}
661
+ onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
587
662
  onReview={runReview}
588
663
  previewAttachments={previewAttachments}
589
664
  reference={selectedReference}
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { browserPathToInternalNewsroomPath } from "../lib/newsroom-base-path";
3
4
  import type { BotAvatarState } from "anthus-vultus";
4
5
  import dynamic from "next/dynamic";
5
6
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
@@ -152,7 +153,8 @@ export function PapyrusConsoleShell({ children }: PapyrusConsoleShellProps) {
152
153
  const [session, setSession] = useState<ReaderSessionSnapshot | null>(null);
153
154
  const [open, setOpen] = useState(false);
154
155
  const [openingReferenceChat, setOpeningReferenceChat] = useState(false);
155
- const isNewsroomPath = pathname === "/newsroom" || pathname.startsWith("/newsroom/");
156
+ const internalPathname = browserPathToInternalNewsroomPath(pathname);
157
+ const isNewsroomPath = internalPathname === "/newsroom" || internalPathname.startsWith("/newsroom/");
156
158
  const canUseConsole = Boolean(session?.hasSession && session.groups.some((group) => group === "editor" || group === "admin"));
157
159
  const shouldOfferConsole = isNewsroomPath && canUseConsole;
158
160
 
@@ -433,7 +435,7 @@ function ConsolePanel({ actorEmail, actorLabel, onClose }: { actorEmail: string
433
435
  const handledNavigationMessageIdsRef = useRef<Set<string>>(new Set());
434
436
 
435
437
  const currentWebPath = useMemo(() => {
436
- const path = pathname ?? "/newsroom";
438
+ const path = browserPathToInternalNewsroomPath(pathname ?? "/newsroom");
437
439
  const query = searchParams.toString();
438
440
  return query ? `${path}?${query}` : path;
439
441
  }, [pathname, searchParams]);
@@ -12,6 +12,9 @@ export type PictogramFigureProps = {
12
12
  credit: string;
13
13
  figureClassName: string;
14
14
  height: number;
15
+ /** Rhythm blog layout: solved frame size in CSS pixels; wins over `height`/`width` when set. */
16
+ frameHeight?: number;
17
+ frameWidth?: number;
15
18
  layout?: ArticleImageLayout;
16
19
  priority?: boolean;
17
20
  sizes: string;
@@ -34,6 +37,8 @@ export function PictogramFigure({
34
37
  caption,
35
38
  credit,
36
39
  figureClassName,
40
+ frameHeight,
41
+ frameWidth,
37
42
  height,
38
43
  priority = false,
39
44
  sizes,
@@ -41,6 +46,8 @@ export function PictogramFigure({
41
46
  themeVariants,
42
47
  width,
43
48
  }: PictogramFigureProps) {
49
+ const imageHeight = frameHeight ?? height;
50
+ const imageWidth = frameWidth ?? width;
44
51
  const resolvedTheme = useResolvedPapyrusTheme();
45
52
  const themedImageSrc = resolveThemedImageSrc(src, themeVariants, resolvedTheme);
46
53
 
@@ -53,8 +60,8 @@ export function PictogramFigure({
53
60
  <Image
54
61
  src={themedImageSrc}
55
62
  alt={alt}
56
- width={width}
57
- height={height}
63
+ width={imageWidth}
64
+ height={imageHeight}
58
65
  sizes={sizes}
59
66
  priority={priority}
60
67
  unoptimized={shouldBypassImageOptimization(themedImageSrc)}
@@ -0,0 +1,135 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { getEditionSectionPath } from "../lib/edition-routes";
5
+ import { SITE_BRAND } from "../lib/site-brand";
6
+
7
+ export type PresentationHeaderSection = {
8
+ key: string;
9
+ label: string;
10
+ };
11
+
12
+ export type PresentationHeaderProps = {
13
+ editionDate?: string;
14
+ title?: string;
15
+ description?: string;
16
+ sections?: PresentationHeaderSection[];
17
+ editionBasePath?: string;
18
+ };
19
+
20
+ export function PresentationHeader({
21
+ editionDate,
22
+ title: contentTitle,
23
+ description,
24
+ sections,
25
+ editionBasePath,
26
+ }: PresentationHeaderProps) {
27
+ const title = SITE_BRAND.mastheadSource === "brand" ? SITE_BRAND.mastheadTitle : contentTitle;
28
+ const subtitle = SITE_BRAND.mastheadSource === "brand" ? SITE_BRAND.mastheadSubtitle : description;
29
+ const eyebrow = SITE_BRAND.mastheadEyebrow ?? null;
30
+ const tagline = SITE_BRAND.mastheadTagline ?? null;
31
+ const taglineLines = SITE_BRAND.mastheadTaglineLines ?? null;
32
+ const displayDate = editionDate
33
+ ? SITE_BRAND.mastheadDateFormat === "formatted"
34
+ ? formatMastheadDate(editionDate)
35
+ : editionDate
36
+ : null;
37
+
38
+ const eyebrowParts = eyebrow ? splitEyebrow(eyebrow) : null;
39
+
40
+ return (
41
+ <>
42
+ <header className="presentation-header">
43
+ <div className="presentation-header__copy-stack">
44
+ {eyebrowParts ? (
45
+ <span className="presentation-header__eyebrow">
46
+ <span className="presentation-header__eyebrow-strong">{eyebrowParts.lead}</span>
47
+ {eyebrowParts.rest ? (
48
+ <span className="presentation-header__eyebrow-muted">{eyebrowParts.rest}</span>
49
+ ) : null}
50
+ </span>
51
+ ) : null}
52
+ <h1>
53
+ {SITE_BRAND.mastheadWordSplit && title
54
+ ? title.split(/\s+/).map((word) => (
55
+ <span className="presentation-header__word" key={word}>
56
+ <span className="presentation-header__word-text">{word}</span>
57
+ </span>
58
+ ))
59
+ : title}
60
+ </h1>
61
+ <div className="presentation-header__meta">
62
+ {subtitle && !eyebrow ? <span className="presentation-header__subtitle">{subtitle}</span> : null}
63
+ {displayDate ? <p className="presentation-header__date">{displayDate}</p> : null}
64
+ </div>
65
+ </div>
66
+ {tagline ? (
67
+ <span className="presentation-header__tagline">
68
+ {taglineLines && taglineLines.length > 0
69
+ ? taglineLines.map((line, index) => (
70
+ <span className="presentation-header__tagline-line" key={index}>
71
+ <span className="presentation-header__tagline-emphasis">{line.emphasis}</span>
72
+ <span className="presentation-header__tagline-tail">{line.tail}</span>
73
+ {index < taglineLines.length - 1 ? " " : null}
74
+ </span>
75
+ ))
76
+ : tagline}
77
+ </span>
78
+ ) : null}
79
+ </header>
80
+ {sections && sections.length > 0 ? (
81
+ <nav className="presentation-section-nav" aria-label="Edition sections">
82
+ {sections.map((section) => (
83
+ <Link
84
+ href={getSectionHref(section, { editionBasePath, editionDate })}
85
+ key={section.key}
86
+ >
87
+ {section.label}
88
+ </Link>
89
+ ))}
90
+ </nav>
91
+ ) : null}
92
+ </>
93
+ );
94
+ }
95
+
96
+ function getSectionHref(
97
+ section: PresentationHeaderSection,
98
+ options: { editionBasePath?: string; editionDate?: string },
99
+ ): string {
100
+ if (SITE_BRAND.sectionLinkStrategy === "anchor") {
101
+ const anchor = `#${getSectionAnchorId(section.key)}`;
102
+ return options.editionBasePath ? `${options.editionBasePath}${anchor}` : anchor;
103
+ }
104
+ if (options.editionBasePath) {
105
+ return `${options.editionBasePath}/section/${encodeURIComponent(section.key)}`;
106
+ }
107
+ if (options.editionDate) {
108
+ const datedPath = getEditionSectionPath(options.editionDate, section.key);
109
+ return datedPath.startsWith("//") ? `#${getSectionAnchorId(section.key)}` : datedPath;
110
+ }
111
+ return `#${getSectionAnchorId(section.key)}`;
112
+ }
113
+
114
+ export function getSectionAnchorId(sectionKey: string): string {
115
+ return `section-${sectionKey}`;
116
+ }
117
+
118
+ function splitEyebrow(text: string): { lead: string; rest: string } {
119
+ const trimmed = text.trim();
120
+ const firstSpace = trimmed.indexOf(" ");
121
+ if (firstSpace === -1) return { lead: trimmed, rest: "" };
122
+ return { lead: trimmed.slice(0, firstSpace), rest: trimmed.slice(firstSpace + 1) };
123
+ }
124
+
125
+ export function formatMastheadDate(value: string): string {
126
+ const normalized = value?.trim();
127
+ if (!normalized) return value;
128
+ const date = new Date(`${normalized}T00:00:00`);
129
+ if (Number.isNaN(date.getTime())) return value;
130
+ return new Intl.DateTimeFormat("en-US", {
131
+ month: "long",
132
+ day: "numeric",
133
+ year: "numeric",
134
+ }).format(date);
135
+ }