@anthusai/papyrus 1.2.0 → 1.4.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/app/newsroom/[section]/page.tsx +6 -0
- package/app/newsroom/articles/page.tsx +3 -0
- package/components/news-desk-client-provider.tsx +17 -2
- package/components/news-desk-taxonomy-client.ts +133 -2
- package/components/newspaper.tsx +4 -4
- package/components/newsroom-board-view.tsx +74 -50
- package/components/newsroom-client-shell.tsx +8 -1
- package/components/newsroom-organizer-shell.tsx +12 -7
- package/components/newsroom-organizer.css +4 -3
- package/components/newsroom-references-view.tsx +18 -0
- package/components/reference-cyclotron-priming.tsx +85 -0
- package/components/topic-steering-workspace.tsx +35 -1
- package/lib/article-flow.ts +95 -0
- package/lib/article-frame-solver.ts +519 -0
- package/lib/chrome-text.ts +504 -0
- package/lib/content-item-lifecycle.ts +25 -26
- package/lib/front-mosaic.ts +1132 -0
- package/lib/furniture.ts +385 -0
- package/lib/labels.ts +37 -0
- package/lib/layout-config.ts +237 -0
- package/lib/layout-plan.ts +37 -9
- package/lib/layout-scenarios.ts +21 -11
- package/lib/layout-types.ts +457 -0
- package/lib/local-preview-seed.ts +97 -0
- package/lib/local-preview.ts +3 -0
- package/lib/newspaper-layout.ts +17 -3410
- package/lib/newsroom-board.ts +63 -62
- package/lib/newsroom-organizer-nav.ts +3 -4
- package/lib/pretext-layout.ts +22 -0
- package/lib/relevance-status.ts +73 -0
- package/lib/site-brand.ts +15 -1
- package/lib/text-columns.ts +251 -0
- package/package.json +13 -2
- package/renderers/pretext/presentation-shell.tsx +1 -1
- package/routes.manifest.json +1 -9
- 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
|
+
}
|
|
@@ -1,36 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One content item, one lifecycle: the stages an Item lineage moves through.
|
|
3
|
-
*
|
|
3
|
+
* The default stages mirror CONTENT_ITEM_STAGES in src/papyrus_content/content_item_lifecycle.py;
|
|
4
|
+
* a publication overrides them with SiteBrand.contentItemStages.
|
|
4
5
|
* See docs/content-item-lifecycle.md.
|
|
5
6
|
*/
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"report",
|
|
11
|
-
"editor_select",
|
|
12
|
-
"copywriting",
|
|
13
|
-
"illustration",
|
|
14
|
-
"typesetting",
|
|
15
|
-
"published",
|
|
16
|
-
] as const;
|
|
7
|
+
export type ContentItemStageDefinition = {
|
|
8
|
+
key: string;
|
|
9
|
+
label: string;
|
|
10
|
+
};
|
|
17
11
|
|
|
18
|
-
export
|
|
12
|
+
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" },
|
|
18
|
+
{ key: "copywriting", label: "Copywriting" },
|
|
19
|
+
{ key: "illustration", label: "Illustration" },
|
|
20
|
+
{ key: "typesetting", label: "Typesetting" },
|
|
21
|
+
{ key: "published", label: "Published" },
|
|
22
|
+
];
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
research: "Research",
|
|
24
|
-
report: "Report",
|
|
25
|
-
editor_select: "Editor select",
|
|
26
|
-
copywriting: "Copywriting",
|
|
27
|
-
illustration: "Illustration",
|
|
28
|
-
typesetting: "Typesetting",
|
|
29
|
-
published: "Published",
|
|
30
|
-
};
|
|
24
|
+
export function contentItemStageKeys(stageDefinitions: readonly ContentItemStageDefinition[]): string[] {
|
|
25
|
+
return stageDefinitions.map(definition => definition.key);
|
|
26
|
+
}
|
|
31
27
|
|
|
32
|
-
export function contentItemStageLabel(
|
|
33
|
-
|
|
28
|
+
export function contentItemStageLabel(
|
|
29
|
+
stage: string,
|
|
30
|
+
stageDefinitions: readonly ContentItemStageDefinition[] = DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS,
|
|
31
|
+
): string {
|
|
32
|
+
return stageDefinitions.find(definition => definition.key === stage)?.label ?? stage;
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
export type ContentItemVersionRecord = {
|