@anthusai/papyrus 2.0.0-next.8 → 2.0.0-next.80

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 (211) hide show
  1. package/amplify/data/function-handler.js +20 -0
  2. package/amplify/data/function-handler.ts +40 -0
  3. package/amplify/data/resource.js +151 -61
  4. package/amplify/data/resource.ts +157 -61
  5. package/amplify/data/schema.js +2096 -1980
  6. package/amplify/data/schema.ts +198 -69
  7. package/amplify/functions/assignment-action/data-access.js +14 -15
  8. package/amplify/functions/assignment-action/data-access.ts +13 -14
  9. package/amplify/functions/assignment-action/handler.ts +1 -1
  10. package/amplify/functions/automation-view/handler.py +17 -0
  11. package/amplify/functions/automation-view/resource.js +24 -0
  12. package/amplify/functions/automation-view/resource.ts +34 -0
  13. package/amplify/functions/automation-view/schema.js +26 -0
  14. package/amplify/functions/automation-view/schema.ts +28 -0
  15. package/amplify/functions/automation-view/wire.js +56 -0
  16. package/amplify/functions/automation-view/wire.ts +76 -0
  17. package/amplify/functions/content-actions/handler.py +61 -2
  18. package/amplify/functions/content-actions/requirements.txt +1 -0
  19. package/amplify/functions/content-actions/resource.js +1 -1
  20. package/amplify/functions/content-actions/resource.ts +1 -1
  21. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/handler.py +3 -3
  22. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  23. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
  24. package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
  25. package/amplify/functions/editorial-checks/schema.js +122 -0
  26. package/amplify/{plugins → functions}/editorial-checks/schema.ts +40 -12
  27. package/amplify/functions/editorial-checks/wire.js +53 -0
  28. package/amplify/functions/editorial-checks/wire.ts +61 -0
  29. package/amplify/functions/monitoring-view/handler.py +11 -0
  30. package/amplify/functions/monitoring-view/resource.js +24 -0
  31. package/amplify/functions/monitoring-view/resource.ts +34 -0
  32. package/amplify/functions/monitoring-view/schema.js +17 -0
  33. package/amplify/functions/monitoring-view/schema.ts +19 -0
  34. package/amplify/functions/monitoring-view/wire.js +37 -0
  35. package/amplify/functions/monitoring-view/wire.ts +47 -0
  36. package/amplify/functions/public-forms/core.js +247 -0
  37. package/amplify/functions/public-forms/core.ts +310 -0
  38. package/amplify/functions/public-forms/handler.ts +41 -0
  39. package/amplify/functions/public-forms/origins.js +35 -0
  40. package/amplify/functions/public-forms/origins.ts +43 -0
  41. package/amplify/functions/public-forms/resource.js +9 -0
  42. package/amplify/functions/public-forms/resource.ts +10 -0
  43. package/amplify/functions/public-forms/wire.js +25 -0
  44. package/amplify/functions/public-forms/wire.ts +32 -0
  45. package/amplify/functions/shared/editorial-context.ts +63 -4
  46. package/amplify/functions/shared/python-bundle.js +20 -14
  47. package/amplify/functions/shared/python-bundle.ts +25 -18
  48. package/amplify/plugins/newsletter/config.js +245 -0
  49. package/amplify/plugins/newsletter/config.ts +305 -0
  50. package/amplify/plugins/newsletter/function/handler.py +6 -0
  51. package/amplify/plugins/newsletter/function/requirements.txt +5 -0
  52. package/amplify/plugins/newsletter/ops/handler.py +12 -0
  53. package/amplify/plugins/newsletter/ops/resource.js +27 -0
  54. package/amplify/plugins/newsletter/ops/resource.ts +37 -0
  55. package/amplify/plugins/newsletter/papyrus.plugin.js +263 -0
  56. package/amplify/plugins/newsletter/papyrus.plugin.ts +311 -0
  57. package/amplify/plugins/newsletter/schema.js +57 -0
  58. package/amplify/plugins/newsletter/schema.ts +62 -0
  59. package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
  60. package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
  61. package/amplify/plugins/newsletter/sending.js +140 -0
  62. package/amplify/plugins/newsletter/sending.ts +186 -0
  63. package/amplify/plugins/reader-steering/config.js +182 -0
  64. package/amplify/plugins/reader-steering/config.ts +290 -0
  65. package/amplify/plugins/reader-steering/function/ai_moderation.py +246 -0
  66. package/amplify/plugins/reader-steering/function/handler.py +1554 -0
  67. package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
  68. package/amplify/plugins/reader-steering/function/moderation.py +316 -0
  69. package/amplify/plugins/reader-steering/function/proposals.py +255 -0
  70. package/amplify/plugins/reader-steering/function/resource.js +27 -0
  71. package/amplify/plugins/reader-steering/function/resource.ts +35 -0
  72. package/amplify/plugins/reader-steering/papyrus.plugin.js +186 -0
  73. package/amplify/plugins/reader-steering/papyrus.plugin.ts +212 -0
  74. package/amplify/plugins/reader-steering/schema.js +205 -0
  75. package/amplify/plugins/reader-steering/schema.ts +207 -0
  76. package/amplify/plugins/registry.js +4 -2
  77. package/amplify/plugins/registry.ts +4 -2
  78. package/amplify/plugins/signup/schema.js +2 -1
  79. package/amplify/plugins/signup/schema.ts +2 -1
  80. package/amplify/plugins/types.ts +5 -0
  81. package/amplify/plugins/wire.js +1 -0
  82. package/amplify/plugins/wire.ts +1 -0
  83. package/amplify/publication/projection-lease.js +30 -0
  84. package/amplify/publication/projection-lease.ts +39 -0
  85. package/amplify/publication/redirect-and-index.js +105 -0
  86. package/amplify/publication/redirect-rules.js +73 -0
  87. package/amplify/publication/redirect-rules.ts +93 -0
  88. package/amplify/publication/s3-projection.js +7 -1
  89. package/amplify/publication/s3-projection.ts +9 -1
  90. package/amplify/site-backend-config.ts +18 -1
  91. package/amplify/site-backend-names.js +4 -2
  92. package/amplify/site-backend-names.ts +4 -2
  93. package/amplify/site-backend.js +41 -2
  94. package/amplify/site-backend.ts +46 -2
  95. package/app/api/auth/prospectus/sign-in/route.ts +4 -0
  96. package/app/api/newsletter/confirm/route.ts +13 -0
  97. package/app/api/newsletter/preferences/route.ts +13 -0
  98. package/app/api/newsletter/state/route.ts +9 -0
  99. package/app/api/newsletter/subscribe/route.ts +9 -0
  100. package/app/api/newsletter/unsubscribe/route.ts +13 -0
  101. package/app/api/newsroom/curation/context/route.ts +9 -0
  102. package/app/api/newsroom/curation/priming/route.ts +9 -0
  103. package/app/api/newsroom/curation/publication/route.ts +9 -0
  104. package/app/api/reader/comment/delete/route.ts +9 -0
  105. package/app/api/reader/comment/edit/route.ts +9 -0
  106. package/app/api/reader/comment/route.ts +9 -0
  107. package/app/api/reader/flag/route.ts +9 -0
  108. package/app/api/reader/handle/route.ts +9 -0
  109. package/app/api/reader/me/route.ts +9 -0
  110. package/app/api/reader/rating/route.ts +9 -0
  111. package/app/api/reader/report/route.ts +9 -0
  112. package/app/api/reader/retired/route.ts +9 -0
  113. package/app/api/reader/submit/route.ts +9 -0
  114. package/app/api/reader/vote/route.ts +9 -0
  115. package/bin/papyrus-infra.mjs +20 -1
  116. package/components/automation-panel.tsx +49 -0
  117. package/components/automation-run-now.tsx +97 -0
  118. package/components/automation-view.tsx +192 -0
  119. package/components/editorial-checks-admin-panel.tsx +36 -11
  120. package/components/moderation-panel.tsx +248 -0
  121. package/components/monitoring-overview-view.tsx +241 -0
  122. package/components/monitoring-panel.tsx +40 -0
  123. package/components/news-desk-client-provider.tsx +18 -18
  124. package/components/news-desk-taxonomy-client.ts +28 -8
  125. package/components/newsletter-panel.tsx +474 -0
  126. package/components/newsletter-sections.tsx +163 -0
  127. package/components/newsroom-access-gate.tsx +15 -1
  128. package/components/newsroom-article-editor.tsx +6 -6
  129. package/components/newsroom-articles-view.tsx +6 -6
  130. package/components/newsroom-board-view.tsx +8 -3
  131. package/components/newsroom-curation-bootstrap.tsx +45 -0
  132. package/components/newsroom-ops-overview.tsx +22 -92
  133. package/components/newsroom-ops-shell.tsx +2 -2
  134. package/components/newsroom-organizer.css +33 -0
  135. package/components/newsroom-overview-skeleton.tsx +25 -0
  136. package/components/newsroom-references-view.tsx +192 -49
  137. package/components/notification-center.tsx +273 -0
  138. package/components/publication-cyclotron-panel.tsx +15 -10
  139. package/components/reader-auth-state.ts +1 -1
  140. package/components/reference-add-entry.tsx +75 -0
  141. package/components/reference-curation-controls.tsx +31 -22
  142. package/components/reference-cyclotron-priming.tsx +58 -19
  143. package/components/reference-cyclotron-suggestion.tsx +10 -6
  144. package/components/reference-priming.tsx +14 -10
  145. package/components/reference-relevance-review.tsx +3 -3
  146. package/components/reference-section-suggestion.tsx +1 -1
  147. package/components/topic-steering-workspace.tsx +223 -144
  148. package/components/use-editorial-checks.ts +12 -10
  149. package/infra/amplify-app-shell.js +94 -16
  150. package/infra/amplify-app-shell.ts +106 -18
  151. package/infra/cloud-email-config.js +32 -0
  152. package/infra/cloud-email-config.ts +39 -0
  153. package/infra/cloud-email.js +57 -0
  154. package/infra/cloud-email.test.js +69 -0
  155. package/infra/cloud-email.test.ts +66 -0
  156. package/infra/cloud-email.ts +60 -0
  157. package/infra/index.js +1 -0
  158. package/infra/secure-string-parameter-handler.js +41 -0
  159. package/infra/secure-string-parameter-handler.ts +41 -0
  160. package/infra/secure-string-parameter.js +79 -0
  161. package/infra/secure-string-parameter.ts +91 -0
  162. package/infra/site-config.js +88 -6
  163. package/infra/site-config.ts +134 -6
  164. package/lib/automation-client.ts +54 -0
  165. package/lib/automation-fixture.ts +93 -0
  166. package/lib/automation-overview.ts +232 -0
  167. package/lib/category-repository.ts +11 -9
  168. package/lib/curation-routes.ts +345 -0
  169. package/lib/cyclotron-suggestions.ts +4 -4
  170. package/lib/editorial-checks-backend.ts +1 -1
  171. package/lib/editorial-checks-client.ts +19 -1
  172. package/lib/editorial-checks-demo.ts +2 -2
  173. package/lib/editorial-checks-settings.ts +3 -0
  174. package/lib/editorial-checks.ts +8 -11
  175. package/lib/editorial-preview.ts +5 -5
  176. package/lib/monitoring-client.ts +29 -0
  177. package/lib/monitoring-overview-fixture.ts +51 -0
  178. package/lib/monitoring-overview.ts +145 -0
  179. package/lib/news-desk-session.ts +12 -21
  180. package/lib/newsletter-api.ts +418 -0
  181. package/lib/newsletter-client.ts +46 -0
  182. package/lib/newsletter-fixture.ts +184 -0
  183. package/lib/newsletter-model.ts +202 -0
  184. package/lib/newsroom-organizer-nav.ts +2 -1
  185. package/lib/prospectus-session.ts +15 -14
  186. package/lib/prospectus-sign-in-routes.ts +84 -19
  187. package/lib/prospectus-site-identity.ts +24 -1
  188. package/lib/reader-api.ts +145 -0
  189. package/lib/reader-moderation.ts +220 -0
  190. package/lib/reader-retirement-route.ts +89 -0
  191. package/lib/reader-subject.md +29 -0
  192. package/lib/reader-subject.ts +53 -0
  193. package/lib/reference-review-details.ts +71 -0
  194. package/lib/reference-review-request.ts +14 -0
  195. package/lib/relevance-decisions.ts +42 -10
  196. package/lib/relevance-status.ts +2 -2
  197. package/lib/review-client.ts +99 -0
  198. package/lib/review-fixture.ts +45 -0
  199. package/lib/review-requests.ts +238 -0
  200. package/lib/semantic-graph.ts +2 -0
  201. package/lib/site-brand-tenant.ts +11 -6
  202. package/lib/site-brand.ts +6 -3
  203. package/lib/site-origin.ts +67 -0
  204. package/lib/use-reference-review-data.ts +44 -0
  205. package/middleware.ts +14 -3
  206. package/package.json +56 -3
  207. package/routes.manifest.json +215 -2
  208. package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
  209. package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -71
  210. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -87
  211. package/amplify/plugins/editorial-checks/schema.js +0 -94
