@anthusai/papyrus 1.4.0-next.3 → 1.4.0-next.5

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 (61) hide show
  1. package/amplify/data/resource.js +3 -3
  2. package/amplify/data/resource.ts +3 -3
  3. package/amplify/functions/category-action/handler.ts +18 -18
  4. package/amplify/functions/console-chat-responder/src/main.rs +1 -1
  5. package/amplify/functions/content-actions/handler.py +23 -0
  6. package/amplify/functions/shared/slack-events.ts +2 -2
  7. package/app/api/local-preview/derive/route.ts +28 -0
  8. package/app/api/local-preview/media/route.ts +25 -0
  9. package/app/globals.css +28 -28
  10. package/app/layout.tsx +8 -5
  11. package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
  12. package/app/newsroom/[section]/page.tsx +0 -1
  13. package/app/newsroom/insights/[threadId]/page.tsx +5 -16
  14. package/app/newsroom/insights/page.tsx +4 -12
  15. package/components/board-card-excerpts.tsx +45 -0
  16. package/components/markus-ir-preview.tsx +47 -2
  17. package/components/news-desk-page.tsx +0 -1
  18. package/components/news-desk-taxonomy-client.ts +28 -222
  19. package/components/newsroom-article-editor.tsx +138 -40
  20. package/components/newsroom-assignment-detail.tsx +79 -0
  21. package/components/newsroom-board-view.tsx +44 -9
  22. package/components/newsroom-newsprint-board.css +10 -2
  23. package/components/newsroom-ops-overview.tsx +27 -5
  24. package/components/newsroom-organizer-shell.tsx +21 -6
  25. package/components/newsroom-organizer.css +208 -6
  26. package/components/newsroom-topics-view.tsx +1 -1
  27. package/components/priming-proof-page.tsx +17 -8
  28. package/components/story-reporting-trail-client.ts +91 -0
  29. package/components/story-reporting-trail.tsx +115 -0
  30. package/components/topic-steering-workspace.tsx +154 -671
  31. package/components/ui/sheet.tsx +10 -4
  32. package/components/wiki-concepts-view.tsx +153 -0
  33. package/components/wiki-index-client.ts +51 -0
  34. package/lib/approved-priming-proof-reports.ts +6 -3
  35. package/lib/approved-priming-proofs/papyrus.json +90 -0
  36. package/lib/content-actions-client.ts +3 -0
  37. package/lib/content-actions-demo.ts +55 -0
  38. package/lib/content-item-lifecycle.ts +4 -2
  39. package/lib/front-matter-fields.ts +42 -0
  40. package/lib/front-mosaic.ts +2 -1
  41. package/lib/layout-plan.ts +3 -2
  42. package/lib/layout-warnings.ts +8 -0
  43. package/lib/local-preview-seed.ts +53 -0
  44. package/lib/newsroom-articles.ts +2 -0
  45. package/lib/newsroom-assignments.ts +13 -0
  46. package/lib/newsroom-board.ts +4 -0
  47. package/lib/newsroom-forum-routes.ts +2 -43
  48. package/lib/newsroom-index-filters.ts +3 -40
  49. package/lib/newsroom-media-backend.ts +5 -0
  50. package/lib/newsroom-nav.ts +1 -3
  51. package/lib/newsroom-organizer-nav.ts +5 -12
  52. package/lib/newsroom-section-routing.ts +1 -1
  53. package/lib/newsroom-sections.ts +12 -0
  54. package/lib/papyrus-web-locations.ts +3 -24
  55. package/lib/priming-proof-report.ts +1 -0
  56. package/lib/semantic-graph.ts +5 -5
  57. package/lib/site-brand.ts +9 -0
  58. package/lib/story-reporting-trail.ts +106 -0
  59. package/lib/wiki.ts +56 -0
  60. package/package.json +13 -1
  61. package/routes.manifest.json +23 -7
@@ -11,9 +11,9 @@ type SheetSide = "bottom" | "left" | "right";
11
11
 
12
12
  const sideClasses: Record<SheetSide, string> = {
13
13
  bottom:
14
- "inset-x-0 bottom-0 top-auto max-h-[85dvh] w-full rounded-t-2xl border-t data-open:slide-in-from-bottom data-closed:slide-out-to-bottom",
15
- left: "inset-y-0 left-0 h-full w-[min(100vw,20rem)] rounded-r-2xl border-r data-open:slide-in-from-left data-closed:slide-out-to-left",
16
- right: "inset-y-0 right-0 h-full w-[min(100vw,20rem)] rounded-l-2xl border-l data-open:slide-in-from-right data-closed:slide-out-to-right",
14
+ "inset-x-0 bottom-0 top-auto max-h-[85dvh] w-full border-t data-open:slide-in-from-bottom data-closed:slide-out-to-bottom",
15
+ left: "inset-y-0 left-0 h-full w-[min(100vw,20rem)] border-r data-open:slide-in-from-left data-closed:slide-out-to-left",
16
+ right: "inset-y-0 right-0 h-full w-[min(100vw,20rem)] border-l data-open:slide-in-from-right data-closed:slide-out-to-right",
17
17
  };
18
18
 
