@anthusai/papyrus 1.3.0 → 1.4.0-next.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/amplify/data/resource.js +134 -3
  2. package/amplify/data/resource.ts +145 -3
  3. package/amplify/data/schema.js +131 -0
  4. package/amplify/data/schema.ts +142 -0
  5. package/amplify/functions/category-action/handler.ts +272 -20
  6. package/amplify/functions/console-chat-responder/src/main.rs +1 -1
  7. package/amplify/functions/content-actions/handler.py +23 -0
  8. package/amplify/functions/content-actions/requirements.txt +1 -1
  9. package/amplify/functions/content-actions/resource.js +1 -1
  10. package/amplify/functions/content-actions/resource.ts +1 -1
  11. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  12. package/amplify/functions/renditions/requirements.txt +1 -1
  13. package/amplify/functions/shared/curation-delegation-flow.ts +147 -0
  14. package/amplify/functions/shared/curation-delegation.ts +115 -0
  15. package/amplify/functions/shared/python-bundle.js +3 -3
  16. package/amplify/functions/shared/python-bundle.ts +3 -3
  17. package/amplify/functions/shared/reference-curation-metadata.ts +51 -0
  18. package/amplify/functions/shared/slack-events.ts +2 -2
  19. package/amplify/publication/renditions-function.js +1 -1
  20. package/amplify/publication/renditions-function.ts +1 -1
  21. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -1
  22. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -1
  23. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +2 -1
  24. package/app/[year]/[month]/[day]/page.tsx +3 -0
  25. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +2 -0
  26. package/app/api/archive/editions/route.ts +2 -0
  27. package/app/api/auth/prospectus/accounts/route.ts +9 -0
  28. package/app/api/local-preview/derive/route.ts +28 -0
  29. package/app/api/local-preview/media/route.ts +25 -0
  30. package/app/archive/page.tsx +3 -0
  31. package/app/globals.css +28 -28
  32. package/app/layout.tsx +8 -5
  33. package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
  34. package/app/newsroom/[section]/page.tsx +8 -1
  35. package/app/newsroom/articles/page.tsx +10 -0
  36. package/app/newsroom/insights/[threadId]/page.tsx +5 -16
  37. package/app/newsroom/insights/page.tsx +4 -12
  38. package/app/newsroom/sections/[sectionId]/page.tsx +3 -0
  39. package/app/page.tsx +30 -1
  40. package/components/administration-delegations-panel.tsx +156 -0
  41. package/components/curation-delegation-client.ts +104 -0
  42. package/components/item-stream-view.tsx +63 -0
  43. package/components/markus-ir-preview.tsx +47 -2
  44. package/components/news-desk-client-provider.tsx +17 -2
  45. package/components/news-desk-page.tsx +0 -1
  46. package/components/news-desk-taxonomy-client.ts +154 -240
  47. package/components/newspaper.tsx +4 -4
  48. package/components/newsroom-article-editor.tsx +138 -40
  49. package/components/newsroom-assignment-detail.tsx +79 -0
  50. package/components/newsroom-assignments-view.tsx +1 -1
  51. package/components/newsroom-board-view.tsx +153 -43
  52. package/components/newsroom-client-shell.tsx +8 -1
  53. package/components/newsroom-newsprint-board.css +5 -46
  54. package/components/newsroom-ops-overview.tsx +27 -5
  55. package/components/newsroom-organizer-shell.tsx +36 -13
  56. package/components/newsroom-organizer.css +269 -13
  57. package/components/newsroom-references-view.tsx +118 -23
  58. package/components/newsroom-topics-view.tsx +1 -1
  59. package/components/priming-proof-page.tsx +17 -8
  60. package/components/prospectus-account-chooser.tsx +131 -0
  61. package/components/reference-cyclotron-priming.tsx +85 -0
  62. package/components/reference-cyclotron-status.tsx +5 -5
  63. package/components/reference-cyclotron-suggestion.tsx +143 -0
  64. package/components/reference-live-agreement.tsx +62 -0
  65. package/components/reference-section-suggestion.tsx +104 -0
  66. package/components/story-reporting-trail-client.ts +91 -0
  67. package/components/story-reporting-trail.tsx +115 -0
  68. package/components/topic-steering-workspace.tsx +280 -679
  69. package/components/ui/sheet.tsx +10 -4
  70. package/components/use-paged-content-item-versions.ts +151 -0
  71. package/components/wiki-concepts-view.tsx +153 -0
  72. package/components/wiki-index-client.ts +51 -0
  73. package/infra/amplify-app-shell.js +17 -3
  74. package/infra/amplify-app-shell.ts +18 -3
  75. package/infra/site-config.js +6 -1
  76. package/infra/site-config.ts +7 -1
  77. package/lib/approved-priming-proof-reports.ts +6 -3
  78. package/lib/approved-priming-proofs/papyrus.json +90 -0
  79. package/lib/article-flow.ts +95 -0
  80. package/lib/article-frame-solver.ts +519 -0
  81. package/lib/blog-feature-solver.ts +1 -109
  82. package/lib/category-repository.ts +1 -1
  83. package/lib/chrome-text.ts +504 -0
  84. package/lib/content-actions-client.ts +3 -0
  85. package/lib/content-actions-demo.ts +55 -0
  86. package/lib/content-item-lifecycle.ts +34 -5
  87. package/lib/content-source-context.ts +9 -1
  88. package/lib/curation-delegation-helpers.ts +69 -0
  89. package/lib/cyclotron-suggestions.ts +263 -0
  90. package/lib/front-matter-fields.ts +42 -0
  91. package/lib/front-mosaic.ts +1133 -0
  92. package/lib/furniture.ts +385 -0
  93. package/lib/graphql-content-repository.ts +42 -7
  94. package/lib/item-stream.ts +47 -0
  95. package/lib/labels.ts +37 -0
  96. package/lib/layout-config.ts +237 -0
  97. package/lib/layout-plan.ts +40 -11
  98. package/lib/layout-scenarios.ts +21 -11
  99. package/lib/layout-types.ts +457 -0
  100. package/lib/layout-warnings.ts +8 -0
  101. package/lib/local-preview-section-fixtures.ts +72 -0
  102. package/lib/local-preview-seed.ts +286 -0
  103. package/lib/local-preview-tenant.ts +27 -0
  104. package/lib/local-preview.ts +3 -0
  105. package/lib/newspaper-layout.ts +17 -3410
  106. package/lib/newsroom-articles.ts +2 -0
  107. package/lib/newsroom-assignments.ts +14 -1
  108. package/lib/newsroom-board.ts +99 -47
  109. package/lib/newsroom-demo-dashboard.ts +1 -1
  110. package/lib/newsroom-forum-routes.ts +2 -43
  111. package/lib/newsroom-index-filters.ts +3 -40
  112. package/lib/newsroom-media-backend.ts +5 -0
  113. package/lib/newsroom-nav.ts +1 -3
  114. package/lib/newsroom-organizer-nav.ts +9 -16
  115. package/lib/newsroom-section-routing.ts +1 -1
  116. package/lib/newsroom-sections.ts +12 -0
  117. package/lib/papyrus-web-locations.ts +3 -24
  118. package/lib/pretext-layout.ts +22 -0
  119. package/lib/priming-proof-report.ts +1 -0
  120. package/lib/prospectus-account-client.ts +90 -0
  121. package/lib/prospectus-sign-in-routes.ts +45 -0
  122. package/lib/prospectus-site-identity.ts +13 -1
  123. package/lib/prospectus-tenant.ts +22 -0
  124. package/lib/reader-static-params.ts +2 -1
  125. package/lib/relevance-decisions.ts +29 -0
  126. package/lib/relevance-status.ts +73 -0
  127. package/lib/reporting-story-budget.ts +2 -2
  128. package/lib/semantic-graph.ts +5 -5
  129. package/lib/site-brand.ts +28 -1
  130. package/lib/site-env.ts +5 -0
  131. package/lib/site-section-suggestions.ts +120 -0
  132. package/lib/site-stack.ts +1 -1
  133. package/lib/story-reporting-trail.ts +106 -0
  134. package/lib/tenant-keys.ts +32 -0
  135. package/lib/text-columns.ts +251 -0
  136. package/lib/wiki.ts +56 -0
  137. package/package.json +46 -3
  138. package/renderers/pretext/presentation-shell.tsx +1 -1
  139. package/renderers/pretext/rhythm-article-page.tsx +1 -18
  140. package/routes.manifest.json +34 -15
  141. package/app/newsroom/work/page.tsx +0 -10
