@anthusai/papyrus 1.0.0-next.18 → 1.0.0-next.19

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.
@@ -52,10 +52,12 @@ export default async function DateScopedArticlePage({ params }: DateScopedArticl
52
52
  editionFooter={{
53
53
  editionBasePath,
54
54
  entries: buildPresentationFooterEntries(content),
55
+ sections: content.sections.map((section) => ({ key: section.key, label: section.label })),
55
56
  subtitle: SITE_BRAND.footerSubtitleOverride ?? (content.description?.trim() || "Inside Papyrus"),
56
57
  title: SITE_BRAND.footerTitle,
57
58
  }}
58
59
  item={item}
60
+ videoScript={content.videoScripts?.[item.slug] ?? null}
59
61
  backHref={`${editionBasePath}#${item.slug}`}
60
62
  />
61
63
  );
@@ -0,0 +1,278 @@
1
+ /*
2
+ Structural CSS for the rhythm blog layout (blogLayout: "rhythm").
3
+ Every rule is scoped to .blog-rhythm-shell, which only the rhythm layout
4
+ renders, so classic layouts are unaffected. The shell sets --blog-rhythm,
5
+ --blog-row-height, --blog-paint-buffer and --blog-paint-height inline.
6
+ Typography, colors and the header look belong to the publication's theme.css.
7
+ Optional tokens a publication may set:
8
+ --blog-hrule-foreground, --blog-section-rule, --blog-section-band-height,
9
+ --blog-page-padding-x, --blog-secondary-pair-min-width.
10
+ */
11
+
12
+ .blog-rhythm-shell {
13
+ --blog-hrule-foreground: var(--border);
14
+ --blog-section-rule: var(--border);
15
+ --blog-section-band-height: calc(var(--blog-row-height) * 2);
16
+ --blog-page-padding-x: calc(var(--blog-rhythm) * 3);
17
+ --paper-rhythm: var(--blog-row-height);
18
+
19
+ isolation: isolate;
20
+ position: relative;
21
+ }
22
+
23
+ .blog-rhythm-shell.presentation-page--blog {
24
+ padding: calc(var(--blog-row-height) * 4) var(--blog-page-padding-x) 0;
25
+ }
26
+
27
+ .blog-rhythm-shell > :not(.blog-page-background) {
28
+ position: relative;
29
+ z-index: 1;
30
+ }
31
+
32
+ .blog-rhythm-shell[data-rhythm-overlay="true"]::before {
33
+ background-image:
34
+ repeating-linear-gradient(
35
+ to bottom,
36
+ rgb(var(--rhythm-overlay-rule-rgb) / var(--rhythm-overlay-rule-alpha)) 0,
37
+ rgb(var(--rhythm-overlay-rule-rgb) / var(--rhythm-overlay-rule-alpha)) 1px,
38
+ transparent 1px,
39
+ transparent var(--blog-row-height)
40
+ ),
41
+ repeating-linear-gradient(
42
+ to bottom,
43
+ rgb(var(--rhythm-overlay-blue-rgb) / var(--rhythm-overlay-blue-alpha)) 0,
44
+ rgb(var(--rhythm-overlay-blue-rgb) / var(--rhythm-overlay-blue-alpha)) var(--blog-row-height),
45
+ rgb(var(--rhythm-overlay-yellow-rgb) / var(--rhythm-overlay-yellow-alpha)) var(--blog-row-height),
46
+ rgb(var(--rhythm-overlay-yellow-rgb) / var(--rhythm-overlay-yellow-alpha)) calc(var(--blog-row-height) * 2)
47
+ );
48
+ content: "";
49
+ inset: 0;
50
+ pointer-events: none;
51
+ position: absolute;
52
+ z-index: 900;
53
+ }
54
+
55
+ .blog-rhythm-shell .presentation-header {
56
+ max-width: 100%;
57
+ min-width: 0;
58
+ width: 100%;
59
+ }
60
+
61
+ .blog-rhythm-shell .presentation-section-header,
62
+ .blog-rhythm-shell .presentation-item,
63
+ .blog-rhythm-shell .presentation-item__copy {
64
+ max-width: 100%;
65
+ min-width: 0;
66
+ }
67
+
68
+ .blog-rhythm-shell .presentation-section-header {
69
+ align-items: stretch;
70
+ border-bottom: 0;
71
+ border-top: 0;
72
+ display: flex;
73
+ flex-direction: column;
74
+ gap: var(--blog-row-height);
75
+ margin: 0;
76
+ min-height: 0;
77
+ padding: calc(var(--blog-row-height) * 4) 0 calc(var(--blog-row-height) * 2);
78
+ width: 100%;
79
+ }
80
+
81
+ .blog-rhythm-shell .presentation-section-header__band {
82
+ align-items: center;
83
+ display: grid;
84
+ gap: 0 var(--blog-rhythm);
85
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
86
+ grid-template-rows: var(--blog-section-band-height);
87
+ width: 100%;
88
+ }
89
+
90
+ .blog-rhythm-shell .presentation-section-header__band::before,
91
+ .blog-rhythm-shell .presentation-section-header__band::after {
92
+ background: var(--blog-section-rule);
93
+ content: "";
94
+ grid-row: 1;
95
+ height: var(--blog-section-band-height);
96
+ }
97
+
98
+ .blog-rhythm-shell .presentation-section-header__band::before {
99
+ grid-column: 1;
100
+ }
101
+
102
+ .blog-rhythm-shell .presentation-section-header__band::after {
103
+ grid-column: 3;
104
+ }
105
+
106
+ .blog-rhythm-shell .presentation-section-header__band p {
107
+ grid-column: 2;
108
+ grid-row: 1;
109
+ height: var(--blog-section-band-height);
110
+ justify-self: center;
111
+ line-height: var(--blog-section-band-height);
112
+ margin: 0;
113
+ padding: 0 var(--blog-rhythm);
114
+ width: max-content;
115
+ }
116
+
117
+ .blog-rhythm-shell .presentation-item {
118
+ border-bottom: 0;
119
+ gap: var(--blog-row-height) calc(var(--blog-rhythm) * 4);
120
+ padding-bottom: 0;
121
+ }
122
+
123
+ .blog-rhythm-shell .presentation-item--blog {
124
+ grid-template-columns: minmax(0, 1fr);
125
+ }
126
+
127
+ .blog-rhythm-shell .presentation-rhythm-hrule {
128
+ display: grid;
129
+ grid-column: 1 / -1;
130
+ grid-template-rows: repeat(5, var(--blog-row-height));
131
+ height: calc(var(--blog-row-height) * 5);
132
+ width: 100%;
133
+ }
134
+
135
+ .blog-rhythm-shell .presentation-rhythm-hrule__line {
136
+ background: var(--blog-hrule-foreground);
137
+ grid-row: 3;
138
+ height: var(--blog-row-height);
139
+ }
140
+
141
+ @media (min-width: 1100px) {
142
+ .blog-rhythm-shell .blog-section:has(> .presentation-item[data-item-index="2"]) {
143
+ column-gap: calc(var(--blog-row-height) * 2);
144
+ grid-template-columns: repeat(2, minmax(0, 1fr));
145
+ }
146
+
147
+ .blog-rhythm-shell .blog-section:has(> .presentation-item[data-item-index="2"]) > .presentation-section-header,
148
+ .blog-rhythm-shell .blog-section:has(> .presentation-item[data-item-index="2"]) > .presentation-item[data-item-index="0"] {
149
+ grid-column: 1 / -1;
150
+ }
151
+
152
+ .blog-rhythm-shell .blog-section:has(> .presentation-item[data-item-index="2"]) > .presentation-rhythm-hrule[data-hide-in-secondary-pair="true"] {
153
+ display: none;
154
+ }
155
+ }
156
+
157
+ .blog-rhythm-shell .presentation-item[data-feature-layout][data-has-image="true"] {
158
+ display: flex;
159
+ flex-direction: column;
160
+ gap: 0;
161
+ grid-template-columns: minmax(0, 1fr);
162
+ }
163
+
164
+ .blog-rhythm-shell .presentation-item[data-feature-layout] .presentation-item__text-frame {
165
+ height: var(--feature-text-frame-height);
166
+ max-width: none;
167
+ overflow: hidden;
168
+ position: relative;
169
+ width: 100%;
170
+ }
171
+
172
+ .blog-rhythm-shell .presentation-item[data-feature-layout] .presentation-item__media {
173
+ height: var(--feature-media-height);
174
+ width: var(--feature-image-width);
175
+ }
176
+
177
+ .blog-rhythm-shell .presentation-item[data-feature-layout] .pictogram-figure__frame {
178
+ aspect-ratio: auto;
179
+ height: var(--feature-image-height);
180
+ width: var(--feature-image-width);
181
+ }
182
+
183
+ .blog-rhythm-shell .presentation-item--blog[data-feature-layout][data-has-image="true"] .presentation-item__copy {
184
+ display: contents;
185
+ }
186
+
187
+ .blog-rhythm-shell .presentation-item--blog[data-feature-layout][data-has-image="true"] .presentation-item__header {
188
+ width: 100%;
189
+ }
190
+
191
+ .blog-rhythm-shell .presentation-item--blog[data-feature-layout][data-has-image="true"] .presentation-item__body {
192
+ min-width: 0;
193
+ position: relative;
194
+ width: 100%;
195
+ }
196
+
197
+ .blog-rhythm-shell .presentation-item--blog[data-feature-layout][data-has-image="true"] .presentation-item__body .presentation-item__text-frame {
198
+ margin-top: 0;
199
+ }
200
+
201
+ .blog-rhythm-shell .presentation-item--blog[data-feature-layout][data-has-image="true"] .presentation-item__media {
202
+ margin: 0;
203
+ position: absolute;
204
+ right: 0;
205
+ top: 0;
206
+ z-index: 1;
207
+ }
208
+
209
+ .blog-rhythm-shell .presentation-item__image img {
210
+ height: 100%;
211
+ object-fit: contain;
212
+ width: 100%;
213
+ }
214
+
215
+ .blog-rhythm-shell .presentation-item__video {
216
+ grid-column: 1 / -1;
217
+ margin: var(--blog-row-height) 0 0;
218
+ }
219
+
220
+ .blog-rhythm-shell .article-page__hero-video {
221
+ margin: 0 0 calc(var(--blog-rhythm) * 5);
222
+ }
223
+
224
+ .blog-rhythm-shell .article-page__hero-video .article-video,
225
+ .blog-rhythm-shell .presentation-item__video-figure {
226
+ container-type: inline-size;
227
+ }
228
+
229
+ .blog-rhythm-shell .presentation-item__video-figure .article-video__media,
230
+ .blog-rhythm-shell .article-page__hero-video .article-video__media {
231
+ height: round(up, calc(100cqw * 9 / 16), var(--blog-row-height));
232
+ }
233
+
234
+ .blog-rhythm-shell .article-float-grid[data-feature-layout][data-has-image="true"] {
235
+ display: grid;
236
+ gap: var(--feature-layout-gap);
237
+ grid-template-columns: minmax(0, 1fr) var(--feature-image-width);
238
+ }
239
+
240
+ .blog-rhythm-shell .article-float-grid[data-feature-layout] .article-page__hero-video,
241
+ .blog-rhythm-shell .article-float-grid[data-feature-layout] .article-float-grid__header {
242
+ grid-column: 1 / -1;
243
+ }
244
+
245
+ .blog-rhythm-shell .article-float-grid[data-feature-layout] .article-float-grid__body {
246
+ align-self: start;
247
+ grid-column: 1;
248
+ max-width: var(--feature-copy-width);
249
+ min-width: 0;
250
+ width: var(--feature-copy-width);
251
+ }
252
+
253
+ .blog-rhythm-shell .article-float-grid[data-feature-layout] .article-float-grid__media,
254
+ .blog-rhythm-shell .article-float-grid[data-feature-layout] .pictogram-figure__frame {
255
+ height: var(--feature-image-height);
256
+ width: var(--feature-image-width);
257
+ }
258
+
259
+ .blog-rhythm-shell .article-float-grid[data-feature-layout] .pictogram-figure__frame {
260
+ aspect-ratio: auto;
261
+ }
262
+
263
+ .blog-rhythm-shell .article-float-grid[data-feature-layout] .article-float-grid__media {
264
+ align-self: start;
265
+ grid-column: 2;
266
+ margin: 0;
267
+ position: relative;
268
+ }
269
+
270
+ .blog-rhythm-shell .article-float-grid .presentation-item__image {
271
+ margin: 0;
272
+ }
273
+
274
+ @media (max-width: 900px) {
275
+ .blog-rhythm-shell .presentation-item--blog {
276
+ grid-template-columns: 1fr;
277
+ }
278
+ }
package/app/globals.css CHANGED
@@ -45,6 +45,7 @@
45
45
  --color-surface-foreground: var(--surface-foreground);
