@anthusai/papyrus 1.0.0-next.2 → 1.0.0-next.21

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 (140) hide show
  1. package/amplify/auth/resource.js +17 -4
  2. package/amplify/auth/resource.ts +21 -4
  3. package/amplify/data/resource.js +52 -70
  4. package/amplify/data/resource.ts +57 -70
  5. package/amplify/data/schema.js +52 -58
  6. package/amplify/data/schema.ts +57 -58
  7. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  8. package/amplify/functions/console-chat-responder/resource.js +0 -7
  9. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  10. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  11. package/amplify/functions/content-actions/handler.py +149 -0
  12. package/amplify/functions/content-actions/requirements.txt +4 -0
  13. package/amplify/functions/content-actions/resource.js +40 -0
  14. package/amplify/functions/content-actions/resource.ts +54 -0
  15. package/amplify/functions/shared/python-bundle.js +17 -2
  16. package/amplify/functions/shared/python-bundle.ts +23 -2
  17. package/amplify/rebuild-trigger-settings.js +22 -0
  18. package/amplify/rebuild-trigger-settings.ts +42 -0
  19. package/amplify/site-backend-config.ts +12 -1
  20. package/amplify/site-backend.js +51 -27
  21. package/amplify/site-backend.ts +66 -34
  22. package/amplify/storage/resource.js +3 -0
  23. package/amplify/storage/resource.ts +3 -0
  24. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  25. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +4 -2
  26. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  27. package/app/api/revalidate/route.ts +8 -7
  28. package/app/articles/[slug]/page.tsx +6 -4
  29. package/app/blog-rhythm.css +278 -0
  30. package/app/globals.css +22 -4
  31. package/app/layout.tsx +24 -12
  32. package/app/newsroom/articles/[id]/page.tsx +15 -0
  33. package/app/newsroom/articles/page.tsx +13 -0
  34. package/app/page.tsx +12 -4
  35. package/app/robots.ts +11 -0
  36. package/bin/papyrus-app.mjs +12 -2
  37. package/bin/papyrus-infra.mjs +74 -18
  38. package/components/archive-shell.tsx +2 -22
  39. package/components/article-video.tsx +247 -3
  40. package/components/blog-page-background.tsx +23 -6
  41. package/components/markus-ir-preview.tsx +300 -0
  42. package/components/news-desk-page.tsx +1 -4
  43. package/components/newspaper.tsx +6 -2
  44. package/components/newsroom-access-gate.tsx +66 -0
  45. package/components/newsroom-article-editor.tsx +741 -0
  46. package/components/newsroom-articles-view.tsx +129 -0
  47. package/components/newsroom-ops-overview.tsx +28 -11
  48. package/components/newsroom-ops-shell.tsx +3 -1
  49. package/components/pictogram-figure.tsx +9 -2
  50. package/components/presentation-header.tsx +135 -0
  51. package/components/presentation-rhythm-hrule.tsx +17 -0
  52. package/components/settings-page.tsx +2 -1
  53. package/components/staging-banner.tsx +11 -0
  54. package/components/topic-steering-workspace.tsx +1 -59
  55. package/components/use-blog-header-obstacles.ts +81 -0
  56. package/components/use-brand-fonts-ready.ts +28 -0
  57. package/components/use-rhythm-overlay.ts +29 -0
  58. package/infra/amplify-app-shell.js +285 -80
  59. package/infra/amplify-app-shell.ts +326 -113
  60. package/infra/build-specs.js +94 -0
  61. package/infra/build-specs.ts +141 -0
  62. package/infra/github-oidc-provider.js +24 -0
  63. package/infra/github-oidc-provider.ts +30 -0
  64. package/infra/index.js +3 -0
  65. package/infra/site-config.js +273 -0
  66. package/infra/site-config.ts +336 -0
  67. package/lib/blog-feature-solver.ts +459 -0
  68. package/lib/blog-rhythm.ts +134 -0
  69. package/lib/brand-fonts-watch.ts +43 -0
  70. package/lib/brand-fonts.ts +112 -0
  71. package/lib/cached-content-repository.ts +14 -1
  72. package/lib/category-repository.ts +1 -93
  73. package/lib/content-actions-client.ts +98 -0
  74. package/lib/content-actions-demo.ts +190 -0
  75. package/lib/content-repository.ts +6 -0
  76. package/lib/content-source-context.ts +26 -0
  77. package/lib/content-types.ts +4 -0
  78. package/lib/edition-routes.ts +31 -29
  79. package/lib/empty-theme.css +1 -0
  80. package/lib/graphql-content-repository.ts +239 -43
  81. package/lib/layout-scenarios.ts +8 -8
  82. package/lib/newspaper-layout.ts +3 -2
  83. package/lib/newsroom-articles.ts +325 -0
  84. package/lib/newsroom-demo-articles.ts +90 -0
  85. package/lib/newsroom-media-backend.ts +142 -0
  86. package/lib/newsroom-media.ts +182 -0
  87. package/lib/newsroom-nav.ts +3 -0
  88. package/lib/papyrus-web-locations.ts +7 -6
  89. package/lib/presentation-footer.ts +3 -2
  90. package/lib/pretext-layout.ts +12 -3
  91. package/lib/reader-base-path.ts +24 -0
  92. package/lib/reader-static-params.ts +8 -0
  93. package/lib/renderer.ts +7 -0
  94. package/lib/revalidate-secret.ts +59 -0
  95. package/lib/root-route-redirect.ts +16 -0
  96. package/lib/site-brand-tenant.ts +3 -22
  97. package/lib/site-brand.ts +95 -42
  98. package/lib/site-env.ts +52 -0
  99. package/lib/staging-gate.ts +37 -0
  100. package/lib/staging-gated-path.ts +22 -0
  101. package/lib/staging-preview-object.ts +98 -0
  102. package/lib/video-mode.ts +42 -0
  103. package/lib/video-script.ts +77 -0
  104. package/middleware.ts +56 -42
  105. package/package.json +40 -1
  106. package/renderers/pretext/article-page.tsx +40 -9
  107. package/renderers/pretext/presentation-shared.tsx +113 -0
  108. package/renderers/pretext/presentation-shell.tsx +18 -112
  109. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  110. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  111. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  112. package/routes.manifest.json +37 -2
  113. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  114. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  115. package/scripts/videoml/bundle-common.mjs +129 -0
  116. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  117. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  118. package/scripts/videoml/preview/preview-timing.ts +42 -0
  119. package/scripts/videoml/preview.html +77 -0
  120. package/scripts/videoml/standard-entry.tsx +46 -0
  121. package/src/sync.mjs +30 -6
  122. package/src/with-papyrus.mjs +15 -2
  123. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  124. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  125. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
  126. package/publications/anth_us/brand.ts +0 -68
  127. package/publications/anth_us/theme.css +0 -66
  128. package/publications/pilobol_us/brand.ts +0 -37
  129. package/publications/pilobol_us/theme.css +0 -140
  130. package/publications/threat_intelligence/blog-defense/graph.ts +0 -636
  131. package/publications/threat_intelligence/blog-defense/layout.ts +0 -934
  132. package/publications/threat_intelligence/blog-defense/page-background.tsx +0 -590
  133. package/publications/threat_intelligence/brand.ts +0 -40
  134. package/publications/threat_intelligence/pictograms/art.tsx +0 -608
  135. package/publications/threat_intelligence/pictograms/figure.tsx +0 -82
  136. package/publications/threat_intelligence/pictograms/registry.ts +0 -30
  137. package/publications/threat_intelligence/pictograms/system.tsx +0 -305
  138. package/publications/threat_intelligence/seed/seed-edition-content.json +0 -629
  139. package/publications/threat_intelligence/theme.css +0 -1440
  140. /package/{publications/papyrus/theme.css → app/papyrus-theme.css} +0 -0
