@anthusai/papyrus 2.0.0-next.1 → 2.0.0-next.10

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 (68) hide show
  1. package/amplify/data/resource.js +7 -0
  2. package/amplify/data/resource.ts +8 -0
  3. package/amplify/data/schema.js +8 -0
  4. package/amplify/data/schema.ts +9 -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 +7 -0
  8. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  9. package/amplify/plugins/editorial-checks/function/handler.py +14 -0
  10. package/amplify/plugins/editorial-checks/function/requirements.txt +1 -0
  11. package/amplify/plugins/editorial-checks/function/resource.js +43 -0
  12. package/amplify/plugins/editorial-checks/function/resource.ts +60 -0
  13. package/amplify/plugins/editorial-checks/papyrus.plugin.js +71 -0
  14. package/amplify/plugins/editorial-checks/papyrus.plugin.ts +87 -0
  15. package/amplify/plugins/editorial-checks/schema.js +94 -0
  16. package/amplify/plugins/editorial-checks/schema.ts +96 -0
  17. package/amplify/plugins/registry.js +2 -0
  18. package/amplify/plugins/registry.ts +2 -0
  19. package/amplify/site-backend.js +5 -0
  20. package/amplify/site-backend.ts +11 -0
  21. package/components/editorial-checks-admin-panel.tsx +337 -0
  22. package/components/editorial-finding-card.tsx +162 -0
  23. package/components/editorial-findings-panel.tsx +91 -0
  24. package/components/editorial-preview-popover.tsx +103 -0
  25. package/components/markus-body-editor.tsx +213 -0
  26. package/components/markus-ir-preview.tsx +147 -6
  27. package/components/newsroom-article-editor.tsx +138 -11
  28. package/components/newsroom-board-view.tsx +6 -7
  29. package/components/newsroom-ops-shell.tsx +7 -3
  30. package/components/newsroom-organizer.css +57 -61
  31. package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
  32. package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
  33. package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
  34. package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
  35. package/components/newsroom-theme-fonts/night-desk.css +10 -0
  36. package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
  37. package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
  38. package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
  39. package/components/newsroom-theme.css +39 -0
  40. package/components/newsroom-theme.tsx +33 -0
  41. package/components/reference-cyclotron-priming.tsx +12 -13
  42. package/components/reference-cyclotron-status.tsx +4 -4
  43. package/components/reference-cyclotron-suggestion.tsx +6 -6
  44. package/components/reference-live-agreement.tsx +10 -10
  45. package/components/reference-priming.tsx +1 -1
  46. package/components/reference-relevance-review.tsx +1 -1
  47. package/components/reference-section-suggestion.tsx +1 -1
  48. package/components/topic-steering-workspace.tsx +19 -1
  49. package/components/use-editorial-checks.ts +131 -0
  50. package/lib/cyclotron-suggestions.ts +9 -9
  51. package/lib/editorial-checks-backend.ts +16 -0
  52. package/lib/editorial-checks-client.ts +194 -0
  53. package/lib/editorial-checks-demo.ts +147 -0
  54. package/lib/editorial-checks-settings.ts +24 -0
  55. package/lib/editorial-checks.ts +178 -0
  56. package/lib/editorial-preview.ts +228 -0
  57. package/lib/newsroom-board.ts +27 -2
  58. package/lib/relevance-status.ts +4 -4
  59. package/lib/site-brand.ts +17 -0
  60. package/package.json +34 -2
  61. package/routes.manifest.json +1 -1
  62. package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
  63. package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
  64. package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
  65. package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
  66. package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
  67. package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
  68. package/components/newsroom-newsprint-board.css +0 -7
