@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,237 @@
1
+ import { type LayoutCursor } from "./pretext-layout";
2
+ import {
3
+ createVerticalRhythm,
4
+ reserveRhythmRows,
5
+ snapDownToRhythm,
6
+ type VerticalRhythm,
7
+ } from "./blog-rhythm";
8
+ import { type ResponsiveSpanPolicy } from "./layout-plan";
9
+ import { type LayoutConfig, type PageChromeMetrics } from "./layout-types";
10
+ import { getFrontGridHeight, getFrontRows } from "./front-mosaic";
11
+
12
+ export const EMPTY_CURSOR: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 };
13
+
14
+ export const SERIF_TEXT_FONT = 'Georgia, "Times New Roman", serif';
15
+
16
+ export const SANS_TEXT_FONT = "Arial, Helvetica, sans-serif";
17
+
18
+ export const IMAGE_CAPTION_FONT_SIZE = 12;
19
+
20
+ export const IMAGE_CAPTION_HORIZONTAL_PADDING_EM = 0.3;
21
+
22
+ const MASTHEAD_TITLE_GLYPH_HEIGHT_RATIO = 0.736;
23
+
24
+ const MASTHEAD_RULE_HEIGHT = 0;
25
+
26
+ const MASTHEAD_RULE_MARGIN_BOTTOM = 0;
27
+
28
+ const MASTHEAD_TITLE_MARGIN_TOP = 0;
29
+
30
+ const MASTHEAD_PADDING_BOTTOM = 0;
31
+
32
+ const MASTHEAD_BORDER_BOTTOM = 0;
33
+
34
+ const MASTHEAD_MARGIN_BOTTOM = 0;
35
+
36
+ const MASTHEAD_META_BORDER_TOP = 0;
37
+
38
+ const MASTHEAD_META_PADDING_TOP = 0;
39
+
40
+ const MASTHEAD_META_PADDING_BOTTOM = 0;
41
+
42
+ const MASTHEAD_META_BORDER_BOTTOM = 0;
43
+
44
+ const INSIDE_HEADER_PADDING_BOTTOM = 8;
45
+
46
+ const INSIDE_HEADER_BORDER_BOTTOM = 2;
47
+
48
+ const INSIDE_HEADER_MARGIN_BOTTOM = 14;
49
+
50
+ const CONTINUED_TITLE_KICKER_LINE_HEIGHT = 14;
51
+
52
+ export const CONTINUED_TITLE_HEADING_MARGIN_TOP = 5;
53
+
54
+ export const CONTINUED_TITLE_PADDING_BOTTOM = 0;
55
+
56
+ export const CONTINUED_TITLE_BORDER_BOTTOM = 0;
57
+
58
+ export const CONTINUED_TITLE_MARGIN_BOTTOM = 0;
59
+
60
+ export const FURNITURE_COLLISION_GUTTER = 14;
61
+
62
+ export const PULL_QUOTE_VERTICAL_PADDING = 24;
63
+
64
+ const NEWSPAPER_PAINT_BUFFER_PX = 4;
65
+
66
+ export function getMinimumTextFrameHeight(config: LayoutConfig): number {
67
+ return reserveRhythmRows(config.linePaintHeight, config.rhythm);
68
+ }
69
+
70
+ export function pageIdFor(pageNumber: number): string {
71
+ return `page-${pageNumber}`;
72
+ }
73
+
74
+ export function getLayoutConfig(pageWidth: number, viewportHeight: number): LayoutConfig {
75
+ const narrow = pageWidth < 560;
76
+ const medium = pageWidth >= 560 && pageWidth < 1040;
77
+ const bodyFontSize = narrow ? 14 : 15;
78
+ const bodyLineHeight = Math.ceil(bodyFontSize * 1.4);
79
+ const rhythm = createVerticalRhythm(bodyLineHeight, NEWSPAPER_PAINT_BUFFER_PX);
80
+ const gap = narrow ? 14 : 18;
81
+ const rowGap = rhythm.rowHeight;
82
+ const sideMargin = narrow ? 18 : 30;
83
+ const contentWidth = Math.max(280, pageWidth - sideMargin * 2);
84
+ const columnCount = getResponsiveColumnCount(pageWidth, contentWidth, gap);
85
+ const pageChrome = getPageChromeMetrics(pageWidth, narrow, rhythm, columnCount);
86
+ const targetPageHeight = reserveRhythmRows(getTargetPageHeight(pageWidth, viewportHeight), rhythm);
87
+ const frontGridHeight = snapDownToRhythm(getFrontGridHeight(targetPageHeight, narrow, medium), rhythm);
88
+ const frontRowMaxHeight = snapDownToRhythm(narrow ? 520 : medium ? 560 : 620, rhythm);
89
+ const continuationChrome =
90
+ pageChrome.pagePaddingTop +
91
+ pageChrome.insideHeaderHeight +
92
+ pageChrome.continuedTitleHeight +
93
+ pageChrome.pagePaddingBottom;
94
+ const continuationHeight = reserveRhythmRows(Math.max(760, targetPageHeight - continuationChrome), rhythm);
95
+
96
+ return {
97
+ columnCount,
98
+ contentWidth,
99
+ gap,
100
+ rowGap,
101
+ rhythm,
102
+ pageChrome,
103
+ lineHeight: rhythm.rowHeight,
104
+ linePaintHeight: rhythm.paintHeight,
105
+ bodyFontSize,
106
+ bodyFont: `${bodyFontSize}px ${SERIF_TEXT_FONT}`,
107
+ frontRows: getFrontRows(columnCount, rowGap, frontGridHeight, frontRowMaxHeight, rhythm),
108
+ continuationHeight,
109
+ };
110
+ }
111
+
112
+ export function getResponsiveColumnCount(pageWidth: number, contentWidth: number, gap: number): number {
113
+ if (pageWidth < 560) return 1;
114
+ const minimumColumnWidth = 168;
115
+ for (const columnCount of [6, 5, 4, 3, 2] as const) {
116
+ const columnWidth = (contentWidth - gap * (columnCount - 1)) / columnCount;
117
+ if (columnWidth >= minimumColumnWidth) return columnCount;
118
+ }
119
+ return 1;
120
+ }
121
+
122
+ function getTargetPageHeight(pageWidth: number, viewportHeight: number): number {
123
+ if (pageWidth < 720) return Math.max(3000, viewportHeight * 3.25);
124
+ if (pageWidth < 1040) return Math.max(1900, viewportHeight * 2.1);
125
+ return Math.max(1320, viewportHeight * 1.72);
126
+ }
127
+
128
+ function getMastheadRowPolicy(columnCount: number): { totalRows: number; titleRows: number } {
129
+ if (columnCount <= 1) return { totalRows: 4, titleRows: 2 };
130
+ if (columnCount <= 3) return { totalRows: 5, titleRows: 3 };
131
+ return { totalRows: 6, titleRows: 4 };
132
+ }
133
+
134
+ function getPageChromeMetrics(pageWidth: number, narrow: boolean, rhythm: VerticalRhythm, columnCount: number): PageChromeMetrics {
135
+ const pagePaddingTop = rhythm.rowHeight;
136
+ const pagePaddingX = narrow ? 18 : 30;
137
+ const pagePaddingBottom = reserveRhythmRows(narrow ? 18 : 30, rhythm);
138
+ const mastheadRows = getMastheadRowPolicy(columnCount);
139
+ const mastheadKickerLineHeight = 14;
140
+ const mastheadTitleLineHeight = rhythm.rowHeight * mastheadRows.titleRows;
141
+ const mastheadTitleFontSize = mastheadTitleLineHeight / MASTHEAD_TITLE_GLYPH_HEIGHT_RATIO;
142
+ const mastheadTitleMarginTop = rhythm.rowHeight / 2;
143
+ const mastheadTitleMarginBottom = rhythm.rowHeight / 2;
144
+ const mastheadTitleOpticalShift = -3;
145
+ const mastheadMetaLineHeight = rhythm.rowHeight - MASTHEAD_META_BORDER_TOP - MASTHEAD_META_BORDER_BOTTOM;
146
+ const mastheadMetaGap = narrow ? 5 : 0;
147
+ const mastheadMetaContentHeight = mastheadMetaLineHeight;
148
+ const rawMastheadHeight =
149
+ MASTHEAD_RULE_HEIGHT +
150
+ MASTHEAD_RULE_MARGIN_BOTTOM +
151
+ MASTHEAD_TITLE_MARGIN_TOP +
152
+ mastheadTitleMarginTop +
153
+ mastheadTitleLineHeight +
154
+ mastheadTitleMarginBottom +
155
+ MASTHEAD_META_BORDER_TOP +
156
+ MASTHEAD_META_PADDING_TOP +
157
+ mastheadMetaContentHeight +
158
+ MASTHEAD_META_PADDING_BOTTOM +
159
+ MASTHEAD_META_BORDER_BOTTOM +
160
+ MASTHEAD_PADDING_BOTTOM +
161
+ MASTHEAD_BORDER_BOTTOM +
162
+ MASTHEAD_MARGIN_BOTTOM;
163
+ const mastheadHeight = rhythm.rowHeight * mastheadRows.totalRows;
164
+ const mastheadMetaPaddingTop = MASTHEAD_META_PADDING_TOP + Math.max(0, mastheadHeight - rawMastheadHeight);
165
+ const frontGridMarginTop = rhythm.rowHeight;
166
+ const insideHeaderHeight = INSIDE_HEADER_PADDING_BOTTOM + INSIDE_HEADER_BORDER_BOTTOM + INSIDE_HEADER_MARGIN_BOTTOM + 18;
167
+ const continuedTitleFontSize = narrow ? clamp(pageWidth * 0.075, 28, 42) : clamp(pageWidth * 0.035, 32, 56);
168
+ const continuedTitleLineHeight = continuedTitleFontSize * 0.94;
169
+ const continuedTitleHeight = Math.ceil(
170
+ CONTINUED_TITLE_KICKER_LINE_HEIGHT +
171
+ CONTINUED_TITLE_HEADING_MARGIN_TOP +
172
+ continuedTitleLineHeight * 2 +
173
+ CONTINUED_TITLE_PADDING_BOTTOM +
174
+ CONTINUED_TITLE_BORDER_BOTTOM +
175
+ CONTINUED_TITLE_MARGIN_BOTTOM,
176
+ );
177
+
178
+ return {
179
+ pagePaddingTop,
180
+ pagePaddingX,
181
+ pagePaddingBottom,
182
+ mastheadHeight,
183
+ mastheadKickerLineHeight,
184
+ mastheadTitleFontSize,
185
+ mastheadTitleLineHeight,
186
+ mastheadTitleMarginTop,
187
+ mastheadTitleMarginBottom,
188
+ mastheadTitleOpticalShift,
189
+ mastheadMetaLineHeight,
190
+ mastheadMetaGap,
191
+ mastheadMetaPaddingTop,
192
+ frontGridMarginTop,
193
+ insideHeaderHeight: reserveRhythmRows(insideHeaderHeight, rhythm),
194
+ continuedTitleHeight: reserveRhythmRows(continuedTitleHeight, rhythm),
195
+ continuedTitleChromeHeight: reserveRhythmRows(
196
+ CONTINUED_TITLE_KICKER_LINE_HEIGHT +
197
+ CONTINUED_TITLE_HEADING_MARGIN_TOP +
198
+ CONTINUED_TITLE_PADDING_BOTTOM +
199
+ CONTINUED_TITLE_BORDER_BOTTOM +
200
+ CONTINUED_TITLE_MARGIN_BOTTOM,
201
+ rhythm,
202
+ ),
203
+ continuedTitleFontSize,
204
+ continuedTitleLineHeight,
205
+ continuationSectionSeparatorHeight: rhythm.rowHeight * 2,
206
+ };
207
+ }
208
+
209
+ export function getBlockSpan(config: LayoutConfig, span: ResponsiveSpanPolicy | undefined): number {
210
+ if (config.columnCount === 1) return 1;
211
+ return clamp(span?.preferred ?? config.columnCount, span?.min ?? 1, Math.min(span?.max ?? config.columnCount, config.columnCount));
212
+ }
213
+
214
+ export function getBlockWidth(config: LayoutConfig, span: ResponsiveSpanPolicy | undefined): number {
215
+ return getSpanWidth(config, getBlockSpan(config, span));
216
+ }
217
+
218
+ export function getSpanWidth(config: LayoutConfig, span: number): number {
219
+ const safeSpan = clamp(span, 1, config.columnCount);
220
+ const singleColumn = (config.contentWidth - config.gap * (config.columnCount - 1)) / config.columnCount;
221
+ return singleColumn * safeSpan + config.gap * (safeSpan - 1);
222
+ }
223
+
224
+ export function getSpanRect(config: LayoutConfig, columnStart: number, columnSpan: number): { x: number; width: number } {
225
+ const safeStart = clamp(columnStart, 0, Math.max(0, config.columnCount - 1));
226
+ const safeSpan = clamp(columnSpan, 1, config.columnCount - safeStart);
227
+ const columnWidth = getSpanWidth(config, 1);
228
+ const rawX = safeStart * (columnWidth + config.gap);
229
+ const rawRight = rawX + getSpanWidth(config, safeSpan);
230
+ const x = Math.round(rawX);
231
+ const right = Math.min(Math.round(rawRight), Math.floor(config.contentWidth));
232
+ return { x, width: Math.max(1, right - x) };
233
+ }
234
+
235
+ export function clamp(value: number, min: number, max: number): number {
236
+ return Math.min(max, Math.max(min, value));
237
+ }
@@ -1,3 +1,4 @@
1
+ import { warnAboutMissingMedia } from "./layout-warnings";
1
2
  import { z } from "zod";
