@anthusai/papyrus 1.3.0 → 1.4.0-next.2

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 (37) hide show
  1. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  2. package/app/newsroom/[section]/page.tsx +8 -0
  3. package/app/newsroom/articles/page.tsx +10 -0
  4. package/components/news-desk-client-provider.tsx +17 -2
  5. package/components/news-desk-taxonomy-client.ts +86 -3
  6. package/components/newspaper.tsx +4 -4
  7. package/components/newsroom-board-view.tsx +70 -29
  8. package/components/newsroom-client-shell.tsx +8 -1
  9. package/components/newsroom-organizer-shell.tsx +13 -8
  10. package/components/newsroom-organizer.css +5 -5
  11. package/components/newsroom-references-view.tsx +18 -0
  12. package/components/reference-cyclotron-priming.tsx +85 -0
  13. package/components/topic-steering-workspace.tsx +35 -1
  14. package/lib/article-flow.ts +95 -0
  15. package/lib/article-frame-solver.ts +519 -0
  16. package/lib/chrome-text.ts +504 -0
  17. package/lib/content-item-lifecycle.ts +32 -5
  18. package/lib/front-mosaic.ts +1132 -0
  19. package/lib/furniture.ts +385 -0
  20. package/lib/labels.ts +37 -0
  21. package/lib/layout-config.ts +237 -0
  22. package/lib/layout-plan.ts +37 -9
  23. package/lib/layout-scenarios.ts +21 -11
  24. package/lib/layout-types.ts +457 -0
  25. package/lib/local-preview-seed.ts +95 -0
  26. package/lib/local-preview.ts +3 -0
  27. package/lib/newspaper-layout.ts +17 -3410
  28. package/lib/newsroom-board.ts +70 -47
  29. package/lib/newsroom-organizer-nav.ts +5 -5
  30. package/lib/pretext-layout.ts +22 -0
  31. package/lib/relevance-status.ts +73 -0
  32. package/lib/site-brand.ts +8 -1
  33. package/lib/text-columns.ts +251 -0
  34. package/package.json +13 -2
  35. package/renderers/pretext/presentation-shell.tsx +1 -1
  36. package/routes.manifest.json +1 -9
  37. package/app/newsroom/work/page.tsx +0 -10
@@ -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
+ }
@@ -7,24 +7,51 @@
7
7
  export type ContentItemStageDefinition = {
8
8
  key: string;
9
9
  label: string;
10
+ /** Closed or rarely visited stage: its board column starts collapsed and its cards load only when it is opened. */
11
+ collapsedUntilOpened?: boolean;
10
12
  };
11
13
 
14
+ export const PUBLISHED_STAGE_KEY = "published";
15
+
16
+ /** Stage keys written before the story stages were simplified, read as the stage that replaced them. */
17
+ export const LEGACY_CONTENT_ITEM_STAGE_ALIASES: Readonly<Record<string, string>> = {
18
+ research: "reporting",
19
+ report: "reporting",
20
+ editor_select: "reporting",
21
+ };
22
+
23
+ /** Stages for work that has not become a story yet; they appear on the assignments board only. */
24
+ export const ASSIGNMENT_PHASE_STAGE_KEYS: readonly string[] = ["idea", "assignment"];
25
+
12
26
  export const DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS: readonly ContentItemStageDefinition[] = [
13
- { key: "idea", label: "Idea" },
14
- { key: "assignment", label: "Assignment" },
15
- { key: "research", label: "Research" },
16
- { key: "report", label: "Report" },
17
- { key: "editor_select", label: "Editor select" },
27
+ { key: "reporting", label: "Reporting" },
18
28
  { key: "copywriting", label: "Copywriting" },
19
29
  { key: "illustration", label: "Illustration" },
20
30
  { key: "typesetting", label: "Typesetting" },
21
31
  { key: "published", label: "Published" },
32
+ { key: "held", label: "Held" },
33
+ { key: "spiked", label: "Spiked", collapsedUntilOpened: true },
22
34
  ];
23
35
 
24
36
  export function contentItemStageKeys(stageDefinitions: readonly ContentItemStageDefinition[]): string[] {
25
37
  return stageDefinitions.map(definition => definition.key);
26
38
  }
27
39
 
40
+ export function canonicalContentItemStage(
41
+ stage: string,
42
+ stageDefinitions: readonly ContentItemStageDefinition[] = DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS,
43
+ ): string {
44
+ const isDefined = stageDefinitions.some(definition => definition.key === stage);
45
+ return isDefined ? stage : LEGACY_CONTENT_ITEM_STAGE_ALIASES[stage] ?? stage;
46
+ }
47
+
48
+ export function legacyStageKeysToLoad(stageDefinitions: readonly ContentItemStageDefinition[]): string[] {
49
+ const definedKeys = contentItemStageKeys(stageDefinitions);
50
+ return Object.entries(LEGACY_CONTENT_ITEM_STAGE_ALIASES)
51
+ .filter(([legacyKey, replacementKey]) => !definedKeys.includes(legacyKey) && definedKeys.includes(replacementKey))
52
+ .map(([legacyKey]) => legacyKey);
53
+ }
54
+
28
55
  export function contentItemStageLabel(
29
56
  stage: string,
30
57
  stageDefinitions: readonly ContentItemStageDefinition[] = DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS,