@anthusai/papyrus 1.0.0-next.9 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) 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 +42 -80
  6. package/amplify/data/resource.ts +42 -80
  7. package/amplify/data/schema.js +45 -60
  8. package/amplify/data/schema.ts +47 -60
  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 +143 -1
  15. package/amplify/functions/content-actions/requirements.txt +2 -1
  16. package/amplify/functions/content-actions/resource.js +6 -1
  17. package/amplify/functions/content-actions/resource.ts +9 -2
  18. package/amplify/functions/renditions/handler.py +120 -0
  19. package/amplify/functions/renditions/requirements.txt +4 -0
  20. package/amplify/functions/shared/python-bundle.js +42 -2
  21. package/amplify/functions/shared/python-bundle.ts +47 -2
  22. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  23. package/amplify/inbound-email/ses-intake.js +86 -0
  24. package/amplify/inbound-email/ses-intake.ts +110 -0
  25. package/amplify/plugins/registry.js +19 -0
  26. package/amplify/plugins/registry.ts +23 -0
  27. package/amplify/plugins/signup/function/handler.py +200 -0
  28. package/amplify/plugins/signup/function/resource.js +27 -0
  29. package/amplify/plugins/signup/function/resource.ts +35 -0
  30. package/amplify/plugins/signup/papyrus.plugin.js +59 -0
  31. package/amplify/plugins/signup/papyrus.plugin.ts +74 -0
  32. package/amplify/plugins/signup/schema.js +40 -0
  33. package/amplify/plugins/signup/schema.ts +41 -0
  34. package/amplify/plugins/signup/waf.js +48 -0
  35. package/amplify/plugins/signup/waf.ts +51 -0
  36. package/amplify/plugins/types.js +1 -0
  37. package/amplify/plugins/types.ts +39 -0
  38. package/amplify/plugins/wire.js +24 -0
  39. package/amplify/plugins/wire.ts +26 -0
  40. package/amplify/publication/default-publication.js +18 -0
  41. package/amplify/publication/default-publication.ts +20 -0
  42. package/amplify/publication/invalidation-alarm.js +26 -0
  43. package/amplify/publication/invalidation-alarm.ts +30 -0
  44. package/amplify/publication/redirect-and-index.js +37 -0
  45. package/amplify/publication/renditions-function.js +74 -0
  46. package/amplify/publication/renditions-function.ts +112 -0
  47. package/amplify/publication/renditions-settings.js +51 -0
  48. package/amplify/publication/renditions-settings.ts +58 -0
  49. package/amplify/publication/reproject-on-deploy.js +73 -0
  50. package/amplify/publication/reproject-on-deploy.ts +78 -0
  51. package/amplify/publication/s3-projection-settings.js +67 -0
  52. package/amplify/publication/s3-projection-settings.ts +101 -0
  53. package/amplify/publication/s3-projection.js +151 -0
  54. package/amplify/publication/s3-projection.ts +201 -0
  55. package/amplify/resolver-waves.js +26 -0
  56. package/amplify/resolver-waves.ts +34 -0
  57. package/amplify/revalidate-invalidation-settings.js +30 -0
  58. package/amplify/revalidate-invalidation-settings.ts +35 -0
  59. package/amplify/revalidate-secret-policy.js +13 -0
  60. package/amplify/revalidate-secret-policy.ts +19 -0
  61. package/amplify/site-backend-config.js +65 -1
  62. package/amplify/site-backend-config.ts +153 -2
  63. package/amplify/site-backend-names.js +103 -0
  64. package/amplify/site-backend-names.ts +166 -0
  65. package/amplify/site-backend.js +143 -176
  66. package/amplify/site-backend.ts +181 -201
  67. package/amplify/storage-backups/construct.js +31 -0
  68. package/amplify/storage-backups/construct.ts +47 -0
  69. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +8 -1
  70. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  71. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +6 -1
  72. package/app/[year]/[month]/[day]/page.tsx +1 -1
  73. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +6 -1
  74. package/app/api/archive/editions/route.ts +1 -1
  75. package/app/api/cyclotron/priming-proof/route.ts +11 -0
  76. package/app/api/media/[...path]/route.ts +5 -16
  77. package/app/api/revalidate/route.ts +15 -7
  78. package/app/archive/page.tsx +1 -1
  79. package/app/articles/[slug]/page.tsx +7 -5
  80. package/app/blog-rhythm.css +278 -0
  81. package/app/cyclotron/page.tsx +13 -0
  82. package/app/globals.css +2 -0
  83. package/app/layout.tsx +16 -0
  84. package/app/newsroom/[section]/[...selection]/page.tsx +9 -6
  85. package/app/newsroom/[section]/page.tsx +6 -3
  86. package/app/newsroom/layout.tsx +4 -9
  87. package/app/newsroom/settings/page.tsx +6 -0
  88. package/app/newsroom/work/page.tsx +10 -0
  89. package/app/page.tsx +22 -8
  90. package/app/robots.ts +2 -5
  91. package/app/tailwind.css +1 -0
  92. package/components/amplify-client-provider.tsx +5 -22
  93. package/components/archive-shell.tsx +2 -22
  94. package/components/article-video.tsx +152 -5
  95. package/components/blog-page-background.tsx +23 -6
  96. package/components/news-desk-page.tsx +4 -15
  97. package/components/news-desk-taxonomy-client.ts +90 -3
  98. package/components/newspaper.tsx +6 -2
  99. package/components/newsroom-access-gate.tsx +2 -1
  100. package/components/newsroom-board-view.tsx +99 -0
  101. package/components/newsroom-client-shell.tsx +12 -0
  102. package/components/newsroom-curation-provider.tsx +17 -0
  103. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  104. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  105. package/components/newsroom-fonts/playfair-display-OFL.txt +93 -0
  106. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  107. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  108. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +93 -0
  109. package/components/newsroom-newsprint-board.css +48 -0
  110. package/components/newsroom-ops-overview.tsx +28 -11
  111. package/components/newsroom-ops-shell.tsx +7 -1
  112. package/components/newsroom-organizer-shell.tsx +130 -0
  113. package/components/newsroom-organizer.css +63 -0
  114. package/components/newsroom-references-view.tsx +92 -8
  115. package/components/papyrus-console-shell.tsx +4 -2
  116. package/components/pictogram-figure.tsx +9 -2
  117. package/components/presentation-header.tsx +135 -0
  118. package/components/presentation-rhythm-hrule.tsx +17 -0
  119. package/components/priming-proof-page.tsx +92 -0
  120. package/components/publication-cyclotron-panel.tsx +57 -0
  121. package/components/reader-auth-control.tsx +8 -5
  122. package/components/reference-curation-controls.tsx +77 -0
  123. package/components/reference-cyclotron-status.tsx +109 -0
  124. package/components/reference-priming.tsx +18 -0
  125. package/components/reference-relevance-review.tsx +75 -0
  126. package/components/settings-page.tsx +2 -1
  127. package/components/signup-entries-panel.tsx +94 -0
  128. package/components/topic-steering-workspace.tsx +78 -6
  129. package/components/use-blog-header-obstacles.ts +81 -0
  130. package/components/use-brand-fonts-ready.ts +28 -0
  131. package/components/use-near-viewport.ts +30 -0
  132. package/components/use-rhythm-overlay.ts +29 -0
  133. package/infra/amplify-app-shell.js +148 -27
  134. package/infra/amplify-app-shell.ts +160 -31
  135. package/infra/build-specs.js +23 -7
  136. package/infra/build-specs.ts +27 -10
  137. package/infra/site-config.js +248 -36
  138. package/infra/site-config.ts +313 -51
  139. package/lib/amplify-outputs-path.ts +5 -1
  140. package/lib/amplify-server-runtime.ts +36 -1
  141. package/lib/approved-priming-proof-reports.ts +6 -0
  142. package/lib/blog-feature-solver.ts +459 -0
  143. package/lib/blog-rhythm.ts +134 -0
  144. package/lib/brand-fonts-watch.ts +43 -0
  145. package/lib/brand-fonts.ts +112 -0
  146. package/lib/cached-content-repository.ts +12 -0
  147. package/lib/category-repository.ts +43 -93
  148. package/lib/content-item-lifecycle.ts +55 -0
  149. package/lib/content-repository.ts +3 -0
  150. package/lib/content-types.ts +4 -0
  151. package/lib/curation-client.ts +61 -0
  152. package/lib/edition-routes.ts +31 -29
  153. package/lib/graphql-content-repository.ts +56 -22
  154. package/lib/media-route-response.ts +45 -0
  155. package/lib/newspaper-layout.ts +3 -2
  156. package/lib/newsroom-base-path.ts +12 -3
  157. package/lib/newsroom-board.ts +99 -0
  158. package/lib/newsroom-category-drilldown.ts +3 -2
  159. package/lib/newsroom-forum-routes.ts +6 -4
  160. package/lib/newsroom-index-filters.ts +14 -10
  161. package/lib/newsroom-nav.ts +3 -1
  162. package/lib/newsroom-organizer-nav.ts +37 -0
  163. package/lib/newsroom-section-routing.ts +20 -0
  164. package/lib/oauth-redirect-priority.ts +40 -0
  165. package/lib/papyrus-web-locations.ts +7 -6
  166. package/lib/plugin-settings.ts +11 -0
  167. package/lib/presentation-footer.ts +3 -2
  168. package/lib/pretext-layout.ts +12 -3
  169. package/lib/priming-proof-report.ts +89 -0
  170. package/lib/prospectus-site-identity.ts +303 -0
  171. package/lib/publication-cyclotron.ts +74 -0
  172. package/lib/rate-limit-retry.ts +55 -0
  173. package/lib/reader-base-path.ts +24 -0
  174. package/lib/reader-cache-lifetime.ts +5 -0
  175. package/lib/reader-static-params.ts +85 -4
  176. package/lib/reader-storage-url.ts +9 -12
  177. package/lib/reference-policy.ts +5 -0
  178. package/lib/relevance-decisions.ts +103 -0
  179. package/lib/relevance-status.ts +63 -0
  180. package/lib/renderer.ts +7 -0
  181. package/lib/revalidate-prewarm.ts +53 -0
  182. package/lib/revalidate-secret.ts +59 -0
  183. package/lib/robots-policy.ts +14 -0
  184. package/lib/root-route-redirect.ts +16 -0
  185. package/lib/semantic-graph.ts +7 -0
  186. package/lib/shared-credentials-provider.ts +61 -0
  187. package/lib/signup-csv.ts +24 -0
  188. package/lib/site-brand.ts +59 -0
  189. package/lib/staging-gated-path.ts +23 -0
  190. package/lib/staging-preview-object.ts +21 -5
  191. package/lib/test-editor-override.ts +4 -0
  192. package/lib/video-loading.ts +22 -0
  193. package/lib/video-script.ts +14 -0
  194. package/middleware.ts +11 -19
  195. package/package.json +64 -1
  196. package/renderers/pretext/article-page.tsx +39 -3
  197. package/renderers/pretext/presentation-shared.tsx +113 -0
  198. package/renderers/pretext/presentation-shell.tsx +15 -102
  199. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  200. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  201. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  202. package/routes.manifest.json +68 -12
  203. package/src/sync.mjs +30 -6
  204. package/src/with-papyrus.d.mts +9 -0
  205. package/src/with-papyrus.mjs +48 -3
  206. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  207. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  208. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