@@ -1,4 +1,6 @@
1
- import type { ReactNode } from "react";
1
+ import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
2
+ import { CITATION_TOKEN, planPreview, selectionToSource, splitByMarks, type BlockAnnotation, type PreviewMark, type PreviewPlan } from "../lib/editorial-preview";
3
+ import type { EditorialCheckResult, EditorialFinding, Span } from "../lib/editorial-checks";
2
4
  import {
3
5
  parseMarkusDocument,
4
6
  type MarkusBlock,
@@ -17,7 +19,20 @@ type PreviewSidecar = {
17
19
  bibliography: string[];
18
20
  };
19
21
 
22
+ /** Editorial findings drawn on the preview (see lib/editorial-preview.ts). */
23
+ export type PreviewEditorial = {
24
+ /** A check result computed for the same body the preview shows, and the findings to draw from it. */
25
+ result: EditorialCheckResult;
26
+ findings: EditorialFinding[];
27
+ activeFindingIds?: readonly string[];
28
+ /** A marked passage was clicked. */
29
+ onOpenMark: (findingIds: string[], anchor: HTMLElement) => void;
30
+ /** Text was selected inside one prose block: the source span of the sentences it touches. */
31
+ onSelectSource?: (source: Span, anchor: DOMRect) => void;
32
+ };
33
+
20
34
  type PreviewContext = {
35
+ editorial?: PreviewEditorial & { plan: PreviewPlan; blocks: Map<string, BlockAnnotation> };
21
36
  sidecar: PreviewSidecar;
22
37
  citationNumbers: Map<string, number>;
23
38
  imageUrls: Record<string, string>;
@@ -91,7 +106,7 @@ function renderText(text: string, context: PreviewContext): ReactNode {
91
106
  if (!keys) continue;
92
107
  if (index > cursor) parts.push(text.slice(cursor, index));
93
108
  parts.push(
94
- <sup className="text-primary" key={`${index}-${token}`}>
109
+ <sup className="text-primary" data-editorial-skip key={`${index}-${token}`}>
95
110
  {keys.map((key) => `[${context.citationNumbers.get(key)}]`).join("")}
96
111
  </sup>,
97
112
  );
@@ -127,7 +142,7 @@ function renderInlines(inlines: MarkusInline[], context: PreviewContext): ReactN
127
142
  }
128
143
  case "image":
129
144
  return (
130
- <span className="rounded border border-dashed border-border px-1 text-xs text-muted-foreground" key={index}>
145
+ <span className="rounded border border-dashed border-border px-1 text-xs text-muted-foreground" data-editorial-skip key={index}>
131
146
  {`Image: ${node.src ?? ""} (${node.alt})`}
132
147
  </span>
133
148
  );
@@ -141,6 +156,92 @@ function renderInlines(inlines: MarkusInline[], context: PreviewContext): ReactN
141
156
  });
142
157
  }
143
158
 
159
+ const MARK_CLASSES: Record<PreviewMark["severity"], string> = {
160
+ error: "decoration-destructive",
161
+ warn: "decoration-amber-500",
162
+ info: "decoration-sky-500",
163
+ };
164
+
165
+ function markElement(text: string, mark: PreviewMark, key: string, context: PreviewContext): ReactNode {
166
+ const active = mark.findingIds.some((id) => context.editorial?.activeFindingIds?.includes(id));
167
+ return (
168
+ <mark
169
+ className={`cursor-pointer bg-transparent text-inherit underline underline-offset-4 ${mark.status === "review" ? "decoration-dotted" : "decoration-wavy"} ${MARK_CLASSES[mark.severity]} ${active ? "rounded-sm bg-ring/25" : ""}`}
170
+ data-editorial-mark={mark.findingIds.join(" ")}
171
+ key={key}
172
+ onClick={(event) => {
173
+ event.preventDefault();
174
+ context.editorial?.onOpenMark(mark.findingIds, event.currentTarget);
175
+ }}
176
+ >
177
+ {text}
178
+ </mark>
179
+ );
180
+ }
181
+
182
+ /** Like renderInlines, but splits rendered text at editorial marks (offsets as in inlineLeaves). */
183
+ function renderMarkedInlines(inlines: MarkusInline[], context: PreviewContext, marks: PreviewMark[], cursor: { at: number }): ReactNode[] {
184
+ const marked = (text: string, keyPrefix: string): ReactNode[] => {
185
+ const start = cursor.at;
186
+ cursor.at += text.length;
187
+ return splitByMarks(start, start + text.length, marks).map((piece, pieceIndex) => {
188
+ const slice = text.slice(piece.start - start, piece.end - start);
189
+ return piece.mark ? markElement(slice, piece.mark, `${keyPrefix}-${pieceIndex}`, context) : slice;
190
+ });
191
+ };
192
+ return inlines.map((node, index) => {
193
+ switch (node.type) {
194
+ case "text": {
195
+ const parts: ReactNode[] = [];
196
+ let last = 0;
197
+ for (const match of node.text.matchAll(CITATION_TOKEN)) {
198
+ const at = match.index ?? 0;
199
+ if (at > last) parts.push(...marked(node.text.slice(last, at), `${index}-${last}`));
200
+ parts.push(<span key={`${index}-t${at}`}>{renderText(match[0], context)}</span>);
201
+ last = at + match[0].length;
202
+ }
203
+ if (last < node.text.length) parts.push(...marked(node.text.slice(last), `${index}-${last}`));
204
+ return <span key={index}>{parts}</span>;
205
+ }
206
+ case "emphasis":
207
+ return <em key={index}>{renderMarkedInlines(node.children, context, marks, cursor)}</em>;
208
+ case "strong":
209
+ return <strong key={index}>{renderMarkedInlines(node.children, context, marks, cursor)}</strong>;
210
+ case "strikethrough":
211
+ return <s key={index}>{renderMarkedInlines(node.children, context, marks, cursor)}</s>;
212
+ case "code_span":
213
+ return <code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]" key={index}>{marked(node.code, `${index}`)}</code>;
214
+ case "link": {
215
+ const children = renderMarkedInlines(node.children, context, marks, cursor);
216
+ const custom = node.href ? context.renderLink?.(node.href, children) : null;
217
+ if (custom) return <span key={index}>{custom}</span>;
218
+ return node.href ? (
219
+ <a className="text-primary underline" href={node.href} key={index} rel="noopener noreferrer" target="_blank">{children}</a>
220
+ ) : (
221
+ <span key={index}>{children}</span>
222
+ );
223
+ }
224
+ case "soft_break":
225
+ cursor.at += 1;
226
+ return <span key={index}>{" "}</span>;
227
+ case "hard_break":
228
+ cursor.at += 1;
229
+ return <br key={index} />;
230
+ default:
231
+ return renderInlines([node], context)[0];
232
+ }
233
+ });
234
+ }
235
+
236
+ /** Render a prose block's inlines, with editorial marks when the block has an annotation. */
237
+ function proseInlines(block: { inline: MarkusInline[] }, context: PreviewContext): { children: ReactNode[]; blockKey?: string } {
238
+ const annotation = context.editorial?.plan.get(block);
239
+ if (!context.editorial || !annotation) return { children: renderInlines(block.inline, context) };
240
+ const blockKey = String(context.editorial.blocks.size);
241
+ context.editorial.blocks.set(blockKey, annotation);
242
+ return { children: renderMarkedInlines(block.inline, context, annotation.marks, { at: 0 }), blockKey };
243
+ }
244
+
144
245
  function soleToken(inlines: MarkusInline[]): string | null {
145
246
  if (inlines.length !== 1 || inlines[0].type !== "text") return null;
146
247
  const text = inlines[0].text.trim();
@@ -170,12 +271,14 @@ function renderBlock(block: MarkusBlock, index: number, context: PreviewContext)
170
271
  </ol>
171
272
  );