@@ -0,0 +1,273 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useCallback, useEffect, useMemo, useState } from "react";
5
+ import { Badge } from "./ui/badge";
6
+ import { Button } from "./ui/button";
7
+ import { getNewsroomNavHref } from "../lib/newsroom-nav";
8
+ import {
9
+ cyclotronAutoDecided, cyclotronHeldForReview, formatWhen, isUnread, modeLabel, modeMark, nextText, sectionReviews,
10
+ steeringText, subjectHref, summarize, summaryText,
11
+ type AnswerInput, type CyclotronEntry, type ReviewRequest,
12
+ } from "../lib/review-requests";
13
+ import type { ReferenceRecord } from "../lib/category-repository";
14
+ import type { RelevanceDecisionView } from "../lib/relevance-decisions";
15
+ import { FIXTURE_REVIEW_REQUESTS } from "../lib/review-fixture";
16
+
17
+ export type NotificationLoadState = "loading" | "ready" | "forbidden" | "unavailable";
18
+
19
+ type ViewProps = {
20
+ loadState: NotificationLoadState;
21
+ requests: ReviewRequest[];
22
+ readIds: ReadonlySet<string>;
23
+ held: CyclotronEntry[];
24
+ autoDecided: CyclotronEntry[];
25
+ demo?: boolean;
26
+ busyId?: string | null;
27
+ error?: string | null;
28
+ onAnswer?: (request: ReviewRequest, input: AnswerInput) => void;
29
+ onMarkRead?: (requestId: string) => void;
30
+ onMarkAllRead?: () => void;
31
+ };
32
+
33
+ function Entry({ request, unread, demo, busy, onAnswer, onMarkRead }: {
34
+ request: ReviewRequest; unread: boolean; demo: boolean; busy: boolean;
35
+ onAnswer?: ViewProps["onAnswer"]; onMarkRead?: ViewProps["onMarkRead"];
36
+ }) {
37
+ const [note, setNote] = useState("");
38
+ const href = subjectHref(request.subject, demo);
39
+ const open = request.state === "open";
40
+ const steering = steeringText(request);
41
+ const act = (input: AnswerInput) => onAnswer?.(request, { ...input, feedback: input.feedback ?? (note.trim() || null) });
42
+ return (
43
+ <li className="rounded-lg border p-3 space-y-2" data-review-request={request.id} data-review-mode={request.mode} data-review-state={request.state} data-review-unread={unread || undefined}>
44
+ <div className="flex flex-wrap items-center gap-2">
45
+ <Badge variant="outline"><span aria-hidden="true">{modeMark(request.mode)} </span>{modeLabel(request.mode)}</Badge>
46
+ {unread ? <Badge variant="default"><span aria-hidden="true">● </span>New</Badge> : <span className="text-xs text-muted-foreground">Read</span>}
47
+ <strong className="text-sm">{request.reason}</strong>
48
+ </div>
49
+ <p className="text-xs text-muted-foreground">
50
+ Raised by {request.raisedBy} on {formatWhen(request.raisedAt)}
51
+ {" · "}About: {href ? <Link href={href} onClick={() => onMarkRead?.(request.id)}>{request.subject.type.replace("_", " ")} {request.subject.id}</Link> : <span>{request.subject.type} {request.subject.id}</span>}
52
+ </p>
53
+ {request.ask ? <p className="text-sm">{request.ask}</p> : null}
54
+ {request.links.length ? (
55
+ <ul className="text-xs space-y-0.5" aria-label="Items in this request">
56
+ {request.links.slice(0, 10).map((link, index) => {
57
+ const target = link.url ?? (link.type ? subjectHref({ type: link.type, id: link.id ?? "" }, demo) : null);
58
+ return <li key={`${link.id ?? link.url}-${index}`}>{target ? <Link href={target}>{link.label ?? link.id ?? link.url}</Link> : (link.label ?? link.id)}</li>;
59
+ })}
60
+ {request.links.length > 10 ? <li>and {request.links.length - 10} more</li> : null}
61
+ </ul>
62
+ ) : null}
63
+ <p className="text-sm" data-review-next>{nextText(request)}</p>
64
+ {steering ? <p className="text-sm" data-review-steering>{steering}</p> : null}
65
+ {open ? (
66
+ <div className="space-y-2" data-review-controls>
67
+ <label className="block text-xs text-muted-foreground">
68
+ Steering note (optional)
69
+ <textarea
70
+ className="mt-1 block w-full rounded-md border bg-background p-2 text-sm"
71
+ disabled={busy}
72
+ onChange={(event) => setNote(event.target.value)}
73
+ placeholder={request.mode === "flag" ? "Tell the step what to do differently" : "What should change, or what did you want instead?"}
74
+ rows={2}
75
+ value={note}
76
+ />
77
+ </label>
78
+ <div className="flex flex-wrap gap-2">
79
+ {request.mode === "flag" ? (
80
+ <Button disabled={busy} onClick={() => act({ decision: "approve" })} size="sm" variant="outline">Acknowledge</Button>
81
+ ) : (
82
+ <>
83
+ <Button disabled={busy} onClick={() => act({ decision: "approve" })} size="sm" variant="outline">Approve</Button>
84
+ <Button disabled={busy} onClick={() => act({ decision: "reject" })} size="sm" variant="outline">Reject</Button>
85
+ </>
86
+ )}
87
+ <Button disabled={busy || !note.trim()} onClick={() => act({ decision: "feedback", steer: "adjust" })} size="sm" variant="outline">Steer with this note</Button>
88
+ <Button disabled={busy} onClick={() => act({ decision: "feedback", steer: "not_this_kind" })} size="sm" variant="outline">Not this kind of thing</Button>
89
+ {unread ? <Button disabled={busy} onClick={() => onMarkRead?.(request.id)} size="sm" variant="ghost">Mark read</Button> : null}
90
+ </div>
91
+ </div>
92
+ ) : unread ? (
93
+ <Button disabled={busy} onClick={() => onMarkRead?.(request.id)} size="sm" variant="ghost">Mark read</Button>
94
+ ) : null}
95
+ </li>
96
+ );
97
+ }
98
+
99
+ function CyclotronLine({ entry, held }: { entry: CyclotronEntry; held: boolean }) {
100
+ const confidence = entry.confidence === null ? "confidence not recorded" : `confidence ${Math.round(entry.confidence * 100)}%`;
101
+ return (
102
+ <li className="rounded-lg border p-3 space-y-1" data-cyclotron-entry={entry.lineageId} data-cyclotron-held={held || undefined}>
103
+ <div className="flex flex-wrap items-center gap-2">
104
+ <Badge variant="outline"><span aria-hidden="true">{held ? "◔" : "✓"} </span>{held ? "Held for review" : "Decided by the system"}</Badge>
105
+ <strong className="text-sm">{entry.title}</strong>
106
+ </div>
107
+ <p className="text-xs text-muted-foreground">
108
+ Classified as {entry.label} · {confidence}{entry.decidedAt ? ` · ${formatWhen(entry.decidedAt)}` : ""}
109
+ {entry.reviewedByPerson ? " · reviewed by a person" : ""}
110
+ </p>
111
+ {held ? <p className="text-sm">{entry.reviewDetail ? `${entry.reviewDetail}. ` : ""}Held for review: it stays pending until someone decides.</p> : null}
112
+ <Link className="text-sm" href={entry.href}>{held ? "Review it in References" : "Open it in References to correct it"}</Link>
113
+ </li>
114
+ );
115
+ }
116
+
117
+ function Section({ id, title, count, children, empty }: { id: string; title: string; count: number; children: React.ReactNode; empty: string }) {
118
+ return (
119
+ <section aria-labelledby={`notification-${id}`} className="space-y-2" data-notification-section={id}>
120
+ <h2 className="text-sm font-medium" id={`notification-${id}`}>{title} ({count})</h2>
121
+ {count ? children : <p className="text-sm text-muted-foreground">{empty}</p>}
122
+ </section>
123
+ );
124
+ }
125
+
126
+ /** The CMS console home: what needs a person, what will proceed on its own, and what the system decided. Ignore-safe by design. */
127
+ export function NotificationCenterView({ loadState, requests, readIds, held, autoDecided, demo = false, busyId = null, error = null, onAnswer, onMarkRead, onMarkAllRead }: ViewProps) {
128
+ const sections = useMemo(() => sectionReviews(requests), [requests]);
129
+ const unreadCount = requests.filter((request) => isUnread(request, readIds)).length;
130
+ const summary = summarize(sections, held, autoDecided, unreadCount);
131
+ const entry = (request: ReviewRequest) => (
132
+ <Entry busy={busyId === request.id} demo={demo} key={request.id} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={isUnread(request, readIds)} />
133
+ );
134
+ if (loadState === "loading") return (
135
+ <section aria-label="Loading Notification Center" className="space-y-4" data-notification-state="loading" role="status">
136
+ <div className="h-6 w-48 rounded bg-muted" />
137
+ <div className="h-4 w-3/4 max-w-xl rounded bg-muted" />
138
+ <div className="h-4 w-2/3 max-w-lg rounded bg-muted" />
139
+ <div className="grid gap-3 sm:grid-cols-2">
140
+ {Array.from({ length: 4 }, (_, index) => <div className="h-28 rounded-xl border bg-card p-4" key={index}>
141
+ <div className="h-4 w-2/3 rounded bg-muted" /><div className="mt-4 h-3 w-1/2 rounded bg-muted" />
142
+ </div>)}
143
+ </div>
144
+ </section>
145
+ );
146
+ if (loadState === "forbidden") return <div role="alert" data-notification-state="forbidden"><strong>The Notification Center is for editors and admins</strong></div>;
147
+ if (loadState === "unavailable") return <div role="alert" data-notification-state="unavailable"><strong>Review requests could not be read right now</strong><p>Try again in a minute. The current request state is unknown.</p></div>;
148
+ return (
149
+ <div className="space-y-6" data-notification-center>
150
+ <header className="space-y-1">
151
+ <h1 className="text-lg font-semibold">Notification Center</h1>
152
+ <p className="text-sm" data-notification-summary>{summaryText(summary)}</p>
153
+ <p className="text-xs text-muted-foreground">
154
+ Look, or steer. Anything you ignore keeps going: soft reviews proceed at their deadline and flags never wait. Only a hard gate waits for you.
155
+ </p>
156
+ <p className="text-xs text-muted-foreground" data-notification-unread>
157
+ {unreadCount ? `${unreadCount} unread` : "Nothing unread"}
158
+ {unreadCount ? <Button className="ml-2" onClick={onMarkAllRead} size="sm" variant="ghost">Mark all read</Button> : null}
159
+ </p>
160
+ {error ? <p role="alert" className="text-sm" data-notification-error>{error}</p> : null}
161
+ </header>
162
+
163
+ <Section count={sections.hardGates.length} empty="No hard gates. Nothing is blocked on you." id="hard-gates" title="Waiting for you: hard gates">
164
+ <ul className="space-y-2">{sections.hardGates.map(entry)}</ul>
165
+ </Section>
166
+ <Section count={sections.softReviews.length} empty="No soft reviews are running." id="soft-reviews" title="Proceeding soon: soft reviews, soonest deadline first">
167
+ <ul className="space-y-2">{sections.softReviews.map(entry)}</ul>
168
+ </Section>
169
+ <Section count={held.length} empty="No Cyclotron items are held for review." id="cyclotron" title="Cyclotron items routed to a person (low confidence)">
170
+ <ul className="space-y-2">{held.slice(0, 10).map((item) => <CyclotronLine entry={item} held key={item.lineageId} />)}</ul>
171
+ {held.length > 10 ? <p className="text-sm">and {held.length - 10} more. <Link href={getNewsroomNavHref("/newsroom/references", demo)}>See all in References</Link></p> : null}
172
+ </Section>
173
+ <Section count={sections.flags.length} empty="No flags." id="flags" title="Flags: for your information">
174
+ <ul className="space-y-2">{sections.flags.map(entry)}</ul>
175
+ </Section>
176
+ <Section count={sections.decided.length} empty="Nothing answered or proceeded yet." id="decided" title="Answered, and what became of it">
177
+ <ul className="space-y-2">{sections.decided.slice(0, 20).map(entry)}</ul>
178
+ </Section>
179
+ <details data-notification-section="auto-decided">
180
+ <summary className="cursor-pointer text-sm font-medium">Recently decided by the system ({autoDecided.length})</summary>
181
+ <div className="mt-2 space-y-2">
182
+ <p className="text-xs text-muted-foreground">Read only. The Cyclotron was confident enough to decide these without you. To correct one, open it in References.</p>
183
+ {autoDecided.length ? <ul className="space-y-2">{autoDecided.map((item) => <CyclotronLine entry={item} held={false} key={item.lineageId} />)}</ul> : <p className="text-sm text-muted-foreground">No automatic decisions yet.</p>}
184
+ </div>
185
+ </details>
186
+ </div>
187
+ );
188
+ }
189
+
190
+ type ContainerProps = {
191
+ references: ReferenceRecord[];
192
+ decisions: ReadonlyMap<string, RelevanceDecisionView>;
193
+ humanCuratedReferenceLineageIds: ReadonlySet<string>;
194
+ actorLabel: string;
195
+ demo: boolean;
196
+ };
197
+
198
+ const REFRESH_MILLISECONDS = 60_000;
199
+
200
+ export function NotificationCenter({ references, decisions, humanCuratedReferenceLineageIds, actorLabel, demo }: ContainerProps) {
201
+ const [loadState, setLoadState] = useState<NotificationLoadState>("loading");
202
+ const [requests, setRequests] = useState<ReviewRequest[]>([]);
203
+ const [readIds, setReadIds] = useState<Set<string>>(new Set());
204
+ const [userKey, setUserKey] = useState<string | null>(null);
205
+ const [busyId, setBusyId] = useState<string | null>(null);
206
+ const [error, setError] = useState<string | null>(null);
207
+
208
+ const refresh = useCallback(async () => {
209
+ if (demo) {
210
+ setRequests(FIXTURE_REVIEW_REQUESTS);
211
+ setReadIds(new Set(["rr-flag-1"]));
212
+ setLoadState("ready");
213
+ return;
214
+ }
215
+ try {
216
+ const { loadReviews } = await import("../lib/review-client");
217
+ const loaded = await loadReviews();
218
+ if (loaded.state !== "ready") {
219
+ setLoadState(loaded.state);
220
+ return;
221
+ }
222
+ setRequests(loaded.requests);
223
+ setReadIds(new Set(loaded.readIds));
224
+ setUserKey(loaded.userKey);
225
+ setLoadState("ready");
226
+ } catch {
227
+ setLoadState("unavailable");
228
+ }
229
+ }, [demo]);
230
+
231
+ useEffect(() => {
232
+ void refresh();
233
+ const timer = window.setInterval(() => void refresh(), REFRESH_MILLISECONDS);
234
+ return () => window.clearInterval(timer);
235
+ }, [refresh]);
236
+
237
+ const held = useMemo(() => cyclotronHeldForReview(references, decisions, demo), [references, decisions, demo]);
238
+ const autoDecided = useMemo(() => cyclotronAutoDecided(references, decisions, humanCuratedReferenceLineageIds, demo), [references, decisions, humanCuratedReferenceLineageIds, demo]);
239
+
240
+ const markRead = useCallback(async (requestId: string) => {
241
+ setReadIds((current) => new Set(current).add(requestId));
242
+ if (demo || !userKey) return;
243
+ const { markReviewRead } = await import("../lib/review-client");
244
+ await markReviewRead(userKey, requestId).catch(() => undefined);
245
+ }, [demo, userKey]);
246
+
247
+ const answer = useCallback(async (request: ReviewRequest, input: AnswerInput) => {
248
+ setError(null);
249
+ setBusyId(request.id);
250
+ try {
251
+ if (demo) throw new Error("This is a demo: answers are not saved.");
252
+ const { answerReviewRequest } = await import("../lib/review-client");
253
+ const saved = await answerReviewRequest(request, input, actorLabel);
254
+ setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
255
+ await markRead(request.id);
256
+ } catch (failure) {
257
+ setError(failure instanceof Error ? failure.message : "Could not save the answer.");
258
+ } finally {
259
+ setBusyId(null);
260
+ }
261
+ }, [actorLabel, demo, markRead]);
262
+
263
+ const markAllRead = useCallback(() => {
264
+ for (const request of requests) if (isUnread(request, readIds)) void markRead(request.id);
265
+ }, [markRead, readIds, requests]);
266
+
267
+ return (
268
+ <NotificationCenterView
269
+ autoDecided={autoDecided} busyId={busyId} demo={demo} error={error} held={held} loadState={loadState}
270
+ onAnswer={answer} onMarkAllRead={markAllRead} onMarkRead={markRead} readIds={readIds} requests={requests}
271
+ />
272
+ );
273
+ }
@@ -5,6 +5,9 @@ import {ReviewControl,type ReviewControlReview} from 'cyclotron/components/revie
5
5
  import {CyclotronStatusView} from 'cyclotron/components/cyclotron-status';
