@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.
- package/amplify/functions/shared/python-bundle.js +3 -3
- package/amplify/functions/shared/python-bundle.ts +3 -3
- package/app/newsroom/sections/[sectionId]/page.tsx +3 -0
- package/components/news-desk-taxonomy-client.ts +36 -29
- package/components/newsroom-article-editor.tsx +3 -1
- package/components/newsroom-assignment-detail.tsx +45 -0
- package/components/newsroom-board-view.tsx +97 -48
- package/components/newsroom-organizer.css +28 -7
- package/components/newsroom-topics-view.tsx +1 -1
- package/components/topic-steering-workspace.tsx +1 -1
- package/components/ui/sheet.tsx +9 -4
- package/components/use-paged-content-item-versions.ts +151 -0
- package/lib/approved-priming-proof-reports.ts +6 -3
- package/lib/approved-priming-proofs/papyrus.json +90 -0
- package/lib/blog-feature-solver.ts +1 -109
- package/lib/content-actions-demo.ts +3 -0
- package/lib/front-mosaic.ts +2 -1
- package/lib/layout-plan.ts +3 -2
- package/lib/layout-warnings.ts +8 -0
- package/lib/local-preview-seed.ts +20 -0
- package/lib/newsroom-assignments.ts +13 -0
- package/lib/newsroom-board.ts +25 -0
- package/lib/prospectus-account-client.ts +64 -0
- package/lib/site-stack.ts +1 -1
- package/package.json +8 -1
- package/renderers/pretext/rhythm-article-page.tsx +1 -18
- package/routes.manifest.json +1 -1
|
@@ -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
|
|
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 = "
|
|
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,
|
package/lib/front-mosaic.ts
CHANGED
|
@@ -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)
|
|
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);
|
package/lib/layout-plan.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/newsroom-board.ts
CHANGED
|
@@ -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.
|
|
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",
|