172
273
  }
173
- return <p className="my-3 leading-relaxed" key={index}>{renderInlines(block.inline, context)}</p>;
274
+ const prose = proseInlines(block, context);
275
+ return <p className="my-3 leading-relaxed" data-editorial-block={prose.blockKey} key={index}>{prose.children}</p>;
174
276
  }
175
277
  case "heading": {
176
278
  const level = Math.min(Math.max(block.level, 1), 6);
177
279
  const Tag = `h${level}` as "h1";
178
- return <Tag className={HEADING_CLASSES[level - 1]} key={index}>{renderInlines(block.inline, context)}</Tag>;
280
+ const prose = proseInlines(block, context);
281
+ return <Tag className={HEADING_CLASSES[level - 1]} data-editorial-block={prose.blockKey} key={index}>{prose.children}</Tag>;
179
282
  }
180
283
  case "list": {
181
284
  const items = block.items.map((item, itemIndex) => (
@@ -310,12 +413,45 @@ function renderBlocks(nodes: MarkusNode[], context: PreviewContext): ReactNode[]
310
413
  );
311
414
  }
312
415
 
416
+ /** Characters of rendered text from the start of `block` to (node, offset), skipping citation and image furniture. */
417
+ function renderedOffset(block: HTMLElement, node: Node, offset: number): number {
418
+ const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
419
+ let total = 0;
420
+ for (let current = walker.nextNode(); current; current = walker.nextNode()) {
421
+ const skipped = current.parentElement?.closest("[data-editorial-skip]");
422
+ if (current === node) return total + (skipped ? 0 : offset);
423
+ if (!skipped) total += current.textContent?.length ?? 0;
424
+ }
425
+ return total;
426
+ }
427
+
428
+ function previewSelectionHandler(context: PreviewContext) {
429
+ return (event: ReactMouseEvent<HTMLDivElement>) => {
430
+ const editorial = context.editorial;
431
+ if (!editorial?.onSelectSource) return;
432
+ const selection = window.getSelection();
433
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
434
+ const range = selection.getRangeAt(0);
435
+ const startBlock = (range.startContainer.parentElement ?? null)?.closest<HTMLElement>("[data-editorial-block]");
436
+ const endBlock = (range.endContainer.parentElement ?? null)?.closest<HTMLElement>("[data-editorial-block]");
437
+ if (!startBlock || startBlock !== endBlock || !event.currentTarget.contains(startBlock)) return;
438
+ const annotation = editorial.blocks.get(startBlock.dataset.editorialBlock ?? "");
439
+ const source = selectionToSource(annotation, {
440
+ start: renderedOffset(startBlock, range.startContainer, range.startOffset),
441
+ end: renderedOffset(startBlock, range.endContainer, range.endOffset),
442
+ });
443
+ if (source) editorial.onSelectSource(source, range.getBoundingClientRect());
444
+ };
445
+ }
446
+
313
447
  export function MarkusIrPreview({
314
448
  bodyIr,
315
449
  imageUrls = {},
316
450
  dynamicDirectives = {},
317
451
  renderLink,
452
+ editorial,
318
453
  }: {
454
+ editorial?: PreviewEditorial;
319
455
  bodyIr: unknown;
320
456
  imageUrls?: Record<string, string>;
321
457
  /** Live components for leaf directives whose content the host app computes, keyed by directive name. */
@@ -333,8 +469,13 @@ export function MarkusIrPreview({
333
469
  imageUrls,
334
470
  dynamicDirectives,
335
471
  renderLink,
472
+ editorial: editorial ? { ...editorial, plan: planPreview(document, editorial.result, editorial.findings), blocks: new Map() } : undefined,
336
473
  };
337
- return <div data-markus-preview>{renderBlocks(document.children, context)}</div>;
474
+ return (
475
+ <div data-markus-preview onMouseUp={editorial?.onSelectSource ? previewSelectionHandler(context) : undefined}>
476
+ {renderBlocks(document.children, context)}
477
+ </div>
478
+ );
338
479
  } catch (error) {
339
480
  return (
340
481
  <p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive" data-markus-preview-error role="alert">
@@ -48,7 +48,17 @@ import {
48
48
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
49
49
  import { SITE_BRAND } from "../lib/site-brand";
50
50
  import { cn } from "../lib/utils";
51
+ import amplifyOutputs from "papyrus-amplify-outputs";
52
+ import { applyReplacement, editorialChecksEnabled, inlineFindings, visibleFindings } from "../lib/editorial-checks";
53
+ import { graphqlEditorialBackend } from "../lib/editorial-checks-client";
54
+ import { demoEditorialBackend } from "../lib/editorial-checks-demo";
55
+ import type { PluginOutputs } from "../lib/plugin-settings";
56
+ import type { FindingActions } from "./editorial-finding-card";
57
+ import { EditorialFindingsPanel } from "./editorial-findings-panel";
58
+ import { EditorialPreviewPopover, type PreviewPopoverTarget } from "./editorial-preview-popover";
59
+ import { MarkusBodyEditor, type MarkusBodyEditorHandle } from "./markus-body-editor";
51
60
  import { MarkusIrPreview } from "./markus-ir-preview";
61
+ import { useEditorialChecks } from "./use-editorial-checks";
52
62
  import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
53
63
  import { NewsDeskAccessGate } from "./newsroom-access-gate";
54
64
  import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
@@ -118,6 +128,9 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
118
128
  const [notice, setNotice] = useState<Notice | null>(null);
119
129
  const [conflictOpen, setConflictOpen] = useState(false);
120
130
  const [unpublishOpen, setUnpublishOpen] = useState(false);
131
+ const [publishWarningOpen, setPublishWarningOpen] = useState(false);
132
+ const [previewPopover, setPreviewPopover] = useState<PreviewPopoverTarget | null>(null);
133
+ const previewRef = useRef<HTMLDivElement | null>(null);
121
134
  const [mobileTab, setMobileTab] = useState<MobileTab>("preview");
122
135
  const [focusedReferenceId, setFocusedReferenceId] = useState<string | null>(null);
123
136
  const [lineageId, setLineageId] = useState<string | null>(null);
@@ -133,7 +146,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
133
146
  const [imageError, setImageError] = useState<string | null>(null);
134
147
 
135
148
  const frontMatterRef = useRef<HTMLTextAreaElement | null>(null);
136
- const bodyRef = useRef<HTMLTextAreaElement | null>(null);
149
+ const bodyRef = useRef<MarkusBodyEditorHandle | null>(null);
137
150
  const imageInputRef = useRef<HTMLInputElement | null>(null);
138
151
  const mediaBackend = useMemo(() => resolveMediaBackend(previewMode), [previewMode]);
139
152
  const validationSequence = useRef(0);
@@ -205,6 +218,21 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
205
218
 
206
219
  const frontMatterYaml = editor?.frontMatterYaml ?? null;
207
220
  const bodyMarkus = editor?.bodyMarkus ?? null;
221
+ const editorialBackend = useMemo(
222
+ () => (demo ? demoEditorialBackend : !previewMode && editorialChecksEnabled(amplifyOutputs as PluginOutputs) ? graphqlEditorialBackend : null),
223
+ [demo, previewMode],
224
+ );
225
+ const editorial = useEditorialChecks({
226
+ backend: editorialBackend,
227
+ body: bodyMarkus,
228
+ section: editor?.section ?? "",
229
+ itemId: editor?.id ?? null,
230
+ });
231
+ const editorialOpen = useMemo(
232
+ () => (editorial.result ? visibleFindings(editorial.result.findings, editorial.dismissed) : []),
233
+ [editorial.result, editorial.dismissed],
234
+ );
235
+ const editorialMarks = useMemo(() => inlineFindings(editorialOpen), [editorialOpen]);
208
236
 
209
237
  useEffect(() => {
210
238
  if (frontMatterYaml === null || bodyMarkus === null) return;
@@ -381,7 +409,45 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
381
409
  }
382
410
  }, [aliases, backend, demo, editor]);
383
411
 
412
+ const requestPublish = () => {
413
+ if (editorial.openWarnings.length > 0) {
414
+ setPublishWarningOpen(true);
415
+ return;
416
+ }
417
+ void publish();
418
+ };
419
+
420
+ const applyEditorialRewrite = (
421
+ option: { span: { start: number; end: number }; replacement: string; expected: string },
422
+ finding: Parameters<typeof editorial.fixed>[0] | null,
423
+ ) => {
424
+ if (!editor) return;
425
+ const next = applyReplacement(editor.bodyMarkus, option.span, option.expected, option.replacement);
426
+ if (next === null) {
427
+ setNotice({ tone: "info", text: "That text changed since the check. Check again, then apply the rewrite." });
428
+ return;
429
+ }
430
+ patchEditor({ bodyMarkus: next });
431
+ if (finding) editorial.fixed(finding);
432
+ setPreviewPopover(null);
433
+ };
434
+
435
+ const editorialActions: FindingActions = {
436
+ onSelect: (finding) => {
437
+ editorial.setActiveFindingId(finding.id);
438
+ if (editorial.inlineResult && finding.excerpt) bodyRef.current?.reveal(finding.span.start, finding.span.end);
439
+ },
440
+ onDismiss: (finding, reason) => {
441
+ editorial.dismiss(finding, reason);
442
+ setPreviewPopover(null);
443
+ },
444
+ onAccept: editorial.accept,
445
+ onSuggest: editorial.suggest,
446
+ onApply: applyEditorialRewrite,
447
+ };
448
+
384
449
  const publish = async () => {
450
+ setPublishWarningOpen(false);
385
451
  if (!editor) return;
386
452
  let id = editor.id;
387
453
  if (editor.dirty || id === null) {
@@ -551,7 +617,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
551
617
  <Button
552
618
  data-newsroom-article-publish
553
619
  disabled={publishBlocked}
554
- onClick={() => void publish()}
620
+ onClick={requestPublish}
555
621
  size="sm"
556
622
  type="button"
557
623
  >
@@ -714,18 +780,29 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
714
780
  {imageError}
715
781
  </p>
716
782
  ) : null}
717
- <Textarea
718
- className={TEXTAREA_CLASS}
719
- data-newsroom-article-body
720
- id="newsroom-article-body"
721
- onChange={(event) => patchEditor({ bodyMarkus: event.target.value })}
783
+ <MarkusBodyEditor
784
+ activeFindingId={editorial.activeFindingId}
785
+ findings={editorialMarks}
786
+ onChange={(value) => patchEditor({ bodyMarkus: value })}
787
+ onFindingClick={editorial.setActiveFindingId}
788
+ onSelectionChange={editorial.setSelection}
722
789
  ref={bodyRef}
723
- rows={28}
724
- spellCheck={false}
725
790
  value={editor.bodyMarkus}
726
791
  />
727
792
  </div>
728
793
 
794
+ {editorial.enabled ? (
795
+ <EditorialFindingsPanel
796
+ {...editorialActions}
797
+ activeFindingId={editorial.activeFindingId}
798
+ dismissed={editorial.dismissed}
799
+ onCheck={() => void editorial.check()}
800
+ result={editorial.result}
801
+ selection={editorial.selection}
802
+ status={editorial.status}
803
+ />
804
+ ) : null}
805
+
729
806
  {validation.status === "unavailable" ? (
730
807
  <NewsroomOpsStatusBanner tone="error">{validation.message}</NewsroomOpsStatusBanner>
731
808
  ) : null}
@@ -763,7 +840,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
763
840
  className={cn("min-w-0", mobileTab === "preview" ? "block" : "hidden")}
764
841
  data-newsroom-article-preview-column
765
842
  >
766
- <div className="rounded-xl border border-border bg-card p-4" data-newsroom-article-preview>
843
+ <div className="relative rounded-xl border border-border bg-card p-4" data-newsroom-article-preview ref={previewRef}>
767
844
  {(readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY) || readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_BLURB_KEY)) ? (
768
845
  <div className="newsroom-article-summary" data-newsroom-article-summary>
769
846
  {readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY) ? <p className="newsroom-article-subtitle">{readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY)}</p> : null}
@@ -774,12 +851,40 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
774
851
  <p className="mb-2 text-xs text-muted-foreground">Showing the last valid version.</p>
775
852
  ) : null}
776
853
  {bodyIr ? (
777
- <MarkusIrPreview bodyIr={bodyIr} imageUrls={imageUrls} />
854
+ <MarkusIrPreview
855
+ bodyIr={bodyIr}
856
+ editorial={
857
+ editorial.inlineResult
858
+ ? {
859
+ result: editorial.inlineResult,
860
+ findings: editorialOpen,
861
+ activeFindingIds: previewPopover?.kind === "findings" ? previewPopover.findingIds : editorial.activeFindingId ? [editorial.activeFindingId] : [],
862
+ onOpenMark: (findingIds, anchor) => {
863
+ editorial.setActiveFindingId(findingIds[0] ?? null);
864
+ setPreviewPopover({ kind: "findings", findingIds, anchor: anchor.getBoundingClientRect() });
865
+ },
866
+ onSelectSource: (source, anchor) => setPreviewPopover({ kind: "selection", source, anchor }),
867
+ }
868
+ : undefined
869
+ }
870
+ imageUrls={imageUrls}
871
+ />
778
872
  ) : (
779
873
  <p className="text-sm text-muted-foreground">
780
874
  {invalid ? "Fix the errors to see a preview." : "The preview appears once the markup is valid."}
781
875
  </p>
782
876
  )}
877
+ {previewPopover && editorial.enabled ? (
878
+ <EditorialPreviewPopover
879
+ actions={editorialActions}
880
+ body={editor.bodyMarkus}
881
+ container={previewRef.current}
882
+ findings={editorialOpen}
883
+ key={previewPopover.kind === "findings" ? previewPopover.findingIds.join() : `${previewPopover.source.start}-${previewPopover.source.end}`}
884
+ onClose={() => setPreviewPopover(null)}
885
+ target={previewPopover}
886
+ />
887
+ ) : null}
783
888
  </div>
784
889
  </div>
785
890
  </div>
@@ -801,6 +906,28 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
801
906
  </DialogContent>
802
907
  </Dialog>
803
908
 
909
+ <Dialog onOpenChange={setPublishWarningOpen} open={publishWarningOpen}>
910
+ <DialogContent>
911
+ <DialogHeader>
912
+ <DialogTitle>Publish with open editorial findings?</DialogTitle>
913
+ <DialogDescription>
914
+ {editorial.openWarnings.length === 1
915
+ ? "One editorial finding is still open."
916
+ : `${editorial.openWarnings.length} editorial findings are still open.`}{" "}
917
+ Findings are advice: you can publish anyway.
918
+ </DialogDescription>
919
+ </DialogHeader>
920
+ <ul className="max-h-48 list-disc space-y-1 overflow-auto pl-5 text-sm" data-newsroom-article-publish-warnings>
921
+ {editorial.openWarnings.slice(0, 8).map((finding) => (
922
+ <li key={finding.id}>{finding.message}{finding.excerpt ? `: “${finding.excerpt}”` : ""}</li>
923
+ ))}
924
+ </ul>
925
+ <DialogFooter>
926
+ <Button onClick={() => setPublishWarningOpen(false)} type="button" variant="outline">Keep editing</Button>
927
+ <Button data-newsroom-article-publish-anyway onClick={() => void publish()} type="button">Publish anyway</Button>
928
+ </DialogFooter>
929
+ </DialogContent>
930
+ </Dialog>
804
931
  <Dialog onOpenChange={setUnpublishOpen} open={unpublishOpen}>
805
932
  <DialogContent>
806
933
  <DialogHeader>
@@ -3,10 +3,8 @@
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import Link from "next/link";
5
5
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
6
- import { IssueCollection, IssueDetailPanel, IssueTypeFilter, issueTypeFromFilter, useBoardColumnState } from "@kanbus/ui";
7
- import "@kanbus/ui/styles/kanban.css";
8
- import "@kanbus/ui/styles/newsprint.css";
9
- import "./newsroom-newsprint-board.css";
6
+ import { IssueCollection, IssueDetailPanel, IssueIdFormatProvider, IssueTypeFilter, issueTypeFromFilter, useBoardColumnState } from "@kanbus/ui";
7
+ import "./newsroom-theme";
10
8
  import { PlusIcon } from "lucide-react";
11
9
  import { newsroomHref } from "../lib/newsroom-base-path";
12
10
  import {
@@ -18,6 +16,7 @@ import {
18
16
  deskBoardConfig,
19
17
  deskContainsAssignment,
20
18
  deskContainsContentItem,
19
+ formatNativeIssueId,
21
20
  projectAssignmentBoard,
22
21
  projectContentItemBoard,
23
22
  type NativeBoardIssue,
@@ -35,7 +34,6 @@ import { isLocalPreviewEnabled } from "../lib/local-preview";
35
34
  import { NewsroomArticleEditor } from "./newsroom-article-editor";
36
35
  import { NewsroomAssignmentDetail } from "./newsroom-assignment-detail";
37
36
  import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
38
- import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
39
37
  import { readLocalReaderSettings, subscribeReaderSettingsChanges } from "./reader-settings";
40
38
 
41
39
  /** A board with a story or assignment open lives at `?open=<id>`, distinct from the standalone page for that record. */
@@ -61,7 +59,6 @@ export type NewsroomBoardSubject = "contentItem" | "assignment" | "desk";
61
59
  export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardSubject; deskId?: string }) {
62
60
  const session = useOptionalNewsDeskClient();
63
61
  const allowed = canReadNewsroomBoard(session?.shell.phase);
64
- const theme = useResolvedPapyrusTheme();
65
62
  const [lowMotion, setLowMotion] = useState(false);
66
63
  const [assignmentBoardIssues, setAssignmentBoardIssues] = useState<NativeBoardIssue[]>([]);
67
64
  const [publishedVersions, setPublishedVersions] = useState<ContentItemVersionRecord[]>([]);
@@ -172,7 +169,8 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
172
169
  </Link>
173
170
  )}