46
46
  --color-foreground-strong: var(--foreground-strong);
47
47
  --color-foreground-muted: var(--foreground-muted);
48
+ --color-foreground-subtle: var(--foreground-subtle);
48
49
  }
49
50
 
50
51
  :root {
@@ -71,6 +72,7 @@
71
72
  --surface-foreground: var(--card-foreground);
72
73
  --foreground-strong: var(--slate-12);
73
74
  --foreground-muted: var(--slate-10);
75
+ --foreground-subtle: var(--slate-9);
74
76
  --border: var(--slate-6);
75
77
  --input: var(--slate-6);
76
78
  --ring: var(--slate-8);
package/app/layout.tsx CHANGED
@@ -4,12 +4,14 @@ import Script from "next/script";
4
4
  import { AmplifyClientProvider } from "../components/amplify-client-provider";
5
5
  import { PapyrusConsoleShell } from "../components/papyrus-console-shell";
6
6
  import { TooltipProvider } from "../components/ui/tooltip";
7
+ import { resolveBrandFontAssets } from "../lib/brand-fonts";
7
8
  import { getPresentationChoices, getSiteBrand } from "../lib/site-brand";
8
9
  import { getSiteStack } from "../lib/site-stack";
9
10
  import { isIndexable, showStagingBanner } from "../lib/site-env";
10
11
  import { StagingBanner } from "../components/staging-banner";
11
12
  import "./tailwind.css";
12
13
  import "./globals.css";
14
+ import "./blog-rhythm.css";
13
15
  import "papyrus-site-theme";
14
16
 
15
17
  const playfairDisplay = Playfair_Display({
@@ -43,6 +45,7 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
43
45
  const siteBrand = getSiteBrand();
44
46
  const presentationChoices = getPresentationChoices(siteBrand);
45
47
  const siteStack = getSiteStack(siteBrand);
48
+ const brandFonts = resolveBrandFontAssets(siteBrand.fonts);
46
49
 
47
50
  return (
48
51
  <html
@@ -56,8 +59,21 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
56
59
  data-default-presentation={siteBrand.defaultPresentation}
57
60
  data-presentation-choices={presentationChoices.join(",")}
58
61
  {...(siteBrand.forcedPresentation ? { "data-forced-presentation": siteBrand.forcedPresentation } : {})}
62
+ {...(siteBrand.blogLayout === "rhythm" ? { "data-blog-layout": "rhythm" } : {})}
63
+ {...(brandFonts.isEmpty ? {} : { style: brandFonts.rootStyle as React.CSSProperties })}
59
64
  suppressHydrationWarning
60
65
  >
66
+ {brandFonts.isEmpty ? null : (
67
+ <head>
68
+ {brandFonts.preconnectOrigins.map((origin) => (
69
+ <link crossOrigin="anonymous" href={origin} key={origin} rel="preconnect" />
70
+ ))}
71
+ {brandFonts.stylesheetHrefs.map((href) => (
72
+ <link href={href} key={href} rel="stylesheet" />
73
+ ))}
74
+ {brandFonts.fontFaceCss ? <style data-brand-fonts="true">{brandFonts.fontFaceCss}</style> : null}
75
+ </head>
76
+ )}
61
77
  <body className={`${playfairDisplay.variable} ${plusJakartaSans.variable}`}>
62
78
  <Script id="papyrus-favicon-color-scheme" strategy="beforeInteractive">
63
79
  {`(() => {
@@ -1,27 +1,14 @@
1
1
  "use client";
2
2
 
3
3
  import type { ReactNode } from "react";
4
- import { useEffect, useState } from "react";
4
+ import { useRhythmOverlay } from "./use-rhythm-overlay";
5
5
 
6
6
  type ArchiveShellProps = {
7
7
  children: ReactNode;
8
8
  };
9
9
 
10
10
  export function ArchiveShell({ children }: ArchiveShellProps) {
11
- const [showRhythmOverlay, setShowRhythmOverlay] = useState(false);
12
-
13
- useEffect(() => {
14
- const handleKeyDown = (event: KeyboardEvent) => {
15
- if (isEditableEventTarget(event.target)) return;
16
- if ((event.key === "=" || event.code === "Equal") && event.ctrlKey && !event.metaKey && !event.altKey) {
17
- event.preventDefault();
18
- setShowRhythmOverlay((current) => !current);
19
- }
20
- };
21
-
22
- window.addEventListener("keydown", handleKeyDown);
23
- return () => window.removeEventListener("keydown", handleKeyDown);
24
- }, []);
11
+ const showRhythmOverlay = useRhythmOverlay();
25
12
 
26
13
  return (
27
14
  <main
@@ -32,10 +19,3 @@ export function ArchiveShell({ children }: ArchiveShellProps) {
32
19
  </main>
33
20
  );
34
21
  }
35
-
36
- function isEditableEventTarget(target: EventTarget | null): boolean {
37
- if (!(target instanceof HTMLElement)) return false;
38
- const tagName = target.tagName.toLowerCase();
39
- if (tagName === "input" || tagName === "textarea" || tagName === "select") return true;
40
- return target.isContentEditable || target.closest("[contenteditable='true']") !== null;
41
- }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
4
+ import { Pause, Play } from "lucide-react";
4
5
  import type { ArticleVideoAsset } from "../lib/articles";
5
6
  import { resolveThemedVideoSrc } from "../lib/themed-image";
6
7
  import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
@@ -32,6 +33,8 @@ export function ArticleVideoFigure({
32
33
  const [duration, setDuration] = useState(0);
33
34
  const [previewReady, setPreviewReady] = useState(false);
34
35
  const iframeRef = useRef<HTMLIFrameElement>(null);
36
+ const videoRef = useRef<HTMLVideoElement>(null);
37
+ const isFramed = getSiteBrand().videoPlayer === "framed";
35
38
 
36
39
  useEffect(() => {
37
40
  setHasHydrated(true);
@@ -91,6 +94,58 @@ export function ArticleVideoFigure({
91
94
  setCurrentTime(clamped);
92
95
  }, []);
93
96
 
97
+ const toggleNativePlayback = useCallback(() => {
98
+ const element = videoRef.current;
99
+ if (!element) return;
100
+ if (element.paused) void element.play().catch(() => setIsPlaying(false));
101
+ else element.pause();
102
+ }, []);
103
+
104
+ const seekNative = useCallback((nextTime: number) => {
105
+ const element = videoRef.current;
106
+ if (!element) return;
107
+ element.currentTime = Math.max(0, Number.isFinite(nextTime) ? nextTime : 0);
108
+ setCurrentTime(element.currentTime);
109
+ }, []);
110
+
111
+ const framedControls = (
112
+ <FramedVideoControls
113
+ currentTime={currentTime}
114
+ duration={duration}
115
+ isPlaying={isPlaying}
116
+ onSeek={usePreview ? seekPreview : seekNative}
117
+ onToggle={usePreview ? togglePreviewPlayback : toggleNativePlayback}
118
+ />
119
+ );
120
+
121
+ if (isFramed && usePreview) {
122
+ return (
123
+ <figure
124
+ className={`${figureClassName} article-video--framed article-video--preview`}
125
+ data-media-type="videoml-preview"
126
+ data-video-theme={resolvedTheme}
127
+ data-video-mode={videoMode}
128
+ >
129
+ <div className="article-video__media">
130
+ {hasHydrated ? (
131
+ <iframe
132
+ key={`${slug}-${resolvedTheme}-${src}`}
133
+ ref={iframeRef}
134
+ className="article-video__preview-frame"
135
+ src={previewSrc}
136
+ title={video.alt}
137
+ onLoad={() => setPreviewReady(true)}
138
+ />
139
+ ) : null}
140
+ </div>
141
+ {framedControls}
142
+ <span className="sr-only" data-video-slug={slug}>
143
+ {video.alt}
144
+ </span>
145
+ </figure>
146
+ );
147
+ }
148
+
94
149
  if (usePreview) {
95
150
  return (
96
151
  <figure
@@ -133,6 +188,43 @@ export function ArticleVideoFigure({
133
188
  );
134
189
  }
135
190
 
191
+ if (isFramed) {
192
+ return (
193
+ <figure
194
+ className={`${figureClassName} article-video--framed`}
195
+ data-media-type="video"
196
+ data-video-theme={resolvedTheme}
197
+ data-video-mode={videoMode}
198
+ >
199
+ <div className="article-video__media">
200
+ <video
201
+ ref={videoRef}
202
+ playsInline
203
+ preload="metadata"
204
+ poster={video.posterSrc}
205
+ aria-label={video.alt}
206
+ className="article-video__player"
207
+ key={hasHydrated ? src : "ssr"}
208
+ onPlay={() => setIsPlaying(true)}
209
+ onPause={() => setIsPlaying(false)}
210
+ onEnded={() => setIsPlaying(false)}
211
+ onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
212
+ onDurationChange={(event) => {
213
+ const nextDuration = event.currentTarget.duration;
214
+ if (Number.isFinite(nextDuration) && nextDuration > 0) setDuration(nextDuration);
215
+ }}
216
+ >
217
+ <source src={src} type="video/mp4" />
218
+ </video>
219
+ </div>
220
+ {framedControls}
221
+ <span className="sr-only" data-video-slug={slug}>
222
+ {video.alt}
223
+ </span>
224
+ </figure>
225
+ );
226
+ }
227
+
136
228
  return (
137
229
  <figure className={figureClassName} data-media-type="video" data-video-theme={resolvedTheme} data-video-mode={videoMode}>
138
230
  <video
@@ -154,3 +246,46 @@ export function ArticleVideoFigure({
154
246
  </figure>
155
247
  );
156
248
  }
249
+
250
+ function FramedVideoControls({
251
+ currentTime,
252
+ duration,
253
+ isPlaying,
254
+ onSeek,
255
+ onToggle,
256
+ }: {
257
+ currentTime: number;
258
+ duration: number;
259
+ isPlaying: boolean;
260
+ onSeek: (nextTime: number) => void;
261
+ onToggle: () => void;
262
+ }) {
263
+ const seekProgress = duration > 0 ? (currentTime / duration) * 100 : 0;
264
+ return (
265
+ <div className="article-video__cta" data-playing={isPlaying ? "true" : "false"}>
266
+ <button type="button" className="article-video__cta-toggle" onClick={onToggle} aria-pressed={isPlaying}>
267
+ <span className="article-video__cta-label">
268
+ <span className="article-video__cta-label-sizer" aria-hidden="true">
269
+ Pause Video
270
+ </span>
271
+ <span className="article-video__cta-label-text">{isPlaying ? "Pause Video" : "Play Video"}</span>
272
+ </span>
273
+ <span className="article-video__cta-icon" aria-hidden="true">
274
+ {isPlaying ? <Pause /> : <Play />}
275
+ </span>
276
+ </button>
277
+ <input
278
+ className="article-video__cta-seek"
279
+ type="range"
280
+ min={0}
281
+ max={duration || 0}
282
+ step={0.1}
283
+ value={Math.min(currentTime, duration || 0)}
284
+ onChange={(event) => onSeek(Number(event.target.value))}
285
+ aria-label="Seek video"
286
+ disabled={!duration}
287
+ style={{ "--seek-progress": `${seekProgress}%` } as CSSProperties}
288
+ />
289
+ </div>
290
+ );
291
+ }
@@ -2,18 +2,35 @@
2
2
 
3
3
  import type { RefObject } from "react";
4
4
 
5
+ /** A rectangle, in the background layer's own coordinates, that header text occupies. */
6
+ export type BlogHeaderObstacle = {
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ };
12
+
13
+ /** Vertical rhythm grid the rhythm blog layout is built on. */
14
+ export type BlogBackgroundRhythm = {
15
+ rowHeight: number;
16
+ paintBuffer: number;
17
+ paintHeight: number;
18
+ };
19
+
5
20
  export type BlogPageBackgroundProps = {
6
21
  pageRef: RefObject<HTMLElement | null>;
22
+ /** Rhythm blog layout only: header text boxes the artwork should keep clear of. */
23
+ headerObstacles?: BlogHeaderObstacle[];
24
+ /** Rhythm blog layout only: the rhythm grid, so artwork can align to it. */
25
+ rhythm?: BlogBackgroundRhythm;
7
26
  };
8
27
 
9
28
  /**
10
29
  * Default (no-op) blog page background for publications without a bespoke
11
- * backdrop. Threat Intelligence supplies its own animated defense-graph
12
- * background (publications/threat_intelligence/blog-defense/page-background.tsx);
13
- * every other publication gets no decorative background layer. This is what
14
- * `renderers/pretext` falls back to for any publication that isn't Threat
15
- * Intelligence, so the blog layout loads cleanly without depending on TI's
16
- * package.
30
+ * backdrop. A publication supplies header artwork through the
31
+ * `components.BlogPageBackground` brand slot; with the rhythm blog layout the
32
+ * slot also receives `headerObstacles` and `rhythm`. This is what
33
+ * `renderers/pretext` falls back to when no slot component is registered.
17
34
  */
18
35
  export function BlogPageBackground(_props: BlogPageBackgroundProps) {
19
36
  return null;
@@ -12,6 +12,9 @@ export type PictogramFigureProps = {
12
12
  credit: string;
13
13
  figureClassName: string;
14
14
  height: number;
15
+ /** Rhythm blog layout: solved frame size in CSS pixels; wins over `height`/`width` when set. */
16
+ frameHeight?: number;
17
+ frameWidth?: number;
15
18
  layout?: ArticleImageLayout;
16
19
  priority?: boolean;
17
20
  sizes: string;
@@ -34,6 +37,8 @@ export function PictogramFigure({
34
37
  caption,
35
38
  credit,
36
39
  figureClassName,
40
+ frameHeight,
41
+ frameWidth,
37
42
  height,
38
43
  priority = false,
39
44
  sizes,
@@ -41,6 +46,8 @@ export function PictogramFigure({
41
46
  themeVariants,
42
47
  width,
43
48
  }: PictogramFigureProps) {
49
+ const imageHeight = frameHeight ?? height;
50
+ const imageWidth = frameWidth ?? width;
44
51
  const resolvedTheme = useResolvedPapyrusTheme();
45
52
  const themedImageSrc = resolveThemedImageSrc(src, themeVariants, resolvedTheme);
46
53
 
@@ -53,8 +60,8 @@ export function PictogramFigure({
53
60
  <Image
54
61
  src={themedImageSrc}
55
62
  alt={alt}
56
- width={width}
57
- height={height}
63
+ width={imageWidth}
64
+ height={imageHeight}
58
65
  sizes={sizes}
59
66
  priority={priority}
60
67
  unoptimized={shouldBypassImageOptimization(themedImageSrc)}