@anthusai/papyrus 2.0.0-next.4 → 2.0.0-next.41

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 (144) hide show
  1. package/amplify/data/resource.js +23 -3
  2. package/amplify/data/resource.ts +25 -3
  3. package/amplify/data/schema.js +2030 -1967
  4. package/amplify/data/schema.ts +73 -2
  5. package/amplify/functions/assignment-action/data-access.js +88 -0
  6. package/amplify/functions/assignment-action/data-access.ts +87 -0
  7. package/amplify/functions/assignment-action/handler.ts +1 -1
  8. package/amplify/functions/content-actions/handler.py +36 -0
  9. package/amplify/functions/content-actions/requirements.txt +1 -0
  10. package/amplify/functions/content-actions/resource.js +1 -1
  11. package/amplify/functions/content-actions/resource.ts +1 -1
  12. package/amplify/functions/editorial-checks/handler.py +14 -0
  13. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  14. package/amplify/functions/editorial-checks/resource.js +43 -0
  15. package/amplify/functions/editorial-checks/resource.ts +60 -0
  16. package/amplify/functions/editorial-checks/schema.js +121 -0
  17. package/amplify/functions/editorial-checks/schema.ts +123 -0
  18. package/amplify/functions/editorial-checks/wire.js +53 -0
  19. package/amplify/functions/editorial-checks/wire.ts +61 -0
  20. package/amplify/functions/monitoring-view/handler.py +11 -0
  21. package/amplify/functions/monitoring-view/resource.js +24 -0
  22. package/amplify/functions/monitoring-view/resource.ts +34 -0
  23. package/amplify/functions/monitoring-view/schema.js +16 -0
  24. package/amplify/functions/monitoring-view/schema.ts +18 -0
  25. package/amplify/functions/monitoring-view/wire.js +37 -0
  26. package/amplify/functions/monitoring-view/wire.ts +47 -0
  27. package/amplify/functions/public-forms/core.js +247 -0
  28. package/amplify/functions/public-forms/core.ts +310 -0
  29. package/amplify/functions/public-forms/handler.ts +41 -0
  30. package/amplify/functions/public-forms/origins.js +35 -0
  31. package/amplify/functions/public-forms/origins.ts +43 -0
  32. package/amplify/functions/public-forms/resource.js +9 -0
  33. package/amplify/functions/public-forms/resource.ts +10 -0
  34. package/amplify/functions/public-forms/wire.js +25 -0
  35. package/amplify/functions/public-forms/wire.ts +32 -0
  36. package/amplify/functions/shared/editorial-context.ts +9 -3
  37. package/amplify/functions/shared/python-bundle.js +20 -14
  38. package/amplify/functions/shared/python-bundle.ts +25 -18
  39. package/amplify/plugins/reader-steering/config.js +110 -0
  40. package/amplify/plugins/reader-steering/config.ts +153 -0
  41. package/amplify/plugins/reader-steering/function/handler.py +544 -0
  42. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  43. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  44. package/amplify/plugins/reader-steering/papyrus.plugin.js +103 -0
  45. package/amplify/plugins/reader-steering/papyrus.plugin.ts +122 -0
  46. package/amplify/plugins/reader-steering/schema.js +40 -0
  47. package/amplify/plugins/reader-steering/schema.ts +42 -0
  48. package/amplify/plugins/registry.js +2 -0
  49. package/amplify/plugins/registry.ts +2 -0
  50. package/amplify/plugins/types.ts +3 -0
  51. package/amplify/plugins/wire.js +1 -0
  52. package/amplify/plugins/wire.ts +1 -0
  53. package/amplify/publication/redirect-and-index.js +105 -0
  54. package/amplify/publication/redirect-rules.js +73 -0
  55. package/amplify/publication/redirect-rules.ts +93 -0
  56. package/amplify/publication/s3-projection.js +7 -1
  57. package/amplify/publication/s3-projection.ts +9 -1
  58. package/amplify/site-backend-config.ts +17 -0
  59. package/amplify/site-backend.js +36 -1
  60. package/amplify/site-backend.ts +44 -1
  61. package/app/api/newsroom/curation/context/route.ts +9 -0
  62. package/app/api/newsroom/curation/priming/route.ts +9 -0
  63. package/app/api/newsroom/curation/publication/route.ts +9 -0
  64. package/app/api/reader/handle/route.ts +9 -0
  65. package/app/api/reader/me/route.ts +9 -0
  66. package/app/api/reader/rating/route.ts +9 -0
  67. package/app/api/reader/retired/route.ts +9 -0
  68. package/app/api/reader/vote/route.ts +9 -0
  69. package/bin/papyrus-infra.mjs +20 -1
  70. package/components/editorial-checks-admin-panel.tsx +362 -0
  71. package/components/editorial-finding-card.tsx +162 -0
  72. package/components/editorial-findings-panel.tsx +91 -0
  73. package/components/editorial-preview-popover.tsx +103 -0
  74. package/components/markus-body-editor.tsx +213 -0
  75. package/components/markus-ir-preview.tsx +147 -6
  76. package/components/monitoring-overview-view.tsx +241 -0
  77. package/components/monitoring-panel.tsx +40 -0
  78. package/components/newsroom-article-editor.tsx +136 -11
  79. package/components/newsroom-board-view.tsx +6 -7
  80. package/components/newsroom-curation-bootstrap.tsx +45 -0
  81. package/components/newsroom-ops-shell.tsx +7 -3
  82. package/components/newsroom-organizer.css +74 -61
  83. package/components/newsroom-references-view.tsx +14 -14
  84. package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
  85. package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
  86. package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
  87. package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
  88. package/components/newsroom-theme-fonts/night-desk.css +10 -0
  89. package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
  90. package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
  91. package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
  92. package/components/newsroom-theme.css +39 -0
  93. package/components/newsroom-theme.tsx +33 -0
  94. package/components/publication-cyclotron-panel.tsx +15 -10
  95. package/components/reference-add-entry.tsx +75 -0
  96. package/components/reference-curation-controls.tsx +31 -22
  97. package/components/reference-cyclotron-priming.tsx +58 -19
  98. package/components/reference-cyclotron-suggestion.tsx +1 -1
  99. package/components/reference-priming.tsx +14 -10
  100. package/components/reference-section-suggestion.tsx +1 -1
  101. package/components/topic-steering-workspace.tsx +27 -2
  102. package/components/use-editorial-checks.ts +133 -0
  103. package/infra/amplify-app-shell.js +90 -16
  104. package/infra/amplify-app-shell.ts +102 -18
  105. package/infra/index.js +1 -0
  106. package/infra/secure-string-parameter-handler.js +41 -0
  107. package/infra/secure-string-parameter-handler.ts +41 -0
  108. package/infra/secure-string-parameter.js +79 -0
  109. package/infra/secure-string-parameter.ts +91 -0
  110. package/infra/site-config.js +85 -6
  111. package/infra/site-config.ts +123 -6
  112. package/lib/category-repository.ts +11 -9
  113. package/lib/curation-routes.ts +345 -0
  114. package/lib/editorial-checks-backend.ts +16 -0
  115. package/lib/editorial-checks-client.ts +212 -0
  116. package/lib/editorial-checks-demo.ts +147 -0
  117. package/lib/editorial-checks-settings.ts +27 -0
  118. package/lib/editorial-checks.ts +173 -0
  119. package/lib/editorial-preview.ts +228 -0
  120. package/lib/monitoring-client.ts +29 -0
  121. package/lib/monitoring-overview-fixture.ts +51 -0
  122. package/lib/monitoring-overview.ts +145 -0
  123. package/lib/newsroom-board.ts +27 -2
  124. package/lib/prospectus-session.ts +15 -14
  125. package/lib/prospectus-sign-in-routes.ts +54 -13
  126. package/lib/prospectus-site-identity.ts +24 -1
  127. package/lib/reader-api.ts +143 -0
  128. package/lib/reader-retirement-route.ts +89 -0
  129. package/lib/reader-subject.md +29 -0
  130. package/lib/reader-subject.ts +53 -0
  131. package/lib/relevance-status.ts +2 -2
  132. package/lib/site-brand-tenant.ts +11 -6
  133. package/lib/site-brand.ts +23 -3
  134. package/lib/site-origin.ts +67 -0
  135. package/middleware.ts +14 -3
  136. package/package.json +56 -4
  137. package/routes.manifest.json +89 -1
  138. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  139. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  140. package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
  141. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  142. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  143. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
  144. package/components/newsroom-newsprint-board.css +0 -7