174
171
  </>}>
175
- <div className="organizer-native-board papyrus-newsprint-board" data-kanbus-theme="newsprint" data-color-mode={theme}>
172
+ <IssueIdFormatProvider formatIssueId={formatNativeIssueId}>
173
+ <div className="organizer-native-board">
176
174
  {error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
177
175
  {subject === "desk" && <IssueTypeFilter config={config} value={typeFilter} onChange={setTypeFilter} name="Record type" className="organizer-board-type-filter" />}
178
176
  <div className="organizer-board-body" data-detail-open={Boolean(issue)} ref={boardBodyRef} onScrollCapture={handleBoardScroll}>
@@ -196,5 +194,6 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
196
194
  </IssueDetailPanel>}
197
195
  </div>
198
196
  </div>
197
+ </IssueIdFormatProvider>
199
198
  </NewsroomOpsShell>;
200
199
  }
@@ -19,6 +19,7 @@ import { ProspectusAccountChooser, useProspectusAccountSummary } from "./prospec
19
19
  import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetTrigger } from "./ui/sheet";
20
20
  import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator } from "./ui/dropdown-menu";
21
21
  import "./newsroom-organizer.css";
22
+ import { NewsroomThemeFonts, useNewsroomThemeAttributes } from "./newsroom-theme";
22
23
 