2
3
  import {
3
4
  type PublicationItem,
@@ -469,7 +470,12 @@ export type LayoutRegionSpec = z.infer<typeof LayoutRegionSchema>;
469
470
  export type LayoutPageSpec = z.infer<typeof LayoutPageSchema>;
470
471
  export type EditionLayoutPlan = z.infer<typeof EditionLayoutPlanSchema>;
471
472
 
472
- export function createDefaultEditionLayoutPlan(itemIds: string[]): EditionLayoutPlan {
473
+ export type ArticleCutPolicy = ArticleFrameBlockSpec["cutPolicy"];
474
+
475
+ export function createDefaultEditionLayoutPlan(
476
+ itemIds: string[],
477
+ cutPoliciesByItemId: Record<string, ArticleCutPolicy> = {},
478
+ ): EditionLayoutPlan {
473
479
  const frontItemIds = getDefaultFrontItemOrder(itemIds);
474
480
  const frontBlocks = frontItemIds.map((itemId, index) => ({
475
481
  id: `front-${itemId}`,
@@ -563,7 +569,7 @@ export function createDefaultEditionLayoutPlan(itemIds: string[]): EditionLayout
563
569
  ],
564
570
  }
565
571
  : undefined,
566
- cutPolicy: getDefaultCutPolicy(itemId),
572
+ cutPolicy: cutPoliciesByItemId[itemId],
567
573
  }));
568
574
 
569
575
  const plan: EditionLayoutPlan = normalizeEditionLayoutPlan({
@@ -812,9 +818,39 @@ export function validateEditionLayoutPlanForItems(
812
818
  }
813
819
  }
814
820
 
821
+ validateJumpTargetsHaveContinuationFrames(layoutPlan, label);
822
+
815
823
  return layoutPlan;
816
824
  }
817
825
 
826
+ function validateJumpTargetsHaveContinuationFrames(layoutPlan: EditionLayoutPlan, label: string): void {
827
+ const continuationFlowKeysByPageNumber = new Map<number, Set<string>>();
828
+ for (const page of layoutPlan.pages) {
829
+ const flowKeys = new Set<string>();
830
+ for (const region of page.regions) {
831
+ for (const block of region.blocks) {
832
+ if (block.type === "articleFrame" && block.startCursor === "current") flowKeys.add(block.flowKey ?? block.itemId);
833
+ }
834
+ }
835
+ continuationFlowKeysByPageNumber.set(page.pageNumber, flowKeys);
836
+ }
837
+ for (const page of layoutPlan.pages) {
838
+ for (const region of page.regions) {
839
+ for (const block of region.blocks) {
840
+ if (block.type !== "articleFrame") continue;
841
+ const jumpTargetPage = block.cutPolicy?.jumpTargetPage;
842
+ if (!jumpTargetPage) continue;
843
+ const flowKey = block.flowKey ?? block.itemId;
844
+ if (!continuationFlowKeysByPageNumber.get(jumpTargetPage)?.has(flowKey)) {
845
+ throw new Error(
846
+ `${label}.pages[${page.pageNumber}].regions.${region.id}.${block.id} advertises page ${jumpTargetPage} but that page has no startCursor "current" frame for flow ${flowKey}`,
847
+ );
848
+ }
849
+ }
850
+ }
851
+ }
852
+ }
853
+
818
854
  function validateBlock(
819
855
  block: LayoutBlockSpec,
820
856
  itemsBySlug: Map<string, PublicationItem>,
@@ -859,7 +895,7 @@ function validateBlock(
859
895
  if (block.type === "mediaCluster") {
860
896
  const assets = findAssetsByRole(item, block.assetRole);
861
897
  if (block.required && assets.length < block.count.min) {
862
- 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`);
863
899
  }
864
900
  }
865
901
  }
@@ -867,7 +903,7 @@ function validateBlock(
867
903
  function validateMediaSpec(item: PublicationItem, media: LayoutMediaSpec, label: string): void {
868
904
  const matchingAssets = findAssetsByRole(item, media.assetRole);
869
905
  if (media.required && matchingAssets.length === 0) {
870
- 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`);
871
907
  }
872
908
  }
873
909
 
@@ -954,13 +990,6 @@ function getDefaultFrontItemOrder(itemIds: string[]): string[] {
954
990
  return [itemIds[1], itemIds[0], itemIds[2], ...itemIds.slice(3)];
955
991
  }
956
992
 
957
- function getDefaultCutPolicy(itemId: string): ArticleFrameBlockSpec["cutPolicy"] | undefined {
958
- if (itemId === "agent-procedure-patterns") return { maxBodyLines: 22, jumpTargetPage: 2 };
959
- if (itemId === "schools-reading-lab") return { maxBodyLines: 16, jumpTargetPage: 3 };
960
- if (itemId === "market-hall") return { maxBodyLines: 14, jumpTargetPage: 3 };
961
- return undefined;
962
- }
963
-
964
993
  function parseMaybeJson(value: unknown, label: string): unknown {
965
994
  if (typeof value !== "string") {
966
995
  if (value === undefined || value === null) throw new Error(`${label} is required`);
@@ -1,7 +1,7 @@
1
1
  import { type Article, type ArticleVideoAsset, articles, editionDate } from "./articles";
2
2
  import type { EditionContent, NewsDeskAppendix } from "./content-types";
3
3
  import { createEditionSectionPlan } from "./edition-sections";
4
- import { createDefaultEditionLayoutPlan, type EditionLayoutPlan } from "./layout-plan";
4
+ import { createDefaultEditionLayoutPlan, type ArticleCutPolicy, type EditionLayoutPlan } from "./layout-plan";
5
5
  import { articleToPublicationItem, cloneArticle } from "./publication-items";
6
6
  import { PILOBOL_SAMPLE_SCENARIO_ID, createPilobolSampleEditionContent } from "./pilobol-sample";
7
7
  import { SITE_BRAND } from "./site-brand";
@@ -17,6 +17,16 @@ type RawLayoutScenario = Omit<LayoutScenario, "sections"> & {
17
17
  sections?: LayoutScenario["sections"];
18
18
  };
19
19
 
20
+ const fixtureCutPoliciesByItemId: Record<string, ArticleCutPolicy> = {
21
+ "agent-procedure-patterns": { maxBodyLines: 22, jumpTargetPage: 2 },
22
+ "schools-reading-lab": { maxBodyLines: 16, jumpTargetPage: 3 },
23
+ "market-hall": { maxBodyLines: 14, jumpTargetPage: 3 },
24
+ };
25
+
26
+ export function createFixtureEditionLayoutPlan(itemIds: string[]): EditionLayoutPlan {
27
+ return createDefaultEditionLayoutPlan(itemIds, fixtureCutPoliciesByItemId);
28
+ }
29
+
20
30
  const defaultScenarioSeed = getDefaultScenarioSeed();
21
31
 
22
32
  const rawLayoutScenarios: RawLayoutScenario[] = [
@@ -27,7 +37,7 @@ const rawLayoutScenarios: RawLayoutScenario[] = [
27
37
  editionDate: defaultScenarioSeed.editionDate,
28
38
  scenarioId: DEFAULT_LAYOUT_SCENARIO_ID,
29
39
  description: defaultScenarioSeed.description,
30
- layoutPlan: createDefaultEditionLayoutPlan(defaultScenarioSeed.articles.map((article) => article.slug)),
40
+ layoutPlan: createFixtureEditionLayoutPlan(defaultScenarioSeed.articles.map((article) => article.slug)),
31
41
  items: cloneArticles(defaultScenarioSeed.articles).map(articleToPublicationItem),
32
42
  suppressNewsDeskAppendix: defaultScenarioSeed.suppressNewsDeskAppendix,
33
43
  editionVideo: defaultScenarioSeed.editionVideo,
@@ -40,7 +50,7 @@ const rawLayoutScenarios: RawLayoutScenario[] = [
40
50
  editionDate,
41
51
  scenarioId: "blank-edition-title",
42
52
  description: "A fixture edition with a blank title used to verify masthead fallback labeling.",
43
- layoutPlan: createDefaultEditionLayoutPlan(articles.map((article) => article.slug)),
53
+ layoutPlan: createFixtureEditionLayoutPlan(articles.map((article) => article.slug)),
44
54
  items: cloneArticles(articles).map(articleToPublicationItem),
45
55
  },
46
56
  {
@@ -51,7 +61,7 @@ const rawLayoutScenarios: RawLayoutScenario[] = [
51
61
  scenarioId: "shared-blank-column-pressure",
52
62
  description:
53
63
  "Shorter shared continuation tails with images and pull quotes, used to prove that empty columns are repaired with solved furniture.",
54
- layoutPlan: createDefaultEditionLayoutPlan(articles.map((article) => article.slug)),
64
+ layoutPlan: createFixtureEditionLayoutPlan(articles.map((article) => article.slug)),
55
65
  items: createSharedBlankColumnPressureArticles().map(articleToPublicationItem),
56
66
  },
57
67
  {
@@ -62,7 +72,7 @@ const rawLayoutScenarios: RawLayoutScenario[] = [
62
72
  scenarioId: "shared-continuation-no-pull-quotes",
63
73
  description:
64
74
  "The shared continuation page with editorial pull quotes removed, used to prove pull quotes are optional display furniture.",
65
- layoutPlan: createDefaultEditionLayoutPlan(articles.map((article) => article.slug)),
75
+ layoutPlan: createFixtureEditionLayoutPlan(articles.map((article) => article.slug)),
66
76
  items: createNoPullQuoteArticles().map(articleToPublicationItem),
67
77
  },
68
78
  {
@@ -106,7 +116,7 @@ const rawLayoutScenarios: RawLayoutScenario[] = [
106
116
  scenarioId: "long-image-caption",
107
117
  description:
108
118
  "A continuation image with a long caption proves image furniture reserves enough rhythm rows for complete caption text.",
109
- layoutPlan: createDefaultEditionLayoutPlan(articles.map((article) => article.slug)),
119
+ layoutPlan: createFixtureEditionLayoutPlan(articles.map((article) => article.slug)),
110
120
  items: createLongImageCaptionArticles().map(articleToPublicationItem),
111
121
  },
112
122
  {
@@ -116,7 +126,7 @@ const rawLayoutScenarios: RawLayoutScenario[] = [
116
126
  editionDate,
117
127
  scenarioId: "themed-ink-study",
118
128
  description: "A raster fixture verifies dark-mode image variant switching in solved newspaper image furniture.",
119
- layoutPlan: createDefaultEditionLayoutPlan(articles.map((article) => article.slug)),
129
+ layoutPlan: createFixtureEditionLayoutPlan(articles.map((article) => article.slug)),
120
130
  items: createThemedInkStudyArticles().map(articleToPublicationItem),
121
131
  },
122
132
  {
@@ -377,7 +387,7 @@ function createHeightPolicyLayoutPlan({
377
387
  defaultRows?: number;
378
388
  blockShrinkToContent?: boolean;
379
389
  }): EditionLayoutPlan {
380
- const plan = cloneLayoutPlan(createDefaultEditionLayoutPlan(articles.map((article) => article.slug)));
390
+ const plan = cloneLayoutPlan(createFixtureEditionLayoutPlan(articles.map((article) => article.slug)));
381
391
  const region = findPlanRegion(plan, "harbor-continuation");
382
392
  if (region) {
383
393
  if (regionShrinkToContent === undefined) {
@@ -472,7 +482,7 @@ function cloneArticles(source: Article[]): Article[] {
472
482
  }
473
483
 
474
484
  function createCompactChromeLayoutPlan(): EditionLayoutPlan {
475
- const plan = cloneLayoutPlan(createDefaultEditionLayoutPlan(articles.map((article) => article.slug)));
485
+ const plan = cloneLayoutPlan(createFixtureEditionLayoutPlan(articles.map((article) => article.slug)));
476
486
  const frontPage = plan.pages.find((page) => page.pageNumber === 1);
477
487
  const frontBlocks = frontPage?.regions.flatMap((region) => region.blocks) ?? [];
478
488
  for (const block of frontBlocks) {
@@ -498,7 +508,7 @@ function createCompactChromeLayoutPlan(): EditionLayoutPlan {
498
508
  }
499
509
 
500
510
  function createFrontBodyDepthLayoutPlan(): EditionLayoutPlan {
501
- const plan = cloneLayoutPlan(createDefaultEditionLayoutPlan(articles.map((article) => article.slug)));
511
+ const plan = cloneLayoutPlan(createFixtureEditionLayoutPlan(articles.map((article) => article.slug)));
502
512
  for (const blockId of ["front-agent-procedure-patterns", "front-schools-reading-lab", "front-market-hall"]) {
503
513
  const block = findPlanBlock(plan, blockId);
504
514
  if (block?.type !== "articleFrame") continue;
@@ -511,7 +521,7 @@ function createFrontBodyDepthLayoutPlan(): EditionLayoutPlan {
511
521
  }
512
522
 
513
523
  function createFurnitureSufficiencyLayoutPlan(): EditionLayoutPlan {
514
- const plan = cloneLayoutPlan(createDefaultEditionLayoutPlan(articles.map((article) => article.slug)));
524
+ const plan = cloneLayoutPlan(createFixtureEditionLayoutPlan(articles.map((article) => article.slug)));
515
525
  const frontSchoolsBlock = findPlanBlock(plan, "front-schools-reading-lab");
516
526
  if (frontSchoolsBlock?.type === "articleFrame") {
517
527
  frontSchoolsBlock.cutPolicy = { maxBodyLines: 4, jumpTargetPage: 3 };