@@ -0,0 +1,241 @@
1
+ import Link from "next/link";
2
+ import {
3
+ confidenceText,
4
+ describeDelivery,
5
+ describeRelevance,
6
+ everyText,
7
+ formatUtc,
8
+ referenceHref,
9
+ routingLabel,
10
+ usageText,
11
+ type MonitoringItem,
12
+ type MonitoringOverview,
13
+ type MonitoringRelevance,
14
+ type MonitoringResult,
15
+ } from "../lib/monitoring-overview";
16
+
17
+ export type MonitoringMode = "publication" | "all";
18
+
19
+ const STATUS_WORDS: Record<string, string> = { complete: "Complete", partial: "Partial", stopped: "Stopped", failed: "Failed", error: "Error" };
20
+ const status = (value: string) => STATUS_WORDS[value] ?? value;
21
+ const STOP_WORDS: Record<string, string> = {
22
+ request_ceiling_reached: "stopped at the request ceiling",
23
+ standing_capacity_reached: "stopped: the standing run is full",
24
+ provider_key_unavailable: "stopped: the Cyclotron has no model key",
25
+ run_busy: "stopped: the Cyclotron run was busy",
26
+ time_budget: "stopped for time",
27
+ };
28
+ const MARKS: Record<string, string> = { delivered: "✓", failed: "✕", pending: "…", not_applicable: "–" };
29
+
30
+ function feedHost(url: string | null): string {
31
+ try {
32
+ return url ? new URL(url).host : "unknown source";
33
+ } catch {
34
+ return "unknown source";
35
+ }
36
+ }
37
+
38
+ function Message({ title, children, kind }: { title: string; children?: React.ReactNode; kind: string }) {
39
+ return (
40
+ <div className="category-steering-empty" role={kind === "loading" ? "status" : "alert"} data-monitoring-state={kind}>
41
+ <strong>{title}</strong>
42
+ {children ? <p>{children}</p> : null}
43
+ </div>
44
+ );
45
+ }
46
+
47
+ function RelevanceLine({ entry, linkable, demo }: { entry: MonitoringRelevance; linkable: boolean; demo: boolean }) {
48
+ const lineage = entry.delivery.referenceLineageId ?? entry.delivery.referenceId ?? null;
49
+ const delivery = describeDelivery(entry);
50
+ return (
51
+ <li data-relevance={entry.relevant === null ? "unknown" : entry.relevant ? "yes" : "no"} data-delivery={entry.delivery.state ?? "none"}>
52
+ <strong>{entry.publicationId}</strong>
53
+ <span>{describeRelevance(entry)}{entry.relevant !== null ? `, ${confidenceText(entry.confidence)}` : ""}</span>
54
+ {delivery ? (
55
+ <span>
56
+ <span aria-hidden="true">{MARKS[entry.delivery.state ?? ""] ?? ""} </span>
57
+ {delivery}
58
+ {entry.delivery.state === "delivered" && lineage && linkable ? (
59
+ <> <Link href={referenceHref(lineage, demo)} data-monitoring-reference-link>Open in References</Link></>
60
+ ) : null}
61
+ {entry.delivery.state === "failed" && entry.delivery.error ? <> ({entry.delivery.error})</> : null}
62
+ </span>
63
+ ) : null}
64
+ </li>
65
+ );
66
+ }
67
+
68
+ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string | null; demo: boolean }) {
69
+ return (
70
+ <li data-monitoring-item={item.sourceId} data-routing={item.routing}>
71
+ <div>
72
+ <strong>{item.url ? <a href={item.url} target="_blank" rel="noreferrer noopener">{item.title}</a> : item.title}</strong>
73
+ <span>
74
+ {formatUtc(item.observedAt)}
75
+ {item.discussionUrl ? <> &middot; <a href={item.discussionUrl} target="_blank" rel="noreferrer noopener">Hacker News thread</a></> : null}
76
+ </span>
77
+ </div>
78
+ <p className="news-desk-monitoring__routing">
79
+ {routingLabel(item.routing)}
80
+ {item.routingNote ? <>: {item.routingNote}</> : null}
81
+ </p>
82
+ {item.relevance.length ? (
83
+ <ul className="news-desk-monitoring__relevance">
84
+ {item.relevance.map((entry) => <RelevanceLine key={entry.publicationId} entry={entry} linkable={entry.publicationId === ownId} demo={demo} />)}
85
+ </ul>
86
+ ) : null}
87
+ </li>
88
+ );
89
+ }
90
+
91
+ function Ready({ data, demo }: { data: MonitoringOverview; demo: boolean }) {
92
+ const ceilings = data.usage?.ceilings ?? null;
93
+ const router = data.router;
94
+ return (
95
+ <>
96
+ {data.notice === "publication_not_routed" ? (
97
+ <p className="news-desk-delegation-note" data-monitoring-notice>This publication is not one of the router&apos;s outputs, so no relevance is shown for it here.</p>
98
+ ) : null}
99
+
100
+ <article className="news-desk-doctrine-card" data-monitoring-section="router">
101
+ <div className="news-desk-doctrine-card__header"><div><h3>Router</h3><span>Sends collected items to the shared Cyclotron and delivers relevant ones to References</span></div></div>
102
+ {router ? (
103
+ <p data-monitoring-router>
104
+ <strong>{router.mode === "live" ? "Live" : "Dry run"}</strong>
105
+ {router.mode === "live" ? " · decisions are made and delivered" : " · requests are checked but nothing is sent"}
106
+ {" · "}{router.feedback ? "Feedback on" : "Feedback off"}
107
+ {router.enabled ? "" : " · Paused"}
108
+ </p>
109
+ ) : <p>The router has not been configured.</p>}
110
+ </article>
111
+
112
+ <article className="news-desk-doctrine-card" data-monitoring-section="usage">
113
+ <div className="news-desk-doctrine-card__header"><div><h3>Usage</h3><span>Decisions the router recorded, against the shared Cyclotron&apos;s ceilings (UTC days)</span></div></div>
114
+ {data.usage ? (
115
+ <>
116
+ <dl className="news-desk-monitoring__usage">
117
+ <div><dt>Decisions today ({data.usage.day})</dt><dd>{usageText(data.usage.decisionsToday, ceilings?.decisionsPerDay)}</dd></div>
118
+ <div><dt>Decisions in total</dt><dd>{usageText(data.usage.decisionsTotal, ceilings?.decisionsTotal)}</dd></div>
119
+ {ceilings?.items !== undefined ? <div><dt>Items judged (ceiling)</dt><dd>{usageText(data.usage.decisionsTotal, ceilings.items)}</dd></div> : null}
120
+ </dl>
121
+ {ceilings === null ? <p className="news-desk-delegation-note">No ceilings are configured here, so only usage is shown.</p> : null}
122
+ </>
123
+ ) : <p>No usage recorded yet.</p>}
124
+ </article>
125
+
126
+ <article className="news-desk-doctrine-card" data-monitoring-section="feeds">
127
+ <div className="news-desk-doctrine-card__header"><div><h3>Feeds watched</h3><span>What the collector reads</span></div></div>
128
+ {data.feeds.length ? (
129
+ <ul className="news-desk-monitoring__list">
130
+ {data.feeds.map((feed) => (
131
+ <li key={feed.id} data-monitoring-feed={feed.id}>
132
+ <div>
133
+ <strong>{feed.id}</strong>
134
+ <span>{feedHost(feed.sourceUrl)}</span>
135
+ </div>
136
+ <span>{feed.enabled ? "Enabled" : "Disabled"} &middot; {everyText(feed.intervalSeconds)}{feed.limit ? ` · up to ${feed.limit} items` : ""}</span>
137
+ <span>Last run {formatUtc(feed.lastFinishedAt)}{feed.lastStatus ? ` (${status(feed.lastStatus)})` : ""} &middot; next {formatUtc(feed.nextDueAt)}</span>
138
+ </li>
139
+ ))}
140
+ </ul>
141
+ ) : <p>No feed is configured.</p>}
142
+ </article>
143
+
144
+ <article className="news-desk-doctrine-card" data-monitoring-section="runs">
145
+ <div className="news-desk-doctrine-card__header"><div><h3>Recent runs</h3><span>Collector receipts, newest first</span></div></div>
146
+ {data.runs.length ? (
147
+ <ul className="news-desk-monitoring__list">
148
+ {data.runs.map((run) => (
149
+ <li key={run.runId} data-monitoring-run={run.runId}>
150
+ <strong>{formatUtc(run.startedAt)}</strong>
151
+ <span>{status(run.status)}</span>
152
+ <span>{run.new} new &middot; {run.revised} revised &middot; {run.unchanged} unchanged &middot; {run.errorCount} {run.errorCount === 1 ? "error" : "errors"}{run.errors.length ? ` (${run.errors.join("; ")})` : ""}</span>
153
+ </li>
154
+ ))}
155
+ </ul>
156
+ ) : <p>No collector runs recorded yet.</p>}
157
+ {data.routerRuns.length ? (
158
+ <ul className="news-desk-monitoring__list" data-monitoring-router-runs>
159
+ {data.routerRuns.map((run) => (
160
+ <li key={run.runId} data-monitoring-router-run={run.runId}>
161
+ <strong>{formatUtc(run.startedAt)}</strong>
162
+ <span>Router, {run.mode === "live" ? "live" : "dry run"}: {status(run.status)}{run.stoppedBy ? `, ${STOP_WORDS[run.stoppedBy] ?? run.stoppedBy}` : ""}</span>
163
+ <span>{run.counts.examined ?? 0} examined &middot; {run.counts.decided ?? 0} judged &middot; {run.counts.delivered ?? 0} delivered &middot; {run.counts.deliveryFailed ?? 0} {(run.counts.deliveryFailed ?? 0) === 1 ? "delivery failure" : "delivery failures"} &middot; {run.errorCount} {run.errorCount === 1 ? "error" : "errors"}</span>
164
+ {run.waitingForClassifier?.length ? <span data-monitoring-waiting-for-classifier>Waiting for classifier: {run.waitingForClassifier.join(", ")} (skipped, not an error; probed on a 30 minute to 6 hour backoff)</span> : null}
165
+ </li>
166
+ ))}
167
+ </ul>
168
+ ) : null}
169
+ </article>
170
+
171
+ <article className="news-desk-doctrine-card" data-monitoring-section="queue">
172
+ <div className="news-desk-doctrine-card__header"><div><h3>Collected queue</h3><span>Newest first, with what the shared Cyclotron decided</span></div></div>
173
+ {data.items.length ? (
174
+ <ul className="news-desk-monitoring__queue">
175
+ {data.items.map((item) => <QueueItem key={`${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
176
+ </ul>
177
+ ) : <p>No items in the queue.</p>}
178
+ {data.hiddenNotRelevant > 0 ? (
179
+ <p className="news-desk-delegation-note" data-monitoring-hidden>
180
+ {data.hiddenNotRelevant} {data.hiddenNotRelevant === 1 ? "item" : "items"} judged not relevant to this publication {data.hiddenNotRelevant === 1 ? "is" : "are"} not listed.
181
+ </p>
182
+ ) : null}
183
+ </article>
184
+ </>
185
+ );
186
+ }
187
+
188
+ /** Presentation only: the panel fetches, this renders any result, so every state is testable from fixtures. */
189
+ export function MonitoringOverviewView({
190
+ result,
191
+ canViewAll,
192
+ mode,
193
+ onModeChange,
194
+ demo = false,
195
+ }: {
196
+ result: MonitoringResult | null;
197
+ canViewAll: boolean;
198
+ mode: MonitoringMode;
199
+ onModeChange: (mode: MonitoringMode) => void;
200
+ demo?: boolean;
201
+ }) {
202
+ let body: React.ReactNode;
203
+ if (result === null) {
204
+ body = <Message kind="loading" title="Loading monitoring" />;
205
+ } else if ("error" in result) {
206
+ body = result.error === "forbidden"
207
+ ? <Message kind="forbidden" title="Monitoring is for editors and admins">Your account does not have access to this page. Ask an admin for the editor role.</Message>
208
+ : <Message kind="error" title="Monitoring could not be read">The request was not understood. Reload the page.</Message>;
209
+ } else if (result.state === "not_configured") {
210
+ body = <Message kind="not-configured" title="Monitoring is not set up for this publication">The monitoring pipeline is not connected to this site&apos;s account, so there is nothing to show.</Message>;
211
+ } else if (result.state === "unavailable") {
212
+ body = <Message kind="unavailable" title="Monitoring could not be read right now">The pipeline is not affected. Try again in a minute.</Message>;
213
+ } else if (result.state === "empty") {
214
+ body = <Message kind="empty" title="Nothing has been collected yet">No feed is configured and no run has been recorded. Items appear here after the collector&apos;s first run.</Message>;
215
+ } else {
216
+ body = <Ready data={result} demo={demo} />;
217
+ }
218
+ const scopeLine = result && "scope" in result
219
+ ? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
220
+ : null;
221
+ return (
222
+ <div data-news-desk-admin-panel="monitoring">
223
+ <article className="news-desk-doctrine-card">
224
+ <div className="news-desk-doctrine-card__header">
225
+ <div>
226
+ <h3>Monitoring</h3>
227
+ <span>The automated research pipeline: collection, judging and delivery to References. Read only.</span>
228
+ </div>
229
+ </div>
230
+ {canViewAll ? (
231
+ <div className="news-desk-monitoring__scope" role="group" aria-label="Scope">
232
+ <button type="button" aria-pressed={mode === "publication"} data-monitoring-scope="publication" onClick={() => onModeChange("publication")}>This publication</button>
233
+ <button type="button" aria-pressed={mode === "all"} data-monitoring-scope="all" onClick={() => onModeChange("all")}>Whole shared pipeline</button>
234
+ </div>
235
+ ) : null}
236
+ {scopeLine ? <p className="news-desk-delegation-note" data-monitoring-scope-line>{scopeLine} {result && "asOf" in result ? `As of ${formatUtc(result.asOf)}.` : ""}</p> : null}
237
+ </article>
238
+ {body}
239
+ </div>
240
+ );
241
+ }
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { loadMonitoringOverview } from "../lib/monitoring-client";
5
+ import { FIXTURE_ALL_PIPELINE_OVERVIEW, FIXTURE_MONITORING_OVERVIEW } from "../lib/monitoring-overview-fixture";
6
+ import type { MonitoringResult } from "../lib/monitoring-overview";
7
+ import { MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
8
+
9
+ const REFRESH_MILLISECONDS = 60_000;
10
+
11
+ /** Administration, Monitoring: loads the overview, refreshes it each minute, and lets an admin widen it to the whole pipeline. */
12
+ export function MonitoringPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
13
+ const [mode, setMode] = useState<MonitoringMode>("publication");
14
+ const [result, setResult] = useState<MonitoringResult | null>(null);
15
+
16
+ useEffect(() => {
17
+ if (isDemo) {
18
+ setResult(mode === "all" ? FIXTURE_ALL_PIPELINE_OVERVIEW : FIXTURE_MONITORING_OVERVIEW);
19
+ return;
20
+ }
21
+ let live = true;
22
+ const refresh = async () => {
23
+ try {
24
+ const next = await loadMonitoringOverview(mode);
25
+ if (live) setResult(next);
26
+ } catch {
27
+ if (live) setResult({ state: "unavailable" });
28
+ }
29
+ };
30
+ setResult(null);
31
+ void refresh();
32
+ const timer = window.setInterval(() => void refresh(), REFRESH_MILLISECONDS);
33
+ return () => {
34
+ live = false;
35
+ window.clearInterval(timer);
36
+ };
37
+ }, [mode, isDemo]);
38
+
39
+ return <MonitoringOverviewView result={result} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo} />;
40
+ }
@@ -48,7 +48,15 @@ import {
48
48
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
49
49
  import { SITE_BRAND } from "../lib/site-brand";
50
50
  import { cn } from "../lib/utils";
51
+ import { applyReplacement, inlineFindings, visibleFindings } from "../lib/editorial-checks";
52
+ import { graphqlEditorialBackend } from "../lib/editorial-checks-client";
53
+ import { demoEditorialBackend } from "../lib/editorial-checks-demo";
54
+ import type { FindingActions } from "./editorial-finding-card";
55
+ import { EditorialFindingsPanel } from "./editorial-findings-panel";
56
+ import { EditorialPreviewPopover, type PreviewPopoverTarget } from "./editorial-preview-popover";
57
+ import { MarkusBodyEditor, type MarkusBodyEditorHandle } from "./markus-body-editor";
51
58
  import { MarkusIrPreview } from "./markus-ir-preview";
59
+ import { useEditorialChecks } from "./use-editorial-checks";
52
60
  import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
53
61
  import { NewsDeskAccessGate } from "./newsroom-access-gate";
54
62
  import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
@@ -118,6 +126,9 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
118
126
  const [notice, setNotice] = useState<Notice | null>(null);
119
127
  const [conflictOpen, setConflictOpen] = useState(false);
120
128
  const [unpublishOpen, setUnpublishOpen] = useState(false);
129
+ const [publishWarningOpen, setPublishWarningOpen] = useState(false);
130
+ const [previewPopover, setPreviewPopover] = useState<PreviewPopoverTarget | null>(null);
131
+ const previewRef = useRef<HTMLDivElement | null>(null);
121
132
  const [mobileTab, setMobileTab] = useState<MobileTab>("preview");
122
133
  const [focusedReferenceId, setFocusedReferenceId] = useState<string | null>(null);
123
134
  const [lineageId, setLineageId] = useState<string | null>(null);
@@ -133,7 +144,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
133
144
  const [imageError, setImageError] = useState<string | null>(null);
134
145
 
135
146
  const frontMatterRef = useRef<HTMLTextAreaElement | null>(null);
136
- const bodyRef = useRef<HTMLTextAreaElement | null>(null);
147
+ const bodyRef = useRef<MarkusBodyEditorHandle | null>(null);
137
148
  const imageInputRef = useRef<HTMLInputElement | null>(null);
138
149
  const mediaBackend = useMemo(() => resolveMediaBackend(previewMode), [previewMode]);
139
150
  const validationSequence = useRef(0);
@@ -205,6 +216,21 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
205
216
 
206
217
  const frontMatterYaml = editor?.frontMatterYaml ?? null;
207
218
  const bodyMarkus = editor?.bodyMarkus ?? null;
219
+ const editorialBackend = useMemo(
220
+ () => (demo ? demoEditorialBackend : !previewMode ? graphqlEditorialBackend : null),
221
+ [demo, previewMode],
222
+ );
223
+ const editorial = useEditorialChecks({
224
+ backend: editorialBackend,
225
+ body: bodyMarkus,
226
+ section: editor?.section ?? "",
227
+ itemId: editor?.id ?? null,
228
+ });
229
+ const editorialOpen = useMemo(
230
+ () => (editorial.result ? visibleFindings(editorial.result.findings, editorial.dismissed) : []),
231
+ [editorial.result, editorial.dismissed],
232
+ );
233
+ const editorialMarks = useMemo(() => inlineFindings(editorialOpen), [editorialOpen]);
208
234
 
209
235
  useEffect(() => {
210
236
  if (frontMatterYaml === null || bodyMarkus === null) return;
@@ -381,7 +407,45 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
381
407
  }
382
408
  }, [aliases, backend, demo, editor]);
383
409
 
410
+ const requestPublish = () => {
411
+ if (editorial.openWarnings.length > 0) {
412
+ setPublishWarningOpen(true);
413
+ return;
414
+ }
415
+ void publish();
416
+ };
417
+
418
+ const applyEditorialRewrite = (
419
+ option: { span: { start: number; end: number }; replacement: string; expected: string },
420
+ finding: Parameters<typeof editorial.fixed>[0] | null,
421
+ ) => {
422
+ if (!editor) return;
423
+ const next = applyReplacement(editor.bodyMarkus, option.span, option.expected, option.replacement);
424
+ if (next === null) {
425
+ setNotice({ tone: "info", text: "That text changed since the check. Check again, then apply the rewrite." });
426
+ return;
427
+ }
428
+ patchEditor({ bodyMarkus: next });
429
+ if (finding) editorial.fixed(finding);
430
+ setPreviewPopover(null);
431
+ };
432
+
433
+ const editorialActions: FindingActions = {
434
+ onSelect: (finding) => {
435
+ editorial.setActiveFindingId(finding.id);
436
+ if (editorial.inlineResult && finding.excerpt) bodyRef.current?.reveal(finding.span.start, finding.span.end);
437
+ },
438
+ onDismiss: (finding, reason) => {
439
+ editorial.dismiss(finding, reason);
440
+ setPreviewPopover(null);
441
+ },
442
+ onAccept: editorial.accept,
443
+ onSuggest: editorial.suggest,
444
+ onApply: applyEditorialRewrite,
445
+ };
446
+
384
447
  const publish = async () => {
448
+ setPublishWarningOpen(false);
385
449
  if (!editor) return;
386
450
  let id = editor.id;
387
451
  if (editor.dirty || id === null) {
@@ -551,7 +615,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
551
615
  <Button
552
616
  data-newsroom-article-publish
553
617
  disabled={publishBlocked}
554
- onClick={() => void publish()}
618
+ onClick={requestPublish}
555
619
  size="sm"
556
620
  type="button"
557
621
  >
@@ -714,18 +778,29 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
714
778
  {imageError}
715
779
  </p>
716
780
  ) : null}
717
- <Textarea
718
- className={TEXTAREA_CLASS}
719
- data-newsroom-article-body
720
- id="newsroom-article-body"
721
- onChange={(event) => patchEditor({ bodyMarkus: event.target.value })}
781
+ <MarkusBodyEditor
782
+ activeFindingId={editorial.activeFindingId}
783
+ findings={editorialMarks}
784
+ onChange={(value) => patchEditor({ bodyMarkus: value })}
785
+ onFindingClick={editorial.setActiveFindingId}
786
+ onSelectionChange={editorial.setSelection}
722
787
  ref={bodyRef}
723
- rows={28}
724
- spellCheck={false}
725
788
  value={editor.bodyMarkus}
726
789
  />
727
790
  </div>
728
791
 
792
+ {editorial.enabled ? (
793
+ <EditorialFindingsPanel
794
+ {...editorialActions}
795
+ activeFindingId={editorial.activeFindingId}
796
+ dismissed={editorial.dismissed}
797
+ onCheck={() => void editorial.check()}
798
+ result={editorial.result}
799
+ selection={editorial.selection}
800
+ status={editorial.status}
801
+ />
802
+ ) : null}
803
+
729
804
  {validation.status === "unavailable" ? (
730
805
  <NewsroomOpsStatusBanner tone="error">{validation.message}</NewsroomOpsStatusBanner>
731
806
  ) : null}
@@ -763,7 +838,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
763
838
  className={cn("min-w-0", mobileTab === "preview" ? "block" : "hidden")}
764
839
  data-newsroom-article-preview-column
765
840
  >
766
- <div className="rounded-xl border border-border bg-card p-4" data-newsroom-article-preview>
841
+ <div className="relative rounded-xl border border-border bg-card p-4" data-newsroom-article-preview ref={previewRef}>
767
842
  {(readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY) || readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_BLURB_KEY)) ? (
768
843
  <div className="newsroom-article-summary" data-newsroom-article-summary>
769
844
  {readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY) ? <p className="newsroom-article-subtitle">{readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY)}</p> : null}
@@ -774,12 +849,40 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
774
849
  <p className="mb-2 text-xs text-muted-foreground">Showing the last valid version.</p>
775
850
  ) : null}
776
851
  {bodyIr ? (
777
- <MarkusIrPreview bodyIr={bodyIr} imageUrls={imageUrls} />
852
+ <MarkusIrPreview
853
+ bodyIr={bodyIr}
854
+ editorial={
855
+ editorial.inlineResult
856
+ ? {
857
+ result: editorial.inlineResult,
858
+ findings: editorialOpen,
859
+ activeFindingIds: previewPopover?.kind === "findings" ? previewPopover.findingIds : editorial.activeFindingId ? [editorial.activeFindingId] : [],
860
+ onOpenMark: (findingIds, anchor) => {
861
+ editorial.setActiveFindingId(findingIds[0] ?? null);
862
+ setPreviewPopover({ kind: "findings", findingIds, anchor: anchor.getBoundingClientRect() });
863
+ },
864
+ onSelectSource: (source, anchor) => setPreviewPopover({ kind: "selection", source, anchor }),
865
+ }
866
+ : undefined
867
+ }
868
+ imageUrls={imageUrls}
869
+ />
778
870
  ) : (
779
871
  <p className="text-sm text-muted-foreground">
780
872
  {invalid ? "Fix the errors to see a preview." : "The preview appears once the markup is valid."}
781
873
  </p>
782
874
  )}
875
+ {previewPopover && editorial.enabled ? (
876
+ <EditorialPreviewPopover
877
+ actions={editorialActions}
878
+ body={editor.bodyMarkus}
879
+ container={previewRef.current}
880
+ findings={editorialOpen}
881
+ key={previewPopover.kind === "findings" ? previewPopover.findingIds.join() : `${previewPopover.source.start}-${previewPopover.source.end}`}
882
+ onClose={() => setPreviewPopover(null)}
883
+ target={previewPopover}
884
+ />
885
+ ) : null}
783
886
  </div>
784
887
  </div>
785
888
  </div>
@@ -801,6 +904,28 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
801
904
  </DialogContent>
802
905
  </Dialog>
803
906
 
907
+ <Dialog onOpenChange={setPublishWarningOpen} open={publishWarningOpen}>
908
+ <DialogContent>
909
+ <DialogHeader>
910
+ <DialogTitle>Publish with open editorial findings?</DialogTitle>
911
+ <DialogDescription>
912
+ {editorial.openWarnings.length === 1
913
+ ? "One editorial finding is still open."
914
+ : `${editorial.openWarnings.length} editorial findings are still open.`}{" "}
915
+ Findings are advice: you can publish anyway.
916
+ </DialogDescription>
917
+ </DialogHeader>
918
+ <ul className="max-h-48 list-disc space-y-1 overflow-auto pl-5 text-sm" data-newsroom-article-publish-warnings>
919
+ {editorial.openWarnings.slice(0, 8).map((finding) => (
920
+ <li key={finding.id}>{finding.message}{finding.excerpt ? `: “${finding.excerpt}”` : ""}</li>
921
+ ))}
922
+ </ul>
923
+ <DialogFooter>
924
+ <Button onClick={() => setPublishWarningOpen(false)} type="button" variant="outline">Keep editing</Button>
925
+ <Button data-newsroom-article-publish-anyway onClick={() => void publish()} type="button">Publish anyway</Button>
926
+ </DialogFooter>
927
+ </DialogContent>
928
+ </Dialog>
804
929
  <Dialog onOpenChange={setUnpublishOpen} open={unpublishOpen}>
805
930
  <DialogContent>
806
931
  <DialogHeader>
@@ -3,10 +3,8 @@
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import Link from "next/link";
5
5
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
6
- import { IssueCollection, IssueDetailPanel, IssueTypeFilter, issueTypeFromFilter, useBoardColumnState } from "@kanbus/ui";
7
- import "@kanbus/ui/styles/kanban.css";
8
- import "@kanbus/ui/styles/newsprint.css";
9
- import "./newsroom-newsprint-board.css";
6
+ import { IssueCollection, IssueDetailPanel, IssueIdFormatProvider, IssueTypeFilter, issueTypeFromFilter, useBoardColumnState } from "@kanbus/ui";
7
+ import "./newsroom-theme";
10
8
  import { PlusIcon } from "lucide-react";
11
9
  import { newsroomHref } from "../lib/newsroom-base-path";
12
10
  import {
@@ -18,6 +16,7 @@ import {
18
16
  deskBoardConfig,
19
17
  deskContainsAssignment,
20
18
  deskContainsContentItem,
19
+ formatNativeIssueId,
21
20
  projectAssignmentBoard,
22
21
  projectContentItemBoard,
23
22
  type NativeBoardIssue,
@@ -35,7 +34,6 @@ import { isLocalPreviewEnabled } from "../lib/local-preview";
35
34
  import { NewsroomArticleEditor } from "./newsroom-article-editor";
36
35
  import { NewsroomAssignmentDetail } from "./newsroom-assignment-detail";
37
36
  import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
38
- import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
39
37
  import { readLocalReaderSettings, subscribeReaderSettingsChanges } from "./reader-settings";
40
38
 
41
39
  /** A board with a story or assignment open lives at `?open=<id>`, distinct from the standalone page for that record. */
@@ -61,7 +59,6 @@ export type NewsroomBoardSubject = "contentItem" | "assignment" | "desk";
61
59
  export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardSubject; deskId?: string }) {
62
60
  const session = useOptionalNewsDeskClient();
63
61
  const allowed = canReadNewsroomBoard(session?.shell.phase);
64
- const theme = useResolvedPapyrusTheme();
65
62
  const [lowMotion, setLowMotion] = useState(false);
66
63
  const [assignmentBoardIssues, setAssignmentBoardIssues] = useState<NativeBoardIssue[]>([]);
67
64
  const [publishedVersions, setPublishedVersions] = useState<ContentItemVersionRecord[]>([]);
@@ -172,7 +169,8 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
172
169
  </Link>
173
170
  )}
174
171
  </>}>
175
- <div className="organizer-native-board papyrus-newsprint-board" data-kanbus-theme="newsprint" data-color-mode={theme}>
172
+ <IssueIdFormatProvider formatIssueId={formatNativeIssueId}>
173
+ <div className="organizer-native-board">
176
174
  {error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
177
175
  {subject === "desk" && <IssueTypeFilter config={config} value={typeFilter} onChange={setTypeFilter} name="Record type" className="organizer-board-type-filter" />}
178
176
  <div className="organizer-board-body" data-detail-open={Boolean(issue)} ref={boardBodyRef} onScrollCapture={handleBoardScroll}>
@@ -196,5 +194,6 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
196
194
  </IssueDetailPanel>}
197
195
  </div>
198
196
  </div>
197
+ </IssueIdFormatProvider>
199
198
  </NewsroomOpsShell>;
200
199
  }
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ import {useEffect,useState,type ReactNode} from 'react';
4
+ import {loadProspectusAccountState} from '../lib/prospectus-account-client';
5
+ import type {CurationBootstrap} from '../lib/curation-client';
6
+ import {NewsroomCurationProvider} from './newsroom-curation-provider';
7
+
8
+ type Fetcher=(input:string,init?:RequestInit)=>Promise<Response>;
9
+ const CONTEXT_PATH='/api/newsroom/curation/context';
10
+
11
+ function validBootstrap(value:unknown):value is CurationBootstrap{
12
+ const body=value as Partial<CurationBootstrap>|null;
13
+ const scope=body?.scope;
14
+ return !!scope&&[scope.accountId,scope.publicationId,scope.bindingRevision].every(item=>typeof item==='string'&&item.trim()!=='')
15
+ &&(body!.mode==='embedded'||body!.mode==='remote')&&Array.isArray(body!.capabilities);
16
+ }
17
+
18
+ /**
19
+ * Ask the server for the curation bootstrap, or null. Only a signed-in person with a selected
20
+ * account causes a context read; every other outcome (no Prospectus, signed out, no selection,
21
+ * any refusal or failure) is null, so the released CMS is unchanged.
22
+ */
23
+ export async function loadCurationBootstrap(returnPath:string,fetcher:Fetcher=fetch):Promise<CurationBootstrap|null>{
24
+ try{
25
+ const account=await loadProspectusAccountState(returnPath,fetcher);
26
+ if(account.status!=='signedIn'||!account.selectedAccountId)return null;
27
+ const response=await fetcher(CONTEXT_PATH,{credentials:'same-origin',cache:'no-store'});
28
+ if(!response.ok)return null;
29
+ const body=await response.json().catch(()=>null);
30
+ if(!validBootstrap(body)||body.scope.accountId!==account.selectedAccountId)return null;
31
+ return body;
32
+ }catch{return null;}
33
+ }
34
+
35
+ /** Mounts the curation provider for the References tab. Fails closed to no provider content. */
36
+ export function NewsroomCurationBootstrap({enabled=true,children}:{enabled?:boolean;children:ReactNode}){
37
+ const [bootstrap,setBootstrap]=useState<CurationBootstrap|null>(null);
38
+ useEffect(()=>{
39
+ if(!enabled)return;
40
+ let current=true;
41
+ void loadCurationBootstrap(window.location.pathname).then(value=>{if(current)setBootstrap(value);});
42
+ return()=>{current=false;setBootstrap(null);};
43
+ },[enabled]);
44
+ return <NewsroomCurationProvider bootstrap={bootstrap}>{children}</NewsroomCurationProvider>;
45
+ }