@@ -0,0 +1,57 @@
1
+ 'use client';
2
+
3
+ import {useRef,useState} from 'react';
4
+ import {ReviewControl,type ReviewControlReview} from 'cyclotron/components/review-control';
5
+ import {CyclotronStatusView} from 'cyclotron/components/cyclotron-status';
6
+ import {assertPublicationScope,publicationScopeKey,publicationReviewPayload,type PublicationStatus,type PublicationHistory,type PublicationHistoryRow,type PublicationReviewPayload} from '../lib/publication-cyclotron';
7
+
8
+ function requestId(){
9
+ const bytes=crypto.getRandomValues(new Uint8Array(16));
10
+ return Array.from(bytes,value=>value.toString(16).padStart(2,'0')).join('');
11
+ }
12
+
13
+ /** Data-only shared operator panel. The authorized host owns reads, writes and refresh. */
14
+ type PublicationPanelProps={mode:'embedded'|'remote';status:PublicationStatus;history:PublicationHistory;onReview?:(payload:PublicationReviewPayload)=>Promise<void>;onSteer?:(classifier:string)=>void};
15
+ export function PublicationCyclotronPanel(props:PublicationPanelProps){
16
+ assertPublicationScope(props.status,props.history);
17
+ // Remount all drafts, retry identities, pending messages and busy state on scope change.
18
+ return <PublicationControls key={publicationScopeKey(props.status)} {...props}/>;
19
+ }
20
+ function PublicationControls({mode,status,history,onReview,onSteer}:PublicationPanelProps){
21
+ assertPublicationScope(status,history);
22
+ const context=publicationScopeKey(status);const current=useRef(context);current.current=context;
23
+ const [message,setMessage]=useState<{context:string;key:string;error:boolean;text:string}|null>(null);const [busy,setBusy]=useState<string|null>(null);
24
+ const [editing,setEditing]=useState<Record<string,boolean>>({});const requests=useRef<Record<string,{signature:string;id:string}>>({});
25
+ async function submit(row:PublicationHistoryRow,classifier:string,review:ReviewControlReview){
26
+ const key=`${context}:${row.decision.decisionId}:${classifier}:${row.context?.contextId??'legacy'}`;
27
+ try{
28
+ const signature=JSON.stringify(review);const previous=requests.current[key];
29
+ const id=previous?.signature===signature?previous.id:requestId();requests.current[key]={signature,id};
30
+ const payload=publicationReviewPayload(status,row,classifier,review,id);
31
+ if(!onReview)throw new Error('This host has not enabled human review');
32
+ 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.'});}
35
+ finally{if(current.current===context)setBusy(null);}
36
+ }
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>
39
+ {status.outputs.map(output=><section key={`${context}:${output.id}`} aria-label={output.question} className="space-y-3 rounded-md border p-3">
40
+ <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}
42
+ {history.history.map(row=>{
43
+ const prediction=row.decision.classifiers[output.id];if(!prediction)return null;
44
+ 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
+ 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>
47
+ <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}
49
+ <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
+ {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>
53
+ </article>;
54
+ })}
55
+ </section>)}
56
+ </section>;
57
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { signInWithRedirect, signOut } from "aws-amplify/auth";
4
4
  import { Hub } from "aws-amplify/utils";
5
- import { useCallback, useEffect, useState } from "react";
5
+ import { useCallback, useEffect, useState, type ReactNode } from "react";
6
6
  import { configureAmplifyClient } from "./amplify-client-provider";
7
7
  import { loadReaderSessionSnapshot, type ReaderAuthSnapshot } from "./reader-auth-state";
8
8
 
@@ -12,6 +12,7 @@ type ReaderAuthControlProps = {
12
12
  postAuthPath?: string;
13
13
  showIdentity?: boolean;
14
14
  authState?: ReaderAuthSnapshot;
15
+ renderAction?: (action: { disabled: boolean; onClick: () => void; signedIn: boolean }) => ReactNode;
15
16
  };
16
17
 
17
18
  const POST_AUTH_PATH_KEY = "papyrus:reader-auth:post-auth-path";
@@ -22,6 +23,7 @@ export function ReaderAuthControl({
22
23
  postAuthPath,
23
24
  showIdentity = false,
24
25
  authState: externalAuthState,
26
+ renderAction,
25
27
  }: ReaderAuthControlProps) {
26
28
  const [authState, setAuthState] = useState<ReaderAuthSnapshot>(externalAuthState ?? { status: "loading", label: "Checking sign-in" });
27
29
  const [busy, setBusy] = useState(false);
@@ -124,19 +126,20 @@ export function ReaderAuthControl({
124
126
 
125
127
  const signedIn = authState.status === "signedIn";
126
128
  const actionLabel = signedIn ? "LOGOUT" : "LOGIN";
129
+ const Container = renderAction ? "div" : "span";
127
130
  return (
128
- <span className={className ? `reader-auth ${className}` : "reader-auth"} aria-live="polite" data-footer-utility={dataFooterUtility}>
131
+ <Container className={className ? `reader-auth ${className}` : "reader-auth"} aria-live="polite" data-footer-utility={dataFooterUtility}>
129
132
  {signedIn && showIdentity ? <span className="reader-auth__identity">{authState.label}</span> : null}
130
133
  {error ? <span className="reader-auth__error">{error}</span> : null}
131
- <button
134
+ {renderAction ? renderAction({ disabled: authState.status === "loading" || busy, onClick: signedIn ? signOutCurrentUser : signIn, signedIn }) : <button
132
135
  className="reader-auth__button"
133
136
  disabled={authState.status === "loading" || busy}
134
137
  onClick={signedIn ? signOutCurrentUser : signIn}
135
138
  type="button"
136
139
  >
137
140
  {actionLabel}
138
- </button>
139
- </span>
141
+ </button>}
142
+ </Container>
140
143
  );
141
144
  }
142
145
 
@@ -0,0 +1,77 @@
1
+ 'use client';
2
+
3
+ import {useCallback,useEffect,useRef,useState} from 'react';
4
+ import {useNewsroomCuration} from './newsroom-curation-provider';
5
+ import {PublicationCyclotronPanel} from './publication-cyclotron-panel';
6
+ import {ReferencePriming} from './reference-priming';
7
+ import {Button} from './ui/button';
8
+ import {publicationSteeringPayload,type PublicationHistory,type PublicationStatus} from '../lib/publication-cyclotron';
9
+ import type {CurationSession} from '../lib/curation-client';
10
+
11
+ export function ReferenceCurationControls({disabled=false}:{disabled?:boolean}){
12
+ const session=useNewsroomCuration();
13
+ return session&&!disabled?<Controls key={session.key} session={session} disabled={disabled}/>:null;
14
+ }
15
+
16
+ function Controls({session,disabled}:{session:CurationSession;disabled:boolean}){
17
+ const [snapshot,setSnapshot]=useState<{status:PublicationStatus;history:PublicationHistory}|null>(null);
18
+ const [error,setError]=useState('');
19
+ const [loading,setLoading]=useState(true);
20
+ const [primingMessage,setPrimingMessage]=useState('');
21
+ const [steering,setSteering]=useState<string|null>(null);
22
+ const [rubric,setRubric]=useState('');
23
+ const [explanation,setExplanation]=useState('');
24
+ const [busy,setBusy]=useState(false);
25
+ const [trial,setTrial]=useState<{fingerprint:string;improved:boolean}|null>(null);
26
+ const [steeringMessage,setSteeringMessage]=useState('');
27
+ const steeringRequests=useRef<Record<string,string>>({});
28
+ const canReview=!disabled&&session.bootstrap.capabilities.includes('review');
29
+ const canSteer=!disabled&&session.bootstrap.capabilities.includes('steer');
30
+ const refresh=useCallback(async()=>{
31
+ setLoading(true);setError('');
32
+ try{setSnapshot(await session.snapshot());}
33
+ catch(failure){setSnapshot(null);setError(failure instanceof Error?failure.message:'Curation status unavailable');}
34
+ finally{setLoading(false);}
35
+ },[session]);
36
+ useEffect(()=>{
37
+ 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);});
39
+ return()=>{active=false;};
40
+ },[session]);
41
+ async function steer(action:'evaluate'|'promote'){
42
+ if(!snapshot||!steering||!session.bootstrap.bindingRef)return;
43
+ setBusy(true);setSteeringMessage('');
44
+ try{
45
+ const identity=JSON.stringify([action,steering,rubric.trim(),explanation.trim(),trial?.fingerprint??'',snapshot.status.classifiers[steering].cyclotron.fingerprint]);
46
+ const requestId=steeringRequests.current[identity]??=crypto.randomUUID();
47
+ const command=action==='evaluate'?{action,proposal:{rubric:rubric.trim()},explanation,requestId} as const:{action,trialFingerprint:trial?.fingerprint??'',explanation,requestId} as const;
48
+ const payload=publicationSteeringPayload(snapshot.status,steering,session.bootstrap.bindingRef,command);
49
+ const receipt=await session.publication.steer(payload);
50
+ const result=(receipt.steering as {result?:{trial_fingerprint?:string;improved?:boolean;promoted?:boolean}}|undefined)?.result;
51
+ 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.');
53
+ await refresh();
54
+ }catch(failure){setSteeringMessage(failure instanceof Error?failure.message:'Proposal outcome unavailable; check before retrying');}
55
+ finally{setBusy(false);}
56
+ }
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}
61
+ {error?<p role="alert">{error}</p>:null}
62
+ {snapshot?<PublicationCyclotronPanel mode={session.bootstrap.mode} status={snapshot.status} history={snapshot.history}
63
+ 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>
69
+ <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>
71
+ {steeringMessage?<p role="status">{steeringMessage}</p>:null}
72
+ </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
+ </section>;
77
+ }
@@ -0,0 +1,109 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { CyclotronStatusView } from "cyclotron/components/cyclotron-status";
5
+ import type { CyclotronStatus } from "cyclotron/cyclotron-status";
6
+
7
+ const RATE_CHOICES = [1, 0.5, 0.25, 0.1, 0];
8
+ const DAY_CHOICES = [1, 7, 14, 30];
9
+
10
+ export type ReviewRateChoice = { rate: number; days: number } | { clear: true };
11
+
12
+ /**
13
+ * The relevance cyclotron's status above the References list: a strip, the
14
+ * detail card on demand, and, for editors who may change it, a form that asks
15
+ * the next sweep for a manual review rate.
16
+ */
17
+ export function ReferenceCyclotronStatus({
18
+ onRequestRate,
19
+ status,
20
+ staleAfterHours = 24,
21
+ now = new Date(),
22
+ }: {
23
+ onRequestRate?: (choice: ReviewRateChoice) => Promise<void> | void;
24
+ status: CyclotronStatus;
25
+ staleAfterHours?: number;
26
+ now?: Date;
27
+ }) {
28
+ const [open, setOpen] = useState(false);
29
+ const [rate, setRate] = useState(0.5);
30
+ const [days, setDays] = useState(7);
31
+ const [message, setMessage] = useState<string | null>(null);
32
+ const ageHours = Math.max(0, (now.getTime() - Date.parse(status.asOf)) / 3_600_000);
33
+ const stale = Number.isFinite(ageHours) && ageHours > staleAfterHours;
34
+ const override = status.reviewRate.override;
35
+
36
+ async function submit(choice: ReviewRateChoice) {
37
+ if (!onRequestRate) return;
38
+ setMessage("Sending…");
39
+ try {
40
+ await onRequestRate(choice);
41
+ setMessage("The next sweep applies it and the status will show who set it.");
42
+ } catch (error) {
43
+ setMessage(error instanceof Error ? error.message : String(error));
44
+ }
45
+ }
46
+
47
+ return (
48
+ <section aria-label="Relevance cyclotron" className="space-y-2" data-reference-cyclotron-status>
49
+ <CyclotronStatusView status={status} />
50
+ {stale ? (
51
+ <p className="text-xs text-muted-foreground" data-reference-cyclotron-status-stale>
52
+ <span aria-hidden="true">⚠ </span>
53
+ Snapshot from {Math.round(ageHours)} hours ago; the next sweep refreshes it.
54
+ </p>
55
+ ) : null}
56
+ <button
57
+ aria-expanded={open}
58
+ className="text-xs text-muted-foreground underline underline-offset-4"
59
+ onClick={() => setOpen((value) => !value)}
60
+ type="button"
61
+ >
62
+ {open ? "Hide what the cyclotron is doing" : "What the cyclotron is doing"}
63
+ </button>
64
+ {open ? (
65
+ <div className="space-y-3">
66
+ <CyclotronStatusView classifierName="Relevant to this publication" status={status} variant="card" />
67
+ {onRequestRate ? (
68
+ <form
69
+ aria-label="Manual review rate"
70
+ className="flex flex-wrap items-end gap-3 text-sm"
71
+ data-reference-review-rate-form
72
+ onSubmit={(event) => {
73
+ event.preventDefault();
74
+ void submit({ rate, days });
75
+ }}
76
+ >
77
+ <label className="flex flex-col gap-1">
78
+ <span className="text-xs text-muted-foreground">Review rate for confident decisions</span>
79
+ <select aria-label="Manual review rate" onChange={(event) => setRate(Number(event.target.value))} value={rate}>
80
+ {RATE_CHOICES.map((choice) => (
81
+ <option key={choice} value={choice}>{Math.round(choice * 100)}%</option>
82
+ ))}
83
+ </select>
84
+ </label>
85
+ <label className="flex flex-col gap-1">
86
+ <span className="text-xs text-muted-foreground">For</span>
87
+ <select aria-label="How long the manual rate lasts" onChange={(event) => setDays(Number(event.target.value))} value={days}>
88
+ {DAY_CHOICES.map((choice) => (
89
+ <option key={choice} value={choice}>{choice} day{choice === 1 ? "" : "s"}</option>
90
+ ))}
91
+ </select>
92
+ </label>
93
+ <button className="cyclotron-review-secondary" type="submit">Set manual rate</button>
94
+ {override ? (
95
+ <button className="cyclotron-review-secondary" onClick={() => void submit({ clear: true })} type="button">
96
+ Clear manual rate
97
+ </button>
98
+ ) : null}
99
+ <p className="basis-full text-xs text-muted-foreground">
100
+ Low-confidence decisions and the random audit share are always reviewed.
101
+ </p>
102
+ {message ? <p className="basis-full text-xs" role="status">{message}</p> : null}
103
+ </form>
104
+ ) : null}
105
+ </div>
106
+ ) : null}
107
+ </section>
108
+ );
109
+ }
@@ -0,0 +1,18 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { PrimingPanel, type PrimingPanelProps } from "cyclotron/components/priming-panel";
5
+ import { Button } from "./ui/button";
6
+
7
+ /** Papyrus supplies its transport/configuration; Cyclotron owns preview and labels. */
8
+ export function ReferencePriming(props: PrimingPanelProps) {
9
+ const [open, setOpen] = useState(false);
10
+ return (
11
+ <section className="shrink-0 space-y-3" aria-label="Representative reference examples">
12
+ <Button aria-expanded={open} onClick={() => setOpen((value) => !value)} variant="outline">
13
+ {open ? "Hide example priming" : "Curate reference examples (optional)"}
14
+ </Button>
15
+ {open ? <div className="max-h-[55dvh] space-y-3 overflow-y-auto pr-1"><p className="text-sm text-muted-foreground">Corrections to extracted content: describe what needs fixing, then enter the corrected title and abstract or summary. The note is saved with your correction; it does not automatically regenerate the content. Choose Publish / Not publish separately. An explanation of that judgment is optional.</p><PrimingPanel {...props} renderItem={props.renderItem ?? ((item) => <div className="space-y-2"><p className="whitespace-pre-wrap text-sm">{String(item.values.abstract ?? item.values.text ?? "")}</p><p className="break-all text-xs text-muted-foreground">{String(item.values.source_uri ?? "")}</p></div>)} /></div> : null}
16
+ </section>
17
+ );
18
+ }
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ReviewControl, type ReviewReason } from "cyclotron/components/review-control";
5
+ import { isScopeTrainingNegativeReason, REFERENCE_REJECTION_REASON_CODES } from "../lib/reference-policy";
6
+ import {
7
+ curationForRelevanceReview,
8
+ type RelevanceCuration,
9
+ type RelevanceDecisionView,
10
+ } from "../lib/relevance-decisions";
11
+
12
+ /**
13
+ * Papyrus's existing rejection reasons, unchanged. Reasons that are not about
14
+ * relevance (a duplicate, an unavailable source) close the review without
15
+ * teaching the cyclotron.
16
+ */
17
+ export const RELEVANCE_REVIEW_REASONS: ReviewReason[] = REFERENCE_REJECTION_REASON_CODES.map((code) => ({
18
+ code,
19
+ text: code.charAt(0).toUpperCase() + code.slice(1).replaceAll("_", " "),
20
+ ...(isScopeTrainingNegativeReason(code) ? {} : { noLabel: true }),
21
+ }));
22
+
23
+ export function ReferenceRelevanceReview({
24
+ disabled,
25
+ decision,
26
+ onCurate,
27
+ referenceLineageId,
28
+ }: {
29
+ disabled?: boolean;
30
+ decision: RelevanceDecisionView;
31
+ onCurate: (curation: RelevanceCuration) => void;
32
+ referenceLineageId: string;
33
+ }) {
34
+ const [shareable, setShareable] = useState(false);
35
+ const [error, setError] = useState<string | null>(null);
36
+ const classes = decision.classes.length ? decision.classes : [decision.label];
37
+ return (
38
+ <section aria-label="Relevance review" className="space-y-2" data-reference-relevance-review={referenceLineageId}>
39
+ <ReviewControl
40
+ decision={{
41
+ decisionId: decision.decisionId,
42
+ label: decision.label,
43
+ confidence: decision.confidence,
44
+ classes,
45
+ version: decision.version ?? undefined,
46
+ }}
47
+ disabled={disabled}
48
+ itemId={referenceLineageId}
49
+ mode={decision.reviewControl === "thumbs" && classes.length === 2 ? "thumbs" : "labels"}
50
+ onReview={(review) => {
51
+ try {
52
+ setError(null);
53
+ onCurate(curationForRelevanceReview(decision, { ...review, shareable }));
54
+ } catch (failure) {
55
+ setError(failure instanceof Error ? failure.message : String(failure));
56
+ }
57
+ }}
58
+ positiveLabel={decision.positiveLabel ?? undefined}
59
+ question={decision.question ?? undefined}
60
+ reasons={RELEVANCE_REVIEW_REASONS}
61
+ reviewReason={decision.reviewRecommended ? decision.reviewDetail ?? undefined : undefined}
62
+ />
63
+ <label className="flex items-center gap-2 text-xs text-muted-foreground">
64
+ <input
65
+ checked={shareable}
66
+ disabled={disabled}
67
+ onChange={(event) => setShareable(event.target.checked)}
68
+ type="checkbox"
69
+ />
70
+ My explanation may be quoted publicly (for the Cyclotron example page)
71
+ </label>
72
+ {error ? <p className="text-xs text-destructive" role="alert">{error}</p> : null}
73
+ </section>
74
+ );
75
+ }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { toPublicReaderPath } from "../lib/reader-base-path";
3
4
  import { signOut } from "aws-amplify/auth";