6
6
  import {assertPublicationScope,publicationScopeKey,publicationReviewPayload,type PublicationStatus,type PublicationHistory,type PublicationHistoryRow,type PublicationReviewPayload} from '../lib/publication-cyclotron';
7
7
 
8
+ /** Steering is offered only when the Cyclotron contract says so; absent means unknown, so off. */
9
+ export function steeringAvailable(status:PublicationStatus):boolean{return status.features?.steering===true;}
10
+
8
11
  function requestId(){
9
12
  const bytes=crypto.getRandomValues(new Uint8Array(16));
10
13
  return Array.from(bytes,value=>value.toString(16).padStart(2,'0')).join('');
@@ -28,28 +31,30 @@ function PublicationControls({mode,status,history,onReview,onSteer}:PublicationP
28
31
  const signature=JSON.stringify(review);const previous=requests.current[key];
29
32
  const id=previous?.signature===signature?previous.id:requestId();requests.current[key]={signature,id};
30
33
  const payload=publicationReviewPayload(status,row,classifier,review,id);
31
- if(!onReview)throw new Error('This host has not enabled human review');
34
+ if(!onReview)throw new Error('Reviewing is not turned on here.');
32
35
  setBusy(key);setMessage(null);await onReview(payload);
33
- if(current.current===context){setMessage({context,key,error:false,text:'Human label acknowledged.'});setEditing(old=>({...old,[key]:false}));}
34
- }catch(error){if(current.current===context)setMessage({context,key,error:true,text:error instanceof Error?error.message:'Review failed; its outcome must be checked before retrying.'});}
36
+ if(current.current===context){setMessage({context,key,error:false,text:'Your choice was saved.'});setEditing(old=>({...old,[key]:false}));}
37
+ }catch(error){if(current.current===context)setMessage({context,key,error:true,text:error instanceof Error?error.message:'Your review may not have been saved; check before trying again.'});}
35
38
  finally{if(current.current===context)setBusy(null);}