@@ -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
+ }
@@ -0,0 +1,95 @@
1
+ import {
2
+ type ArticlePublicationItem,
3
+ getArticlePublicationItems,
4
+ type PublicationItem,
5
+ } from "./publication-items";
6
+ import { type LayoutCursor } from "./pretext-layout";
7
+ import { type ArticleFlow, type PlacedTextRange, type SolvedFurniture } from "./layout-types";
8
+ import { EMPTY_CURSOR } from "./layout-config";
9
+
10
+ export function createArticleFlows(items: PublicationItem[]): Map<string, ArticleFlow> {
11
+ return new Map(
12
+ getArticlePublicationItems(items).map((article) => [
13
+ article.slug,
14
+ {
15
+ article,
16
+ currentCursor: { ...EMPTY_CURSOR },
17
+ placedRanges: [],
18
+ usedImageAssetIds: new Set<string>(),
19
+ },
20
+ ]),
21
+ );
22
+ }
23
+
24
+ export function getOrCreateFlow(flows: Map<string, ArticleFlow>, flowKey: string, article: ArticlePublicationItem): ArticleFlow {
25
+ const existing = flows.get(flowKey);
26
+ if (existing) return existing;
27
+ const flow = {
28
+ article,
29
+ currentCursor: { ...EMPTY_CURSOR },
30
+ placedRanges: [],
31
+ usedImageAssetIds: new Set<string>(),
32
+ };
33
+ flows.set(flowKey, flow);
34
+ return flow;
35
+ }
36
+
37
+ export function resetArticleFlow(flow: ArticleFlow): void {
38
+ flow.currentCursor = { ...EMPTY_CURSOR };
39
+ flow.placedRanges = [];
40
+ flow.usedImageAssetIds.clear();
41
+ }
42
+
43
+ export function requireKnownItem(itemId: string, itemsBySlug: Map<string, PublicationItem>): PublicationItem {
44
+ const item = itemsBySlug.get(itemId);
45
+ if (!item) throw new Error(`Layout references missing item ${itemId}`);
46
+ return item;
47
+ }
48
+
49
+ export function requireArticleItem(itemId: string, itemsBySlug: Map<string, PublicationItem>): ArticlePublicationItem {
50
+ const item = requireKnownItem(itemId, itemsBySlug);
51
+ if (item.type !== "article") throw new Error(`Layout articleFrame requires article item ${itemId}`);
52
+ return item;
53
+ }
54
+
55
+ export function createTextRange({
56
+ flow,
57
+ pageId,
58
+ blockId,
59
+ startCursor,
60
+ endCursor,
61
+ exhausted,
62
+ }: {
63
+ flow: ArticleFlow;
64
+ pageId: string;
65
+ blockId: string;
66
+ startCursor: LayoutCursor;
67
+ endCursor: LayoutCursor;
68
+ exhausted: boolean;
69
+ }): PlacedTextRange {
70
+ return {
71
+ articleId: flow.article.slug,
72
+ pageId,
73
+ blockId,
74
+ startCursor,
75
+ endCursor,
76
+ exhausted,
77
+ };
78
+ }
79
+
80
+ export function commitTextRange(flow: ArticleFlow, range: PlacedTextRange): void {
81
+ flow.currentCursor = { ...range.endCursor };
82
+ flow.placedRanges.push(range);
83
+ }
84
+
85
+ export function commitUsedImageFurniture(flow: ArticleFlow, furniture: SolvedFurniture[]): void {
86
+ for (const item of furniture) {
87
+ if (item.kind === "image") {
88
+ flow.usedImageAssetIds.add(item.assetId);
89
+ continue;
90
+ }
91
+ if (item.kind === "mediaCluster") {
92
+ for (const image of item.images) flow.usedImageAssetIds.add(image.assetId);
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,519 @@
1
+ import {
2
+ type ArticleFrameBlockSpec,
3
+ type HeadlineScaleId,
4
+ type LayoutBlockSpec,
5
+ type LayoutPageSpec,
6
+ type LayoutRegionSpec,
7
+ type ResponsiveSpanPolicy,
8
+ } from "./layout-plan";
9
+ import { type ArticlePublicationItem, type PublicationItem } from "./publication-items";
10
+ import { clampRhythmHeight, reserveRhythmRows, snapDownToRhythm } from "./blog-rhythm";
11
+ import { type LayoutCursor } from "./pretext-layout";
12
+ import {
13
+ type ArticleFlow,
14
+ type ArticleFrameCandidate,
15
+ type ContinuationTitleMetrics,
16
+ type LayoutConfig,
17
+ type PlacedTextRange,
18
+ type SolvedBlock,
19
+ type SolvedFurniture,
20
+ type SolvedPage,
21
+ type SolvedRegion,
22
+ } from "./layout-types";
23
+ import {
24
+ EMPTY_CURSOR,
25
+ clamp,
26
+ getBlockSpan,
27
+ getBlockWidth,
28
+ getMinimumTextFrameHeight,
29
+ getResponsiveColumnCount,
30
+ pageIdFor,
31
+ } from "./layout-config";
32
+ import {
33
+ commitTextRange,
34
+ commitUsedImageFurniture,
35
+ createTextRange,
36
+ getOrCreateFlow,
37
+ requireArticleItem,
38
+ requireKnownItem,
39
+ resetArticleFlow,
40
+ } from "./article-flow";
41
+ import { formatContinuationJumpLabel, getArticleFrameLabel } from "./labels";
42
+ import { getContinuationTitleMetrics } from "./chrome-text";
43
+ import {
44
+ createImageFurniture,
45
+ createPullQuoteFurniture,
46
+ getMediaVariants,
47
+ getNoMediaVariant,
48
+ getPullQuoteVariants,
49
+ hasAvailablePreferredImage,
50
+ } from "./furniture";
51
+ import {
52
+ getColumnWhitespace,
53
+ getDeadColumnCount,
54
+ getFurnitureBottom,
55
+ getFurnitureSufficiencyReport,
56
+ getLinesHeight,
57
+ layoutTextColumns,
58
+ } from "./text-columns";
59
+
60
+ const ARTICLE_FRAME_SCORE_WEIGHTS = {
61
+ baseScore: 50_000,
62
+ whitespacePerPixel: 1.1,
63
+ deadColumnPenalty: 8_000,
64
+ imageHeightDeviationPerPixel: 0.35,
65
+ missingImagePenalty: 50_000,
66
+ missingPullQuotePenalty: 2_800,
67
+ narrowedColumnPenaltyPerColumn: 240,
68
+ } as const;
69
+
70
+ export function solveStackedEditorialPage(
71
+ pageSpec: LayoutPageSpec,
72
+ itemsBySlug: Map<string, PublicationItem>,
73
+ flows: Map<string, ArticleFlow>,
74
+ config: LayoutConfig,
75
+ ): SolvedPage {
76
+ const pageStartY = config.pageChrome.insideHeaderHeight;
77
+ const regions: SolvedRegion[] = [];
78
+ let currentY = pageStartY;
79
+ const ratios = pageSpec.regions.map((region) => region.size?.ratio ?? 1 / pageSpec.regions.length);
80
+ const ratioTotal = ratios.reduce((total, ratio) => total + ratio, 0) || 1;
81
+
82
+ for (let index = 0; index < pageSpec.regions.length; index += 1) {
83
+ const regionSpec = pageSpec.regions[index];
84
+ const allocatedHeight = Math.max(
85
+ getMinimumTextFrameHeight(config),
86
+ snapDownToRhythm(
87
+ Math.floor((config.continuationHeight * ratios[index]) / ratioTotal) -
88
+ (index > 0 ? config.pageChrome.continuationSectionSeparatorHeight : 0),
89
+ config.rhythm,
90
+ ),
91
+ );
92
+ const region = solveRegion(regionSpec, pageSpec, allocatedHeight, itemsBySlug, flows, config, currentY);
93
+ regions.push(region);
94
+ currentY += region.height + (index < pageSpec.regions.length - 1 ? config.pageChrome.continuationSectionSeparatorHeight : 0);
95
+ }
96
+
97
+ const contentBottom = Math.max(...regions.map((region) => region.y + region.height), pageStartY);
98
+ const height = reserveRhythmRows(
99
+ config.pageChrome.pagePaddingTop +
100
+ contentBottom +
101
+ config.pageChrome.pagePaddingBottom,
102
+ config.rhythm,
103
+ );
104
+
105
+ return {
106
+ id: pageSpec.id ?? pageIdFor(pageSpec.pageNumber),
107
+ pageNumber: pageSpec.pageNumber,
108
+ presetId: pageSpec.presetId,
109
+ kind: getPageKind(pageSpec),
110
+ height,
111
+ columnCount: config.columnCount,
112
+ regions,
113
+ };
114
+ }
115
+
116
+ function solveRegion(
117
+ regionSpec: LayoutRegionSpec,
118
+ pageSpec: LayoutPageSpec,
119
+ allocatedHeight: number,
120
+ itemsBySlug: Map<string, PublicationItem>,
121
+ flows: Map<string, ArticleFlow>,
122
+ config: LayoutConfig,
123
+ y: number,
124
+ ): SolvedRegion {
125
+ const blocks: SolvedBlock[] = [];
126
+ let currentY = 0;
127
+
128
+ for (const blockSpec of regionSpec.blocks) {
129
+ const remainingHeight = Math.max(getMinimumTextFrameHeight(config), snapDownToRhythm(allocatedHeight - currentY, config.rhythm));
130
+ const block = solveBlock(blockSpec, pageSpec, remainingHeight, itemsBySlug, flows, config);
131
+ blocks.push({ ...block, y: currentY });
132
+ currentY += block.height;
133
+ }
134
+
135
+ return {
136
+ id: regionSpec.id,
137
+ type: regionSpec.type,
138
+ role: regionSpec.role,
139
+ x: 0,
140
+ y,
141
+ width: config.contentWidth,
142
+ height: reserveRhythmRows(
143
+ regionSpec.size?.shrinkToContent
144
+ ? Math.max(currentY, getMinimumTextFrameHeight(config))
145
+ : Math.max(currentY, allocatedHeight),
146
+ config.rhythm,
147
+ ),
148
+ columnCount: config.columnCount,
149
+ blocks,
150
+ };
151
+ }
152
+
153
+ function solveBlock(
154
+ blockSpec: LayoutBlockSpec,
155
+ pageSpec: LayoutPageSpec,
156
+ allocatedHeight: number,
157
+ itemsBySlug: Map<string, PublicationItem>,
158
+ flows: Map<string, ArticleFlow>,
159
+ config: LayoutConfig,
160
+ ): SolvedBlock {
161
+ if (blockSpec.type === "articleFrame") {
162
+ return solveArticleFrameBlock(blockSpec, pageSpec, allocatedHeight, itemsBySlug, flows, config);
163
+ }
164
+ return solveStaticBlock(blockSpec, pageSpec, allocatedHeight, itemsBySlug, config);
165
+ }
166
+
167
+ function solveArticleFrameBlock(
168
+ blockSpec: ArticleFrameBlockSpec,
169
+ pageSpec: LayoutPageSpec,
170
+ allocatedHeight: number,
171
+ itemsBySlug: Map<string, PublicationItem>,
172
+ flows: Map<string, ArticleFlow>,
173
+ config: LayoutConfig,
174
+ ): SolvedBlock {
175
+ const item = requireArticleItem(blockSpec.itemId, itemsBySlug);
176
+ const flow = getOrCreateFlow(flows, blockSpec.flowKey ?? blockSpec.itemId, item);
177
+ if (blockSpec.startCursor === "beginning") resetArticleFlow(flow);
178
+ const startCursor = blockSpec.startCursor === "beginning" ? { ...EMPTY_CURSOR } : { ...flow.currentCursor };
179
+ const blockWidth = getBlockWidth(config, blockSpec.span);
180
+ const label = getArticleFrameLabel(blockSpec, item, flow);
181
+ const headlineScale = blockSpec.typography?.headlineScale ?? "standard";
182
+ const title = getContinuationTitleMetrics(
183
+ config,
184
+ item,
185
+ blockWidth,
186
+ label,
187
+ headlineScale,
188
+ Boolean(blockSpec.typography?.headlineScale),
189
+ blockSpec.chrome,
190
+ );
191
+ const offeredHeight = getArticleFrameOfferedHeight(blockSpec, allocatedHeight, config);
192
+ const bodyBudget = Math.max(getMinimumTextFrameHeight(config), snapDownToRhythm(offeredHeight - title.totalHeight, config.rhythm));
193
+ const mustExhaust = !blockSpec.cutPolicy?.maxBodyLines && !blockSpec.cutPolicy?.jumpTargetPage;
194
+ const candidates = solveArticleFrameCandidates({
195
+ blockSpec,
196
+ pageSpec,
197
+ item,
198
+ flow,
199
+ startCursor,
200
+ blockWidth,
201
+ label,
202
+ headlineScale,
203
+ title,
204
+ bodyBudget,
205
+ offeredHeight,
206
+ mustExhaust,
207
+ config,
208
+ });
209
+ const best = chooseBestCandidate(candidates, blockSpec.id);
210
+ commitTextRange(flow, best.range);
211
+ commitUsedImageFurniture(flow, best.block.furniture);
212
+ return best.block;
213
+ }
214
+
215
+ function getArticleFrameOfferedHeight(
216
+ blockSpec: ArticleFrameBlockSpec,
217
+ allocatedHeight: number,
218
+ config: LayoutConfig,
219
+ ): number {
220
+ if (!blockSpec.size?.defaultRows) return allocatedHeight;
221
+ return Math.max(getMinimumTextFrameHeight(config), reserveRhythmRows(blockSpec.size.defaultRows * config.rhythm.rowHeight, config.rhythm));
222
+ }
223
+
224
+ function solveArticleFrameCandidates({
225
+ blockSpec,
226
+ pageSpec,
227
+ item,
228
+ flow,
229
+ startCursor,
230
+ blockWidth,
231
+ label,
232
+ headlineScale,
233
+ title,
234
+ bodyBudget,
235
+ offeredHeight,
236
+ mustExhaust,
237
+ config,
238
+ }: {
239
+ blockSpec: ArticleFrameBlockSpec;
240
+ pageSpec: LayoutPageSpec;
241
+ item: ArticlePublicationItem;
242
+ flow: ArticleFlow;
243
+ startCursor: LayoutCursor;
244
+ blockWidth: number;
245
+ label: string;
246
+ headlineScale: HeadlineScaleId;
247
+ title: ContinuationTitleMetrics;
248
+ bodyBudget: number;
249
+ offeredHeight: number;
250
+ mustExhaust: boolean;
251
+ config: LayoutConfig;
252
+ }): ArticleFrameCandidate[] {
253
+ const localColumnCounts = getLocalColumnCountCandidates(blockSpec.localGrid?.columns, config, blockWidth);
254
+ const candidates: ArticleFrameCandidate[] = [];
255
+
256
+ for (const columnCount of localColumnCounts) {
257
+ const localConfig = { ...config, columnCount, contentWidth: blockWidth };
258
+ const mediaSpec = blockSpec.media[0];
259
+ const hasReusableMedia = mediaSpec
260
+ ? hasAvailablePreferredImage(item, mediaSpec.placement, mediaSpec.assetRole, flow.usedImageAssetIds)
261
+ : false;
262
+ const mediaVariants = getMediaVariants(
263
+ mediaSpec?.required && !hasReusableMedia ? { ...mediaSpec, required: false } : mediaSpec,
264
+ localConfig,
265
+ );
266
+ const effectiveMediaVariants = mediaSpec?.required && hasReusableMedia
267
+ ? [...mediaVariants, getNoMediaVariant(12_000)]
268
+ : mediaVariants;
269
+ const pullQuoteVariants = getPullQuoteVariants(item, blockSpec.pullQuote, localConfig);
270
+
271
+ for (const mediaVariant of effectiveMediaVariants) {
272
+ const image = createImageFurniture(item, mediaVariant, localConfig, bodyBudget, flow.usedImageAssetIds);
273
+ if (mediaSpec?.required && hasReusableMedia && !image) continue;
274
+
275
+ const minimumHeight = reserveRhythmRows(Math.max(config.linePaintHeight, image ? image.y + image.height : 0), config.rhythm);
276
+ const textHeights = mustExhaust
277
+ ? getExhaustiveTextHeightVariants(Math.max(minimumHeight, bodyBudget), minimumHeight, localConfig)
278
+ : getTextHeightVariants(Math.max(minimumHeight, bodyBudget), minimumHeight, localConfig);
279
+ const furnitureLayoutsAlreadyExhausted = new Set<string>();
280
+ for (const textHeight of textHeights) {
281
+ for (const pullQuoteVariant of pullQuoteVariants) {
282
+ const pullQuote = createPullQuoteFurniture(item, pullQuoteVariant, localConfig, textHeight, image, blockSpec.chrome);
283
+ if (blockSpec.pullQuote?.required && !pullQuote) continue;
284
+ const furnitureLayoutKey = JSON.stringify(pullQuote);
285
+ if (furnitureLayoutsAlreadyExhausted.has(furnitureLayoutKey)) continue;
286
+
287
+ const furniture: SolvedFurniture[] = [];
288
+ if (image) furniture.push(image);
289
+ if (pullQuote) furniture.push(pullQuote);
290
+ const textResult = layoutTextColumns({
291
+ item,
292
+ cursor: startCursor,
293
+ columnCount,
294
+ textHeight,
295
+ localConfig,
296
+ furniture,
297
+ furnitureObstacleMarginBottom: config.rhythm.rowHeight,
298
+ });
299
+ if (mustExhaust && textResult.hasMore) continue;
300
+ if (!mustExhaust && textResult.hasMore && textHeight < bodyBudget - 0.75) continue;
301
+ const furnitureSufficiency = getFurnitureSufficiencyReport({
302
+ columns: textResult.columns,
303
+ textHeight,
304
+ furniture,
305
+ hasMore: textResult.hasMore,
306
+ config: localConfig,
307
+ });
308
+ if (!furnitureSufficiency.accepted) continue;
309
+ if (mustExhaust) furnitureLayoutsAlreadyExhausted.add(furnitureLayoutKey);
310
+ const range = createTextRange({
311
+ flow,
312
+ pageId: pageIdFor(pageSpec.pageNumber),
313
+ blockId: blockSpec.id,
314
+ startCursor,
315
+ endCursor: textResult.cursor,
316
+ exhausted: !textResult.hasMore,
317
+ });
318
+ const linesHeight = Math.max(...textResult.columns.map(getLinesHeight), 0);
319
+ const furnitureBottom = getFurnitureBottom(furniture);
320
+ const contentBodyHeight = reserveRhythmRows(Math.max(furnitureBottom, textResult.hasMore ? textHeight : linesHeight, config.linePaintHeight), config.rhythm);
321
+ const reservedBodyHeight = blockSpec.size?.defaultRows && !blockSpec.size.shrinkToContent
322
+ ? Math.max(contentBodyHeight, snapDownToRhythm(offeredHeight - title.totalHeight, config.rhythm))
323
+ : contentBodyHeight;
324
+ const bodyHeight = reserveRhythmRows(reservedBodyHeight, config.rhythm);
325
+ const blockHeight = title.totalHeight + bodyHeight;
326
+ const whitespace = getColumnWhitespace(textResult.columns, bodyHeight, furniture);
327
+ const deadColumns = getDeadColumnCount(textResult.columns, bodyHeight, furniture);
328
+ const score =
329
+ ARTICLE_FRAME_SCORE_WEIGHTS.baseScore -
330
+ whitespace * ARTICLE_FRAME_SCORE_WEIGHTS.whitespacePerPixel -
331
+ deadColumns * ARTICLE_FRAME_SCORE_WEIGHTS.deadColumnPenalty -
332
+ (image
333
+ ? Math.abs(image.height - image.preferredHeight) * ARTICLE_FRAME_SCORE_WEIGHTS.imageHeightDeviationPerPixel
334
+ : ARTICLE_FRAME_SCORE_WEIGHTS.missingImagePenalty) -
335
+ (pullQuote ? 0 : ARTICLE_FRAME_SCORE_WEIGHTS.missingPullQuotePenalty) -
336
+ mediaVariant.fallbackPenalty -
337
+ pullQuoteVariant.fallbackPenalty -
338
+ Math.max(0, config.columnCount - columnCount) * ARTICLE_FRAME_SCORE_WEIGHTS.narrowedColumnPenaltyPerColumn;
339
+
340
+ candidates.push({
341
+ block: {
342
+ id: blockSpec.id,
343
+ type: "articleFrame",
344
+ presetId: blockSpec.presetId,
345
+ headlineScale,
346
+ item,
347
+ article: item,
348
+ pageNumber: pageSpec.pageNumber,
349
+ jumpTargetPage: textResult.hasMore ? blockSpec.cutPolicy?.jumpTargetPage : undefined,
350
+ jumpLabel:
351
+ textResult.hasMore && blockSpec.cutPolicy?.jumpTargetPage
352
+ ? formatContinuationJumpLabel(item, blockSpec.cutPolicy.jumpTargetPage)
353
+ : undefined,
354
+ label,
355
+ title: item.headline,
356
+ deck: item.deck,
357
+ byline: item.byline,
358
+ dateline: item.dateline,
359
+ section: item.section,
360
+ x: 0,
361
+ y: 0,
362
+ width: blockWidth,
363
+ height: blockHeight,
364
+ span: getBlockSpan(config, blockSpec.span),
365
+ columnCount,
366
+ columns: textResult.columns,
367
+ furniture,
368
+ textRange: range,
369
+ hasMore: textResult.hasMore,
370
+ titleChrome: title,
371
+ titleHeight: title.heading.height,
372
+ bodyHeight,
373
+ furnitureSufficiency,
374
+ },
375
+ range,
376
+ score,
377
+ whitespace,
378
+ furnitureSufficiency,
379
+ });
380
+ }
381
+ }
382
+ }
383
+ }
384
+
385
+ return candidates;
386
+ }
387
+
388
+ function solveStaticBlock(
389
+ blockSpec: LayoutBlockSpec,
390
+ pageSpec: LayoutPageSpec,
391
+ allocatedHeight: number,
392
+ itemsBySlug: Map<string, PublicationItem>,
393
+ config: LayoutConfig,
394
+ ): SolvedBlock {
395
+ const width = config.contentWidth;
396
+ if (blockSpec.type === "itemStack") {
397
+ const items = blockSpec.itemIds.map((itemId) => requireKnownItem(itemId, itemsBySlug));
398
+ const gridColumnCount = Math.max(1, Math.min(config.columnCount, 4));
399
+ const rowCount = Math.ceil(items.length / gridColumnCount);
400
+ const stackHeight = reserveRhythmRows(
401
+ Math.max(getMinimumTextFrameHeight(config), 220 + rowCount * 170),
402
+ config.rhythm,
403
+ );
404
+ return {
405
+ id: blockSpec.id,
406
+ type: blockSpec.type,
407
+ pageNumber: pageSpec.pageNumber,
408
+ title: blockSpec.title,
409
+ items,
410
+ x: 0,
411
+ y: 0,
412
+ width,
413
+ height: stackHeight,
414
+ span: config.columnCount,
415
+ columnCount: gridColumnCount,
416
+ columns: [],
417
+ furniture: [],
418
+ };
419
+ }
420
+ if (blockSpec.type === "adBlock") {
421
+ const adHeight = clampRhythmHeight(allocatedHeight, 260, 720, config.rhythm);
422
+ return {
423
+ id: blockSpec.id,
424
+ type: blockSpec.type,
425
+ presetId: blockSpec.presetId,
426
+ item: blockSpec.itemId ? requireKnownItem(blockSpec.itemId, itemsBySlug) : undefined,
427
+ pageNumber: pageSpec.pageNumber,
428
+ title: "Advertisement",
429
+ x: 0,
430
+ y: 0,
431
+ width,
432
+ height: adHeight,
433
+ span: config.columnCount,
434
+ columnCount: config.columnCount,
435
+ columns: [],
436
+ furniture: [
437
+ {
438
+ kind: "ad",
439
+ id: `${blockSpec.id}-ad`,
440
+ src: blockSpec.imageUrl,
441
+ label: blockSpec.label?.trim() || "Advertisement",
442
+ x: 0,
443
+ y: 0,
444
+ width,
445
+ height: adHeight,
446
+ wrapsText: false,
447
+ },
448
+ ],
449
+ };
450
+ }
451
+ const staticHeight = blockSpec.type === "rule"
452
+ ? config.rhythm.rowHeight
453
+ : clampRhythmHeight(120, getMinimumTextFrameHeight(config), allocatedHeight, config.rhythm);
454
+ return {
455
+ id: blockSpec.id,
456
+ type: blockSpec.type,
457
+ pageNumber: pageSpec.pageNumber,
458
+ x: 0,
459
+ y: 0,
460
+ width,
461
+ height: staticHeight,
462
+ span: config.columnCount,
463
+ columnCount: config.columnCount,
464
+ columns: [],
465
+ furniture: [],
466
+ };
467
+ }
468
+
469
+ function chooseBestCandidate(candidates: ArticleFrameCandidate[], blockId: string): ArticleFrameCandidate {
470
+ const best = candidates.reduce<ArticleFrameCandidate | null>(
471
+ (winner, candidate) => (!winner || candidate.score > winner.score ? candidate : winner),
472
+ null,
473
+ );
474
+ if (!best) throw new Error(`No valid articleFrame candidate solved for ${blockId}`);
475
+ return best;
476
+ }
477
+
478
+ export function getLocalColumnCountCandidates(
479
+ policy: ResponsiveSpanPolicy | undefined,
480
+ config: LayoutConfig,
481
+ blockWidth: number,
482
+ ): number[] {
483
+ const maxByWidth = getResponsiveColumnCount(blockWidth + config.pageChrome.pagePaddingX * 2, blockWidth, config.gap);
484
+ const min = Math.min(policy?.min ?? 1, maxByWidth, config.columnCount);
485
+ const max = Math.min(policy?.max ?? config.columnCount, maxByWidth, config.columnCount);
486
+ const preferred = clamp(policy?.preferred ?? max, min, max);
487
+ const candidates = [preferred, min, max];
488
+ return Array.from(new Set(candidates)).sort((left, right) => (
489
+ Math.abs(left - preferred) - Math.abs(right - preferred) || right - left
490
+ ));
491
+ }
492
+
493
+ function getTextHeightVariants(maxHeight: number, minHeight: number, config: LayoutConfig): number[] {
494
+ const snappedMinimum = reserveRhythmRows(minHeight, config.rhythm);
495
+ const snappedMaximum = Math.max(snappedMinimum, snapDownToRhythm(maxHeight, config.rhythm));
496
+ if (config.columnCount === 1) return [snappedMaximum];
497
+ const candidates = [snappedMaximum, snappedMinimum].map((height) => clamp(snapDownToRhythm(height, config.rhythm), snappedMinimum, snappedMaximum));
498
+ return Array.from(new Set(candidates)).sort((leftHeight, rightHeight) => rightHeight - leftHeight);
499
+ }
500
+
501
+ function getExhaustiveTextHeightVariants(startHeight: number, minHeight: number, config: LayoutConfig): number[] {
502
+ const snappedMinimum = reserveRhythmRows(minHeight, config.rhythm);
503
+ const snappedBudget = Math.max(snappedMinimum, reserveRhythmRows(startHeight, config.rhythm));
504
+ const step = config.rhythm.rowHeight * 4;
505
+ const maximum = snappedBudget + config.rhythm.rowHeight * 240;
506
+ const heights: number[] = [];
507
+ for (let height = snappedMinimum; height <= maximum; height += step) heights.push(height);
508
+ return heights;
509
+ }
510
+
511
+ function getPageKind(pageSpec: LayoutPageSpec): string {
512
+ if (pageSpec.presetId === "page.regionStack" && pageSpec.regions.length > 1) return "regionStack";
513
+ if (pageSpec.presetId === "page.railMain") return "railMain";
514
+ return "articlePage";
515
+ }
516
+
517
+ export function collectPageTextRanges(page: SolvedPage): PlacedTextRange[] {
518
+ return page.regions.flatMap((region) => region.blocks.flatMap((block) => (block.textRange ? [block.textRange] : [])));
519
+ }