@@ -0,0 +1,459 @@
1
+ import type { ArticleImageAsset } from "./articles";
2
+ import type { PublicationItem } from "./publication-items";
3
+ import {
4
+ createDefaultBlogVerticalRhythm,
5
+ getMeasuredTextHeight,
6
+ reserveRhythmRows,
7
+ snapDownToRhythm,
8
+ snapPreferredHeightToRhythm,
9
+ snapPreservedImageHeightToRhythm,
10
+ type BlogTextStyle,
11
+ type VerticalRhythm,
12
+ } from "./blog-rhythm";
13
+ import { layoutAllTextLines, layoutTextLines, prepareWithSegments, type TextLine, type TextObstacle } from "./pretext-layout";
14
+
15
+ export type FeaturedLayoutMode = "obstacle" | "float" | "stacked";
16
+
17
+ export type SolvedFeaturedItem = {
18
+ mode: FeaturedLayoutMode;
19
+ textLines: TextLine[];
20
+ imageHeight: number;
21
+ imageWidth: number;
22
+ /** Image frame + caption/credit block reserved below the frame. */
23
+ mediaHeight: number;
24
+ captionHeight: number;
25
+ copyWidth: number;
26
+ textFrameHeight: number;
27
+ hasMore: boolean;
28
+ gap: number;
29
+ };
30
+
31
+ export type SolvedFeaturedFloatGeometry = {
32
+ mode: "float";
33
+ imageHeight: number;
34
+ imageWidth: number;
35
+ mediaHeight: number;
36
+ captionHeight: number;
37
+ copyWidth: number;
38
+ gap: number;
39
+ };
40
+
41
+ export type SolveFeaturedItemInput = {
42
+ text: string;
43
+ containerWidth: number;
44
+ viewportWidth: number;
45
+ rhythm?: VerticalRhythm;
46
+ textStyle: BlogTextStyle;
47
+ imageAsset: ArticleImageAsset;
48
+ itemIndex?: number;
49
+ };
50
+
51
+ export type SolveFeaturedFloatGeometryInput = {
52
+ containerWidth: number;
53
+ viewportWidth: number;
54
+ rhythm?: VerticalRhythm;
55
+ imageAsset: Pick<ArticleImageAsset, "layout" | "caption" | "credit">;
56
+ itemIndex?: number;
57
+ /** Fraction of container width for the image. Defaults to the edition-index teaser ratio. */
58
+ imageWidthRatio?: number;
59
+ /** Fraction of viewport width used as a hard image-width cap. Defaults to 1/3. */
60
+ viewportImageCapRatio?: number;
61
+ /** Absolute float width cap in rhythm rows. Stops percentage sizing from outgrowing the rail. */
62
+ maxWidthRows?: number;
63
+ };
64
+
65
+ const FLOAT_IMAGE_WIDTH_RATIO = 0.32;
66
+ /** Article pages use a larger float so the pictogram reads as a feature, not a teaser. */
67
+ const ARTICLE_FLOAT_IMAGE_WIDTH_RATIO = 0.48;
68
+ const ARTICLE_VIEWPORT_IMAGE_CAP_RATIO = 0.5;
69
+ /** Edition-index floats track the pictogram maxHeight band (~440px at 16px rows). */
70
+ const FLOAT_IMAGE_MAX_WIDTH_ROWS = 27;
71
+ /** Article floats read larger than teasers but must not keep growing on wide rails. */
72
+ const ARTICLE_FLOAT_IMAGE_MAX_WIDTH_ROWS = 24;
73
+ const FLOAT_IMAGE_MIN_PHONE_ROWS = 6;
74
+ const FLOAT_IMAGE_MIN_DESKTOP_ROWS = 8;
75
+ const FLOAT_COPY_MIN_PHONE_ROWS = 11;
76
+ const FLOAT_COPY_MIN_DESKTOP_ROWS = 14;
77
+ const LEAD_GAP_ROWS = 2;
78
+ const LEAD_GAP_ROWS_NARROW = 1;
79
+ const DEFAULT_GAP_ROWS = 4;
80
+
81
+ export function getFeaturedLayoutMode(viewportWidth: number, hasImage: boolean): FeaturedLayoutMode {
82
+ if (!hasImage) return "stacked";
83
+ return "float";
84
+ }
85
+
86
+ export function solveFeaturedItem(input: SolveFeaturedItemInput): SolvedFeaturedItem {
87
+ const rhythm = input.rhythm ?? createDefaultBlogVerticalRhythm();
88
+ const gap = getFeaturedLayoutGap(input.viewportWidth, input.itemIndex, rhythm);
89
+ const mode = getFeaturedLayoutMode(input.viewportWidth, true);
90
+ const prepared = prepareWithSegments(input.text, `${input.textStyle.fontSize}px ${input.textStyle.fontFamily}`, {
91
+ whiteSpace: "pre-wrap",
92
+ });
93
+ const aspectRatio = getImageAspectRatio(input.imageAsset);
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
+ if (mode === "float") {
109
+ return solveFloatFeaturedItem({
110
+ prepared,
111
+ containerWidth: input.containerWidth,
112
+ viewportWidth: input.viewportWidth,
113
+ rhythm,
114
+ textStyle: input.textStyle,
115
+ aspectRatio,
116
+ gap,
117
+ imageAsset: input.imageAsset,
118
+ });
119
+ }
120
+
121
+ const lines = layoutAllTextLines({
122
+ prepared,
123
+ maxWidth: input.containerWidth,
124
+ ...input.textStyle,
125
+ });
126
+ const imageWidth = snapDownToRhythm(Math.round(input.containerWidth), rhythm);
127
+ const imageHeight = snapImageHeight(imageWidth / aspectRatio, rhythm, input.imageAsset.layout);
128
+ const captionHeight = getFeatureCaptionHeight(input.imageAsset, imageWidth, rhythm);
129
+ return {
130
+ mode,
131
+ textLines: lines,
132
+ imageHeight,
133
+ imageWidth,
134
+ captionHeight,
135
+ mediaHeight: imageHeight + captionHeight,
136
+ copyWidth: input.containerWidth,
137
+ textFrameHeight: reserveRhythmRows(getMeasuredTextHeight(lines), rhythm),
138
+ hasMore: false,
139
+ gap,
140
+ };
141
+ }
142
+
143
+ type PreparedSolveInput = {
144
+ prepared: ReturnType<typeof prepareWithSegments>;
145
+ containerWidth: number;
146
+ viewportWidth: number;
147
+ rhythm: VerticalRhythm;
148
+ textStyle: BlogTextStyle;
149
+ aspectRatio: number;
150
+ gap: number;
151
+ imageAsset: ArticleImageAsset;
152
+ };
153
+
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
+ function solveFloatFeaturedItem(input: PreparedSolveInput): SolvedFeaturedItem {
245
+ const geometry = computeFloatGeometry({
246
+ containerWidth: input.containerWidth,
247
+ viewportWidth: input.viewportWidth,
248
+ rhythm: input.rhythm,
249
+ gap: input.gap,
250
+ aspectRatio: input.aspectRatio,
251
+ imageAsset: input.imageAsset,
252
+ });
253
+ // Full-width frame with the image+caption as a top-right obstacle so excerpt
254
+ // lines shorten beside the media block and resume full width below it.
255
+ const frameWidth = input.containerWidth;
256
+ const obstacle: TextObstacle = {
257
+ x: Math.max(0, frameWidth - geometry.imageWidth - geometry.gap),
258
+ y: 0,
259
+ width: geometry.imageWidth + geometry.gap,
260
+ height: geometry.mediaHeight,
261
+ };
262
+ const lines = layoutAllTextLines({
263
+ prepared: input.prepared,
264
+ maxWidth: frameWidth,
265
+ lineHeight: input.textStyle.lineHeight,
266
+ linePaintHeight: input.textStyle.linePaintHeight,
267
+ fontSize: input.textStyle.fontSize,
268
+ fontFamily: input.textStyle.fontFamily,
269
+ obstacles: [obstacle],
270
+ });
271
+
272
+ return {
273
+ mode: "float",
274
+ textLines: lines,
275
+ imageHeight: geometry.imageHeight,
276
+ imageWidth: geometry.imageWidth,
277
+ captionHeight: geometry.captionHeight,
278
+ mediaHeight: geometry.mediaHeight,
279
+ copyWidth: frameWidth,
280
+ textFrameHeight: reserveRhythmRows(getMeasuredTextHeight(lines), input.rhythm),
281
+ hasMore: false,
282
+ gap: geometry.gap,
283
+ };
284
+ }
285
+
286
+ /**
287
+ * Geometry-only float solve for article pages (and any consumer that renders
288
+ * flowing body copy rather than Pretext-measured excerpt lines). Shares the
289
+ * same width/height/gap math as the edition-index lead float.
290
+ */
291
+ export function solveFeaturedFloatGeometry(input: SolveFeaturedFloatGeometryInput): SolvedFeaturedFloatGeometry {
292
+ const rhythm = input.rhythm ?? createDefaultBlogVerticalRhythm();
293
+ const gap = getFeaturedLayoutGap(input.viewportWidth, input.itemIndex, rhythm);
294
+ return {
295
+ mode: "float",
296
+ ...computeFloatGeometry({
297
+ containerWidth: input.containerWidth,
298
+ viewportWidth: input.viewportWidth,
299
+ rhythm,
300
+ gap,
301
+ aspectRatio: getImageAspectRatio(input.imageAsset),
302
+ imageAsset: input.imageAsset,
303
+ // Article pages default larger than edition teasers; callers can override.
304
+ imageWidthRatio: input.imageWidthRatio ?? ARTICLE_FLOAT_IMAGE_WIDTH_RATIO,
305
+ viewportImageCapRatio: input.viewportImageCapRatio ?? ARTICLE_VIEWPORT_IMAGE_CAP_RATIO,
306
+ maxWidthRows: input.maxWidthRows ?? ARTICLE_FLOAT_IMAGE_MAX_WIDTH_ROWS,
307
+ }),
308
+ };
309
+ }
310
+
311
+ type FloatGeometryInput = {
312
+ containerWidth: number;
313
+ viewportWidth: number;
314
+ rhythm: VerticalRhythm;
315
+ gap: number;
316
+ aspectRatio: number;
317
+ imageAsset: Pick<ArticleImageAsset, "caption" | "credit" | "layout">;
318
+ imageWidthRatio?: number;
319
+ viewportImageCapRatio?: number;
320
+ maxWidthRows?: number;
321
+ };
322
+
323
+ function computeFloatGeometry(input: FloatGeometryInput): Omit<SolvedFeaturedFloatGeometry, "mode"> {
324
+ let imageWidth = getFloatImageWidth(input);
325
+ // Keep the frame proportional when layout.maxHeight would otherwise clip a
326
+ // wide float down to the minimum height (snapPreferredHeightToRhythm falls
327
+ // back to min when natural height exceeds max).
328
+ const maxHeight = input.imageAsset.layout?.maxHeight ?? input.rhythm.rowHeight * 36;
329
+ const widthFromMaxHeight = snapDownToRhythm(maxHeight * input.aspectRatio, input.rhythm);
330
+ if (widthFromMaxHeight > 0) {
331
+ imageWidth = Math.min(imageWidth, widthFromMaxHeight);
332
+ }
333
+ const absoluteMaxWidth = rhythmLengthRows(input.maxWidthRows ?? FLOAT_IMAGE_MAX_WIDTH_ROWS, input.rhythm);
334
+ imageWidth = Math.min(imageWidth, absoluteMaxWidth);
335
+ const copyWidth = Math.max(
336
+ getFloatCopyMinimum(input.viewportWidth, input.rhythm),
337
+ input.containerWidth - imageWidth - input.gap,
338
+ );
339
+ const imageHeight = snapImageHeight(imageWidth / input.aspectRatio, input.rhythm, input.imageAsset.layout);
340
+ const captionHeight = getFeatureCaptionHeight(input.imageAsset, imageWidth, input.rhythm);
341
+ return {
342
+ imageHeight,
343
+ imageWidth,
344
+ captionHeight,
345
+ mediaHeight: imageHeight + captionHeight,
346
+ copyWidth,
347
+ gap: input.gap,
348
+ };
349
+ }
350
+
351
+ /** Matches the blog figcaption: meta size, one-row line box, rhythm-unit margin-top. */
352
+ const FEATURE_CAPTION_FONT_SIZE = 12;
353
+
354
+ function getFeatureCaptionHeight(
355
+ asset: Pick<ArticleImageAsset, "caption" | "credit">,
356
+ imageWidth: number,
357
+ rhythm: VerticalRhythm,
358
+ ): number {
359
+ const caption = (asset.caption ?? asset.credit ?? "").trim();
360
+ if (!caption) return 0;
361
+ const fontFamily = 'Georgia, "Times New Roman", serif';
362
+ const prepared = prepareWithSegments(caption, `${FEATURE_CAPTION_FONT_SIZE}px ${fontFamily}`, {
363
+ whiteSpace: "pre-wrap",
364
+ });
365
+ const lines = layoutAllTextLines({
366
+ prepared,
367
+ maxWidth: Math.max(1, imageWidth),
368
+ lineHeight: rhythm.rowHeight,
369
+ linePaintHeight: rhythm.rowHeight,
370
+ fontSize: FEATURE_CAPTION_FONT_SIZE,
371
+ fontFamily,
372
+ });
373
+ const textHeight = Math.max(rhythm.rowHeight, getMeasuredTextHeight(lines));
374
+ // Brand CSS: figcaption margin-top is one rhythm unit, a quarter of a row.
375
+ const marginTop = rhythm.rowHeight / 4;
376
+ return reserveRhythmRows(marginTop + textHeight, rhythm);
377
+ }
378
+
379
+ function getFeaturedLayoutGap(viewportWidth: number, itemIndex: number | undefined, rhythm: VerticalRhythm): number {
380
+ if (itemIndex === 0) {
381
+ return rhythmLengthRows(viewportWidth <= 600 ? LEAD_GAP_ROWS_NARROW : LEAD_GAP_ROWS, rhythm);
382
+ }
383
+ return rhythmLengthRows(DEFAULT_GAP_ROWS, rhythm);
384
+ }
385
+
386
+ function getFloatImageWidth(
387
+ input: Pick<
388
+ FloatGeometryInput,
389
+ "containerWidth" | "viewportWidth" | "rhythm" | "gap" | "imageWidthRatio" | "viewportImageCapRatio" | "maxWidthRows"
390
+ >,
391
+ ): number {
392
+ const imageWidthRatio = input.imageWidthRatio ?? FLOAT_IMAGE_WIDTH_RATIO;
393
+ const viewportImageCapRatio = input.viewportImageCapRatio ?? (1 / 3);
394
+ const preferred = snapDownToRhythm(Math.round(input.containerWidth * imageWidthRatio), input.rhythm);
395
+ const viewportCap = snapDownToRhythm(Math.floor(input.viewportWidth * viewportImageCapRatio), input.rhythm);
396
+ const copyCap = snapDownToRhythm(
397
+ Math.max(0, input.containerWidth - input.gap - getFloatCopyMinimum(input.viewportWidth, input.rhythm)),
398
+ input.rhythm,
399
+ );
400
+ const absoluteMaxWidth = rhythmLengthRows(input.maxWidthRows ?? FLOAT_IMAGE_MAX_WIDTH_ROWS, input.rhythm);
401
+ const softMinimum = getFloatImageMinimum(input.viewportWidth, input.rhythm);
402
+ const hardMinimum = rhythmLengthRows(5, input.rhythm);
403
+ const hardMaximum = Math.max(
404
+ hardMinimum,
405
+ Math.min(absoluteMaxWidth, viewportCap, copyCap > 0 ? copyCap : viewportCap),
406
+ );
407
+
408
+ if (hardMaximum <= softMinimum) return hardMaximum;
409
+ return clamp(Math.min(preferred, hardMaximum), softMinimum, hardMaximum);
410
+ }
411
+
412
+ function getFloatImageMinimum(viewportWidth: number, rhythm: VerticalRhythm): number {
413
+ return rhythmLengthRows(viewportWidth <= 600 ? FLOAT_IMAGE_MIN_PHONE_ROWS : FLOAT_IMAGE_MIN_DESKTOP_ROWS, rhythm);
414
+ }
415
+
416
+ function getFloatCopyMinimum(viewportWidth: number, rhythm: VerticalRhythm): number {
417
+ return rhythmLengthRows(viewportWidth <= 600 ? FLOAT_COPY_MIN_PHONE_ROWS : FLOAT_COPY_MIN_DESKTOP_ROWS, rhythm);
418
+ }
419
+
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
+ function snapImageHeight(
426
+ naturalHeight: number,
427
+ rhythm: VerticalRhythm,
428
+ layout?: ArticleImageAsset["layout"],
429
+ ): number {
430
+ const maxHeight = layout?.maxHeight ?? rhythm.rowHeight * 36;
431
+ // Cover may grow the frame to layout.minHeight. Contain/preserve must not —
432
+ // on narrow viewports the chosen width implies a short natural height, and
433
+ // stretching to minHeight leaves an empty outlined box around the pictogram.
434
+ if (layout?.crop === "cover") {
435
+ const minHeight = layout.minHeight ?? rhythm.rowHeight * 6;
436
+ const clampedHeight = clamp(naturalHeight, minHeight, maxHeight);
437
+ return snapPreferredHeightToRhythm(clampedHeight, rhythm, minHeight, maxHeight);
438
+ }
439
+ const hardMinimum = rhythm.rowHeight;
440
+ const capped = clamp(naturalHeight, hardMinimum, maxHeight);
441
+ return snapPreservedImageHeightToRhythm(capped, rhythm, hardMinimum, maxHeight);
442
+ }
443
+
444
+ function getImageAspectRatio(asset: Pick<ArticleImageAsset, "layout">): number {
445
+ if (asset.layout?.aspectRatio) return asset.layout.aspectRatio;
446
+ return 1;
447
+ }
448
+
449
+ function rhythmLengthRows(rows: number, rhythm: VerticalRhythm): number {
450
+ return rows * rhythm.rowHeight;
451
+ }
452
+
453
+ function clamp(value: number, min: number, max: number): number {
454
+ return Math.max(min, Math.min(max, value));
455
+ }
456
+
457
+ export function isFeaturedBlogItem(item: PublicationItem, index?: number, mode?: string): boolean {
458
+ return mode === "blog" && index === 0 && Boolean(item.type === "article" || item.type === "brief");
459
+ }
@@ -0,0 +1,134 @@
1
+ import type { TextLine } from "./pretext-layout";
2
+
3
+ export type VerticalRhythm = {
4
+ rowHeight: number;
5
+ paintHeight: number;
6
+ paintBuffer: number;
7
+ };
8
+
9
+ export type BlogTextStyle = {
10
+ fontSize: number;
11
+ lineHeight: number;
12
+ linePaintHeight: number;
13
+ fontFamily: string;
14
+ };
15
+
16
+ export const BLOG_RHYTHM_UNIT = 4;
17
+ export const BLOG_COPY_ROW_MULTIPLE = 4;
18
+ export const BLOG_PAINT_BUFFER_PX = 2;
19
+ /* Blog body and excerpt copy must never fall below the section
20
+ subheading scale. At the default 16px rhythm row, that means an 18px font
21
+ set on a two-row (32px) line box so multi-line copy always keeps one blank
22
+ rhythm row between lines. */
23
+ export const BLOG_FONT_SIZE_ROW_RATIO = 18 / 16;
24
+ export const BLOG_LINE_HEIGHT_ROWS = 2;
25
+
26
+ export function createVerticalRhythm(rowHeight: number, paintBuffer = BLOG_PAINT_BUFFER_PX): VerticalRhythm {
27
+ const paintHeight = rowHeight + paintBuffer;
28
+ return {
29
+ rowHeight,
30
+ paintHeight,
31
+ paintBuffer: paintHeight - rowHeight,
32
+ };
33
+ }
34
+
35
+ export function createDefaultBlogVerticalRhythm(): VerticalRhythm {
36
+ return createVerticalRhythm(BLOG_RHYTHM_UNIT * BLOG_COPY_ROW_MULTIPLE, BLOG_PAINT_BUFFER_PX);
37
+ }
38
+
39
+ export function createDefaultBlogTextStyle(fontFamily: string): BlogTextStyle {
40
+ const rhythm = createDefaultBlogVerticalRhythm();
41
+ const lineHeight = rhythm.rowHeight * BLOG_LINE_HEIGHT_ROWS;
42
+ return {
43
+ fontSize: rhythm.rowHeight * BLOG_FONT_SIZE_ROW_RATIO,
44
+ lineHeight,
45
+ /* The measured blog excerpt renderer clips each line to linePaintHeight.
46
+ Once copy was raised to the 18px / 32px minimum policy, the old
47
+ one-row paint box (18px) started chopping off the bottom of the glyphs.
48
+ Use the full two-row line box for paint so measured lines match the
49
+ visible CSS line box and never clip descenders or lower halves. */
50
+ linePaintHeight: lineHeight,
51
+ fontFamily,
52
+ };
53
+ }
54
+
55
+ export function snapUpToRhythm(value: number, rhythm: VerticalRhythm): number {
56
+ if (value <= 0) return 0;
57
+ return Math.ceil(value / rhythm.rowHeight) * rhythm.rowHeight;
58
+ }
59
+
60
+ export function snapDownToRhythm(value: number, rhythm: VerticalRhythm): number {
61
+ if (value <= 0) return 0;
62
+ return Math.floor(value / rhythm.rowHeight) * rhythm.rowHeight;
63
+ }
64
+
65
+ export function snapToNearestRhythm(value: number, rhythm: VerticalRhythm): number {
66
+ if (value <= 0) return 0;
67
+ return Math.round(value / rhythm.rowHeight) * rhythm.rowHeight;
68
+ }
69
+
70
+ export function reserveRhythmRows(value: number, rhythm: VerticalRhythm): number {
71
+ return snapUpToRhythm(value, rhythm);
72
+ }
73
+
74
+ export function clampRhythmHeight(value: number, minimum: number, maximum: number, rhythm: VerticalRhythm): number {
75
+ const snappedMinimum = reserveRhythmRows(minimum, rhythm);
76
+ const snappedMaximum = Math.max(snappedMinimum, snapDownToRhythm(maximum, rhythm));
77
+ return clamp(reserveRhythmRows(value, rhythm), snappedMinimum, snappedMaximum);
78
+ }
79
+
80
+ export function snapPreferredHeightToRhythm(
81
+ value: number,
82
+ rhythm: VerticalRhythm,
83
+ minimum = rhythm.rowHeight,
84
+ maximum = Number.POSITIVE_INFINITY,
85
+ ): number {
86
+ const snappedMinimum = reserveRhythmRows(minimum, rhythm);
87
+ const snappedMaximum = Number.isFinite(maximum)
88
+ ? Math.max(snappedMinimum, snapDownToRhythm(maximum, rhythm))
89
+ : Number.POSITIVE_INFINITY;
90
+ const down = snapDownToRhythm(value, rhythm);
91
+ const up = reserveRhythmRows(value, rhythm);
92
+ const candidates = [down, up].filter((candidate) => candidate >= snappedMinimum && candidate <= snappedMaximum);
93
+ if (candidates.length === 0) return snappedMinimum;
94
+ return candidates.sort((left, right) => Math.abs(left - value) - Math.abs(right - value) || left - right)[0];
95
+ }
96
+
97
+ export function snapPreservedImageHeightToRhythm(
98
+ value: number,
99
+ rhythm: VerticalRhythm,
100
+ minimum = rhythm.rowHeight,
101
+ maximum = Number.POSITIVE_INFINITY,
102
+ ): number {
103
+ const snappedMinimum = reserveRhythmRows(minimum, rhythm);
104
+ const snappedMaximum = Number.isFinite(maximum)
105
+ ? Math.max(snappedMinimum, snapDownToRhythm(maximum, rhythm))
106
+ : Number.POSITIVE_INFINITY;
107
+ return clamp(reserveRhythmRows(value, rhythm), snappedMinimum, snappedMaximum);
108
+ }
109
+
110
+ export function getLineStackHeight(lineCount: number, lineHeight: number, paintHeight: number): number {
111
+ if (lineCount <= 0) return 0;
112
+ return Math.ceil((lineCount - 1) * lineHeight + paintHeight);
113
+ }
114
+
115
+ export function getMeasuredTextHeight(lines: TextLine[]): number {
116
+ const last = lines[lines.length - 1];
117
+ return last ? last.y + last.paintHeight : 0;
118
+ }
119
+
120
+ export function rhythmRows(value: number, rhythm: VerticalRhythm): number {
121
+ return Math.max(0, Math.round(value / rhythm.rowHeight));
122
+ }
123
+
124
+ export function rhythmLength(rows: number, rhythm: VerticalRhythm): number {
125
+ return rows * rhythm.rowHeight;
126
+ }
127
+
128
+ export function snapRhythmCoordinate(value: number, rhythm: VerticalRhythm): number {
129
+ return snapToNearestRhythm(value, rhythm);
130
+ }
131
+
132
+ function clamp(value: number, min: number, max: number): number {
133
+ return Math.max(min, Math.min(max, value));
134
+ }
@@ -0,0 +1,43 @@
1
+ export type FontFaceSetLike = {
2
+ load(font: string): Promise<unknown>;
3
+ ready: Promise<unknown>;
4
+ addEventListener(type: string, listener: () => void): void;
5
+ removeEventListener(type: string, listener: () => void): void;
6
+ };
7
+
8
+ export type WatchBrandFontsOptions = {
9
+ fontFaceSet: FontFaceSetLike | undefined;
10
+ families: string[];
11
+ clearMeasurementCaches: () => void;
12
+ onFontsSettled: () => void;
13
+ };
14
+
15
+ /**
16
+ * Clears Pretext measurement caches and then notifies the caller every time
17
+ * the declared families finish loading, so layout never reuses widths that
18
+ * were measured with fallback metrics. Clearing always happens before the
19
+ * notification, which makes the re-layout order deterministic.
20
+ */
21
+ export function watchBrandFontsLoaded({
22
+ fontFaceSet,
23
+ families,
24
+ clearMeasurementCaches,
25
+ onFontsSettled,
26
+ }: WatchBrandFontsOptions): () => void {
27
+ if (families.length === 0 || !fontFaceSet) return () => undefined;
28
+ let isCancelled = false;
29
+ const settle = () => {
30
+ if (isCancelled) return;
31
+ clearMeasurementCaches();
32
+ onFontsSettled();
33
+ };
34
+ fontFaceSet.addEventListener("loadingdone", settle);
35
+ void Promise.all(families.map((family) => fontFaceSet.load(`16px ${JSON.stringify(family)}`)))
36
+ .then(() => fontFaceSet.ready)
37
+ .then(settle)
38
+ .catch(() => undefined);
39
+ return () => {
40
+ isCancelled = true;
41
+ fontFaceSet.removeEventListener("loadingdone", settle);
42
+ };
43
+ }