23
24
  export type NewsroomNavCount = {
24
25
  count: number | null;
@@ -85,11 +86,12 @@ function Account({ placement }: { placement: "sidebar" | "header" }) {
85
86
  media.addEventListener("change", close);
86
87
  return () => media.removeEventListener("change", close);
87
88
  }, []);
89
+ const themeAttributes = useNewsroomThemeAttributes();
88
90
  return <DropdownMenu open={open} onOpenChange={setOpen}>
89
91
  <DropdownMenuTrigger id={`organizer-account-trigger-${placement}`} className={`organizer-account organizer-account-${placement}`} aria-label="Account menu" title="Account menu">
90
92
  <UserRound size={18} aria-hidden="true" /><span><strong>{auth.label}</strong><small>{accountSummary ?? (auth.status === "signedIn" ? "Account & preferences" : auth.status === "signedOut" ? "Not signed in" : "Checking session")}</small></span>
91
93
  </DropdownMenuTrigger>
92
- <DropdownMenuContent className="organizer-account-popup" side={placement === "sidebar" ? "top" : "bottom"} align="end">
94
+ <DropdownMenuContent className="organizer-account-popup" {...themeAttributes} side={placement === "sidebar" ? "top" : "bottom"} align="end">
93
95
  <DropdownMenuGroup>