19
19
  function Sheet({ ...props }: DialogPrimitive.Root.Props) {
@@ -31,17 +31,23 @@ function SheetClose({ ...props }: DialogPrimitive.Close.Props) {
31
31
  function SheetContent({
32
32
  children,
33
33
  className,
34
+ backdropClassName,
34
35
  showCloseButton = true,
35
36
  side = "bottom",
36
37
  ...props
37
38
  }: DialogPrimitive.Popup.Props & {
39
+ backdropClassName?: string;
38
40
  showCloseButton?: boolean;
39
41
  side?: SheetSide;
40
42
  }) {
41
43
  return (
42
44
  <DialogPrimitive.Portal>
43
45
  <DialogPrimitive.Backdrop
44
- className="fixed inset-0 z-50 bg-black/40 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0"
46
+ className={cn(
47
+ "fixed inset-0 z-50 bg-black/40 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
48
+ typeof className === "string" && className.includes("md:hidden") && "md:hidden",
49
+ backdropClassName,
50
+ )}
45
51
  />
46
52
  <DialogPrimitive.Popup
47
53
  className={cn(
@@ -0,0 +1,153 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
5
+ import type { SemanticNodeRecord } from "../lib/category-repository";
6
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
7
+ import { getNewsroomNavHref } from "../lib/newsroom-nav";
8
+ import type { createSemanticGraphSnapshot } from "../lib/semantic-graph";
9
+ import {
10
+ conceptEditedAt, conceptId, conceptTitle, conceptWikiPath, conceptsByTitle, recentlyEditedConcepts, resolveConceptLink,
11
+ } from "../lib/wiki";
12
+ import { MarkusIrPreview } from "./markus-ir-preview";
13
+ import { createWikiIndexPage, loadWikiIndexPage, type WikiIndexPage } from "./wiki-index-client";
14
+
15
+ type SemanticGraph = ReturnType<typeof createSemanticGraphSnapshot>;
16
+
17
+ const editedFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
18
+
19
+ function formatEdited(value: string): string {
20
+ const parsed = new Date(value);
21
+ return value && !Number.isNaN(parsed.getTime()) ? editedFormat.format(parsed) : "";
22
+ }
23
+
24
+ function kindLabel(kind: string): string {
25
+ return kind.replace(/_/g, " ");
26
+ }
27
+
28
+ function ConceptLink({ children, demo, node, onOpen }: { children?: ReactNode; demo: boolean; node: SemanticNodeRecord; onOpen: (lineageId: string) => void }) {
29
+ return <a className="wiki-link" data-wiki-concept-link={conceptId(node)} href={getNewsroomNavHref(conceptWikiPath(node), demo)} onClick={event => { if (event.metaKey || event.ctrlKey || event.shiftKey) return; event.preventDefault(); onOpen(conceptId(node)); }}>{children ?? conceptTitle(node)}</a>;
30
+ }
31
+
32
+ function ConceptRows({ demo, nodes, onOpen, showEdited }: { demo: boolean; nodes: SemanticNodeRecord[]; onOpen: (lineageId: string) => void; showEdited?: boolean }) {
33
+ if (!nodes.length) return <p className="wiki-empty">No concepts to show.</p>;
34
+ return <ul className="wiki-concept-list">
35
+ {nodes.map(node => <li key={conceptId(node)}>
36
+ <ConceptLink demo={demo} node={node} onOpen={onOpen} />
37
+ <span className="wiki-chip">{kindLabel(node.nodeKind)}</span>
38
+ {showEdited && conceptEditedAt(node) && <span className="wiki-meta">edited {formatEdited(conceptEditedAt(node))}</span>}
39
+ </li>)}
40
+ </ul>;
41
+ }
42
+
43
+ function WikiIndexPageView({ demo, nodes, onOpen }: { demo: boolean; nodes: SemanticNodeRecord[]; onOpen: (lineageId: string) => void }) {
44
+ const [page, setPage] = useState<WikiIndexPage | null>(null);
45
+ const [error, setError] = useState<string | null>(null);
46
+ const [saving, setSaving] = useState(false);
47
+ useEffect(() => {
48
+ let cancelled = false;
49
+ loadWikiIndexPage(demo || isLocalPreviewEnabled()).then(loaded => { if (!cancelled) setPage(loaded); }).catch((reason: unknown) => { if (!cancelled) setError(reason instanceof Error ? reason.message : "Could not load the wiki index."); });
50
+ return () => { cancelled = true; };
51
+ }, [demo]);
52
+ const dynamicDirectives = useMemo(() => ({
53
+ "recent-concepts": (attributes: Record<string, unknown>) => <ConceptRows demo={demo} nodes={recentlyEditedConcepts(nodes, { kind: typeof attributes.kind === "string" ? attributes.kind : null, limit: Number(attributes.limit) || 10 })} onOpen={onOpen} showEdited />,
54
+ "concept-list": (attributes: Record<string, unknown>) => <ConceptRows demo={demo} nodes={conceptsByTitle(nodes, { kind: typeof attributes.kind === "string" ? attributes.kind : null, limit: Number(attributes.limit) || null })} onOpen={onOpen} />,
55
+ "concept-count": (attributes: Record<string, unknown>) => <strong>{nodes.filter(node => !attributes.kind || node.nodeKind === attributes.kind).length}</strong>,
56
+ }), [demo, nodes, onOpen]);
57
+ const renderLink = (href: string, children: ReactNode) => {
58
+ const node = resolveConceptLink(href, nodes);
59
+ return node ? <ConceptLink demo={demo} node={node} onOpen={onOpen}>{children}</ConceptLink> : null;
60
+ };
61
+ if (error) return <p className="wiki-empty" role="alert">{error}</p>;
62
+ if (!page) return <p className="wiki-empty">Loading the wiki…</p>;
63
+ return <article className="wiki-page" data-wiki-index>
64
+ <div className="wiki-page-tools">
65
+ {page.itemId
66
+ ? <Link className="wiki-tool-link" href={getNewsroomNavHref(`/newsroom/articles/${encodeURIComponent(page.itemId)}`, demo)}>Edit this page</Link>
67
+ : <button className="wiki-tool-link" disabled={saving} onClick={() => { setSaving(true); createWikiIndexPage(demo || isLocalPreviewEnabled()).then(setPage).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Could not save the index.")).finally(() => setSaving(false)); }} type="button">{saving ? "Saving…" : "Save this page so it can be edited"}</button>}
68
+ </div>
69
+ <MarkusIrPreview bodyIr={page.bodyIr} dynamicDirectives={dynamicDirectives} renderLink={renderLink} />
70
+ </article>;
71
+ }
72
+
73
+ function ConceptPageView({ demo, graph, node, nodes, onOpen }: { demo: boolean; graph: SemanticGraph; node: SemanticNodeRecord; nodes: SemanticNodeRecord[]; onOpen: (lineageId: string) => void }) {
74
+ const lineage = conceptId(node);
75
+ const groups = graph.neighbors("semanticNode", lineage);
76
+ const references = graph.referencesForSemanticNode(lineage);
77
+ const byLineage = new Map(nodes.map(candidate => [conceptId(candidate), candidate]));
78
+ const aliases = (node.aliases ?? []).filter(Boolean) as string[];
79
+ return <article className="wiki-page" data-wiki-concept={lineage}>
80
+ <h1>{conceptTitle(node)}</h1>
81
+ <p className="wiki-meta"><span className="wiki-chip">{kindLabel(node.nodeKind)}</span> {node.status !== "current" && <span className="wiki-chip">{node.status}</span>} {conceptEditedAt(node) && <>Last edited {formatEdited(conceptEditedAt(node))}</>}{node.versionCreatedBy ? ` by ${node.versionCreatedBy}` : ""}</p>
82
+ {aliases.length > 0 && <p className="wiki-meta">Also known as {aliases.join(", ")}</p>}
83
+ {node.description ? node.description.split(/\n{2,}/).map((paragraph, index) => <p key={index}>{paragraph}</p>) : <p className="wiki-empty">This concept has no description yet.</p>}
84
+ <section data-wiki-section="connections">
85
+ <h2>Connections</h2>
86
+ {groups.length ? groups.map(group => <div className="wiki-connection-group" key={`${group.direction}-${group.predicate}`}>
87
+ <h3>{group.label}</h3>
88
+ <ul className="wiki-concept-list">
89
+ {group.objects.map(object => {
90
+ const target = object.kind === "semanticNode" ? byLineage.get(object.lineageId) : undefined;
91
+ return <li key={`${object.kind}-${object.lineageId}`}>{target ? <ConceptLink demo={demo} node={target} onOpen={onOpen} /> : <span>{object.label}</span>}<span className="wiki-chip">{target ? kindLabel(target.nodeKind) : object.kind}</span></li>;
92
+ })}
93
+ </ul>
94
+ </div>) : <p className="wiki-empty">No connections recorded.</p>}
95
+ </section>
96
+ <section data-wiki-section="references">
97
+ <h2>References</h2>
98
+ {references.length ? <ul className="wiki-concept-list">
99
+ {references.map(reference => <li key={reference.lineageId}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, demo)}>{reference.label}</Link></li>)}
100
+ </ul> : <p className="wiki-empty">No references recorded.</p>}
101
+ </section>
102
+ </article>;
103
+ }
104
+
105
+ /**
106
+ * The concept wiki: the front page is a content item whose Markus body can hold live components
107
+ * (recently edited concepts, concept lists), and every concept in the knowledge graph is a page
108
+ * linked to the concepts and references it connects to.
109
+ */
110
+ export function WikiConceptsView({ demo = false, graph, initialNodeLineageId, nodes }: { demo?: boolean; graph: SemanticGraph; initialNodeLineageId?: string | null; nodes: SemanticNodeRecord[] }) {
111
+ const [selected, setSelected] = useState<string | null>(initialNodeLineageId ?? null);
112
+ const [search, setSearch] = useState("");
113
+ const [kind, setKind] = useState("");
114
+ useEffect(() => { setSelected(initialNodeLineageId ?? null); }, [initialNodeLineageId]);
115
+ useEffect(() => {
116
+ const onPop = () => {
117
+ const match = window.location.pathname.match(/\/concepts\/([^/?#]+)/);
118
+ setSelected(match ? decodeURIComponent(match[1]) : null);
119
+ };
120
+ window.addEventListener("popstate", onPop);
121
+ return () => window.removeEventListener("popstate", onPop);
122
+ }, []);
123
+ const open = (lineageId: string | null) => {
124
+ setSelected(lineageId);
125
+ const target = getNewsroomNavHref(lineageId ? `/newsroom/concepts/${encodeURIComponent(lineageId)}` : "/newsroom/concepts", demo);
126
+ if (`${window.location.pathname}${window.location.search}` !== target) window.history.pushState(null, "", target);
127
+ };
128
+ const node = selected ? nodes.find(candidate => conceptId(candidate) === selected || candidate.id === selected) ?? null : null;
129
+ const kinds = useMemo(() => Array.from(new Set(nodes.map(candidate => candidate.nodeKind))).sort(), [nodes]);
130
+ const matches = useMemo(() => {
131
+ const needle = search.trim().toLowerCase();
132
+ return conceptsByTitle(nodes, { kind: kind || null }).filter(candidate => !needle || conceptTitle(candidate).toLowerCase().includes(needle) || (candidate.aliases ?? []).some(alias => alias?.toLowerCase().includes(needle)));
133
+ }, [kind, nodes, search]);
134
+ return <div className="wiki" data-wiki>
135
+ <nav aria-label="Wiki" className="wiki-sidebar">
136
+ <button className="wiki-home" data-wiki-home aria-current={node ? undefined : "page"} onClick={() => open(null)} type="button">Wiki home</button>
137
+ <input aria-label="Search concepts" className="wiki-search" onChange={event => setSearch(event.target.value)} placeholder="Search concepts" value={search} />
138
+ {kinds.length > 1 && <select aria-label="Concept kind" className="wiki-kind" onChange={event => setKind(event.target.value)} value={kind}>
139
+ <option value="">All kinds</option>
140
+ {kinds.map(option => <option key={option} value={option}>{kindLabel(option)}</option>)}
141
+ </select>}
142
+ <ul className="wiki-sidebar-list">
143
+ {matches.map(candidate => <li key={conceptId(candidate)}><a aria-current={node && conceptId(node) === conceptId(candidate) ? "page" : undefined} href={getNewsroomNavHref(conceptWikiPath(candidate), demo)} onClick={event => { if (event.metaKey || event.ctrlKey || event.shiftKey) return; event.preventDefault(); open(conceptId(candidate)); }}>{conceptTitle(candidate)}</a></li>)}
144
+ </ul>
145
+ </nav>
146
+ <div className="wiki-main">
147
+ {node
148
+ ? <ConceptPageView demo={demo} graph={graph} node={node} nodes={nodes} onOpen={open} />
149
+ : selected ? <p className="wiki-empty" role="alert">That concept page was not found.</p>
150
+ : <WikiIndexPageView demo={demo} nodes={nodes} onOpen={open} />}
151
+ </div>
152
+ </div>;
153
+ }
@@ -0,0 +1,51 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import type { Schema } from "../amplify/data/resource";
5
+ import { resolveArticlesBackend } from "../lib/newsroom-articles";
6
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
7
+ import { DEFAULT_WIKI_INDEX_MARKUS, WIKI_INDEX_SLUG, WIKI_PAGE_ITEM_TYPE } from "../lib/wiki";
8
+ import { configureAmplifyClient } from "./amplify-client-provider";
9
+
10
+ export type WikiIndexPage = {
11
+ /** The id of the stored content item, or null while the wiki still shows the built-in starter content. */
12
+ itemId: string | null;
13
+ bodyMarkus: string;
14
+ bodyIr: unknown;
15
+ };
16
+
17
+ type GraphqlResponse = { data?: { itemBySlug?: { items?: Record<string, unknown>[] | null } | null } | null; errors?: { message?: string }[] | null };
18
+
19
+ async function storedIndexRow(preview: boolean): Promise<Record<string, unknown> | null> {
20
+ if (preview || isLocalPreviewEnabled()) return null;
21
+ configureAmplifyClient();
22
+ const client = generateClient<Schema>() as unknown as { graphql: (options: Record<string, unknown>) => Promise<GraphqlResponse> };
23
+ const response = await client.graphql({
24
+ query: "query ($slug: String!) { itemBySlug(slug: $slug) { items { id versionNumber versionState bodyMarkus bodyIr } } }",
25
+ variables: { slug: WIKI_INDEX_SLUG },
26
+ authMode: "userPool",
27
+ });
28
+ if (response.errors?.length) throw new Error(response.errors.map(error => error.message ?? "GraphQL error").join("; "));
29
+ const rows = (response.data?.itemBySlug?.items ?? []).filter(Boolean) as Record<string, unknown>[];
30
+ const ranked = rows.sort((left, right) => Number(right.versionNumber ?? 0) - Number(left.versionNumber ?? 0));
31
+ return ranked.find(row => row.versionState === "current") ?? ranked[0] ?? null;
32
+ }
33
+
34
+ async function derive(bodyMarkus: string, preview: boolean): Promise<unknown> {
35
+ const result = await resolveArticlesBackend(preview).actions.deriveMarkus({ frontMatterYaml: null, bodyMarkus, includeIr: true });
36
+ return result.bodyIr ?? null;
37
+ }
38
+
39
+ /** Loads the wiki's front page content item, falling back to the starter content until one is saved. */
40
+ export async function loadWikiIndexPage(preview: boolean): Promise<WikiIndexPage> {
41
+ const row = await storedIndexRow(preview);
42
+ const bodyMarkus = typeof row?.bodyMarkus === "string" && row.bodyMarkus.trim() ? row.bodyMarkus : DEFAULT_WIKI_INDEX_MARKUS;
43
+ const storedIr = row?.bodyIr;
44
+ return { itemId: row ? String(row.id) : null, bodyMarkus, bodyIr: storedIr ?? await derive(bodyMarkus, preview) };
45
+ }
46
+
47
+ /** Saves the starter index as the wiki-index content item so editors can change it like any other page. */
48
+ export async function createWikiIndexPage(preview: boolean): Promise<WikiIndexPage> {
49
+ const saved = await resolveArticlesBackend(preview).actions.saveItemDraft({ id: null, type: WIKI_PAGE_ITEM_TYPE, slug: WIKI_INDEX_SLUG, section: null, frontMatterYaml: `title: Concept wiki\n`, bodyMarkus: DEFAULT_WIKI_INDEX_MARKUS, aliases: [], expectedContentHash: null });
50
+ return { itemId: saved.item.id, bodyMarkus: DEFAULT_WIKI_INDEX_MARKUS, bodyIr: await derive(DEFAULT_WIKI_INDEX_MARKUS, preview) };
51
+ }
@@ -1,6 +1,9 @@
1
+ import approvedReport0 from "./approved-priming-proofs/papyrus.json";
2
+
1
3
  /**
2
4
  * Priming proof reports a publication owner approved for visitors, keyed by site brand id.
3
- * A report reaches this map only through a reviewed commit of its
4
- * `publications/priming-proofs/<brand id>.json` file; see docs/cyclotron-priming.md.
5
+ * A report reaches this map only through a reviewed commit made by scripts/approve-priming-proof-report.ts.
5
6
  */
6
- export const APPROVED_PRIMING_PROOF_REPORTS: Record<string, unknown> = {};
7
+ export const APPROVED_PRIMING_PROOF_REPORTS: Record<string, unknown> = {
8
+ "papyrus": approvedReport0,
9
+ };
@@ -0,0 +1,90 @@
1
+ {
2
+ "version": "cyclotron-priming-proof/v1",
3
+ "proofId": "ti-2026-10",
4
+ "publication": "Anthus Threat Intelligence",
5
+ "cyclotronId": "papyrus-relevance",
6
+ "question": "Should this candidate source become a reference for this publication?",
7
+ "labels": [
8
+ "include",
9
+ "exclude"
10
+ ],
11
+ "positiveLabel": "include",
12
+ "decisionModel": "jev:jev-1.13.0",
13
+ "ranAt": "2026-10-18T13:34:13.426310Z",
14
+ "heldOut": {
15
+ "size": 10,
16
+ "byLabel": {
17
+ "include": 5,
18
+ "exclude": 5
19
+ },
20
+ "fingerprint": "b12fe63012c925bd6def343a13b94ccedafd5184ddb93c8312998486045da779",
21
+ "frozenAt": "2026-10-18T13:02:20.090030Z",
22
+ "source": "References editors reviewed in the Newsroom, labeled by the scope-training rule"
23
+ },
24
+ "priming": {
25
+ "acceptedExamples": 13,
26
+ "excludedBecauseInHeldOut": 0,
27
+ "fingerprint": null,
28
+ "source": "editor reviews of References",
29
+ "order": "reviews in review order, then priming examples in acceptance order"
30
+ },
31
+ "conditions": {
32
+ "freshStorePerPoint": true,
33
+ "rubric": "publication doctrine seed",
34
+ "optimizer": false,
35
+ "reviewProgram": false
36
+ },
37
+ "points": [
38
+ {
39
+ "labels": 0,
40
+ "labelsByClass": {
41
+ "include": 0,
42
+ "exclude": 0
43
+ },
44
+ "decided": 10,
45
+ "agreement": 1,
46
+ "precision": 1,
47
+ "recall": 1,
48
+ "confusion": {
49
+ "truePositives": 5,
50
+ "falsePositives": 0,
51
+ "trueNegatives": 5,
52
+ "falseNegatives": 0
53
+ }
54
+ },
55
+ {
56
+ "labels": 5,
57
+ "labelsByClass": {
58
+ "include": 4,
59
+ "exclude": 1
60
+ },
61
+ "decided": 10,
62
+ "agreement": 1,
63
+ "precision": 1,
64
+ "recall": 1,
65
+ "confusion": {
66
+ "truePositives": 5,
67
+ "falsePositives": 0,
68
+ "trueNegatives": 5,
69
+ "falseNegatives": 0
70
+ }
71
+ },
72
+ {
73
+ "labels": 13,
74
+ "labelsByClass": {
75
+ "include": 4,
76
+ "exclude": 9
77
+ },
78
+ "decided": 10,
79
+ "agreement": 0.9,
80
+ "precision": 1,
81
+ "recall": 0.8,
82
+ "confusion": {
83
+ "truePositives": 4,
84
+ "falsePositives": 0,
85
+ "trueNegatives": 5,
86
+ "falseNegatives": 1
87
+ }
88
+ }
89
+ ]
90
+ }
@@ -13,6 +13,9 @@ export type SaveItemDraftInput = {
13
13
  bodyMarkus: string;
14
14
  aliases: string[];
15
15
  expectedContentHash: string | null;
16
+ /** Records a new draft version of the story instead of correcting the current one in place. */
17
+ newVersion?: boolean;
18
+ changeReason?: string | null;
16
19
  };
17
20
 
18
21
  export type DeriveMarkusResult = { ok: true; bodyIrBytes: number; bodyIr?: unknown; errors: [] };
@@ -1,4 +1,5 @@
1
1
  import { ContentActionFailure } from "./content-actions-client";
2
+ import { isLocalPreviewEnabled } from "./local-preview";
2
3
  import type {
3
4
  DeriveMarkusInput,
4
5
  DeriveMarkusResult,
@@ -12,6 +13,7 @@ import {
12
13
  demoContentHash,
13
14
  type DemoArticleRecord,
14
15
  } from "./newsroom-demo-articles";
16
+ import { createLocalPreviewEditableArticle, lineageForPreviewVersion, recordLocalPreviewDesk, recordLocalPreviewVersion } from "./local-preview-seed";
15
17
  import { sourceLineOffset, type ArticlesBackend, type EditableArticle, type NewsroomArticleRow } from "./newsroom-articles";
16
18
 
17
19
  function titleFromYaml(frontMatterYaml: string | null, fallback: string): string {
@@ -29,6 +31,18 @@ function demoAttribute(attributes: string, name: string): string {
29
31
  return match ? match[1] : "";
30
32
  }
31
33
 
34
+ async function deriveThroughPreviewMarkusEngine(input: DeriveMarkusInput): Promise<DeriveMarkusResult | null> {
35
+ try {
36
+ const response = await fetch("/api/local-preview/derive", { method: "POST", body: JSON.stringify({ frontMatterYaml: input.frontMatterYaml, bodyMarkus: input.bodyMarkus }) });
37
+ if (!response.ok) return null;
38
+ const result = await response.json() as DeriveMarkusResult & { error?: string };
39
+ if (result.error) return null;
40
+ return input.includeIr ? result : { ...result, bodyIr: undefined };
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
32
46
  function demoEnvelope(bodyMarkus: string) {
33
47
  const images: Record<string, Record<string, unknown>> = {};
34
48
  const children = bodyMarkus
@@ -73,6 +87,10 @@ export function createDemoArticlesBackend(): ArticlesBackend {
73
87
  return {
74
88
  actions: {
75
89
  async deriveMarkus(input: DeriveMarkusInput): Promise<DeriveMarkusResult> {
90
+ if (isLocalPreviewEnabled()) {
91
+ const derived = await deriveThroughPreviewMarkusEngine(input);
92
+ if (derived) return derived;
93
+ }
76
94
  const lines = input.bodyMarkus.split("\n");
77
95
  const invalidIndex = lines.findIndex((line) => line.includes(":::nope"));
78
96
  if (invalidIndex >= 0) {
@@ -90,6 +108,28 @@ export function createDemoArticlesBackend(): ArticlesBackend {
90
108
  },
91
109
  async saveItemDraft(input: SaveItemDraftInput): Promise<SaveItemDraftResult> {
92
110
  const existing = input.id ? requireRecord(input.id) : null;
111
+ if (input.newVersion && existing) {
112
+ const lineageId = createLocalPreviewEditableArticle(existing.id)?.lineageId ?? existing.id;
113
+ const versionNumber = (existing.versionNumber ?? 1) + 1;
114
+ const id = `${lineageId}-v${versionNumber}`;
115
+ const frontMatterYaml = input.frontMatterYaml ?? "";
116
+ const record: DemoArticleRecord = {
117
+ ...existing,
118
+ id,
119
+ slug: existing.slug,
120
+ section: input.section,
121
+ title: titleFromYaml(input.frontMatterYaml, existing.slug),
122
+ status: "draft",
123
+ frontMatterYaml,
124
+ bodyMarkus: input.bodyMarkus,
125
+ contentHash: demoContentHash(frontMatterYaml, input.bodyMarkus),
126
+ versionNumber,
127
+ updatedAt: new Date().toISOString(),
128
+ };
129
+ records.set(id, record);
130
+ recordLocalPreviewVersion(lineageId, { id, versionNumber, versionState: "current", status: "draft", stage: null, headline: record.title, changeReason: input.changeReason ?? null, versionCreatedBy: "You", versionCreatedAt: record.updatedAt, updatedAt: record.updatedAt });
131
+ return { ok: true, item: { id, contentHash: record.contentHash, status: "draft", slug: record.slug, versionNumber } };
132
+ }
93
133
  if (existing && input.expectedContentHash !== null && input.expectedContentHash !== existing.contentHash) {
94
134
  throw new ContentActionFailure([
95
135
  { code: "conflict", message: "The item changed since it was loaded.", line: null },
@@ -119,6 +159,7 @@ export function createDemoArticlesBackend(): ArticlesBackend {
119
159
  updatedAt: new Date().toISOString(),
120
160
  };
121
161
  records.set(id, record);
162
+ recordLocalPreviewDesk(id, record.section);
122
163
  return {
123
164
  ok: true,
124
165
  item: {
@@ -171,9 +212,23 @@ export function createDemoArticlesBackend(): ArticlesBackend {
171
212
  .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
172
213
  },
173
214
  async loadArticle(id: string): Promise<EditableArticle> {
215
+ if (!records.has(id)) {
216
+ const previewRecord = createLocalPreviewEditableArticle(id);
217
+ if (previewRecord) {
218
+ records.set(id, {
219
+ ...previewRecord,
220
+ title: titleFromYaml(previewRecord.frontMatterYaml, previewRecord.slug),
221
+ status: previewRecord.status === "published" ? "published" : "draft",
222
+ contentHash: demoContentHash(previewRecord.frontMatterYaml, previewRecord.bodyMarkus),
223
+ publishedContentHash: previewRecord.status === "published" ? demoContentHash(previewRecord.frontMatterYaml, previewRecord.bodyMarkus) : null,
224
+ updatedAt: new Date().toISOString(),
225
+ });
226
+ }
227
+ }
174
228
  const record = requireRecord(id);
175
229
  return {
176
230
  id: record.id,
231
+ lineageId: createLocalPreviewEditableArticle(record.id)?.lineageId ?? lineageForPreviewVersion(record.id),
177
232
  type: record.type,
178
233
  section: record.section,
179
234
  slug: record.slug,
@@ -28,9 +28,9 @@ export const DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS: readonly ContentItemStageDe
28
28
  { key: "copywriting", label: "Copywriting" },
29
29
  { key: "illustration", label: "Illustration" },
30
30
  { key: "typesetting", label: "Typesetting" },
31
- { key: "published", label: "Published" },
32
- { key: "held", label: "Held" },
33
31
  { key: "spiked", label: "Spiked", collapsedUntilOpened: true },
32
+ { key: "held", label: "Held" },
33
+ { key: "published", label: "Published" },
34
34
  ];
35
35
 
36
36
  export function contentItemStageKeys(stageDefinitions: readonly ContentItemStageDefinition[]): string[] {
@@ -68,6 +68,8 @@ export type ContentItemVersionRecord = {
68
68
  status: string;
69
69
  title?: string | null;
70
70
  headline?: string | null;
71
+ deck?: string | null;
72
+ metadata?: unknown;
71
73
  slug: string;
72
74
  section?: string | null;
73
75
  updatedAt?: string | null;
@@ -0,0 +1,42 @@
1
+ import { parseDocument } from "yaml";
2
+ import type { ContentItemVersionRecord } from "./content-item-lifecycle";
3
+
4
+ /** Front matter keys the editor shows as form fields instead of raw YAML. */
5
+ export const FRONT_MATTER_TITLE_KEY = "title";
6
+ export const FRONT_MATTER_SUBTITLE_KEY = "deck";
7
+ export const FRONT_MATTER_BLURB_KEY = "blurb";
8
+
9
+ /** Reads one text field from front matter YAML; invalid YAML or a non-text value reads as empty. */
10
+ export function readFrontMatterText(frontMatterYaml: string | null | undefined, key: string): string {
11
+ if (!frontMatterYaml?.trim()) return "";
12
+ const document = parseDocument(frontMatterYaml);
13
+ if (document.errors.length) return "";
14
+ const value = document.get(key);
15
+ return typeof value === "string" ? value : "";
16
+ }
17
+
18
+ /** Sets or removes one text field in front matter YAML, keeping every other line and comment as written. */
19
+ export function writeFrontMatterText(frontMatterYaml: string | null | undefined, key: string, value: string): string {
20
+ const document = parseDocument(frontMatterYaml?.trim() ? frontMatterYaml : "{}\n");
21
+ if (document.errors.length) return frontMatterYaml ?? "";
22
+ if (value.trim()) document.set(key, value);
23
+ else document.delete(key);
24
+ const text = document.toString();
25
+ return text.trim() === "{}" ? "" : text;
26
+ }
27
+
28
+ function metadataFrontMatterYaml(metadata: unknown): string {
29
+ const parsed = typeof metadata === "string" ? safeJson(metadata) : metadata;
30
+ const text = (parsed as { frontMatterYaml?: unknown } | null)?.frontMatterYaml;
31
+ return typeof text === "string" ? text : "";
32
+ }
33
+
34
+ function safeJson(text: string): unknown {
35
+ try { return JSON.parse(text); } catch { return null; }
36
+ }
37
+
38
+ /** A few lines for a board card: the blurb when one is recorded, else the subtitle. */
39
+ export function contentItemExcerpt(version: Pick<ContentItemVersionRecord, "deck" | "metadata">): string | undefined {
40
+ const blurb = readFrontMatterText(metadataFrontMatterYaml(version.metadata), FRONT_MATTER_BLURB_KEY).trim();
41
+ return blurb || version.deck?.trim() || undefined;
42
+ }
@@ -1,3 +1,4 @@
1
+ import { warnAboutMissingMedia } from "./layout-warnings";
1
2
  import {
2
3
  type ArticleFrameBlockSpec,
3
4
  type ArticleFrameCompositionSlotSpec,
@@ -605,7 +606,7 @@ function solveComposedFrontArticleFrame(
605
606
  flow.usedImageAssetIds,
606
607
  );
607
608
  if (!image) {
608
- if (mediaSpec?.required) throw new Error(`${blockSpec.id} requires media slot ${slot.mediaIndex ?? 0} but no image could be solved`);
609
+ if (mediaSpec?.required) warnAboutMissingMedia(`${blockSpec.id} requires media slot ${slot.mediaIndex ?? 0} but no image could be solved and renders without it`);
609
610
  continue;
610
611
  }
611
612
  const stackTop = getOccupiedColumnBottom(columnBottoms, image.columnStart, image.columnSpan);
@@ -1,3 +1,4 @@
1
+ import { warnAboutMissingMedia } from "./layout-warnings";
1
2
  import { z } from "zod";
2
3
  import {
3
4
  type PublicationItem,
@@ -894,7 +895,7 @@ function validateBlock(
894
895
  if (block.type === "mediaCluster") {
895
896
  const assets = findAssetsByRole(item, block.assetRole);
896
897
  if (block.required && assets.length < block.count.min) {
897
- throw new Error(`${label}.${block.id} requires at least ${block.count.min} media assets`);
898
+ warnAboutMissingMedia(`${label}.${block.id} requires at least ${block.count.min} media assets`);
898
899
  }
899
900
  }
900
901
  }
@@ -902,7 +903,7 @@ function validateBlock(
902
903
  function validateMediaSpec(item: PublicationItem, media: LayoutMediaSpec, label: string): void {
903
904
  const matchingAssets = findAssetsByRole(item, media.assetRole);
904
905
  if (media.required && matchingAssets.length === 0) {
905
- throw new Error(`${label} requires an image asset${media.assetRole ? ` with role ${media.assetRole}` : ""}`);
906
+ warnAboutMissingMedia(`${label} requires an image asset${media.assetRole ? ` with role ${media.assetRole}` : ""} and renders without it`);
906
907
  }
907
908
  }
908
909
 
@@ -0,0 +1,8 @@
1
+ const missingMediaWarningsAlreadyLogged = new Set<string>();
2
+
3
+ /** A story without its lead image must still publish; the layout renders without the slot. */
4
+ export function warnAboutMissingMedia(message: string): void {
5
+ if (missingMediaWarningsAlreadyLogged.has(message)) return;
6
+ missingMediaWarningsAlreadyLogged.add(message);
7
+ console.warn(`[layout-plan] ${message}`);
8
+ }
@@ -1,4 +1,7 @@
1
1
  import type { AssignmentRecord, ReferenceRecord } from "./category-repository";
2
+ import type { EditableArticle } from "./newsroom-articles";
3
+ import type { MediaAssetRecord } from "./newsroom-media";
4
+ import { EMPTY_STORY_REPORTING_TRAIL, type StoryReportingTrail, type TrailVersion } from "./story-reporting-trail";
2
5
  import { contentItemStageKeys, type ContentItemVersionRecord } from "./content-item-lifecycle";
3
6
  import { getContentItemStageDefinitions } from "./site-brand";
4
7
 
@@ -93,3 +96,53 @@ export function createLocalPreviewAssignments(): AssignmentRecord[] {
93
96
  }));
94
97
  return [...assignmentsOnContentItems, ...assignmentsWithoutContentItem];
95
98
  }
99
+
100
+ export function createLocalPreviewRealPublishedVersions(): ContentItemVersionRecord[] {
101
+ return [];
102
+ }
103
+
104
+ export function createLocalPreviewEditableArticle(id: string): EditableArticle | null {
105
+ const version = createLocalPreviewContentItemVersions().find(candidate => candidate.id === id);
106
+ if (!version) return null;
107
+ const headline = version.headline || version.title || version.slug;
108
+ return {
109
+ id: version.id,
110
+ lineageId: version.lineageId,
111
+ type: version.type,
112
+ section: version.section ?? null,
113
+ slug: version.slug,
114
+ frontMatterYaml: `title: ${headline}\nsection: ${version.section ?? ""}`,
115
+ bodyMarkus: `# ${headline}\n\nThe local preview shows this story's record. Its full body text is loaded from the live backend.`,
116
+ contentHash: null,
117
+ status: version.status === "published" ? "published" : "draft",
118
+ versionNumber: version.versionNumber,
119
+ aliases: [],
120
+ everPublished: version.status === "published",
121
+ };
122
+ }
123
+
124
+ export function recordLocalPreviewDesk(articleId: string, deskId: string | null): void {
125
+ const version = createLocalPreviewContentItemVersions().find(candidate => candidate.id === articleId);
126
+ if (version) version.section = deskId;
127
+ }
128
+
129
+ const previewSavedVersions = new Map<string, TrailVersion[]>();
130
+ const previewLineageByVersionId = new Map<string, string>();
131
+
132
+ export function recordLocalPreviewVersion(lineageId: string, version: TrailVersion): void {
133
+ previewSavedVersions.set(lineageId, [version, ...(previewSavedVersions.get(lineageId) ?? [])]);
134
+ previewLineageByVersionId.set(version.id, lineageId);
135
+ }
136
+
137
+ export function lineageForPreviewVersion(versionId: string): string | null {
138
+ return previewLineageByVersionId.get(versionId) ?? null;
139
+ }
140
+
141
+ export function createLocalPreviewReportingTrail(lineageId: string): StoryReportingTrail {
142
+ const saved = previewSavedVersions.get(lineageId) ?? [];
143
+ return { ...EMPTY_STORY_REPORTING_TRAIL, versions: saved };
144
+ }
145
+
146
+ export function createLocalPreviewMediaAssets(_itemId: string): MediaAssetRecord[] {
147
+ return [];
148
+ }
@@ -59,6 +59,7 @@ export type NewsroomArticleEditorState = {
59
59
 
60
60
  export type EditableArticle = {
61
61
  id: string;
62
+ lineageId?: string | null;
62
63
  type: string;
63
64
  section: string | null;
64
65
  slug: string;
@@ -267,6 +268,7 @@ export async function loadEditableArticle(client: ArticlesDataClient, id: string
267
268
  const status: ArticleStatus = record.status === "published" ? "published" : "draft";
268
269
  return {
269
270
  id: String(record.id),
271
+ lineageId: typeof record.lineageId === "string" ? record.lineageId : null,
270
272
  type: String(record.type),
271
273
  section: typeof record.section === "string" ? record.section : null,
272
274
  slug: String(record.slug),