@anthusai/papyrus 1.0.0-next.4 → 1.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 (147) hide show
  1. package/amplify/auth/redirect-urls.js +54 -0
  2. package/amplify/auth/redirect-urls.ts +66 -0
  3. package/amplify/auth/resource.js +20 -5
  4. package/amplify/auth/resource.ts +27 -5
  5. package/amplify/data/resource.js +28 -70
  6. package/amplify/data/resource.ts +29 -70
  7. package/amplify/data/schema.js +33 -58
  8. package/amplify/data/schema.ts +34 -58
  9. package/amplify/functions/category-action/handler.ts +5 -3
  10. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  11. package/amplify/functions/console-chat-responder/resource.js +0 -7
  12. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  13. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  14. package/amplify/functions/content-actions/handler.py +45 -3
  15. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  16. package/amplify/inbound-email/ses-intake.js +86 -0
  17. package/amplify/inbound-email/ses-intake.ts +110 -0
  18. package/amplify/rebuild-trigger-settings.js +22 -0
  19. package/amplify/rebuild-trigger-settings.ts +42 -0
  20. package/amplify/resolver-waves.js +26 -0
  21. package/amplify/resolver-waves.ts +34 -0
  22. package/amplify/site-backend-config.ts +36 -1
  23. package/amplify/site-backend-names.js +103 -0
  24. package/amplify/site-backend-names.ts +166 -0
  25. package/amplify/site-backend.js +93 -170
  26. package/amplify/site-backend.ts +126 -196
  27. package/amplify/storage/resource.js +3 -0
  28. package/amplify/storage/resource.ts +3 -0
  29. package/amplify/storage-backups/construct.js +31 -0
  30. package/amplify/storage-backups/construct.ts +47 -0
  31. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  32. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -0
  33. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  34. package/app/api/media/[...path]/route.ts +5 -16
  35. package/app/api/revalidate/route.ts +8 -7
  36. package/app/articles/[slug]/page.tsx +6 -4
  37. package/app/blog-rhythm.css +278 -0
  38. package/app/globals.css +21 -0
  39. package/app/layout.tsx +16 -0
  40. package/app/newsroom/[section]/[...selection]/page.tsx +6 -5
  41. package/app/newsroom/[section]/page.tsx +4 -3
  42. package/app/newsroom/articles/[id]/page.tsx +15 -0
  43. package/app/newsroom/articles/page.tsx +13 -0
  44. package/app/newsroom/layout.tsx +4 -9
  45. package/app/page.tsx +18 -5
  46. package/app/robots.ts +2 -5
  47. package/bin/papyrus-app.mjs +12 -2
  48. package/bin/papyrus-infra.mjs +29 -3
  49. package/components/amplify-client-provider.tsx +4 -22
  50. package/components/archive-shell.tsx +2 -22
  51. package/components/article-video.tsx +247 -3
  52. package/components/blog-page-background.tsx +23 -6
  53. package/components/markus-ir-preview.tsx +300 -0
  54. package/components/news-desk-taxonomy-client.ts +51 -0
  55. package/components/newspaper.tsx +6 -2
  56. package/components/newsroom-access-gate.tsx +67 -0
  57. package/components/newsroom-article-editor.tsx +741 -0
  58. package/components/newsroom-articles-view.tsx +129 -0
  59. package/components/newsroom-client-shell.tsx +12 -0
  60. package/components/newsroom-ops-overview.tsx +28 -11
  61. package/components/newsroom-ops-shell.tsx +3 -1
  62. package/components/newsroom-references-view.tsx +83 -8
  63. package/components/papyrus-console-shell.tsx +4 -2
  64. package/components/pictogram-figure.tsx +9 -2
  65. package/components/presentation-header.tsx +135 -0
  66. package/components/presentation-rhythm-hrule.tsx +17 -0
  67. package/components/reference-cyclotron-status.tsx +109 -0
  68. package/components/reference-relevance-review.tsx +75 -0
  69. package/components/settings-page.tsx +2 -1
  70. package/components/topic-steering-workspace.tsx +54 -64
  71. package/components/use-blog-header-obstacles.ts +81 -0
  72. package/components/use-brand-fonts-ready.ts +28 -0
  73. package/components/use-rhythm-overlay.ts +29 -0
  74. package/infra/amplify-app-shell.js +228 -50
  75. package/infra/amplify-app-shell.ts +256 -53
  76. package/infra/build-specs.js +15 -5
  77. package/infra/build-specs.ts +18 -7
  78. package/infra/github-oidc-provider.js +24 -0
  79. package/infra/github-oidc-provider.ts +30 -0
  80. package/infra/index.js +1 -0
  81. package/infra/site-config.js +236 -11
  82. package/infra/site-config.ts +276 -15
  83. package/lib/amplify-outputs-path.ts +5 -1
  84. package/lib/blog-feature-solver.ts +459 -0
  85. package/lib/blog-rhythm.ts +134 -0
  86. package/lib/brand-fonts-watch.ts +43 -0
  87. package/lib/brand-fonts.ts +112 -0
  88. package/lib/cached-content-repository.ts +12 -0
  89. package/lib/category-repository.ts +42 -93
  90. package/lib/content-actions-demo.ts +190 -0
  91. package/lib/content-repository.ts +3 -0
  92. package/lib/content-source-context.ts +12 -2
  93. package/lib/content-types.ts +4 -0
  94. package/lib/edition-routes.ts +31 -29
  95. package/lib/graphql-content-repository.ts +61 -9
  96. package/lib/media-route-response.ts +45 -0
  97. package/lib/newspaper-layout.ts +3 -2
  98. package/lib/newsroom-articles.ts +325 -0
  99. package/lib/newsroom-base-path.ts +12 -3
  100. package/lib/newsroom-category-drilldown.ts +3 -2
  101. package/lib/newsroom-demo-articles.ts +90 -0
  102. package/lib/newsroom-forum-routes.ts +6 -4
  103. package/lib/newsroom-index-filters.ts +14 -10
  104. package/lib/newsroom-media-backend.ts +142 -0
  105. package/lib/newsroom-media.ts +182 -0
  106. package/lib/newsroom-nav.ts +3 -0
  107. package/lib/oauth-redirect-priority.ts +40 -0
  108. package/lib/papyrus-web-locations.ts +7 -6
  109. package/lib/presentation-footer.ts +3 -2
  110. package/lib/pretext-layout.ts +12 -3
  111. package/lib/reader-base-path.ts +24 -0
  112. package/lib/reader-static-params.ts +13 -0
  113. package/lib/reference-policy.ts +5 -0
  114. package/lib/relevance-decisions.ts +103 -0
  115. package/lib/relevance-status.ts +63 -0
  116. package/lib/renderer.ts +7 -0
  117. package/lib/revalidate-secret.ts +59 -0
  118. package/lib/robots-policy.ts +14 -0
  119. package/lib/root-route-redirect.ts +16 -0
  120. package/lib/semantic-graph.ts +7 -0
  121. package/lib/site-brand.ts +51 -0
  122. package/lib/staging-gated-path.ts +23 -0
  123. package/lib/staging-preview-object.ts +114 -0
  124. package/lib/video-mode.ts +42 -0
  125. package/lib/video-script.ts +77 -0
  126. package/middleware.ts +23 -18
  127. package/package.json +45 -1
  128. package/renderers/pretext/article-page.tsx +39 -3
  129. package/renderers/pretext/presentation-shared.tsx +113 -0
  130. package/renderers/pretext/presentation-shell.tsx +15 -102
  131. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  132. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  133. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  134. package/routes.manifest.json +45 -2
  135. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  136. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  137. package/scripts/videoml/bundle-common.mjs +129 -0
  138. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  139. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  140. package/scripts/videoml/preview/preview-timing.ts +42 -0
  141. package/scripts/videoml/preview.html +77 -0
  142. package/scripts/videoml/standard-entry.tsx +46 -0
  143. package/src/sync.mjs +30 -6
  144. package/src/with-papyrus.mjs +19 -2
  145. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  146. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  147. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