36
39
  }
37
- return <section data-publication-scope={context} aria-label="Publication Cyclotron controls" className="space-y-4">
38
- <header><h2 className="text-lg font-semibold">Cyclotron · {status.publicationId}</h2><p className="text-sm text-muted-foreground">{mode==='embedded'?'Classifier data owned by this deployment.':'Canonical classifier data owned by the shared remote lab.'}</p></header>
40
+ return <section data-publication-scope={context} aria-label="Suggestions and reviews" className="space-y-4">
41
+ <header><h2 className="text-lg font-semibold">Suggestions · {status.publicationId}</h2><p className="text-sm text-muted-foreground">{mode==='embedded'?'Suggestions and reviews are kept by this site.':'Suggestions and reviews are kept by the shared review service.'}</p></header>
39
42
  {status.outputs.map(output=><section key={`${context}:${output.id}`} aria-label={output.question} className="space-y-3 rounded-md border p-3">
40
43
  <CyclotronStatusView status={status.classifiers[output.id]} classifierName={output.question} variant="card"/>
41
- {onSteer?<button type="button" disabled={Boolean(busy)} onClick={()=>onSteer(output.id)}>Evaluate classifier proposal</button>:null}
44
+ {onSteer?(steeringAvailable(status)
45
+ ?<button type="button" disabled={Boolean(busy)} onClick={()=>onSteer(output.id)}>Test a rule change</button>
46
+ :<div><button type="button" disabled aria-describedby={`steer-unavailable:${output.id}`}>Test a rule change · Not available yet</button><p id={`steer-unavailable:${output.id}`} className="text-sm text-muted-foreground">Rule changes cannot be tested on this service yet.</p></div>):null}
42
47
  {history.history.map(row=>{
43
48
  const prediction=row.decision.classifiers[output.id];if(!prediction)return null;
44
49
  const key=`${context}:${row.decision.decisionId}:${output.id}:${row.context?.contextId??'legacy'}`;const saved=row.reviews[output.id];const historical=Boolean(row.historicalContext||row.provenanceUnavailable||row.feedbackReconciliationRequired);
45
50
  return <article key={key} className="space-y-2 rounded-md border p-3">
46
- {historical?<p className="text-sm">Historical source or binding revision · read-only</p>:null}{row.feedbackReconciliationRequired?<p role="status">Canonical feedback changed; provenance reconciliation is required before replay.</p>:null}<h3 className="font-medium">{String(row.item.title??row.decision.itemId)}</h3>
51
+ {historical?<p className="text-sm">An earlier copy of this source · read-only</p>:null}{row.feedbackReconciliationRequired?<p role="status">Earlier feedback changed; check this item before reviewing again.</p>:null}<h3 className="font-medium">{String(row.item.title??row.decision.itemId)}</h3>
47
52
  <p className="whitespace-pre-wrap text-sm">{String(row.item.abstract??row.item.text??'')}</p>
48
- {!saved||editing[key]?<p className="text-sm" role="status">{historical?'This source revision is read-only.':!onReview?'Human review is unavailable in this session.':busy===key?'Submitting review… Your selection is preserved; wait for acknowledgement.':'Choose a label to enable Submit review. Explanation is optional.'}</p>:null}
53
+ {!saved||editing[key]?<p className="text-sm" role="status">{historical?'This earlier copy is read-only.':!onReview?'Reviewing is not available right now.':busy===key?'Saving your review… your choice is kept.':'Choose an option to enable Submit review. An explanation is optional.'}</p>:null}
49
54
  <ReviewControl key={`${key}:${Boolean(editing[key])}`} itemId={`${row.decision.itemId}:${Array.from(new TextEncoder().encode(context+':'+output.id+':'+(row.context?.contextId??'legacy')),byte=>byte.toString(16).padStart(2,'0')).join('')}`} decision={{decisionId:row.decision.decisionId,...prediction,classes:output.classes}} question={output.question} mode="labels" positiveLabel={output.positiveLabel??undefined} disabled={Boolean(busy)||!onReview||historical} value={saved&&!editing[key]?{label:saved.label,reasonCode:saved.reasonCode,explanation:saved.explanation}:undefined} onReview={review=>void submit(row,output.id,{...review,itemId:row.decision.itemId})}/>
50
55
  {message?.context===context&&message.key===key?<p role={message.error?'alert':'status'} className="text-sm">{message.text}</p>:null}
51
- {saved&&onReview&&!historical&&!editing[key]?<button type="button" disabled={Boolean(busy)} onClick={()=>setEditing(old=>({...old,[key]:true}))}>Correct human label</button>:null}
52
- <details><summary>Decision provenance and version</summary><pre className="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{JSON.stringify({itemId:row.decision.itemId,source:row.item.source_uri,decisionId:row.decision.decisionId,prediction,bindingRevision:row.context?.binding.values.revision??status.bindingRevision,sourceRef:row.context?.sourceRef,contextId:row.context?.contextId},null,2)}</pre></details>
56
+ {saved&&onReview&&!historical&&!editing[key]?<button type="button" disabled={Boolean(busy)} onClick={()=>setEditing(old=>({...old,[key]:true}))}>Change my choice</button>:null}
57
+ <details><summary>Where this suggestion came from</summary><pre className="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{JSON.stringify({itemId:row.decision.itemId,source:row.item.source_uri,decisionId:row.decision.decisionId,prediction,bindingRevision:row.context?.binding.values.revision??status.bindingRevision,sourceRef:row.context?.sourceRef,contextId:row.context?.contextId},null,2)}</pre></details>
53
58
  </article>;
54
59
  })}
55
60
  </section>)}
