@anthusai/papyrus 1.4.0-next.2 → 1.4.0-next.4

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.
@@ -0,0 +1,151 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import {
5
+ canonicalContentItemStage,
6
+ legacyStageKeysToLoad,
7
+ type ContentItemVersionRecord,
8
+ } from "../lib/content-item-lifecycle";
9
+ import { getContentItemStageDefinitions } from "../lib/site-brand";
10
+ import { loadNewsroomContentItemStagePage } from "./news-desk-taxonomy-client";
11
+
12
+ const REFRESH_INTERVAL_MILLISECONDS = 30_000;
13
+
14
+ type StageBucket = {
15
+ versions: ContentItemVersionRecord[];
16
+ nextToken: string | null;
17
+ pagesLoaded: number;
18
+ };
19
+
20
+ export type PagedContentItemVersions = {
21
+ versions: ContentItemVersionRecord[];
22
+ loading: boolean;
23
+ error: string | null;
24
+ stageHasMore: (canonicalStage: string) => boolean;
25
+ loadMoreForStage: (canonicalStage: string) => void;
26
+ };
27
+
28
+ function mergeFreshFirstPage(existing: StageBucket | undefined, fresh: StageBucket): StageBucket {
29
+ if (!existing || existing.pagesLoaded <= 1) return fresh;
30
+ const freshIds = new Set(fresh.versions.map(version => version.id));
31
+ return {
32
+ versions: [...fresh.versions, ...existing.versions.filter(version => !freshIds.has(version.id))],
33
+ nextToken: existing.nextToken,
34
+ pagesLoaded: existing.pagesLoaded,
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Newest-updated content items per stage, one page per stage to start, more pages on demand.
40
+ * The periodic refresh re-fetches only first pages and keeps whatever later pages were already loaded.
41
+ */
42
+ export function usePagedContentItemVersions(options: {
43
+ enabled: boolean;
44
+ openedCollapsedUntilOpenedStages: readonly string[];
45
+ }): PagedContentItemVersions {
46
+ const { enabled, openedCollapsedUntilOpenedStages } = options;
47
+ const [buckets, setBuckets] = useState<Record<string, StageBucket>>({});
48
+ const [loading, setLoading] = useState(false);
49
+ const [error, setError] = useState<string | null>(null);
50
+ const bucketsRef = useRef(buckets);
51
+ const stageKeysBeingExtendedRef = useRef(new Set<string>());
52
+ const generationRef = useRef(0);
53
+
54
+ const openedStagesSignature = openedCollapsedUntilOpenedStages.join(",");
55
+ const stageKeysToLoad = useMemo(() => {
56
+ const stageDefinitions = getContentItemStageDefinitions();
57
+ return [
58
+ ...stageDefinitions
59
+ .filter(definition => !definition.collapsedUntilOpened || openedStagesSignature.split(",").includes(definition.key))
60
+ .map(definition => definition.key),
61
+ ...legacyStageKeysToLoad(stageDefinitions),
62
+ ];
63
+ }, [openedStagesSignature]);
64
+ const stageKeysToLoadRef = useRef(stageKeysToLoad);
65
+ stageKeysToLoadRef.current = stageKeysToLoad;
66
+
67
+ const storeBuckets = useCallback((update: (previous: Record<string, StageBucket>) => Record<string, StageBucket>) => {
68
+ const next = update(bucketsRef.current);
69
+ bucketsRef.current = next;
70
+ setBuckets(next);
71
+ }, []);
72
+
73
+ const refreshFirstPages = useCallback(async (request: number, isInitial: boolean, stageKeys: readonly string[]) => {
74
+ if (isInitial) setLoading(true);
75
+ try {
76
+ const freshBuckets = await Promise.all(stageKeys.map(async stageKey => {
77
+ const page = await loadNewsroomContentItemStagePage(stageKey);
78
+ return [stageKey, { versions: page.versions, nextToken: page.nextToken, pagesLoaded: 1 }] as const;
79
+ }));
80
+ if (generationRef.current !== request) return;
81
+ storeBuckets(previous => {
82
+ const next = { ...previous };
83
+ for (const [stageKey, fresh] of freshBuckets) next[stageKey] = mergeFreshFirstPage(previous[stageKey], fresh);
84
+ return next;
85
+ });
86
+ setError(null);
87
+ } catch (reason: unknown) {
88
+ if (generationRef.current === request && isInitial) setError(reason instanceof Error ? reason.message : "Could not load newsroom records.");
89
+ } finally {
90
+ if (generationRef.current === request && isInitial) setLoading(false);
91
+ }
92
+ }, [storeBuckets]);
93
+
94
+ useEffect(() => {
95
+ const request = ++generationRef.current;
96
+ bucketsRef.current = {};
97
+ setBuckets({});
98
+ setError(null);
99
+ if (!enabled) { setLoading(false); return; }
100
+ void refreshFirstPages(request, true, stageKeysToLoadRef.current);
101
+ const refreshWhenVisible = () => {
102
+ if (document.visibilityState === "visible") void refreshFirstPages(request, false, stageKeysToLoadRef.current);
103
+ };
104
+ const interval = window.setInterval(refreshWhenVisible, REFRESH_INTERVAL_MILLISECONDS);
105
+ document.addEventListener("visibilitychange", refreshWhenVisible);
106
+ return () => {
107
+ generationRef.current = request + 1;
108
+ window.clearInterval(interval);
109
+ document.removeEventListener("visibilitychange", refreshWhenVisible);
110
+ };
111
+ }, [enabled, refreshFirstPages]);
112
+
113
+ useEffect(() => {
114
+ if (!enabled) return;
115
+ const stageKeysNotLoadedYet = stageKeysToLoad.filter(stageKey => !(stageKey in bucketsRef.current));
116
+ if (stageKeysNotLoadedYet.length) void refreshFirstPages(generationRef.current, false, stageKeysNotLoadedYet);
117
+ }, [enabled, refreshFirstPages, stageKeysToLoad]);
118
+
119
+ const stageKeysInCanonicalStage = useCallback((canonicalStage: string) => Object.keys(bucketsRef.current)
120
+ .filter(stageKey => canonicalContentItemStage(stageKey, getContentItemStageDefinitions()) === canonicalStage), []);
121
+
122
+ const loadMoreForStage = useCallback((canonicalStage: string) => {
123
+ const request = generationRef.current;
124
+ for (const stageKey of stageKeysInCanonicalStage(canonicalStage)) {
125
+ const bucket = bucketsRef.current[stageKey];
126
+ if (!bucket?.nextToken || stageKeysBeingExtendedRef.current.has(stageKey)) continue;
127
+ stageKeysBeingExtendedRef.current.add(stageKey);
128
+ loadNewsroomContentItemStagePage(stageKey, bucket.nextToken).then(page => {
129
+ if (generationRef.current !== request) return;
130
+ storeBuckets(previous => {
131
+ const current = previous[stageKey] ?? bucket;
132
+ const knownIds = new Set(current.versions.map(version => version.id));
133
+ return {
134
+ ...previous,
135
+ [stageKey]: {
136
+ versions: [...current.versions, ...page.versions.filter(version => !knownIds.has(version.id))],
137
+ nextToken: page.nextToken,
138
+ pagesLoaded: current.pagesLoaded + 1,
139
+ },
140
+ };
141
+ });
142
+ }).catch(() => undefined).finally(() => { stageKeysBeingExtendedRef.current.delete(stageKey); });
143
+ }
144
+ }, [stageKeysInCanonicalStage, storeBuckets]);
145
+
146
+ const stageHasMore = useCallback((canonicalStage: string) => stageKeysInCanonicalStage(canonicalStage)
147
+ .some(stageKey => Boolean(bucketsRef.current[stageKey]?.nextToken)), [stageKeysInCanonicalStage]);
148
+
149
+ const versions = useMemo(() => Object.values(buckets).flatMap(bucket => bucket.versions), [buckets]);
150
+ return { versions, loading, error, stageHasMore, loadMoreForStage };
151
+ }
@@ -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
+ }
@@ -12,7 +12,7 @@ import {
12
12
  } from "./blog-rhythm";
13
13
  import { layoutAllTextLines, layoutTextLines, prepareWithSegments, type TextLine, type TextObstacle } from "./pretext-layout";
14
14
 
15
- export type FeaturedLayoutMode = "obstacle" | "float" | "stacked";
15
+ export type FeaturedLayoutMode = "float" | "stacked";
16
16
 
17
17
  export type SolvedFeaturedItem = {
18
18
  mode: FeaturedLayoutMode;
@@ -92,19 +92,6 @@ export function solveFeaturedItem(input: SolveFeaturedItemInput): SolvedFeatured
92
92
  });
