@anthusai/papyrus 2.0.0-next.2 → 2.0.0-next.21

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 (103) hide show
  1. package/amplify/data/resource.js +15 -0
  2. package/amplify/data/resource.ts +17 -0
  3. package/amplify/data/schema.js +20 -0
  4. package/amplify/data/schema.ts +22 -0
  5. package/amplify/functions/assignment-action/data-access.js +88 -0
  6. package/amplify/functions/assignment-action/data-access.ts +87 -0
  7. package/amplify/functions/content-actions/handler.py +22 -0
  8. package/amplify/functions/editorial-checks/handler.py +14 -0
  9. package/amplify/functions/editorial-checks/requirements.txt +1 -0
  10. package/amplify/functions/editorial-checks/resource.js +43 -0
  11. package/amplify/functions/editorial-checks/resource.ts +60 -0
  12. package/amplify/functions/editorial-checks/schema.js +92 -0
  13. package/amplify/functions/editorial-checks/schema.ts +94 -0
  14. package/amplify/functions/editorial-checks/wire.js +49 -0
  15. package/amplify/functions/editorial-checks/wire.ts +57 -0
  16. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  17. package/amplify/functions/monitoring-view/handler.py +11 -0
  18. package/amplify/functions/monitoring-view/resource.js +24 -0
  19. package/amplify/functions/monitoring-view/resource.ts +34 -0
  20. package/amplify/functions/monitoring-view/schema.js +16 -0
  21. package/amplify/functions/monitoring-view/schema.ts +18 -0
  22. package/amplify/functions/monitoring-view/wire.js +37 -0
  23. package/amplify/functions/monitoring-view/wire.ts +47 -0
  24. package/amplify/functions/public-forms/core.js +247 -0
  25. package/amplify/functions/public-forms/core.ts +310 -0
  26. package/amplify/functions/public-forms/handler.ts +41 -0
  27. package/amplify/functions/public-forms/origins.js +35 -0
  28. package/amplify/functions/public-forms/origins.ts +43 -0
  29. package/amplify/functions/public-forms/resource.js +9 -0
  30. package/amplify/functions/public-forms/resource.ts +10 -0
  31. package/amplify/functions/public-forms/wire.js +25 -0
  32. package/amplify/functions/public-forms/wire.ts +32 -0
  33. package/amplify/functions/shared/python-bundle.js +20 -14
  34. package/amplify/functions/shared/python-bundle.ts +25 -18
  35. package/amplify/publication/redirect-and-index.js +83 -0
  36. package/amplify/publication/redirect-rules.js +61 -0
  37. package/amplify/publication/redirect-rules.ts +79 -0
  38. package/amplify/publication/s3-projection.js +5 -1
  39. package/amplify/publication/s3-projection.ts +7 -1
  40. package/amplify/site-backend-config.ts +10 -0
  41. package/amplify/site-backend.js +31 -0
  42. package/amplify/site-backend.ts +38 -0
  43. package/app/api/newsroom/curation/context/route.ts +9 -0
  44. package/app/api/newsroom/curation/priming/route.ts +9 -0
  45. package/app/api/newsroom/curation/publication/route.ts +9 -0
  46. package/bin/papyrus-infra.mjs +20 -1
  47. package/components/editorial-checks-admin-panel.tsx +347 -0
  48. package/components/editorial-finding-card.tsx +162 -0
  49. package/components/editorial-findings-panel.tsx +91 -0
  50. package/components/editorial-preview-popover.tsx +103 -0
  51. package/components/markus-body-editor.tsx +213 -0
  52. package/components/markus-ir-preview.tsx +147 -6
  53. package/components/monitoring-overview-view.tsx +240 -0
  54. package/components/monitoring-panel.tsx +40 -0
  55. package/components/newsroom-article-editor.tsx +136 -11
  56. package/components/newsroom-board-view.tsx +6 -7
  57. package/components/newsroom-curation-bootstrap.tsx +45 -0
  58. package/components/newsroom-ops-shell.tsx +7 -3
  59. package/components/newsroom-organizer.css +74 -61
  60. package/components/newsroom-references-view.tsx +14 -14
  61. package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
  62. package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
  63. package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
  64. package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
  65. package/components/newsroom-theme-fonts/night-desk.css +10 -0
  66. package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
  67. package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
  68. package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
  69. package/components/newsroom-theme.css +39 -0
  70. package/components/newsroom-theme.tsx +33 -0
  71. package/components/publication-cyclotron-panel.tsx +10 -10
  72. package/components/reference-add-entry.tsx +75 -0
  73. package/components/reference-curation-controls.tsx +30 -21
  74. package/components/reference-cyclotron-priming.tsx +4 -4
  75. package/components/reference-cyclotron-suggestion.tsx +1 -1
  76. package/components/reference-priming.tsx +14 -10
  77. package/components/reference-section-suggestion.tsx +1 -1
  78. package/components/topic-steering-workspace.tsx +27 -2
  79. package/components/use-editorial-checks.ts +133 -0
  80. package/lib/category-repository.ts +11 -9
  81. package/lib/curation-routes.ts +342 -0
  82. package/lib/editorial-checks-backend.ts +16 -0
  83. package/lib/editorial-checks-client.ts +194 -0
  84. package/lib/editorial-checks-demo.ts +147 -0
  85. package/lib/editorial-checks-settings.ts +27 -0
  86. package/lib/editorial-checks.ts +171 -0
  87. package/lib/editorial-preview.ts +228 -0
  88. package/lib/monitoring-client.ts +29 -0
  89. package/lib/monitoring-overview-fixture.ts +51 -0
  90. package/lib/monitoring-overview.ts +144 -0
  91. package/lib/newsroom-board.ts +27 -2
  92. package/lib/relevance-status.ts +2 -2
  93. package/lib/site-brand-tenant.ts +11 -6
  94. package/lib/site-brand.ts +23 -3
  95. package/package.json +47 -3
  96. package/routes.manifest.json +34 -1
  97. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  98. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  99. package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
  100. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  101. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  102. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
  103. package/components/newsroom-newsprint-board.css +0 -7
