@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
@@ -12,7 +12,7 @@ import {
12
12
  } from "./blog-rhythm";
13
13
  import { layoutAllTextLines, layoutTextLines, prepareWithSegments, type TextLine, type TextObstacle } from "./pretext-layout";
14
14
 
15
- export type FeaturedLayoutMode = "obstacle" | "float" | "stacked";
15
+ export type FeaturedLayoutMode = "float" | "stacked";
16
16
 
17
17
  export type SolvedFeaturedItem = {
18
18
  mode: FeaturedLayoutMode;
@@ -92,19 +92,6 @@ export function solveFeaturedItem(input: SolveFeaturedItemInput): SolvedFeatured
92
92
  });
93
93
  const aspectRatio = getImageAspectRatio(input.imageAsset);
94
94
 
95
- if (mode === "obstacle") {
96
- return solveObstacleFeaturedItem({
97
- prepared,
98
- containerWidth: input.containerWidth,
99
- viewportWidth: input.viewportWidth,
100
- rhythm,
101
- textStyle: input.textStyle,
102
- aspectRatio,
103
- gap,
104
- imageAsset: input.imageAsset,
105
- });
106
- }
107
-
108
95
  if (mode === "float") {
109
96
  return solveFloatFeaturedItem({
110
97
  prepared,
@@ -151,96 +138,6 @@ type PreparedSolveInput = {
151
138
  imageAsset: ArticleImageAsset;
152
139
  };
153
140
 
154
- function solveObstacleFeaturedItem(input: PreparedSolveInput): SolvedFeaturedItem {
155
- const maxImageWidth = getFloatImageWidth(input);
156
- let best: {
157
- score: number;
158
- textLines: TextLine[];
159
- imageHeight: number;
160
- imageWidth: number;
161
- captionHeight: number;
162
- mediaHeight: number;
163
- copyWidth: number;
164
- textFrameHeight: number;
165
- hasMore: boolean;
166
- } | null = null;
167
-
168
- for (const imageWidth of getObstacleImageWidths(maxImageWidth, input.rhythm)) {
169
- const copyWidth = Math.max(
170
- getFloatCopyMinimum(input.viewportWidth, input.rhythm),
171
- input.containerWidth - imageWidth - input.gap,
172
- );
173
- const imageHeight = snapImageHeight(imageWidth / input.aspectRatio, input.rhythm, input.imageAsset.layout);
174
- const captionHeight = getFeatureCaptionHeight(input.imageAsset, imageWidth, input.rhythm);
175
- const mediaHeight = imageHeight + captionHeight;
176
- const obstacle: TextObstacle = {
177
- x: Math.max(0, copyWidth - imageWidth),
178
- y: 0,
179
- width: imageWidth,
180
- height: mediaHeight,
181
- };
182
-
183
- let candidateHeight = mediaHeight;
184
- let result = layoutTextLines({
185
- prepared: input.prepared,
186
- cursor: { segmentIndex: 0, graphemeIndex: 0 },
187
- maxHeight: candidateHeight,
188
- maxWidth: copyWidth,
189
- lineHeight: input.textStyle.lineHeight,
190
- linePaintHeight: input.textStyle.linePaintHeight,
191
- fontSize: input.textStyle.fontSize,
192
- fontFamily: input.textStyle.fontFamily,
193
- obstacles: [obstacle],
194
- });
195
-
196
- const textBottom = getMeasuredTextHeight(result.lines);
197
- if (textBottom > 0 && textBottom < candidateHeight) {
198
- candidateHeight = reserveRhythmRows(textBottom, input.rhythm);
199
- obstacle.height = candidateHeight;
200
- result = layoutTextLines({
201
- prepared: input.prepared,
202
- cursor: { segmentIndex: 0, graphemeIndex: 0 },
203
- maxHeight: candidateHeight,
204
- maxWidth: copyWidth,
205
- lineHeight: input.textStyle.lineHeight,
206
- linePaintHeight: input.textStyle.linePaintHeight,
207
- fontSize: input.textStyle.fontSize,
208
- fontFamily: input.textStyle.fontFamily,
209
- obstacles: [obstacle],
210
- });
211
- }
212
-
213
- const whitespace = Math.max(0, candidateHeight - getMeasuredTextHeight(result.lines));
214
- const score = 50_000 - whitespace * 1.4 - Math.abs(candidateHeight - mediaHeight) * 0.35 - Math.abs(maxImageWidth - imageWidth) * 0.2;
215
- const candidate = {
216
- score,
217
- textLines: result.lines,
218
- imageHeight: Math.min(imageHeight, candidateHeight),
219
- imageWidth,
220
- captionHeight,
221
- mediaHeight: candidateHeight,
222
- copyWidth,
223
- textFrameHeight: reserveRhythmRows(getMeasuredTextHeight(result.lines), input.rhythm),
224
- hasMore: result.hasMore,
225
- };
226
- if (!best || candidate.score > best.score) best = candidate;
227
- }
228
-
229
- const fallbackImageHeight = input.rhythm.rowHeight * 6;
230
- return {
231
- mode: "obstacle",
232
- textLines: best?.textLines ?? [],
233
- imageHeight: best?.imageHeight ?? fallbackImageHeight,
234
- imageWidth: best?.imageWidth ?? maxImageWidth,
235
- captionHeight: best?.captionHeight ?? 0,
236
- mediaHeight: best?.mediaHeight ?? fallbackImageHeight,
237
- copyWidth: best?.copyWidth ?? input.containerWidth,
238
- textFrameHeight: best?.textFrameHeight ?? 0,
239
- hasMore: best?.hasMore ?? false,
240
- gap: input.gap,
241
- };
242
- }
243
-
244
141
  function solveFloatFeaturedItem(input: PreparedSolveInput): SolvedFeaturedItem {
245
142
  const geometry = computeFloatGeometry({
246
143
  containerWidth: input.containerWidth,
@@ -417,11 +314,6 @@ function getFloatCopyMinimum(viewportWidth: number, rhythm: VerticalRhythm): num
417
314
  return rhythmLengthRows(viewportWidth <= 600 ? FLOAT_COPY_MIN_PHONE_ROWS : FLOAT_COPY_MIN_DESKTOP_ROWS, rhythm);
418
315
  }
419
316
 
420
- function getObstacleImageWidths(maximum: number, rhythm: VerticalRhythm): number[] {
421
- const candidateRows = [0, 1, 2].map((offset) => Math.max(rhythm.rowHeight * 5, maximum - (offset * rhythm.rowHeight)));
422
- return Array.from(new Set(candidateRows));
423
- }
424
-
425
317
  function snapImageHeight(
426
318
  naturalHeight: number,
427
319
  rhythm: VerticalRhythm,
@@ -1510,7 +1510,7 @@ export function createDemoCategorySteeringDashboard(
1510
1510
  assignments: [
1511
1511
  {
1512
1512
  id: assignmentReportingId,
1513
- assignmentTypeKey: "reporting.edition-candidate",
1513
+ assignmentTypeKey: "reporting.candidate",
1514
1514
  queueKey: "edition:edition-demo:section:news:lane:reporting",
1515
1515
  queueStatusKey: "edition:edition-demo:section:news:lane:reporting#open",
1516
1516
  status: "open",
@@ -0,0 +1,504 @@
1
+ import {
2
+ type ArticleFrameBlockSpec,
3
+ type ArticleFrameChromeSpec,
4
+ type ArticleFrameCompositionSlotSpec,
5
+ type ChromeTextSlotSpec,
6
+ type HeadlineScaleId,
7
+ } from "./layout-plan";
8
+ import { type ArticlePublicationItem } from "./publication-items";
9
+ import { getLineStackHeight, reserveRhythmRows, snapUpToRhythm, type VerticalRhythm } from "./blog-rhythm";
10
+ import { layoutNextLine, prepareWithSegmentsCached } from "./pretext-layout";
11
+ import { resolvePreferredPlacementVariant } from "./furniture";
12
+ import {
13
+ type ChromeBoxTreatment,
14
+ type ChromeTextBoxMetrics,
15
+ type ChromeTextStyle,
16
+ type ContinuationTitleMetrics,
17
+ type FrontStoryRole,
18
+ type LayoutConfig,
19
+ type PlacementVariant,
20
+ type SolvedChromeBox,
21
+ type StoryChromeMetrics,
22
+ } from "./layout-types";
23
+ import {
24
+ CONTINUED_TITLE_BORDER_BOTTOM,
25
+ CONTINUED_TITLE_HEADING_MARGIN_TOP,
26
+ CONTINUED_TITLE_MARGIN_BOTTOM,
27
+ CONTINUED_TITLE_PADDING_BOTTOM,
28
+ EMPTY_CURSOR,
29
+ SANS_TEXT_FONT,
30
+ SERIF_TEXT_FONT,
31
+ clamp,
32
+ getSpanRect,
33
+ } from "./layout-config";
34
+ import { formatByline } from "./labels";
35
+
36
+ const KICKER_BASE_FONT_SIZE = 11.5;
37
+
38
+ const KICKER_BASE_ROW_HEIGHT = 19;
39
+
40
+ function getKickerFontSize(rhythmRowHeight: number): number {
41
+ return Math.round((KICKER_BASE_FONT_SIZE * rhythmRowHeight / KICKER_BASE_ROW_HEIGHT) * 10) / 10;
42
+ }
43
+
44
+ export function solveCompositionChromeBox({
45
+ slot,
46
+ item,
47
+ storyRole,
48
+ headlineScale,
49
+ localConfig,
50
+ y,
51
+ globalYOffset,
52
+ chromeOverrides,
53
+ variant = resolvePreferredPlacementVariant(slot.placement, localConfig),
54
+ }: {
55
+ slot: ArticleFrameCompositionSlotSpec;
56
+ item: ArticlePublicationItem;
57
+ storyRole: FrontStoryRole;
58
+ headlineScale: HeadlineScaleId;
59
+ localConfig: LayoutConfig;
60
+ y: number;
61
+ globalYOffset: number;
62
+ chromeOverrides?: ArticleFrameChromeSpec;
63
+ variant?: PlacementVariant | null;
64
+ }): { localBox: SolvedChromeBox; globalBox: SolvedChromeBox; localReserveBottom: number } | null {
65
+ if (!variant || !isTextCompositionSlot(slot.slot)) return null;
66
+ const text = getCompositionSlotText(slot.slot, item);
67
+ if (!text) return null;
68
+ const { x, width } = getSpanRect(localConfig, variant.columnStart, variant.columnSpan);
69
+ const style = getCompositionSlotStyle(slot.slot, localConfig, storyRole, headlineScale, width);
70
+ const metrics = solveChromeTextBox(text, width, style, localConfig.rhythm, chromeOverrides?.[slot.slot]);
71
+ const treatedMetrics = applyFrontChromeTreatment(slot.slot, metrics, localConfig, storyRole);
72
+ const boxY = snapUpToRhythm(y + treatedMetrics.marginBefore, localConfig.rhythm);
73
+ const localBox: SolvedChromeBox = {
74
+ id: `${item.slug}-${slot.slot}-${variant.id}`,
75
+ slot: slot.slot,
76
+ text,
77
+ columnStart: variant.columnStart,
78
+ columnSpan: variant.columnSpan,
79
+ x,
80
+ y: boxY,
81
+ width,
82
+ height: treatedMetrics.height,
83
+ fontSize: treatedMetrics.fontSize,
84
+ lineHeight: treatedMetrics.lineHeight,
85
+ paintBuffer: treatedMetrics.paintBuffer,
86
+ paddingTop: treatedMetrics.paddingTop,
87
+ paddingBottom: treatedMetrics.paddingBottom,
88
+ ruleTopHeight: treatedMetrics.ruleTopHeight,
89
+ ruleBottomHeight: treatedMetrics.ruleBottomHeight,
90
+ fontFamily: style.fontFamily === SANS_TEXT_FONT ? "sans" : "serif",
91
+ fontWeight: style.fontWeight,
92
+ fontStyle: style.fontStyle,
93
+ textTransform: slot.slot === "label" || slot.slot === "byline" ? "uppercase" : undefined,
94
+ };
95
+ return {
96
+ localBox,
97
+ globalBox: { ...localBox, y: localBox.y + globalYOffset },
98
+ localReserveBottom: reserveRhythmRows(boxY + treatedMetrics.height + treatedMetrics.marginAfter, localConfig.rhythm),
99
+ };
100
+ }
101
+
102
+ export function isTextCompositionSlot(slot: ArticleFrameCompositionSlotSpec["slot"]): slot is SolvedChromeBox["slot"] {
103
+ return slot === "label" || slot === "headline" || slot === "deck" || slot === "byline";
104
+ }
105
+
106
+ function getCompositionSlotText(slot: SolvedChromeBox["slot"], article: ArticlePublicationItem): string {
107
+ if (slot === "label") return article.section;
108
+ if (slot === "headline") return article.headline;
109
+ if (slot === "deck") return article.deck;
110
+ return formatByline(article);
111
+ }
112
+
113
+ function getCompositionSlotStyle(
114
+ slot: SolvedChromeBox["slot"],
115
+ config: LayoutConfig,
116
+ storyRole: FrontStoryRole,
117
+ headlineScale: HeadlineScaleId,
118
+ slotWidth: number,
119
+ ): ChromeTextStyle {
120
+ if (slot === "label") {
121
+ const labelFontSize = getKickerFontSize(config.rhythm.rowHeight);
122
+ return {
123
+ fontSize: labelFontSize,
124
+ fontFamily: SANS_TEXT_FONT,
125
+ fontWeight: 800,
126
+ lineHeightEm: config.rhythm.rowHeight / labelFontSize,
127
+ paintHeightEm: config.rhythm.rowHeight / labelFontSize,
128
+ };
129
+ }
130
+ if (slot === "headline") {
131
+ const displayHeadline = isDisplayHeadlineScale(headlineScale);
132
+ const fontSize = getHeadlineFontSize(config, headlineScale, slotWidth);
133
+ return {
134
+ fontSize,
135
+ fontFamily: SERIF_TEXT_FONT,
136
+ fontWeight: 900,
137
+ lineHeightEm: displayHeadline ? 0.96 : 1,
138
+ paintHeightEm: displayHeadline ? 1.1 : 1.12,
139
+ };
140
+ }
141
+ if (slot === "deck") {
142
+ const feature = storyRole === "feature";
143
+ const fontSize = config.columnCount === 1 ? 17 : feature ? 19 : 16;
144
+ const lineHeight = feature ? Math.round(fontSize * 1.16) : config.rhythm.rowHeight;
145
+ return {
146
+ fontSize,
147
+ fontFamily: SERIF_TEXT_FONT,
148
+ fontStyle: "italic",
149
+ lineHeightEm: lineHeight / fontSize,
150
+ paintHeightEm: lineHeight / fontSize,
151
+ };
152
+ }
153
+ return {
154
+ fontSize: 12,
155
+ fontFamily: SANS_TEXT_FONT,
156
+ fontWeight: 800,
157
+ lineHeightEm: config.rhythm.rowHeight / 12,
158
+ paintHeightEm: config.rhythm.rowHeight / 12,
159
+ };
160
+ }
161
+
162
+ function applyFrontChromeTreatment(
163
+ slot: SolvedChromeBox["slot"],
164
+ metrics: ChromeTextBoxMetrics,
165
+ config: LayoutConfig,
166
+ storyRole: FrontStoryRole,
167
+ ): ChromeTextBoxMetrics {
168
+ if (slot === "deck") {
169
+ const padding = Math.round(config.rhythm.rowHeight * (storyRole === "feature" ? 0.48 : 0.34));
170
+ return applyChromeBoxTreatment(metrics, {
171
+ paddingTop: padding,
172
+ paddingBottom: padding,
173
+ ruleTopHeight: 1,
174
+ ruleBottomHeight: 1,
175
+ }, config.rhythm);
176
+ }
177
+
178
+ if (slot === "byline") {
179
+ return applyChromeBoxTreatment(metrics, {
180
+ paddingTop: config.rhythm.rowHeight,
181
+ paddingBottom: config.rhythm.rowHeight,
182
+ }, config.rhythm);
183
+ }
184
+
185
+ return metrics;
186
+ }
187
+
188
+ function applyChromeBoxTreatment(
189
+ metrics: ChromeTextBoxMetrics,
190
+ treatment: ChromeBoxTreatment,
191
+ rhythm: VerticalRhythm,
192
+ ): ChromeTextBoxMetrics {
193
+ const ruleTopHeight = treatment.ruleTopHeight ?? 0;
194
+ const ruleBottomHeight = treatment.ruleBottomHeight ?? 0;
195
+ const rawHeight =
196
+ ruleTopHeight +
197
+ treatment.paddingTop +
198
+ metrics.contentHeight +
199
+ treatment.paddingBottom +
200
+ ruleBottomHeight;
201
+ const height = reserveRhythmRows(rawHeight, rhythm);
202
+ const paddingBottom = treatment.paddingBottom + Math.max(0, height - rawHeight);
203
+ const rawTotalHeight = metrics.marginBefore + height + metrics.marginAfter;
204
+ const totalHeight = reserveRhythmRows(rawTotalHeight, rhythm);
205
+
206
+ return {
207
+ ...metrics,
208
+ height,
209
+ paddingTop: treatment.paddingTop,
210
+ paddingBottom,
211
+ ruleTopHeight,
212
+ ruleBottomHeight,
213
+ totalHeight,
214
+ marginAfter: metrics.marginAfter + (totalHeight - rawTotalHeight),
215
+ };
216
+ }
217
+
218
+ export function getStoryChromeMetrics(
219
+ config: LayoutConfig,
220
+ article: ArticlePublicationItem,
221
+ storyRole: FrontStoryRole,
222
+ headlineScale: HeadlineScaleId,
223
+ blockWidth: number,
224
+ mediaPreludeHeight = 0,
225
+ overrides?: ArticleFrameChromeSpec,
226
+ ): StoryChromeMetrics {
227
+ const feature = storyRole === "feature";
228
+ const displayHeadline = isDisplayHeadlineScale(headlineScale);
229
+ const headlineFontSize = getHeadlineFontSize(config, headlineScale, blockWidth);
230
+ const labelFontSize = getKickerFontSize(config.rhythm.rowHeight);
231
+ const label = solveChromeTextBox(article.section, blockWidth, {
232
+ fontSize: labelFontSize,
233
+ fontFamily: SANS_TEXT_FONT,
234
+ fontWeight: 800,
235
+ lineHeightEm: config.rhythm.rowHeight / labelFontSize,
236
+ paintHeightEm: config.rhythm.rowHeight / labelFontSize,
237
+ }, config.rhythm, overrides?.label);
238
+ const headline = solveChromeTextBox(article.headline, blockWidth, {
239
+ fontSize: headlineFontSize,
240
+ fontFamily: SERIF_TEXT_FONT,
241
+ fontWeight: 900,
242
+ lineHeightEm: displayHeadline ? 0.96 : 1,
243
+ paintHeightEm: displayHeadline ? 1.1 : 1.12,
244
+ }, config.rhythm, overrides?.headline);
245
+ const deckFontSize = config.columnCount === 1 ? 17 : feature ? 19 : 16;
246
+ const deckLineHeight = feature ? Math.round(deckFontSize * 1.16) : config.rhythm.rowHeight;
247
+ const deck = applyFrontChromeTreatment("deck", solveChromeTextBox(article.deck, blockWidth, {
248
+ fontSize: deckFontSize,
249
+ fontFamily: SERIF_TEXT_FONT,
250
+ fontStyle: "italic",
251
+ lineHeightEm: deckLineHeight / deckFontSize,
252
+ paintHeightEm: deckLineHeight / deckFontSize,
253
+ }, config.rhythm, overrides?.deck), config, storyRole);
254
+ const bylineFontSize = 12;
255
+ const byline = applyFrontChromeTreatment("byline", solveChromeTextBox(formatByline(article), blockWidth, {
256
+ fontSize: bylineFontSize,
257
+ fontFamily: SANS_TEXT_FONT,
258
+ fontWeight: 800,
259
+ lineHeightEm: config.rhythm.rowHeight / bylineFontSize,
260
+ paintHeightEm: config.rhythm.rowHeight / bylineFontSize,
261
+ }, config.rhythm, overrides?.byline), config, storyRole);
262
+ const jump = solveChromeTextBox("SEE MORE", blockWidth, {
263
+ fontSize: 12.2,
264
+ fontFamily: SANS_TEXT_FONT,
265
+ fontWeight: 800,
266
+ lineHeightEm: config.rhythm.rowHeight / 12.2,
267
+ paintHeightEm: config.rhythm.rowHeight / 12.2,
268
+ }, config.rhythm, overrides?.jumpLine);
269
+
270
+ return {
271
+ borderTopHeight: 0,
272
+ paddingTop: 0,
273
+ mediaPreludeHeight,
274
+ mediaPreludeMarginBottom: mediaPreludeHeight > 0 ? config.rhythm.rowHeight : 0,
275
+ labelFontSize: label.fontSize,
276
+ labelLineHeight: label.lineHeight,
277
+ labelPaintHeight: label.paintHeight,
278
+ labelPaintBuffer: label.paintBuffer,
279
+ labelHeight: label.height,
280
+ headlineFontSize: headline.fontSize,
281
+ headlineLineHeight: headline.lineHeight,
282
+ headlinePaintHeight: headline.paintHeight,
283
+ headlinePaintBuffer: headline.paintBuffer,
284
+ headlineHeight: headline.height,
285
+ headlineLineCount: headline.lineCount,
286
+ headlineMarginTop: headline.marginBefore,
287
+ headlineMarginBottom: headline.marginAfter,
288
+ deckFontSize: deck.fontSize,
289
+ deckLineHeight: deck.lineHeight,
290
+ deckPaintHeight: deck.paintHeight,
291
+ deckPaintBuffer: deck.paintBuffer,
292
+ deckPaddingTop: deck.paddingTop,
293
+ deckPaddingBottom: deck.paddingBottom,
294
+ deckRuleTopHeight: deck.ruleTopHeight,
295
+ deckRuleBottomHeight: deck.ruleBottomHeight,
296
+ deckHeight: deck.height,
297
+ deckLineCount: deck.lineCount,
298
+ deckMarginBottom: deck.marginAfter,
299
+ bylineFontSize: byline.fontSize,
300
+ bylineLineHeight: byline.lineHeight,
301
+ bylinePaintHeight: byline.paintHeight,
302
+ bylinePaintBuffer: byline.paintBuffer,
303
+ bylinePaddingTop: byline.paddingTop,
304
+ bylinePaddingBottom: byline.paddingBottom,
305
+ bylineHeight: byline.height,
306
+ bylineLineCount: byline.lineCount,
307
+ bylineMarginBottom: byline.marginAfter,
308
+ measureChromeHeight: 0,
309
+ jumpFontSize: jump.fontSize,
310
+ jumpLineHeight: jump.lineHeight,
311
+ jumpPaintHeight: jump.paintHeight,
312
+ jumpPaintBuffer: jump.paintBuffer,
313
+ jumpHeight: jump.height,
314
+ jumpPaddingTop: 0,
315
+ jumpPaddingBottom: config.rhythm.rowHeight,
316
+ jumpBorderTopHeight: 0,
317
+ };
318
+ }
319
+
320
+ export function resolveFrontStoryRole(blockSpec: ArticleFrameBlockSpec, span: number, hasFeatureCue: boolean): FrontStoryRole {
321
+ if (blockSpec.role === "feature" || blockSpec.role === "primary") return "feature";
322
+ if (blockSpec.role === "rail") return "rail";
323
+ if (span >= 4 || hasFeatureCue) return "feature";
324
+ return "standard";
325
+ }
326
+
327
+ export function resolveHeadlineScale(blockSpec: ArticleFrameBlockSpec, storyRole: FrontStoryRole): HeadlineScaleId {
328
+ if (blockSpec.typography?.headlineScale) return blockSpec.typography.headlineScale;
329
+ if (storyRole === "feature") return "feature";
330
+ if (storyRole === "rail") return "rail";
331
+ return "standard";
332
+ }
333
+
334
+ function isDisplayHeadlineScale(headlineScale: HeadlineScaleId): boolean {
335
+ return headlineScale === "banner" || headlineScale === "feature";
336
+ }
337
+
338
+ function getHeadlineFontSize(config: LayoutConfig, headlineScale: HeadlineScaleId, blockWidth: number): number {
339
+ const width = Math.max(blockWidth, 1);
340
+ if (config.columnCount === 1) {
341
+ if (headlineScale === "banner") return clamp(width * 0.13, 42, 82);
342
+ if (headlineScale === "feature") return clamp(width * 0.11, 34, 54);
343
+ if (headlineScale === "standard") return clamp(width * 0.095, 29, 46);
344
+ if (headlineScale === "rail") return clamp(width * 0.09, 27, 42);
345
+ return clamp(width * 0.075, 22, 32);
346
+ }
347
+
348
+ if (headlineScale === "banner") return clamp(width * 0.105, 54, 112);
349
+ if (headlineScale === "feature") return clamp(width * 0.052, 34, 58);
350
+ if (headlineScale === "standard") return clamp(width * 0.07, 27, 40);
351
+ if (headlineScale === "rail") return clamp(width * 0.15, 24, 34);
352
+ return clamp(width * 0.055, 18, 26);
353
+ }
354
+
355
+ function solveChromeTextBox(
356
+ text: string,
357
+ maxWidth: number,
358
+ defaults: ChromeTextStyle,
359
+ rhythm: VerticalRhythm,
360
+ overrides?: ChromeTextSlotSpec,
361
+ ): ChromeTextBoxMetrics {
362
+ const fontSize = defaults.fontSize;
363
+ const lineHeight = Math.ceil(emToPx(parseEmOverride(overrides?.lineHeight, defaults.lineHeightEm), fontSize));
364
+ const paintHeight = Math.ceil(emToPx(parseEmOverride(overrides?.paintHeight, defaults.paintHeightEm), fontSize));
365
+ const marginBefore = Math.round(emToPx(parseEmOverride(overrides?.marginBefore, defaults.marginBeforeEm ?? 0), fontSize));
366
+ const rawMarginAfter = Math.round(emToPx(parseEmOverride(overrides?.marginAfter, defaults.marginAfterEm ?? 0), fontSize));
367
+ const minHeight = Math.ceil(emToPx(parseEmOverride(overrides?.minHeight, defaults.minHeightEm ?? 0), fontSize));
368
+ const measured = measureWrappedTextBlock(text, buildChromeFont(defaults), maxWidth, lineHeight, paintHeight);
369
+ const height = reserveRhythmRows(Math.max(measured.height, minHeight), rhythm);
370
+ const rawTotalHeight = marginBefore + height + rawMarginAfter;
371
+ const totalHeight = reserveRhythmRows(rawTotalHeight, rhythm);
372
+ const marginAfter = rawMarginAfter + (totalHeight - rawTotalHeight);
373
+
374
+ return {
375
+ fontSize,
376
+ lineHeight,
377
+ paintHeight,
378
+ paintBuffer: Math.max(0, paintHeight - lineHeight),
379
+ contentHeight: measured.height,
380
+ paddingTop: 0,
381
+ paddingBottom: 0,
382
+ ruleTopHeight: 0,
383
+ ruleBottomHeight: 0,
384
+ height,
385
+ lineCount: measured.lineCount,
386
+ marginBefore,
387
+ marginAfter,
388
+ totalHeight,
389
+ };
390
+ }
391
+
392
+ export function buildChromeFont(style: ChromeTextStyle): string {
393
+ const pieces: string[] = [];
394
+ if (style.fontStyle && style.fontStyle !== "normal") pieces.push(style.fontStyle);
395
+ if (style.fontWeight) pieces.push(String(style.fontWeight));
396
+ pieces.push(`${style.fontSize}px`);
397
+ pieces.push(style.fontFamily);
398
+ return pieces.join(" ");
399
+ }
400
+
401
+ export function parseEmOverride(value: ChromeTextSlotSpec[keyof ChromeTextSlotSpec] | undefined, fallback: number): number {
402
+ if (!value) return fallback;
403
+ return parseEmValue(value);
404
+ }
405
+
406
+ function parseEmValue(value: string): number {
407
+ return Number(value.slice(0, -2));
408
+ }
409
+
410
+ export function emToPx(value: number, fontSize: number): number {
411
+ return value * fontSize;
412
+ }
413
+
414
+ export function getStoryChromeHeight(chrome: StoryChromeMetrics): number {
415
+ return (
416
+ chrome.borderTopHeight +
417
+ chrome.paddingTop +
418
+ chrome.mediaPreludeHeight +
419
+ chrome.mediaPreludeMarginBottom +
420
+ chrome.labelHeight +
421
+ chrome.headlineMarginTop +
422
+ chrome.headlineHeight +
423
+ chrome.headlineMarginBottom +
424
+ chrome.deckHeight +
425
+ chrome.deckMarginBottom +
426
+ chrome.bylineHeight +
427
+ chrome.bylineMarginBottom +
428
+ chrome.measureChromeHeight
429
+ );
430
+ }
431
+
432
+ export function getStoryJumpReserveHeight(chrome: StoryChromeMetrics): number {
433
+ return chrome.jumpBorderTopHeight + chrome.jumpPaddingTop + chrome.jumpHeight + chrome.jumpPaddingBottom;
434
+ }
435
+
436
+ export function getContinuationTitleMetrics(
437
+ config: LayoutConfig,
438
+ article: ArticlePublicationItem,
439
+ blockWidth: number,
440
+ labelText: string,
441
+ headlineScale: HeadlineScaleId,
442
+ explicitHeadlineScale: boolean,
443
+ overrides?: ArticleFrameChromeSpec,
444
+ ): ContinuationTitleMetrics {
445
+ const labelFontSize = getKickerFontSize(config.rhythm.rowHeight);
446
+ const label = solveChromeTextBox(labelText, blockWidth, {
447
+ fontSize: labelFontSize,
448
+ fontFamily: SANS_TEXT_FONT,
449
+ fontWeight: 800,
450
+ lineHeightEm: config.rhythm.rowHeight / labelFontSize,
451
+ paintHeightEm: config.rhythm.rowHeight / labelFontSize,
452
+ }, config.rhythm, overrides?.label);
453
+ const headingWidth = Math.min(blockWidth, 980);
454
+ const headingFontSize = explicitHeadlineScale
455
+ ? getHeadlineFontSize(config, headlineScale, headingWidth)
456
+ : config.pageChrome.continuedTitleFontSize;
457
+ const headingLineHeight = explicitHeadlineScale
458
+ ? headingFontSize * (isDisplayHeadlineScale(headlineScale) ? 0.94 : 1)
459
+ : config.pageChrome.continuedTitleLineHeight;
460
+ const heading = solveChromeTextBox(article.headline, headingWidth, {
461
+ fontSize: headingFontSize,
462
+ fontFamily: SERIF_TEXT_FONT,
463
+ fontWeight: 900,
464
+ lineHeightEm: headingLineHeight / headingFontSize,
465
+ paintHeightEm: Math.max(1.08, headingLineHeight / headingFontSize),
466
+ marginBeforeEm: CONTINUED_TITLE_HEADING_MARGIN_TOP / headingFontSize,
467
+ }, config.rhythm, overrides?.headline);
468
+ const chromeHeight = reserveRhythmRows(
469
+ label.totalHeight +
470
+ heading.totalHeight +
471
+ CONTINUED_TITLE_PADDING_BOTTOM +
472
+ CONTINUED_TITLE_BORDER_BOTTOM +
473
+ CONTINUED_TITLE_MARGIN_BOTTOM,
474
+ config.rhythm,
475
+ );
476
+ return {
477
+ label,
478
+ heading,
479
+ chromeHeight,
480
+ totalHeight: chromeHeight,
481
+ };
482
+ }
483
+
484
+ export function measureWrappedTextBlock(
485
+ text: string,
486
+ font: string,
487
+ maxWidth: number,
488
+ lineHeight: number,
489
+ paintHeight = lineHeight,
490
+ ): { lineCount: number; height: number } {
491
+ const prepared = prepareWithSegmentsCached(text, font);
492
+ let cursor = { ...EMPTY_CURSOR };
493
+ let lineCount = 0;
494
+ while (lineCount < 20) {
495
+ const line = layoutNextLine(prepared, cursor, maxWidth);
496
+ if (!line) break;
497
+ cursor = line.end;
498
+ lineCount += 1;
499
+ }
500
+ return {
501
+ lineCount,
502
+ height: getLineStackHeight(lineCount, lineHeight, paintHeight),
503
+ };
504
+ }
@@ -13,6 +13,9 @@ export type SaveItemDraftInput = {
13
13
  bodyMarkus: string;
14
14
  aliases: string[];
15
15
  expectedContentHash: string | null;
16
+ /** Records a new draft version of the story instead of correcting the current one in place. */
17
+ newVersion?: boolean;
18
+ changeReason?: string | null;
16
19
  };
17
20
 
18
21
  export type DeriveMarkusResult = { ok: true; bodyIrBytes: number; bodyIr?: unknown; errors: [] };