93
93
  const aspectRatio = getImageAspectRatio(input.imageAsset);
94
94
 
95
- if (mode === "obstacle") {
96
- return solveObstacleFeaturedItem({
97
- prepared,
98
- containerWidth: input.containerWidth,
99
- viewportWidth: input.viewportWidth,
100
- rhythm,
101
- textStyle: input.textStyle,
102
- aspectRatio,
103
- gap,
104
- imageAsset: input.imageAsset,
105
- });
106
- }
107
-
108
95
  if (mode === "float") {
109
96
  return solveFloatFeaturedItem({
110
97
  prepared,
@@ -151,96 +138,6 @@ type PreparedSolveInput = {
151
138
  imageAsset: ArticleImageAsset;
152
139
  };
153
140
 
154
- function solveObstacleFeaturedItem(input: PreparedSolveInput): SolvedFeaturedItem {
155
- const maxImageWidth = getFloatImageWidth(input);
156
- let best: {
157
- score: number;
158
- textLines: TextLine[];
159
- imageHeight: number;
160
- imageWidth: number;
161
- captionHeight: number;
162
- mediaHeight: number;
163
- copyWidth: number;
164
- textFrameHeight: number;
165
- hasMore: boolean;
166
- } | null = null;
167
-
168
- for (const imageWidth of getObstacleImageWidths(maxImageWidth, input.rhythm)) {
169
- const copyWidth = Math.max(
170
- getFloatCopyMinimum(input.viewportWidth, input.rhythm),
171
- input.containerWidth - imageWidth - input.gap,
172
- );
173
- const imageHeight = snapImageHeight(imageWidth / input.aspectRatio, input.rhythm, input.imageAsset.layout);
174
- const captionHeight = getFeatureCaptionHeight(input.imageAsset, imageWidth, input.rhythm);
175
- const mediaHeight = imageHeight + captionHeight;
176
- const obstacle: TextObstacle = {
177
- x: Math.max(0, copyWidth - imageWidth),
178
- y: 0,
179
- width: imageWidth,
180
- height: mediaHeight,
181
- };
182
-
183
- let candidateHeight = mediaHeight;
184
- let result = layoutTextLines({
185
- prepared: input.prepared,
186
- cursor: { segmentIndex: 0, graphemeIndex: 0 },
187
- maxHeight: candidateHeight,
188
- maxWidth: copyWidth,
189
- lineHeight: input.textStyle.lineHeight,
190
- linePaintHeight: input.textStyle.linePaintHeight,
191
- fontSize: input.textStyle.fontSize,
192
- fontFamily: input.textStyle.fontFamily,
193
- obstacles: [obstacle],
194
- });
195
-
196
- const textBottom = getMeasuredTextHeight(result.lines);
197
- if (textBottom > 0 && textBottom < candidateHeight) {
198
- candidateHeight = reserveRhythmRows(textBottom, input.rhythm);
199
- obstacle.height = candidateHeight;
200
- result = layoutTextLines({
201
- prepared: input.prepared,
202
- cursor: { segmentIndex: 0, graphemeIndex: 0 },
203
- maxHeight: candidateHeight,
204
- maxWidth: copyWidth,
205
- lineHeight: input.textStyle.lineHeight,
206
- linePaintHeight: input.textStyle.linePaintHeight,
207
- fontSize: input.textStyle.fontSize,
208
- fontFamily: input.textStyle.fontFamily,
209
- obstacles: [obstacle],
210
- });
211
- }
212
-
213
- const whitespace = Math.max(0, candidateHeight - getMeasuredTextHeight(result.lines));
214
- const score = 50_000 - whitespace * 1.4 - Math.abs(candidateHeight - mediaHeight) * 0.35 - Math.abs(maxImageWidth - imageWidth) * 0.2;
215
- const candidate = {
216
- score,
217
- textLines: result.lines,
218
- imageHeight: Math.min(imageHeight, candidateHeight),
219
- imageWidth,
220
- captionHeight,
221
- mediaHeight: candidateHeight,
222
- copyWidth,
223
- textFrameHeight: reserveRhythmRows(getMeasuredTextHeight(result.lines), input.rhythm),
224
- hasMore: result.hasMore,
225
- };
226
- if (!best || candidate.score > best.score) best = candidate;
227
- }
228
-
229
- const fallbackImageHeight = input.rhythm.rowHeight * 6;
230
- return {
231
- mode: "obstacle",
232
- textLines: best?.textLines ?? [],
233
- imageHeight: best?.imageHeight ?? fallbackImageHeight,
234
- imageWidth: best?.imageWidth ?? maxImageWidth,
235
- captionHeight: best?.captionHeight ?? 0,
236
- mediaHeight: best?.mediaHeight ?? fallbackImageHeight,
237
- copyWidth: best?.copyWidth ?? input.containerWidth,
238
- textFrameHeight: best?.textFrameHeight ?? 0,
239
- hasMore: best?.hasMore ?? false,
240
- gap: input.gap,
241
- };
242
- }
243
-
244
141
  function solveFloatFeaturedItem(input: PreparedSolveInput): SolvedFeaturedItem {
245
142
  const geometry = computeFloatGeometry({
246
143
  containerWidth: input.containerWidth,
@@ -417,11 +314,6 @@ function getFloatCopyMinimum(viewportWidth: number, rhythm: VerticalRhythm): num
417
314
  return rhythmLengthRows(viewportWidth <= 600 ? FLOAT_COPY_MIN_PHONE_ROWS : FLOAT_COPY_MIN_DESKTOP_ROWS, rhythm);
418
315
  }
419
316
 
420
- function getObstacleImageWidths(maximum: number, rhythm: VerticalRhythm): number[] {
421
- const candidateRows = [0, 1, 2].map((offset) => Math.max(rhythm.rowHeight * 5, maximum - (offset * rhythm.rowHeight)));
422
- return Array.from(new Set(candidateRows));
423
- }
424
-
425
317
  function snapImageHeight(
426
318
  naturalHeight: number,
427
319
  rhythm: VerticalRhythm,
@@ -12,6 +12,7 @@ import {
12
12
  demoContentHash,
13
13
  type DemoArticleRecord,
14
14
  } from "./newsroom-demo-articles";
15
+ import { createLocalPreviewEditableArticle } from "./local-preview-seed";
15
16
  import { sourceLineOffset, type ArticlesBackend, type EditableArticle, type NewsroomArticleRow } from "./newsroom-articles";
16
17
 
17
18
  function titleFromYaml(frontMatterYaml: string | null, fallback: string): string {
@@ -171,6 +172,8 @@ export function createDemoArticlesBackend(): ArticlesBackend {
171
172
  .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
172
173
  },
173
174
  async loadArticle(id: string): Promise<EditableArticle> {
175
+ const previewRecord = records.get(id) ? null : createLocalPreviewEditableArticle(id);
176
+ if (previewRecord) return previewRecord;
174
177
  const record = requireRecord(id);
175
178
  return {
176
179
  id: record.id,
@@ -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,5 @@
1
1
  import type { AssignmentRecord, ReferenceRecord } from "./category-repository";
2
+ import type { EditableArticle } from "./newsroom-articles";
2
3
  import { contentItemStageKeys, type ContentItemVersionRecord } from "./content-item-lifecycle";
3
4
  import { getContentItemStageDefinitions } from "./site-brand";
4
5
 
@@ -93,3 +94,22 @@ export function createLocalPreviewAssignments(): AssignmentRecord[] {
93
94
  }));
94
95
  return [...assignmentsOnContentItems, ...assignmentsWithoutContentItem];
95
96
  }
97
+
98
+ export function createLocalPreviewEditableArticle(id: string): EditableArticle | null {
99
+ const version = createLocalPreviewContentItemVersions().find(candidate => candidate.id === id);
100
+ if (!version) return null;
101
+ const headline = version.headline || version.title || version.slug;
102
+ return {
103
+ id: version.id,
104
+ type: version.type,
105
+ section: version.section ?? null,
106
+ slug: version.slug,
107
+ frontMatterYaml: `title: ${headline}\nsection: ${version.section ?? ""}`,
108
+ bodyMarkus: `# ${headline}\n\nThe local preview shows this story's record. Its full body text is loaded from the live backend.`,
109
+ contentHash: null,
110
+ status: version.status === "published" ? "published" : "draft",
111
+ versionNumber: version.versionNumber,
112
+ aliases: [],
113
+ everPublished: version.status === "published",
114
+ };
115
+ }
@@ -18,6 +18,19 @@ export type AssignmentMetrics = {
18
18
  canceled: number;
19
19
  };
20
20
 
21
+ export const BOOKKEEPING_ASSIGNMENT_TYPE_KEYS: ReadonlySet<string> = new Set([
22
+ "analysis.reindex",
23
+ "curation.reference-intake",
24
+ "reference.corpus-accession",
25
+ "reference.identifier-backfill",
26
+ "reference.doi-backfill",
27
+ "reference.text-extraction",
28
+ ]);
29
+
30
+ export function isEditorialWorkAssignment(assignment: Pick<AssignmentRecord, "assignmentTypeKey">): boolean {
31
+ return !BOOKKEEPING_ASSIGNMENT_TYPE_KEYS.has(assignment.assignmentTypeKey?.trim() ?? "");
32
+ }
33
+
21
34
  export function assignmentTypeKeyForFilter(assignment: AssignmentRecord): string {
22
35
  return assignment.assignmentTypeKey?.trim() || "unknown";
23
36
  }
@@ -114,6 +114,31 @@ export function assignmentBoardConfig(issues: readonly NativeBoardIssue[]): Kanb
114
114
  .map(status => ({ key: `${ASSIGNMENT_BOARD_TYPE}/${status}`, name: assignmentStatusLabel(status) })));
115
115
  }
116
116
 
117
+ function matchesDesk(candidate: string | null | undefined, deskId: string): boolean {
118
+ return Boolean(candidate) && candidate!.trim().toLowerCase() === deskId.trim().toLowerCase();
119
+ }
120
+
121
+ export function deskContainsContentItem(version: ContentItemVersionRecord, deskId: string): boolean {
122
+ return matchesDesk(version.section, deskId);
123
+ }
124
+
125
+ export function deskContainsAssignment(assignment: AssignmentRecord, deskId: string): boolean {
126
+ return matchesDesk(assignment.sectionId, deskId) || matchesDesk(assignment.sectionKey, deskId);
127
+ }
128
+
129
+ /** A desk board shows stories and assignments side by side; each keeps its own columns and the type filter narrows to one. */
130
+ export function deskBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
131
+ const storiesConfig = contentItemBoardConfig(issues.filter(issue => issue.kind === "contentItem"));
132
+ const assignmentsConfig = assignmentBoardConfig(issues.filter(issue => issue.kind === "assignment"));
133
+ return {
134
+ ...storiesConfig,
135
+ types: [...(storiesConfig.types ?? []), ...(assignmentsConfig.types ?? [])],
136
+ statuses: [...storiesConfig.statuses, ...assignmentsConfig.statuses],
137
+ categories: storiesConfig.categories,
138
+ workflows: { ...storiesConfig.workflows, ...assignmentsConfig.workflows },
139
+ };
140
+ }
141
+
117
142
  export function canReadNewsroomBoard(phase: string | undefined): boolean {
118
143
  return phase === "ready";
119
144
  }
@@ -0,0 +1,64 @@
1
+ export interface ProspectusPublishingAccountChoice {
2
+ accountId: string;
3
+ name?: string;
4
+ role: string;
5
+ }
6
+
7
+ export type ProspectusAccountState =
8
+ | { status: "unavailable" }
9
+ | { status: "signedOut"; signInUrl: string }
10
+ | {
11
+ status: "signedIn";
12
+ publishingAccounts: ProspectusPublishingAccountChoice[];
13
+ selectedAccountId: string | null;
14
+ cyclotronLabAccess: boolean;
15
+ };
16
+
17
+ type FetchImplementation = (input: string, init?: RequestInit) => Promise<Response>;
18
+
19
+ const SESSION_PATH = "/api/auth/prospectus/session";
20
+ const SELECTION_PATH = "/api/auth/prospectus/selection";
21
+ const SIGN_IN_PATH = "/api/auth/prospectus/sign-in";
22
+
23
+ export function prospectusSignInUrl(returnPath: string): string {
24
+ return `${SIGN_IN_PATH}?returnTo=${encodeURIComponent(returnPath)}`;
25
+ }
26
+
27
+ export async function loadProspectusAccountState(
28
+ returnPath: string,
29
+ fetchImplementation: FetchImplementation = fetch,
30
+ ): Promise<ProspectusAccountState> {
31
+ let response: Response;
32
+ try {
33
+ response = await fetchImplementation(SESSION_PATH, { credentials: "same-origin", cache: "no-store" });
34
+ } catch {
35
+ return { status: "unavailable" };
36
+ }
37
+ if (!response.ok) return { status: "unavailable" };
38
+ const body = await response.json().catch(() => null) as
39
+ | { signedIn?: boolean; publishingAccounts?: ProspectusPublishingAccountChoice[]; selectedAccountId?: string | null; cyclotronLabAccess?: boolean }
40
+ | null;
41
+ if (!body) return { status: "unavailable" };
42
+ if (!body.signedIn) return { status: "signedOut", signInUrl: prospectusSignInUrl(returnPath) };
43
+ return {
44
+ status: "signedIn",
45
+ publishingAccounts: Array.isArray(body.publishingAccounts) ? body.publishingAccounts : [],
46
+ selectedAccountId: body.selectedAccountId ?? null,
47
+ cyclotronLabAccess: body.cyclotronLabAccess === true,
48
+ };
49
+ }
50
+
51
+ export async function selectProspectusAccount(
52
+ accountId: string,
53
+ fetchImplementation: FetchImplementation = fetch,
54
+ ): Promise<{ selectedAccountId: string; role: string } | { error: string }> {
55
+ const response = await fetchImplementation(SELECTION_PATH, {
56
+ method: "POST",
57
+ credentials: "same-origin",
58
+ headers: { "content-type": "application/json" },
59
+ body: JSON.stringify({ accountId }),
60
+ });
61
+ const body = await response.json().catch(() => null) as { selectedAccountId?: string; role?: string; message?: string } | null;
62
+ if (!response.ok || !body?.selectedAccountId) return { error: body?.message ?? "That account could not be selected." };
63
+ return { selectedAccountId: body.selectedAccountId, role: body.role ?? "" };
64
+ }
package/lib/site-stack.ts CHANGED
@@ -149,7 +149,7 @@ export const PILOBOL_US_THEME_PACK_TOKENS: ThemePackTokens = {
149
149
  };
150
150
 
151
151
  /**
152
- * Anthus palette, read off the live Gatsby site at `AnthusAI/Anth.us`
152
+ * Anthus palette, read off the live Gatsby site at `AnthusAI/Anth.us-legacy-Gatsby`
153
153
  * (`src/styles/variables.scss` `:root`). Colors only — Shadcn owns typography.
154
154
  * Mapping from that file's names to the pack's slots:
155
155
  *
package/package.json CHANGED
@@ -84,6 +84,7 @@
84
84
  "./components/newsroom-access-gate": "./components/newsroom-access-gate.tsx",
85
85
  "./components/newsroom-article-editor": "./components/newsroom-article-editor.tsx",
86
86
  "./components/newsroom-articles-view": "./components/newsroom-articles-view.tsx",
87
+ "./components/newsroom-assignment-detail": "./components/newsroom-assignment-detail.tsx",
87
88
  "./components/newsroom-assignments-view": "./components/newsroom-assignments-view.tsx",
88
89
  "./components/newsroom-board-view": "./components/newsroom-board-view.tsx",
89
90
  "./components/newsroom-client-shell": "./components/newsroom-client-shell.tsx",
@@ -135,6 +136,7 @@
135
136
  "./components/use-blog-header-obstacles": "./components/use-blog-header-obstacles.ts",
136
137
  "./components/use-brand-fonts-ready": "./components/use-brand-fonts-ready.ts",
137
138
  "./components/use-near-viewport": "./components/use-near-viewport.ts",
139
+ "./components/use-paged-content-item-versions": "./components/use-paged-content-item-versions.ts",
138
140
  "./components/use-resolved-papyrus-theme": "./components/use-resolved-papyrus-theme.ts",
139
141
  "./components/use-rhythm-overlay": "./components/use-rhythm-overlay.ts",
140
142
  "./lib/amplify-outputs-guard": "./lib/amplify-outputs-guard.ts",
@@ -181,6 +183,7 @@
181
183
  "./lib/layout-plan": "./lib/layout-plan.ts",
182
184
  "./lib/layout-scenarios": "./lib/layout-scenarios.ts",
183
185
  "./lib/layout-types": "./lib/layout-types.ts",
186
+ "./lib/layout-warnings": "./lib/layout-warnings.ts",
184
187
  "./lib/local-preview-seed": "./lib/local-preview-seed.ts",
185
188
  "./lib/local-preview": "./lib/local-preview.ts",
186
189
  "./lib/markus-body": "./lib/markus-body.ts",
@@ -219,6 +222,7 @@
219
222
  "./lib/presentation-footer": "./lib/presentation-footer.ts",
220
223
  "./lib/pretext-layout": "./lib/pretext-layout.ts",
221
224
  "./lib/priming-proof-report": "./lib/priming-proof-report.ts",
225
+ "./lib/prospectus-account-client": "./lib/prospectus-account-client.ts",
222
226
  "./lib/prospectus-publication-access": "./lib/prospectus-publication-access.ts",
223
227
  "./lib/prospectus-session": "./lib/prospectus-session.ts",
224
228
  "./lib/prospectus-sign-in-routes": "./lib/prospectus-sign-in-routes.ts",
@@ -350,7 +354,7 @@
350
354
  "optional": true
351
355
  }
352
356
  },
353
- "version": "1.4.0-next.2",
357
+ "version": "1.4.0-next.4",
354
358
  "dependencies": {
355
359
  "@aws-amplify/adapter-nextjs": "^1.7.3",
356
360
  "@aws-crypto/sha256-js": "5.2.0",
@@ -376,7 +380,9 @@
376
380
  "clsx": "^2.1.1",
377
381
  "cmdk": "^1.1.1",
378
382
  "cyclotron": "https://github.com/AnthusAI/Cyclotron/releases/download/ui-v0.2.2/cyclotron-0.2.2.tgz",
383
+ "d3": "^7.9.0",
379
384
  "esbuild": "^0.28.2",
385
+ "framer-motion": "^14.3.0",
380
386
  "gray-matter": "^4.0.3",
381
387
  "gsap": "^3.15.0",
382
388
  "lucide-react": "^1.16.0",
@@ -385,6 +391,7 @@
385
391
  "shadcn": "^4.8.0",
386
392
  "tailwind-merge": "^3.6.0",
387
393
  "tailwindcss": "^4.3.0",
394
+ "three": "^0.186.1",
388
395
  "tsx": "^4.23.15",
389
396
  "tw-animate-css": "^1.4.0",
390
397
  "use-stick-to-bottom": "^1.1.4",