@@ -0,0 +1,162 @@
1
+ "use client";
2
+
3
+ import { CheckIcon, SparklesIcon, XIcon } from "lucide-react";
4
+ import { useState } from "react";
5
+ import { Button } from "./ui/button";
6
+ import { Input } from "./ui/input";
7
+ import { Spinner } from "./ui/spinner";
8
+ import { describeProbability, type EditorialFinding, type RewriteResult, type Span } from "../lib/editorial-checks";
9
+
10
+ export type RewriteTarget = { finding: EditorialFinding } | { selection: Span };
11
+ export type ApplyRewrite = (option: { span: Span; replacement: string; expected: string }, finding: EditorialFinding | null) => void;
12
+
13
+ export type FindingActions = {
14
+ onSelect?: (finding: EditorialFinding) => void;
15
+ onDismiss: (finding: EditorialFinding, reason: string) => void;
16
+ onAccept: (finding: EditorialFinding) => void;
17
+ onSuggest: (target: RewriteTarget, note: string | null) => Promise<RewriteResult>;
18
+ onApply: ApplyRewrite;
19
+ };
20
+
21
+ type SuggestionState =
22
+ | { state: "loading" }
23
+ | { state: "ready"; result: RewriteResult }
24
+ | { state: "error"; message: string };
25
+
26
+ /** Ask for rewrite options for a target and let the editor pick one. Shared by findings and selections. */
27
+ export function useRewriteOptions(actions: Pick<FindingActions, "onSuggest">) {
28
+ const [suggestion, setSuggestion] = useState<SuggestionState | null>(null);
29
+ const request = async (target: RewriteTarget, note: string | null) => {
30
+ setSuggestion({ state: "loading" });
31
+ try {
32
+ setSuggestion({ state: "ready", result: await actions.onSuggest(target, note) });
33
+ } catch (error) {
34
+ setSuggestion({ state: "error", message: error instanceof Error ? error.message : "Could not get rewrite options." });
35
+ }
36
+ };
37
+ return { suggestion, request, clear: () => setSuggestion(null) };
38
+ }
39
+
40
+ export function RewriteOptions({
41
+ suggestion,
42
+ finding,
43
+ onApply,
44
+ }: {
45
+ suggestion: SuggestionState | null;
46
+ finding: EditorialFinding | null;
47
+ onApply: ApplyRewrite;
48
+ }) {
49
+ if (!suggestion) return null;
50
+ if (suggestion.state === "loading") {
51
+ return <p className="flex items-center gap-2 text-muted-foreground" data-editorial-rewrite-loading><Spinner /> Asking for rewrite options…</p>;
52
+ }
53
+ if (suggestion.state === "error") return <p className="text-destructive" role="alert">{suggestion.message}</p>;
54
+ const { result } = suggestion;
55
+ if (result.options.length === 0) return <p className="text-muted-foreground">No usable options this time. Try again or add a note.</p>;
56
+ return (
57
+ <div className="space-y-2" data-editorial-rewrite-options>
58
+ <p className="text-xs text-muted-foreground" data-editorial-rewrite-target>Rewriting: “{result.target.excerpt}”</p>
59
+ <ol className="space-y-2">
60
+ {result.options.map((option, index) => (
61
+ <li className="space-y-1 rounded-md bg-muted/40 p-2" data-editorial-rewrite-option={index} key={option.id}>
62
+ <p>{option.replacement}</p>
63
+ <p className="text-xs text-muted-foreground">{option.rationale}</p>
64
+ {option.factVerificationRequired ? <p className="text-xs text-destructive">Changes a factual claim: verify before using.</p> : null}
65
+ {option.unresolvedQuestions.length ? (
66
+ <ul className="list-disc pl-4 text-xs text-muted-foreground">
67
+ {option.unresolvedQuestions.map((question) => <li key={question}>{question}</li>)}
68
+ </ul>
69
+ ) : null}
70
+ <Button
71
+ data-editorial-use-option
72
+ onClick={() => onApply({ span: option.span, replacement: option.replacement, expected: result.target.excerpt }, finding)}
73
+ size="xs"
74
+ type="button"
75
+ >
76
+ Use this
77
+ </Button>
78
+ </li>
79
+ ))}
80
+ </ol>
81
+ </div>
82
+ );
83
+ }
84
+
85
+ /** One finding: what is wrong, how sure, and what the editor can do about it. */
86
+ export function EditorialFindingCard({
87
+ finding,
88
+ active = false,
89
+ note,
90
+ actions,
91
+ }: {
92
+ finding: EditorialFinding;
93
+ active?: boolean;
94
+ note: string;
95
+ actions: FindingActions;
96
+ }) {
97
+ const [dismissing, setDismissing] = useState<string | null>(null);
98
+ const rewrites = useRewriteOptions(actions);
99
+ return (
100
+ <div
101
+ className="space-y-1 rounded-lg border border-border p-2 data-[active=true]:border-ring"
102
+ data-active={active}
103
+ data-editorial-finding-item={finding.id}
104
+ >
105
+ <button className="block w-full text-left" onClick={() => actions.onSelect?.(finding)} type="button">
106
+ <span className="block">{finding.message}</span>
107
+ {finding.excerpt ? <q className="block truncate text-muted-foreground">{finding.excerptText ?? finding.excerpt}</q> : null}
108
+ <span className="text-xs text-muted-foreground">
109
+ {finding.status === "review" ? "Needs your judgment · " : ""}
110
+ {describeProbability(finding) ?? (finding.lane === "rules" ? "Rule" : "")}
111
+ </span>
112
+ </button>
113
+ <div className="flex flex-wrap gap-1">
114
+ {finding.replacement !== undefined ? (
115
+ <Button
116
+ data-editorial-use-replacement={finding.replacement}
117
+ onClick={() => actions.onApply({ span: finding.span, replacement: finding.replacement ?? "", expected: finding.excerpt }, finding)}
118
+ size="xs"
119
+ type="button"
120
+ variant="outline"
121
+ >
122
+ <CheckIcon aria-hidden="true" /> {finding.replacement ? `Use “${finding.replacement}”` : "Remove"}
123
+ </Button>
124
+ ) : null}
125
+ {finding.excerpt ? (
126
+ <Button data-editorial-suggest onClick={() => void rewrites.request({ finding }, note.trim() || null)} size="xs" type="button" variant="outline">
127
+ <SparklesIcon aria-hidden="true" /> Suggest rewrites
128
+ </Button>
129
+ ) : null}
130
+ {finding.status === "review" ? (
131
+ <Button onClick={() => actions.onAccept(finding)} size="xs" type="button" variant="ghost">
132
+ It&apos;s a problem
133
+ </Button>
134
+ ) : null}
135
+ <Button data-editorial-dismiss onClick={() => setDismissing("")} size="xs" type="button" variant="ghost">
136
+ <XIcon aria-hidden="true" /> Dismiss
137
+ </Button>
138
+ </div>
139
+ {dismissing !== null ? (
140
+ <form
141
+ className="flex gap-1"
142
+ onSubmit={(event) => {
143
+ event.preventDefault();
144
+ actions.onDismiss(finding, dismissing);
145
+ setDismissing(null);
146
+ }}
147
+ >
148
+ <Input
149
+ aria-label="Why dismiss?"
150
+ autoFocus
151
+ className="h-8"
152
+ onChange={(event) => setDismissing(event.target.value)}
153
+ placeholder="Why? (optional, helps tune this policy)"
154
+ value={dismissing}
155
+ />
156
+ <Button data-editorial-confirm-dismiss size="xs" type="submit">Dismiss</Button>
157
+ </form>
158
+ ) : null}
159
+ <RewriteOptions finding={finding} onApply={actions.onApply} suggestion={rewrites.suggestion} />
160
+ </div>
161
+ );
162
+ }
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import { SparklesIcon } from "lucide-react";
4
+ import { useState } from "react";
5
+ import { Badge } from "./ui/badge";
6
+ import { Button } from "./ui/button";
7
+ import { Input } from "./ui/input";
8
+ import { Spinner } from "./ui/spinner";
9
+ import { groupByPolicy, laneNotice, visibleFindings, type EditorialCheckResult, type Span } from "../lib/editorial-checks";
10
+ import { EditorialFindingCard, RewriteOptions, useRewriteOptions, type FindingActions } from "./editorial-finding-card";
11
+
12
+ export type CheckStatus = { state: "idle" } | { state: "checking" } | { state: "error"; message: string };
13
+
14
+ type Props = FindingActions & {
15
+ result: EditorialCheckResult | null;
16
+ status: CheckStatus;
17
+ dismissed: ReadonlySet<string>;
18
+ activeFindingId: string | null;
19
+ selection: Span;
20
+ onCheck: () => void;
21
+ };
22
+
23
+ export const SEVERITY_LABEL = { error: "Must fix", warn: "Should fix", info: "Consider" } as const;
24
+ const SEVERITY_VARIANT = { error: "default", warn: "secondary", info: "muted" } as const;
25
+
26
+ export function EditorialFindingsPanel(props: Props) {
27
+ const { result, status, dismissed, activeFindingId, selection } = props;
28
+ const [note, setNote] = useState("");
29
+ const selectionRewrites = useRewriteOptions(props);
30
+ const findings = result ? visibleFindings(result.findings, dismissed) : [];
31
+ const groups = groupByPolicy(findings);
32
+ const notice = result ? laneNotice(result) : null;
33
+ const hasSelection = selection.end > selection.start;
34
+
35
+ return (
36
+ <section aria-label="Editorial checks" className="space-y-3 rounded-xl border border-border bg-card p-3 text-sm" data-editorial-findings>
37
+ <div className="flex flex-wrap items-center gap-2">
38
+ <h3 className="font-medium">Editorial checks</h3>
39
+ {status.state === "checking" ? <Spinner aria-label="Checking" /> : null}
40
+ <span className="text-muted-foreground" data-editorial-findings-count>
41
+ {result ? `${findings.length} open` : "Not checked yet"}
42
+ </span>
43
+ <Button className="ml-auto" data-editorial-check-now disabled={status.state === "checking"} onClick={props.onCheck} size="xs" type="button" variant="outline">
44
+ Check now
45
+ </Button>
46
+ </div>
47
+
48
+ {status.state === "error" ? <p className="text-destructive" role="alert">{status.message}</p> : null}
49
+ {notice ? <p className="text-muted-foreground" data-editorial-lane-notice>{notice}</p> : null}
50
+
51
+ <div className="flex flex-wrap items-center gap-2" data-editorial-rewrite-tools>
52
+ <Input
53
+ aria-label="Note for rewrites"
54
+ className="h-8 max-w-xs flex-1"
55
+ onChange={(event) => setNote(event.target.value)}
56
+ placeholder="Note for rewrites, e.g. shorter"
57
+ value={note}
58
+ />
59
+ <Button
60
+ disabled={!hasSelection}
61
+ onClick={() => void selectionRewrites.request({ selection }, note.trim() || null)}
62
+ size="xs"
63
+ title={hasSelection ? "Get rewrite options for the selected text" : "Select text in the body first"}
64
+ type="button"
65
+ variant="outline"
66
+ >
67
+ <SparklesIcon aria-hidden="true" /> Rewrite selection
68
+ </Button>
69
+ </div>
70
+ <RewriteOptions finding={null} onApply={props.onApply} suggestion={selectionRewrites.suggestion} />
71
+
72
+ {result && findings.length === 0 ? <p className="text-muted-foreground">No open findings.</p> : null}
73
+ {groups.map((group) => (
74
+ <div className="space-y-2" data-editorial-policy={group.policyId} key={group.policyId}>
75
+ <div className="flex items-center gap-2">
76
+ <Badge variant={SEVERITY_VARIANT[group.severity]}>{SEVERITY_LABEL[group.severity]}</Badge>
77
+ <strong>{group.policyId}</strong>
78
+ <span className="text-muted-foreground">{group.findings.length}</span>
79
+ </div>
80
+ <ul className="space-y-2">
81
+ {group.findings.map((finding) => (
82
+ <li key={finding.id}>
83
+ <EditorialFindingCard actions={props} active={finding.id === activeFindingId} finding={finding} note={note} />
84
+ </li>
85
+ ))}
86
+ </ul>
87
+ </div>
88
+ ))}
89
+ </section>
90
+ );
91
+ }
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ import { SparklesIcon, XIcon } from "lucide-react";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { Button } from "./ui/button";
6
+ import { Input } from "./ui/input";
7
+ import type { EditorialFinding, Span } from "../lib/editorial-checks";
8
+ import { EditorialFindingCard, RewriteOptions, useRewriteOptions, type FindingActions } from "./editorial-finding-card";
9
+
10
+ /** What the popover is about: findings at a marked passage, or a passage the editor selected. */
11
+ export type PreviewPopoverTarget =
12
+ | { kind: "findings"; findingIds: string[]; anchor: DOMRect }
13
+ | { kind: "selection"; source: Span; anchor: DOMRect };
14
+
15
+ const WIDTH = 352;
16
+
17
+ /**
18
+ * Floats next to the passage on the preview, inside `container` (which must be
19
+ * positioned). Escape or the close button dismisses it.
20
+ */
21
+ export function EditorialPreviewPopover({
22
+ target,
23
+ findings,
24
+ body,
25
+ container,
26
+ actions,
27
+ onClose,
28
+ }: {
29
+ target: PreviewPopoverTarget;
30
+ findings: EditorialFinding[];
31
+ body: string;
32
+ container: HTMLElement | null;
33
+ actions: FindingActions;
34
+ onClose: () => void;
35
+ }) {
36
+ const [note, setNote] = useState("");
37
+ const panel = useRef<HTMLDivElement | null>(null);
38
+ const selectionRewrites = useRewriteOptions(actions);
39
+
40
+ useEffect(() => {
41
+ const onKey = (event: KeyboardEvent) => {
42
+ if (event.key === "Escape") onClose();
43
+ };
44
+ window.addEventListener("keydown", onKey);
45
+ return () => window.removeEventListener("keydown", onKey);
46
+ }, [onClose]);
47
+
48
+ useEffect(() => {
49
+ panel.current?.focus();
50
+ }, [target]);
51
+
52
+ const box = container?.getBoundingClientRect();
53
+ const top = box ? target.anchor.bottom - box.top + container!.scrollTop + 6 : 0;
54
+ const left = box ? Math.max(0, Math.min(target.anchor.left - box.left, box.width - WIDTH)) : 0;
55
+ const shown = target.kind === "findings" ? findings.filter((finding) => target.findingIds.includes(finding.id)) : [];
56
+
57
+ return (
58
+ <div
59
+ aria-label={target.kind === "findings" ? "Editorial findings for this passage" : "Rewrite the selected passage"}
60
+ className="absolute z-20 max-h-[28rem] space-y-2 overflow-auto rounded-xl border border-border bg-popover p-3 text-sm text-popover-foreground shadow-lg outline-none"
61
+ data-editorial-preview-popover={target.kind}
62
+ ref={panel}
63
+ role="dialog"
64
+ style={{ top, left, width: WIDTH }}
65
+ tabIndex={-1}
66
+ >
67
+ <div className="flex items-center gap-2">
68
+ <Input
69
+ aria-label="Note for rewrites"
70
+ className="h-8 flex-1"
71
+ onChange={(event) => setNote(event.target.value)}
72
+ placeholder="Note for rewrites, e.g. shorter"
73
+ value={note}
74
+ />
75
+ <Button aria-label="Close" onClick={onClose} size="icon-xs" type="button" variant="ghost">
76
+ <XIcon aria-hidden="true" />
77
+ </Button>
78
+ </div>
79
+ {target.kind === "findings" ? (
80
+ shown.length ? (
81
+ shown.map((finding) => <EditorialFindingCard actions={actions} active finding={finding} key={finding.id} note={note} />)
82
+ ) : (
83
+ <p className="text-muted-foreground">This finding was resolved.</p>
84
+ )
85
+ ) : (
86
+ <div className="space-y-2" data-editorial-selection-rewrite>
87
+ <q className="block text-muted-foreground">{body.slice(target.source.start, target.source.end)}</q>
88
+ <Button
89
+ data-editorial-rewrite-selection
90
+ onClick={() => {
91
+ void selectionRewrites.request({ selection: target.source }, note.trim() || null);
92
+ }}
93
+ size="xs"
94
+ type="button"
95
+ >
96
+ <SparklesIcon aria-hidden="true" /> Suggest rewrites
97
+ </Button>
98
+ <RewriteOptions finding={null} onApply={actions.onApply} suggestion={selectionRewrites.suggestion} />
99
+ </div>
100
+ )}
101
+ </div>
102
+ );
103
+ }
@@ -0,0 +1,213 @@
1
+ "use client";
2
+
3
+ import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
4
+ import { markdown } from "@codemirror/lang-markdown";
5
+ import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
6
+ import { EditorState, RangeSetBuilder, StateEffect, StateField } from "@codemirror/state";
7
+ import { Decoration, type DecorationSet, EditorView, keymap, placeholder as placeholderExtension } from "@codemirror/view";
8
+ import { tags } from "@lezer/highlight";
9
+ import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
10
+ import type { EditorialFinding } from "../lib/editorial-checks";
11
+
12
+ /** The textarea-shaped surface the article editor already drives (focus, selection, value). */
13
+ export type MarkusBodyEditorHandle = {
14
+ readonly value: string;
15
+ readonly selectionStart: number;
16
+ readonly selectionEnd: number;
17
+ focus: () => void;
18
+ setSelectionRange: (start: number, end: number) => void;
19
+ /** Scroll to and select a span, e.g. a finding the editor clicked in the findings panel. */
20
+ reveal: (start: number, end: number) => void;
21
+ };
22
+
23
+ type Props = {
24
+ value: string;
25
+ onChange: (value: string) => void;
26
+ findings?: EditorialFinding[];
27
+ activeFindingId?: string | null;
28
+ onFindingClick?: (findingId: string) => void;
29
+ onSelectionChange?: (selection: { start: number; end: number }) => void;
30
+ placeholder?: string;
31
+ ariaLabel?: string;
32
+ };
33
+
34
+ type MarkedFinding = Pick<EditorialFinding, "id" | "span" | "severity" | "status" | "message">;
35
+
36
+ /** Markup is told apart by weight, emphasis and opacity, never by a fixed colour, so it reads in light and dark themes. */
37
+ const markusHighlight = HighlightStyle.define([
38
+ { tag: tags.heading, fontWeight: "700" },
39
+ { tag: tags.strong, fontWeight: "700" },
40
+ { tag: tags.emphasis, fontStyle: "italic" },
41
+ { tag: tags.strikethrough, textDecoration: "line-through" },
42
+ { tag: tags.link, textDecoration: "underline" },
43
+ { tag: [tags.url, tags.processingInstruction, tags.meta, tags.contentSeparator, tags.labelName], opacity: "0.6" },
44
+ { tag: tags.monospace, opacity: "0.85" },
45
+ { tag: tags.quote, fontStyle: "italic", opacity: "0.85" },
46
+ ]);
47
+
48
+ const setFindings = StateEffect.define<{ findings: MarkedFinding[]; activeId: string | null }>();
49
+
50
+ function decorationsFor(findings: MarkedFinding[], activeId: string | null, length: number): DecorationSet {
51
+ const builder = new RangeSetBuilder<Decoration>();
52
+ const sorted = [...findings]
53
+ .filter((finding) => finding.span.start < finding.span.end && finding.span.end <= length)
54
+ .sort((a, b) => a.span.start - b.span.start || a.span.end - b.span.end);
55
+ for (const finding of sorted) {
56
+ builder.add(
57
+ finding.span.start,
58
+ finding.span.end,
59
+ Decoration.mark({
60
+ class: [
61
+ "cm-editorial-finding",
62
+ `cm-editorial-${finding.severity}`,
63
+ `cm-editorial-${finding.status}`,
64
+ finding.id === activeId ? "cm-editorial-active" : "",
65
+ ].join(" "),
66
+ attributes: { "data-editorial-finding": finding.id, title: finding.message },
67
+ }),
68
+ );
69
+ }
70
+ return builder.finish();
71
+ }
72
+
73
+ /** Findings are drawn as marks; edits move them with the text until the next check replaces them. */
74
+ const findingField = StateField.define<DecorationSet>({
75
+ create: () => Decoration.none,
76
+ update(marks, transaction) {
77
+ let next = marks.map(transaction.changes);
78
+ for (const effect of transaction.effects) {
79
+ if (effect.is(setFindings)) next = decorationsFor(effect.value.findings, effect.value.activeId, transaction.state.doc.length);
80
+ }
81
+ return next;
82
+ },
83
+ provide: (field) => EditorView.decorations.from(field),
84
+ });
85
+
86
+ const theme = EditorView.theme({
87
+ "&": { fontSize: "0.875rem", border: "1px solid var(--border)", borderRadius: "0.5rem", backgroundColor: "var(--background)", color: "var(--foreground)" },
88
+ ".cm-cursor": { borderLeftColor: "var(--foreground)" },
89
+ "&.cm-focused": { outline: "2px solid var(--ring)", outlineOffset: "1px" },
90
+ ".cm-content": { fontFamily: "var(--font-mono, ui-monospace, monospace)", minHeight: "28rem", padding: "0.75rem 0" },
91
+ ".cm-line": { padding: "0 0.75rem" },
92
+ ".cm-editorial-finding": { textDecorationLine: "underline", textDecorationStyle: "wavy", textUnderlineOffset: "3px", cursor: "pointer" },
93
+ ".cm-editorial-error": { textDecorationColor: "var(--destructive)" },
94
+ ".cm-editorial-warn": { textDecorationColor: "oklch(0.75 0.16 70)" },
95
+ ".cm-editorial-info": { textDecorationColor: "oklch(0.65 0.12 250)" },
96
+ ".cm-editorial-review": { textDecorationStyle: "dotted" },
97
+ ".cm-editorial-active": { backgroundColor: "color-mix(in oklch, var(--ring) 22%, transparent)" },
98
+ });
99
+
100
+ /** Replace only the stretch that differs, so finding marks elsewhere keep their place. */
101
+ export function minimalChange(before: string, after: string): { from: number; to: number; insert: string } {
102
+ let prefix = 0;
103
+ const limit = Math.min(before.length, after.length);
104
+ while (prefix < limit && before.charCodeAt(prefix) === after.charCodeAt(prefix)) prefix += 1;
105
+ let suffix = 0;
106
+ while (
107
+ suffix < limit - prefix &&
108
+ before.charCodeAt(before.length - 1 - suffix) === after.charCodeAt(after.length - 1 - suffix)
109
+ ) suffix += 1;
110
+ return { from: prefix, to: before.length - suffix, insert: after.slice(prefix, after.length - suffix) };
111
+ }
112
+
113
+ export const MarkusBodyEditor = forwardRef<MarkusBodyEditorHandle, Props>(function MarkusBodyEditor(
114
+ { value, onChange, findings = [], activeFindingId = null, onFindingClick, onSelectionChange, placeholder, ariaLabel = "Body" },
115
+ ref,
116
+ ) {
117
+ const host = useRef<HTMLDivElement | null>(null);
118
+ const view = useRef<EditorView | null>(null);
119
+ const callbacks = useRef({ onChange, onFindingClick, onSelectionChange });
120
+ callbacks.current = { onChange, onFindingClick, onSelectionChange };
121
+
122
+ useEffect(() => {
123
+ if (!host.current) return;
124
+ const editor = new EditorView({
125
+ parent: host.current,
126
+ state: EditorState.create({
127
+ doc: value,
128
+ extensions: [
129
+ history(),
130
+ keymap.of([...defaultKeymap, ...historyKeymap]),
131
+ markdown(),
132
+ syntaxHighlighting(markusHighlight),
133
+ EditorView.lineWrapping,
134
+ findingField,
135
+ theme,
136
+ placeholder ? placeholderExtension(placeholder) : [],
137
+ EditorView.contentAttributes.of({
138
+ "aria-label": ariaLabel,
139
+ "aria-multiline": "true",
140
+ "data-newsroom-article-body": "",
141
+ id: "newsroom-article-body",
142
+ spellcheck: "false",
143
+ }),
144
+ EditorView.updateListener.of((update) => {
145
+ if (update.docChanged) callbacks.current.onChange(update.state.doc.toString());
146
+ if (update.selectionSet || update.docChanged) {
147
+ const main = update.state.selection.main;
148
+ callbacks.current.onSelectionChange?.({ start: main.from, end: main.to });
149
+ }
150
+ }),
151
+ EditorView.domEventHandlers({
152
+ click(event) {
153
+ const mark = (event.target as HTMLElement | null)?.closest("[data-editorial-finding]");
154
+ const id = mark?.getAttribute("data-editorial-finding");
155
+ if (id) callbacks.current.onFindingClick?.(id);
156
+ return false;
157
+ },
158
+ }),
159
+ ],
160
+ }),
161
+ });
162
+ view.current = editor;
163
+ // Browser specs read the document through the host element (CodeMirror only renders visible lines).
164
+ (host.current as HTMLDivElement & { papyrusEditorValue?: () => string }).papyrusEditorValue = () => editor.state.doc.toString();
165
+ return () => {
166
+ editor.destroy();
167
+ view.current = null;
168
+ };
169
+ // The editor is created once; `value` changes are synced below.
170
+ // eslint-disable-next-line react-hooks/exhaustive-deps
171
+ }, []);
172
+
173
+ useEffect(() => {
174
+ const editor = view.current;
175
+ if (!editor) return;
176
+ const current = editor.state.doc.toString();
177
+ if (current !== value) editor.dispatch({ changes: minimalChange(current, value) });
178
+ }, [value]);
179
+
180
+ useEffect(() => {
181
+ view.current?.dispatch({ effects: setFindings.of({ findings, activeId: activeFindingId }) });
182
+ }, [findings, activeFindingId]);
183
+
184
+ useImperativeHandle(
185
+ ref,
186
+ () => ({
187
+ get value() {
188
+ return view.current?.state.doc.toString() ?? value;
189
+ },
190
+ get selectionStart() {
191
+ return view.current?.state.selection.main.from ?? 0;
192
+ },
193
+ get selectionEnd() {
194
+ return view.current?.state.selection.main.to ?? 0;
195
+ },
196
+ focus() {
197
+ view.current?.focus();
198
+ },
199
+ setSelectionRange(start: number, end: number) {
200
+ view.current?.dispatch({ selection: { anchor: start, head: end }, scrollIntoView: true });
201
+ },
202
+ reveal(start: number, end: number) {
203
+ const editor = view.current;
204
+ if (!editor) return;
205
+ editor.dispatch({ selection: { anchor: start, head: end }, effects: EditorView.scrollIntoView(start, { y: "center" }) });
206
+ editor.focus();
207
+ },
208
+ }),
209
+ [value],
210
+ );
211
+
212
+ return <div data-newsroom-article-body-editor ref={host} />;
213
+ });