94
96
  <DropdownMenuLabel>{auth.label}</DropdownMenuLabel><DropdownMenuSeparator />
95
97
  <ProspectusAccountChooser />
@@ -104,6 +106,7 @@ function Account({ placement }: { placement: "sidebar" | "header" }) {
104
106
 
105
107
  export function NewsroomOpsShell({ activeTab, appTitle, backHref = "/", backLabel, children, demo = false, headerActions, pageTitle, showNavigation = true }: NewsroomOpsShellProps) {
106
108
  const pathname = usePathname();
109
+ const themeAttributes = useNewsroomThemeAttributes();
107
110
  const session = useOptionalNewsDeskClient();
108
111
  const admin = demo || session?.shell.dashboard?.canManageUsers === true;
109
112
  const [menuOpen, setMenuOpen] = useState(false);
@@ -145,7 +148,8 @@ export function NewsroomOpsShell({ activeTab, appTitle, backHref = "/", backLabe
145
148
  aria-label={sidebarToggleLabel} aria-expanded={!collapsed} aria-controls="organizer-sidebar" onClick={() => setCollapsed(value => !value)}>
146
149
  {collapsed ? <PanelLeftOpen size={16} aria-hidden="true" /> : <PanelLeftClose size={16} aria-hidden="true" />}
147
150
  </button>;
148
- return <div className="newsroom-organizer" data-news-desk data-newsroom-ops-shell="true" data-newsroom-chrome="organizer" data-sidebar-collapsed={collapsed || !showNavigation ? "true" : "false"}>
151
+ return <div className="newsroom-organizer" {...themeAttributes} data-news-desk data-newsroom-ops-shell="true" data-newsroom-chrome="organizer" data-sidebar-collapsed={collapsed || !showNavigation ? "true" : "false"}>
152
+ <NewsroomThemeFonts />
149
153
  {showNavigation && <aside className="organizer-sidebar" hidden={collapsed} id="organizer-sidebar">
150
154
  <div className="organizer-sidebar-head">
151
155
  <Link className="organizer-brand" href={getNewsroomNavHref("/newsroom", demo)}>{SITE_BRAND.components?.NewsroomLogo ? <SITE_BRAND.components.NewsroomLogo className="organizer-brand-logo" /> : appTitle}<small>The newsroom</small></Link>
@@ -167,7 +171,7 @@ export function NewsroomOpsShell({ activeTab, appTitle, backHref = "/", backLabe
167
171
  href={getNewsroomNavHref(item.path, demo)} title={item.description} aria-label={item.label} aria-current={activeTab === item.key ? "page" : undefined}>
168
172
  <Icon size={18} aria-hidden="true" /><span>{item.label}</span></Link></li>; })}
169
173
  <li><Sheet open={menuOpen} onOpenChange={setMenuOpen}><SheetTrigger className="organizer-more" aria-label="More"><Menu size={18} aria-hidden="true" /><span>More</span></SheetTrigger>
170
- <SheetContent side="bottom" className="organizer-menu"><SheetHeader><SheetTitle>Newsroom</SheetTitle><SheetDescription>Workspaces and administration</SheetDescription></SheetHeader>
174
+ <SheetContent side="bottom" className="organizer-menu" {...themeAttributes}><SheetHeader><SheetTitle>Newsroom</SheetTitle><SheetDescription>Workspaces and administration</SheetDescription></SheetHeader>
171
175
  <Navigation activePath={activePath} demo={demo} admin={admin} surface="menu" onNavigate={() => setMenuOpen(false)} />
172
176
  </SheetContent>
173
177
  </Sheet></li>