@@ -61,7 +61,7 @@ export async function loadReaderSessionSnapshot(options?: { forceRefresh?: boole
61
61
 
62
62
  export function isUnauthenticatedError(error: unknown): boolean {
63
63
  const message = error instanceof Error ? error.message : String(error);
64
- return /unauth|not authenticated|no current user|not signed in/i.test(message);
64
+ return /unauth|not.?authorized|not authenticated|no current user|not signed in|session expired|token expired|invalid.*token|\b401\b/i.test(message);
65
65
  }
66
66
 
67
67
  function readIdentityLabel(claims: {
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import type { PrimingPanelProps } from "cyclotron/components/priming-panel";
5
+ import { ReferenceCyclotronPriming, type PrimingFormInput } from "./reference-cyclotron-priming";
6
+ import { ReferencePriming } from "./reference-priming";
7
+ import type { PrimingRequestView } from "../lib/relevance-status";
8
+ import { Button } from "./ui/button";
9
+
10
+ export type ReferenceUrlOption = {
11
+ labels: readonly string[];
12
+ requests: PrimingRequestView[];
13
+ onRequest: (input: PrimingFormInput) => Promise<void> | void;
14
+ };
15
+
16
+ type Option = "url" | "file";
17
+
18
+ /**
19
+ * The one place an editor adds a reference. "Paste a URL" is always offered when
20
+ * the host can take one. "Import a file" appears only when a curation session
21
+ * (or the host) supplies the import transport, so without one it never renders.
22
+ */
23
+ export function ReferenceAddEntry({
24
+ url,
25
+ importOptions,
26
+ onImported,
27
+ }: {
28
+ url?: ReferenceUrlOption | null;
29
+ importOptions?: PrimingPanelProps | null;
30
+ /** Called after an import is saved, so the host can reload what depends on it. */
31
+ onImported?: () => void;
32
+ }) {
33
+ const [chosen, setChosen] = useState<Option>("url");
34
+ const [fileOpened, setFileOpened] = useState(false);
35
+ const [saved, setSaved] = useState("");
36
+ if (!url && !importOptions) return null;
37
+
38
+ const option: Option = importOptions && (!url || chosen === "file") ? "file" : "url";
39
+ const choose = (next: Option) => {
40
+ setChosen(next);
41
+ if (next === "file") setFileOpened(true);
42
+ };
43
+
44
+ return (
45
+ <section aria-label="Add a reference" className="shrink-0 space-y-3" data-add-reference>
46
+ <div className="flex flex-wrap items-center justify-between gap-2">
47
+ <h3 className="font-semibold">Add a reference</h3>
48
+ {url && importOptions ? (
49
+ <div aria-label="How to add a reference" className="flex gap-2" role="group">
50
+ <Button aria-pressed={option === "url"} onClick={() => choose("url")} size="sm" type="button" variant={option === "url" ? "secondary" : "outline"}>Paste a URL</Button>
51
+ <Button aria-pressed={option === "file"} onClick={() => choose("file")} size="sm" type="button" variant={option === "file" ? "secondary" : "outline"}>Import a file</Button>
52
+ </div>
53
+ ) : null}
54
+ </div>
55
+ {url ? (
56
+ <div hidden={option !== "url"}>
57
+ <ReferenceCyclotronPriming labels={url.labels} onRequestPriming={url.onRequest} requests={url.requests} />
58
+ </div>
59
+ ) : null}
60
+ {importOptions && (fileOpened || !url) ? (
61
+ <div className="max-h-[55dvh] space-y-3 overflow-y-auto pr-1" hidden={option !== "file"}>
62
+ <ReferencePriming
63
+ {...importOptions}
64
+ onAccepted={(draft) => {
65
+ setSaved(`Added ${draft.rows.length} reference${draft.rows.length === 1 ? "" : "s"}.`);
66
+ importOptions.onAccepted?.(draft);
67
+ onImported?.();
68
+ }}
69
+ />
70
+ {saved ? <p role="status">{saved}</p> : null}
71
+ </div>
72
+ ) : null}
73
+ </section>
74
+ );
75
+ }
@@ -3,21 +3,31 @@
3
3
  import {useCallback,useEffect,useRef,useState} from 'react';
4
4
  import {useNewsroomCuration} from './newsroom-curation-provider';
5
5
  import {PublicationCyclotronPanel} from './publication-cyclotron-panel';
6
- import {ReferencePriming} from './reference-priming';
6
+ import type {PrimingPanelProps} from 'cyclotron/components/priming-panel';
7
7
  import {Button} from './ui/button';
8
8
  import {publicationSteeringPayload,type PublicationHistory,type PublicationStatus} from '../lib/publication-cyclotron';
9
9
  import type {CurationSession} from '../lib/curation-client';
10
10
 
11
- export function ReferenceCurationControls({disabled=false}:{disabled?:boolean}){
11
+ /** `refreshSignal` changes when references were just added, so the panel reloads by itself. */
12
+ export function ReferenceCurationControls({disabled=false,refreshSignal=0}:{disabled?:boolean;refreshSignal?:number}){
12
13
  const session=useNewsroomCuration();
13
- return session&&!disabled?<Controls key={session.key} session={session} disabled={disabled}/>:null;
14
+ return session&&!disabled?<Controls key={session.key} session={session} disabled={disabled} refreshSignal={refreshSignal}/>:null;
14
15
  }
15
16
 
16
- function Controls({session,disabled}:{session:CurationSession;disabled:boolean}){
17
+ /**
18
+ * The import option of the Add a reference area, or null. Fail-closed: it exists
19
+ * only when a curation session supplies priming and the person may review.
20
+ */
21
+ export function useReferenceImport(disabled=false):{panel:PrimingPanelProps;sessionKey:string}|null{
22
+ const session=useNewsroomCuration();
23
+ if(!session||disabled||!session.bootstrap.priming||!session.bootstrap.capabilities.includes('review'))return null;
24
+ return {sessionKey:session.key,panel:{client:session.priming,listId:session.bootstrap.priming.listId,classifiers:session.bootstrap.priming.classifiers}};
25
+ }
26
+
27
+ function Controls({session,disabled,refreshSignal}:{session:CurationSession;disabled:boolean;refreshSignal:number}){
17
28
  const [snapshot,setSnapshot]=useState<{status:PublicationStatus;history:PublicationHistory}|null>(null);
18
29
  const [error,setError]=useState('');
19
30
  const [loading,setLoading]=useState(true);
20
- const [primingMessage,setPrimingMessage]=useState('');
21
31
  const [steering,setSteering]=useState<string|null>(null);
22
32
  const [rubric,setRubric]=useState('');
23
33
  const [explanation,setExplanation]=useState('');
@@ -30,14 +40,16 @@ function Controls({session,disabled}:{session:CurationSession;disabled:boolean})
30
40
  const refresh=useCallback(async()=>{
31
41
  setLoading(true);setError('');
32
42
  try{setSnapshot(await session.snapshot());}
33
- catch(failure){setSnapshot(null);setError(failure instanceof Error?failure.message:'Curation status unavailable');}
43
+ catch(failure){setSnapshot(null);setError(failure instanceof Error?failure.message:'Suggestions are unavailable right now.');}
34
44
  finally{setLoading(false);}
35
45
  },[session]);
36
46
  useEffect(()=>{
37
47
  let active=true;
38
- void session.snapshot().then(value=>{if(active){setSnapshot(value);setError('');}}).catch(failure=>{if(active){setSnapshot(null);setError(failure instanceof Error?failure.message:'Curation status unavailable');}}).finally(()=>{if(active)setLoading(false);});
48
+ void session.snapshot().then(value=>{if(active){setSnapshot(value);setError('');}}).catch(failure=>{if(active){setSnapshot(null);setError(failure instanceof Error?failure.message:'Suggestions are unavailable right now.');}}).finally(()=>{if(active)setLoading(false);});
39
49
  return()=>{active=false;};
40
50
  },[session]);
51
+ const lastSignal=useRef(refreshSignal);
52
+ useEffect(()=>{if(lastSignal.current!==refreshSignal){lastSignal.current=refreshSignal;void refresh();}},[refreshSignal,refresh]);
41
53
  async function steer(action:'evaluate'|'promote'){
42
54
  if(!snapshot||!steering||!session.bootstrap.bindingRef)return;
43
55
  setBusy(true);setSteeringMessage('');
@@ -49,29 +61,26 @@ function Controls({session,disabled}:{session:CurationSession;disabled:boolean})
49
61
  const receipt=await session.publication.steer(payload);
50
62
  const result=(receipt.steering as {result?:{trial_fingerprint?:string;improved?:boolean;promoted?:boolean}}|undefined)?.result;
51
63
  if(action==='evaluate')setTrial(result?.trial_fingerprint?{fingerprint:result.trial_fingerprint,improved:result.improved===true}:null);
52
- setSteeringMessage(action==='promote'?'Evaluated proposal promoted.':result?.improved?'Evaluation complete: the candidate improved the measured result.':'Evaluation complete: no improvement was reported.');
64
+ setSteeringMessage(action==='promote'?'The tested change is now in use.':result?.improved?'Test finished: the change improved the results.':'Test finished: no improvement was found.');
53
65
  await refresh();
54
- }catch(failure){setSteeringMessage(failure instanceof Error?failure.message:'Proposal outcome unavailable; check before retrying');}
66
+ }catch(failure){setSteeringMessage(failure instanceof Error?failure.message:'The result is unknown; check before trying again.');}
55
67
  finally{setBusy(false);}
56
68
  }
57
- return <section aria-label="Publication curation" className="max-h-[55dvh] min-h-0 shrink-0 space-y-3 overflow-y-auto rounded-md border p-3" data-curation-mode={session.bootstrap.mode}>
58
- <div className="flex items-center justify-between gap-3"><h3 className="font-semibold">Publication curation</h3><Button variant="outline" size="sm" disabled={loading||busy} onClick={()=>void refresh()}>Refresh curation</Button></div>
59
- <p className="text-xs text-muted-foreground">{session.bootstrap.mode==='remote'?'Remote workspace':'Embedded workspace'} · {session.bootstrap.scope.publicationId}</p>
60
- {loading?<p role="status">Loading publication monitoring…</p>:null}
69
+ return <section aria-label="Suggestions for this publication" className="max-h-[55dvh] min-h-0 shrink-0 space-y-3 overflow-y-auto rounded-md border p-3" data-curation-mode={session.bootstrap.mode}>
70
+ <div className="flex items-center justify-between gap-3"><h3 className="font-semibold">Suggestions for this publication</h3><Button variant="outline" size="sm" disabled={loading||busy} onClick={()=>void refresh()}>Refresh</Button></div>
71
+ <p className="text-xs text-muted-foreground">{session.bootstrap.mode==='remote'?'Shared review service':'This site'} · {session.bootstrap.scope.publicationId}</p>
72
+ {loading?<p role="status">Loading suggestions…</p>:null}
61
73
  {error?<p role="alert">{error}</p>:null}
62
74
  {snapshot?<PublicationCyclotronPanel mode={session.bootstrap.mode} status={snapshot.status} history={snapshot.history}
63
75
  onReview={canReview?async payload=>{await session.publication.review(payload);await refresh();}:undefined}
64
- onSteer={canSteer&&snapshot.status.features?.steering&&session.bootstrap.bindingRef?classifier=>{setSteering(classifier);setRubric('');setExplanation('');setTrial(null);setSteeringMessage('');}:undefined}/>:null}
65
- {steering?<form aria-label="Classifier proposal" className="space-y-3 rounded-md border p-3" onSubmit={event=>{event.preventDefault();void steer('evaluate');}}>
66
- <h4 className="font-semibold">Evaluate classifier proposal</h4>
67
- <p className="text-sm">A proposal is evaluated before an explicit promotion. Existing classifications are not relabeled by opening this form.</p>
68
- <label className="block text-sm">Proposed instructions<textarea className="mt-1 block min-h-24 w-full rounded border bg-background p-2" required value={rubric} onChange={event=>{setRubric(event.target.value);setTrial(null);}}/></label>
76
+ onSteer={canSteer&&session.bootstrap.bindingRef?classifier=>{setSteering(classifier);setRubric('');setExplanation('');setTrial(null);setSteeringMessage('');}:undefined}/>:null}
77
+ {steering?<form aria-label="Rule change" className="space-y-3 rounded-md border p-3" onSubmit={event=>{event.preventDefault();void steer('evaluate');}}>
78
+ <h4 className="font-semibold">Test a rule change</h4>
79
+ <p className="text-sm">A change is tested before it is adopted. Opening this form does not change any existing suggestion.</p>
80
+ <label className="block text-sm">Proposed rule<textarea className="mt-1 block min-h-24 w-full rounded border bg-background p-2" required value={rubric} onChange={event=>{setRubric(event.target.value);setTrial(null);}}/></label>
69
81
  <label className="block text-sm">Why make this change?<textarea className="mt-1 block min-h-20 w-full rounded border bg-background p-2" required value={explanation} onChange={event=>setExplanation(event.target.value)}/></label>
70
- <div className="flex flex-wrap gap-2"><Button type="submit" disabled={busy||!canSteer||!rubric.trim()||!explanation.trim()}>Evaluate proposal</Button><Button type="button" variant="outline" disabled={busy||!canSteer||!trial?.improved||!explanation.trim()} onClick={()=>void steer('promote')}>Promote evaluated proposal</Button><Button type="button" variant="ghost" disabled={busy} onClick={()=>setSteering(null)}>Close proposal</Button></div>
82
+ <div className="flex flex-wrap gap-2"><Button type="submit" disabled={busy||!canSteer||!rubric.trim()||!explanation.trim()}>Test change</Button><Button type="button" variant="outline" disabled={busy||!canSteer||!trial?.improved||!explanation.trim()} onClick={()=>void steer('promote')}>Adopt tested change</Button><Button type="button" variant="ghost" disabled={busy} onClick={()=>setSteering(null)}>Close</Button></div>
71
83
  {steeringMessage?<p role="status">{steeringMessage}</p>:null}
72
84
  </form>:null}
73
- {session.bootstrap.priming&&canReview?<ReferencePriming client={session.priming} listId={session.bootstrap.priming.listId} classifiers={session.bootstrap.priming.classifiers}
74
- onAccepted={draft=>{setPrimingMessage(`Accepted ${draft.rows.length} example item${draft.rows.length===1?'':'s'} for this pinned publication. Operation remains available without priming.`);void refresh();}}/>:null}
75
- {primingMessage?<p role="status">{primingMessage}</p>:null}
76
85
  </section>;
77
86
  }