@@ -0,0 +1,17 @@
1
+ type PresentationRhythmHRuleProps = {
2
+ /** Hide in wide 2-column secondary pairs (items 1+2 side-by-side). */
3
+ hideInSecondaryPair?: boolean;
4
+ };
5
+
6
+ export function PresentationRhythmHRule({ hideInSecondaryPair = false }: PresentationRhythmHRuleProps) {
7
+ return (
8
+ <div
9
+ aria-hidden="true"
10
+ className="presentation-rhythm-hrule"
11
+ data-hide-in-secondary-pair={hideInSecondaryPair ? "true" : undefined}
12
+ role="presentation"
13
+ >
14
+ <div className="presentation-rhythm-hrule__line" />
15
+ </div>
16
+ );
17
+ }
@@ -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,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>
@@ -53,6 +53,8 @@ import {
53
53
  type ForumThreadWithMessages,
54
54
  type KnowledgeQueryResponse,
55
55
  type NewsroomRecordPage,
56
+ loadCyclotronStatus,
57
+ requestCyclotronReviewRate,
56
58
  } from "./news-desk-taxonomy-client";
57
59
  import { listConsoleThreads } from "../lib/console-chat-client";
58
60
  import {
@@ -76,12 +78,13 @@ import { buildNewsroomKnowledgeQueryInput, type NewsroomKnowledgeQueryAnchor as
76
78
  import { NewsroomConsoleProgressToggle, PapyrusConsoleChatIcon, usePapyrusConsole } from "./papyrus-console-shell";
77
79
  import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
78
80
  import { SITE_BRAND } from "../lib/site-brand";
81
+ import { browserPathToInternalNewsroomPath, toPublicNewsroomPath } from "../lib/newsroom-base-path";
79
82
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
80
83
  import { cn } from "../lib/utils";
81
84
  import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
82
85
  import { ReferenceSourcePreview } from "./reference-source-preview";
83
86
  import type { ReaderAuthSnapshot } from "./reader-auth-state";
84
- import { ReaderAuthControl } from "./reader-auth-control";
87
+ import { NewsDeskAccessGate } from "./newsroom-access-gate";
85
88
  import { NewsroomOpsOverview } from "./newsroom-ops-overview";
86
89
  import { NewsroomReferencesView } from "./newsroom-references-view";
87
90
  import { NewsroomAssignmentsView } from "./newsroom-assignments-view";
@@ -150,9 +153,13 @@ import {
150
153
  type SemanticObjectSummary,
151
154
  } from "../lib/semantic-graph";
152
155
  import {
156
+ normalizeReferenceRejectionReasonCode,
153
157
  REFERENCE_REJECTION_REASON_CODES,
154
158
  referenceCurationStatusForAction,
155
159
  } from "../lib/reference-policy";
160
+ import { currentRelevanceDecisions } from "../lib/relevance-decisions";
161
+ import { buildReviewRateRequest, DEMO_CYCLOTRON_STATUS } from "../lib/relevance-status";
162
+ import type { CyclotronStatus } from "cyclotron/cyclotron-status";
156
163
  import {
157
164
  referenceDisplaySummary,
158
165
  referenceMetadataField,
@@ -169,7 +176,6 @@ import {
169
176
  uniqueNeighborGroupsForCategoryContext,
170
177
  type CategoryDrilldownContext,
171
178
  } from "../lib/newsroom-category-drilldown";
172
- import type { NewsDeskShellState } from "../lib/news-desk-session";
173
179
  import {
174
180
  resolveNewsroomCardTemplate,
175
181
  type NewsroomCardSpan,
@@ -776,6 +782,24 @@ function NewsDeskDashboard({
776
782
  const [referenceAttachments, setReferenceAttachments] = useState(dashboard.referenceAttachments);
777
783
  const [messages, setMessages] = useState(dashboard.messages);
778
784
  const [semanticRelations, setSemanticRelations] = useState(dashboard.semanticRelations);
785
+ const relevanceDecisions = useMemo(() => currentRelevanceDecisions(semanticRelations), [semanticRelations]);
786
+ const relevanceCyclotronId = useMemo(
787
+ () => [...relevanceDecisions.values()].find((decision) => decision.cyclotronId)?.cyclotronId ?? null,
788
+ [relevanceDecisions],
789
+ );
790
+ const [cyclotronStatus, setCyclotronStatus] = useState<CyclotronStatus | null>(null);
791
+ useEffect(() => {
792
+ if (!relevanceCyclotronId) return;
793
+ if (dashboard.isDemo) {
794
+ setCyclotronStatus(DEMO_CYCLOTRON_STATUS);
795
+ return;
796
+ }
797
+ let cancelled = false;
798
+ loadCyclotronStatus(relevanceCyclotronId)
799
+ .then((status) => { if (!cancelled) setCyclotronStatus(status); })
800
+ .catch(() => { if (!cancelled) setCyclotronStatus(null); });
801
+ return () => { cancelled = true; };
802
+ }, [dashboard.isDemo, relevanceCyclotronId]);
779
803
  const [semanticNodes, setSemanticNodes] = useState(dashboard.semanticNodes);
780
804
  const [assignments, setAssignments] = useState(dashboard.assignments);
781
805
  const [assignmentEvents, setAssignmentEvents] = useState(dashboard.assignmentEvents);
@@ -1507,7 +1531,13 @@ function NewsDeskDashboard({
1507
1531
  });
1508
1532
  }
1509
1533
 
1510
- function runReferenceCurationAction(reference: ReferenceRecord, action: ReferenceCurationAction, note?: string, reasonCode?: ReferenceRejectionReasonCode | null) {
1534
+ function runReferenceCurationAction(
1535
+ reference: ReferenceRecord,
1536
+ action: ReferenceCurationAction,
1537
+ note?: string,
1538
+ reasonCode?: ReferenceRejectionReasonCode | null,
1539
+ relevance?: { decisionRelationId: string; shareable: boolean },
1540
+ ) {
1511
1541
  const nextStatus = referenceCurationStatusForAction(action);
1512
1542
  setActionState({ id: reference.id, message: `${action} pending`, tone: "pending" });
1513
1543
  if (dashboard.isDemo) {
@@ -1535,6 +1565,7 @@ function NewsDeskDashboard({
1535
1565
  action,
1536
1566
  curationStatus: nextStatus,
1537
1567
  reasonCode: action === "reject" ? reasonCode ?? null : null,
1568
+ ...(relevance ? { decisionRelationId: relevance.decisionRelationId, shareable: relevance.shareable } : {}),
1538
1569
  },
1539
1570
  }, ...current]);
1540
1571
  setSemanticRelations((current) => [{
@@ -1575,6 +1606,8 @@ function NewsDeskDashboard({
1575
1606
  actorLabel: authState.label,
1576
1607
  note: note?.trim() || undefined,
1577
1608
  reasonCode: action === "reject" ? reasonCode ?? undefined : undefined,
1609
+ decisionRelationId: relevance?.decisionRelationId,
1610
+ shareable: relevance ? relevance.shareable : undefined,
1578
1611
  },
1579
1612
  { authMode: USER_POOL_AUTH_MODE },
1580
1613
  );
@@ -3153,6 +3186,20 @@ function NewsDeskDashboard({
3153
3186
  demo={Boolean(dashboard.isDemo)}
3154
3187
  disabled={controlsDisabled}
3155
3188
  initialReferenceLineageId={initialSelection.reference ?? pathnameReferenceLineageId}
3189
+ cyclotronStatus={cyclotronStatus}
3190
+ decisions={relevanceDecisions}
3191
+ onRequestReviewRate={relevanceCyclotronId ? async (choice) => {
3192
+ const request = buildReviewRateRequest(choice, authState.label);
3193
+ if (dashboard.isDemo) return;
3194
+ await requestCyclotronReviewRate(relevanceCyclotronId, request);
3195
+ } : undefined}
3196
+ onRelevanceReview={(reference, curation) => runReferenceCurationAction(
3197
+ reference,
3198
+ curation.action,
3199
+ curation.note ?? undefined,
3200
+ normalizeReferenceRejectionReasonCode(curation.reasonCode),
3201
+ { decisionRelationId: curation.decisionRelationId, shareable: curation.shareable },
3202
+ )}
3156
3203
  onReview={(reference, action) => runReferenceCurationAction(reference, action)}
3157
3204
  referenceAttachments={referenceAttachments}
3158
3205
  references={references}
@@ -15188,14 +15235,14 @@ function normalizeKnowledgeQueryAnchorKind(value: string | null | undefined): Kn
15188
15235
 
15189
15236
  function normalizeNewsroomFromHref(value: string | null | undefined): string | null {
15190
15237
  if (!value) return null;
15191
- const trimmed = value.trim();
15238
+ const trimmed = browserPathToInternalNewsroomPath(value.trim());
15192
15239
  if (!trimmed.startsWith("/newsroom")) return null;
15193
- return trimmed;
15240
+ return toPublicNewsroomPath(trimmed);
15194
15241
  }
15195
15242
 
15196
15243
  function currentNewsroomOriginHref(fallback: string | null): string | null {
15197
15244
  if (typeof window === "undefined") return fallback;
15198
- const current = `${window.location.pathname}${window.location.search}`;
15245
+ const current = browserPathToInternalNewsroomPath(`${window.location.pathname}${window.location.search}`);
15199
15246
  return current.startsWith("/newsroom/search") ? fallback : normalizeNewsroomFromHref(current);
15200
15247
  }
15201
15248
 
@@ -15229,7 +15276,7 @@ function buildNewsroomSearchHref(request: NewsroomSearchRequest, demo?: boolean)
15229
15276
  if (request.from) params.set("from", request.from);
15230
15277
  if (demo) params.set("demo", "1");
15231
15278
  const queryString = params.toString();
15232
- return queryString ? `/newsroom/search?${queryString}` : "/newsroom/search";
15279
+ return toPublicNewsroomPath(queryString ? `/newsroom/search?${queryString}` : "/newsroom/search");
15233
15280
  }
15234
15281
 
15235
15282
  function focusNewsroomSearchForm() {
@@ -15399,63 +15446,6 @@ function readTextClaim(value: unknown): string | null {
15399
15446
  return typeof value === "string" && value.trim() ? value.trim() : null;
15400
15447
  }
15401
15448
 
15402
- function NewsDeskAccessGate({ shell, showSectionTabs = false }: { shell: NewsDeskShellState | null; showSectionTabs?: boolean }) {
15403
- const accessPhase = shell?.phase ?? "checkingAccess";
15404
-
15405
- return (
15406
- <NewsroomOpsShell
15407
- activeTab="overview"
15408
- appTitle={SITE_BRAND.appTitle}
15409
- backHref="/"
15410
- backLabel={SITE_BRAND.backToHomeLabel}
15411
- headerActions={(
15412
- <Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
15413
- )}
15414
- pageTitle="Newsroom"
15415
- showNavigation={showSectionTabs}
15416
- >
15417
- <section aria-live="polite" data-news-desk-access={accessPhase} data-news-desk-access-phase={accessPhase}>
15418
- <div className="space-y-3 rounded-xl border border-border bg-card p-6">
15419
- <p className="m-0 text-[0.7rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Access</p>
15420
- <h2 className="m-0 font-sans text-xl font-semibold tracking-tight">{formatAccessTitle(shell)}</h2>
15421
- <p className="m-0 text-sm text-muted-foreground">{formatAccessDetail(shell)}</p>
15422
- {shell?.error ? <p className="text-sm text-destructive">{shell.error}</p> : null}
15423
- <p className="m-0 text-sm text-foreground">{formatAccessActionDetail(shell)}</p>
15424
- {shell?.phase === "signedOut" || shell?.phase === "error" ? (
15425
- <div className="news-desk-access-panel__auth">
15426
- <ReaderAuthControl postAuthPath="/newsroom" showIdentity />
15427
- </div>
15428
- ) : null}
15429
- </div>
15430
- </section>
15431
- </NewsroomOpsShell>
15432
- );
15433
- }
15434
-
15435
- function formatAccessTitle(state: NewsDeskShellState | null): string {
15436
- if (!state || state.phase === "checkingAccess") return "Checking Desk Credentials";
15437
- if (state.phase === "loadingDesk") return "Loading Newsroom Records";
15438
- if (state.phase === "forbidden") return "Editor Role Required";
15439
- if (state.phase === "error") return "Newsroom Unavailable";
15440
- return "Editor Sign-In Required";
15441
- }
15442
-
15443
- function formatAccessDetail(state: NewsDeskShellState | null): string {
15444
- if (!state || state.phase === "checkingAccess") return `${SITE_BRAND.appTitle} is checking the current browser session before loading steering state.`;
15445
- if (state.phase === "loadingDesk") return `${SITE_BRAND.appTitle} verified the browser session and is loading private Newsroom records.`;
15446
- if (state.phase === "forbidden") return "This account is signed in, but the Cognito session does not include the editor or admin group.";
15447
- if (state.phase === "error") return `${SITE_BRAND.appTitle} could not verify this editor session or load the private Newsroom data.`;
15448
- return "Sign in with an editor or admin account to inspect category, category tree, ontology, and graph steering.";
15449
- }
15450
-
15451
- function formatAccessActionDetail(state: NewsDeskShellState | null): string {
15452
- if (!state || state.phase === "checkingAccess") return "This should only take a moment. If it hangs, reload the page.";
15453
- if (state.phase === "loadingDesk") return "Private records are loading from GraphQL. Large reference queues may take several seconds.";
15454
- if (state.phase === "forbidden") return "Ask an admin to add this account to the editor or admin group, then sign out and back in.";
15455
- if (state.phase === "error") return "Reload the page or check the local development console for the GraphQL error.";
15456
- return "Use the login button above to authenticate with an editor or admin account.";
15457
- }
15458
-
15459
15449
  function SectionHeader({ title, detail }: { title: string; detail: string }) {
15460
15450
  const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
15461
15451
  return (
@@ -0,0 +1,81 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, type RefObject } from "react";
4
+ import type { BlogHeaderObstacle } from "./blog-page-background";
5
+
6
+ const HEADER_OBSTACLE_SELECTORS = [
7
+ ".presentation-header__eyebrow",
8
+ ".presentation-header__word",
9
+ ".presentation-header__meta",
10
+ ".presentation-header__subtitle",
11
+ ".presentation-header__date",
12
+ ".presentation-header__tagline",
13
+ ".presentation-section-nav a",
14
+ ];
15
+
16
+ function measureObstacleRect(element: HTMLElement): DOMRect {
17
+ const range = document.createRange();
18
+ range.selectNodeContents(element);
19
+ const contentRect = range.getBoundingClientRect();
20
+ range.detach();
21
+ if (contentRect.width > 0 && contentRect.height > 0) return contentRect;
22
+ return element.getBoundingClientRect();
23
+ }
24
+
25
+ /**
26
+ * Measures the rendered header text (eyebrow, masthead words, meta, tagline,
27
+ * section links) as rectangles in the coordinates of `.blog-page-background`,
28
+ * so a brand's background artwork can keep clear of them. Block-level header
29
+ * elements span the full page width even when their text ends mid-line, so
30
+ * the text range is measured instead of the element box.
31
+ */
32
+ export function useBlogHeaderObstacles(
33
+ pageRef: RefObject<HTMLElement | null>,
34
+ paintBuffer: number,
35
+ ): BlogHeaderObstacle[] {
36
+ const [obstacles, setObstacles] = useState<BlogHeaderObstacle[]>([]);
37
+
38
+ useEffect(() => {
39
+ const page = pageRef.current;
40
+ if (!page) return;
41
+
42
+ const update = () => {
43
+ const background = page.querySelector<HTMLElement>(".blog-page-background");
44
+ const backgroundRect = background?.getBoundingClientRect();
45
+ if (!backgroundRect) return;
46
+
47
+ const next: BlogHeaderObstacle[] = [];
48
+ for (const selector of HEADER_OBSTACLE_SELECTORS) {
49
+ for (const element of Array.from(page.querySelectorAll<HTMLElement>(selector))) {
50
+ const rect = measureObstacleRect(element);
51
+ const intersectionTop = Math.max(rect.top, backgroundRect.top);
52
+ const intersectionBottom = Math.min(rect.bottom, backgroundRect.bottom);
53
+ const intersectionLeft = Math.max(rect.left, backgroundRect.left);
54
+ const intersectionRight = Math.min(rect.right, backgroundRect.right);
55
+ const width = intersectionRight - intersectionLeft;
56
+ const height = intersectionBottom - intersectionTop;
57
+ if (width <= 0 || height <= 0) continue;
58
+ next.push({
59
+ x: intersectionLeft - backgroundRect.left,
60
+ y: intersectionTop - backgroundRect.top,
61
+ width,
62
+ height: height + paintBuffer * 2,
63
+ });
64
+ }
65
+ }
66
+ setObstacles(next);
67
+ };
68
+
69
+ update();
70
+ const observer = new ResizeObserver(update);
71
+ observer.observe(page);
72
+ window.addEventListener("resize", update);
73
+ void document.fonts?.ready.then(update);
74
+ return () => {
75
+ observer.disconnect();
76
+ window.removeEventListener("resize", update);
77
+ };
78
+ }, [pageRef, paintBuffer]);
79
+
80
+ return obstacles;
81
+ }
@@ -0,0 +1,28 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { listBrandFontFamilies } from "../lib/brand-fonts";
5
+ import { watchBrandFontsLoaded } from "../lib/brand-fonts-watch";
6
+ import { clearPretextMeasurementCache } from "../lib/pretext-layout";
7
+ import { SITE_BRAND } from "../lib/site-brand";
8
+
9
+ /**
10
+ * Returns a number that increments each time the brand's declared font
11
+ * families finish loading. Pretext's width cache is cleared first, so
12
+ * measurements taken with fallback metrics are re-run with the real glyph
13
+ * widths. Stays 0 for brands without a font slot, and on the server.
14
+ */
15
+ export function useBrandFontsReadyVersion(): number {
16
+ const [version, setVersion] = useState(0);
17
+
18
+ useEffect(() => {
19
+ return watchBrandFontsLoaded({
20
+ fontFaceSet: typeof document === "undefined" ? undefined : document.fonts,
21
+ families: listBrandFontFamilies(SITE_BRAND.fonts),
22
+ clearMeasurementCaches: clearPretextMeasurementCache,
23
+ onFontsSettled: () => setVersion((current) => current + 1),
24
+ });
25
+ }, []);
26
+
27
+ return version;
28
+ }
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ export function useRhythmOverlay(): boolean {
6
+ const [showRhythmOverlay, setShowRhythmOverlay] = useState(false);
7
+
8
+ useEffect(() => {
9
+ const handleKeyDown = (event: KeyboardEvent) => {
10
+ if (isEditableEventTarget(event.target)) return;
11
+ if ((event.key === "=" || event.code === "Equal") && event.ctrlKey && !event.metaKey && !event.altKey) {
12
+ event.preventDefault();
13
+ setShowRhythmOverlay((current) => !current);
14
+ }
15
+ };
16
+
17
+ window.addEventListener("keydown", handleKeyDown);
18
+ return () => window.removeEventListener("keydown", handleKeyDown);
19
+ }, []);
20
+
21
+ return showRhythmOverlay;
22
+ }
23
+
24
+ function isEditableEventTarget(target: EventTarget | null): boolean {
25
+ if (!(target instanceof HTMLElement)) return false;
26
+ const tagName = target.tagName.toLowerCase();
27
+ if (tagName === "input" || tagName === "textarea" || tagName === "select") return true;
28
+ return target.isContentEditable || target.closest("[contenteditable='true']") !== null;
29
+ }