4
5
  import Link from "next/link";
5
6
  import { useCallback, useEffect, useRef, useState } from "react";
@@ -114,7 +115,7 @@ export function SettingsPage() {
114
115
  return (
115
116
  <main className="settings-page">
116
117
  <nav className="edition-progress edition-progress--newsroom settings-progress" aria-label="Settings navigation">
117
- <Link className="edition-progress__button edition-progress__button--previous" href="/">
118
+ <Link className="edition-progress__button edition-progress__button--previous" href={toPublicReaderPath("/")}>
118
119
  <svg aria-hidden="true" className="edition-progress__icon" focusable="false" viewBox="0 0 10 10">
119
120
  <path d="M7.5 1 2.5 5 7.5 9Z" fill="currentColor" />
120
121
  </svg>
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import amplifyOutputs from "papyrus-amplify-outputs";
5
+ import { useCallback, useEffect, useMemo, useState } from "react";
6
+ import { signupForms, type PluginOutputs } from "../lib/plugin-settings";
7
+ import { signupEntriesToCsv, type SignupEntryRecord } from "../lib/signup-csv";
8
+
9
+ const LIST_QUERY = `query ListSignupEntries($form: String!, $nextToken: String) {
10
+ listSignupEntriesByFormAndSubmittedAt(form: $form, sortDirection: DESC, limit: 200, nextToken: $nextToken) {
11
+ items { id form value submittedAt }
12
+ nextToken
13
+ }
14
+ }`;
15
+
16
+ type ListResult = { data?: { listSignupEntriesByFormAndSubmittedAt?: { items: SignupEntryRecord[]; nextToken?: string | null } }; errors?: { message?: string }[] };
17
+
18
+ async function loadAllEntries(form: string): Promise<SignupEntryRecord[]> {
19
+ const client = generateClient({ authMode: "userPool" });
20
+ const entries: SignupEntryRecord[] = [];
21
+ let nextToken: string | null | undefined;
22
+ do {
23
+ const result = (await client.graphql({ query: LIST_QUERY, variables: { form, nextToken } })) as ListResult;
24
+ if (result.errors?.length) throw new Error(result.errors[0]?.message ?? "Could not load signups.");
25
+ const page = result.data?.listSignupEntriesByFormAndSubmittedAt;
26
+ entries.push(...(page?.items ?? []));
27
+ nextToken = page?.nextToken;
28
+ } while (nextToken);
29
+ return entries;
30
+ }
31
+
32
+ export function SignupEntriesPanel() {
33
+ const forms = useMemo(() => signupForms(amplifyOutputs as PluginOutputs), []);
34
+ const [form, setForm] = useState(forms[0] ?? "");
35
+ const [entries, setEntries] = useState<SignupEntryRecord[] | null>(null);
36
+ const [problem, setProblem] = useState<string | null>(null);
37
+
38
+ const refresh = useCallback(async () => {
39
+ if (!form) return;
40
+ setEntries(null);
41
+ setProblem(null);
42
+ try {
43
+ setEntries(await loadAllEntries(form));
44
+ } catch (error) {
45
+ setProblem(error instanceof Error ? error.message : "Could not load signups.");
46
+ }
47
+ }, [form]);
48
+
49
+ useEffect(() => {
50
+ void refresh();
51
+ }, [refresh]);
52
+
53
+ const exportCsv = () => {
54
+ if (!entries) return;
55
+ const url = URL.createObjectURL(new Blob([signupEntriesToCsv(entries)], { type: "text/csv;charset=utf-8" }));
56
+ const link = document.createElement("a");
57
+ link.href = url;
58
+ link.download = `signups-${form}.csv`;
59
+ link.click();
60
+ URL.revokeObjectURL(url);
61
+ };
62
+
63
+ return (
64
+ <section aria-labelledby="newsroom-signups-title" data-news-desk-admin-panel="signups">
65
+ <h3 id="newsroom-signups-title">Signups</h3>
66
+ <p>Addresses people entered in the site&apos;s signup forms. Only editors and admins can see them.</p>
67
+ <label>
68
+ Form{" "}
69
+ <select value={form} onChange={(event) => setForm(event.target.value)} disabled={forms.length < 2}>
70
+ {forms.map((name) => (
71
+ <option key={name} value={name}>{name}</option>
72
+ ))}
73
+ </select>
74
+ </label>{" "}
75
+ <button type="button" onClick={() => void refresh()}>Refresh</button>{" "}
76
+ <button type="button" onClick={exportCsv} disabled={!entries || entries.length === 0}>Export CSV</button>
77
+ <p role="status" aria-live="polite">
78
+ {problem ? `Error: ${problem}` : entries === null ? "Loading..." : `${entries.length} ${entries.length === 1 ? "entry" : "entries"}`}
79
+ </p>
80
+ {entries && entries.length > 0 ? (
81
+ <table>
82
+ <thead>
83
+ <tr><th scope="col">Submitted</th><th scope="col">Address</th></tr>
84
+ </thead>
85
+ <tbody>
86
+ {entries.map((entry) => (
87
+ <tr key={entry.id}><td>{entry.submittedAt}</td><td>{entry.value}</td></tr>
88
+ ))}
89
+ </tbody>
90
+ </table>
91
+ ) : null}
92
+ </section>
93
+ );
94
+ }