privateboard 0.1.8 → 0.1.9

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.
@@ -5,68 +5,60 @@
5
5
  <meta name="viewport" content="width=900, initial-scale=1">
6
6
  <title>Newspaper · PrivateBoard</title>
7
7
  <link rel="icon" href="/avatars/chair.svg" type="image/svg+xml">
8
+ <!-- NYT blackletter nameplate · UnifrakturMaguntia is the free
9
+ open-source font that reads as "Old English / Fraktur" the way
10
+ the iconic NYT mast does. Loaded from Google Fonts; system
11
+ fallback to Old English Text MT when offline. -->
12
+ <link rel="preconnect" href="https://fonts.googleapis.com">
13
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
14
+ <link href="https://fonts.googleapis.com/css2?family=UnifrakturMaguntia&display=swap" rel="stylesheet">
8
15
  <style>
9
16
  /* ═══════════════════════════════════════════════════════════════════
10
- Newspaper · broadsheet single-page front page.
11
- · Masthead band · two flanking decoration blocks + huge display
12
- serif nameplate ("BOARDROOM") + thin double-rule below
13
- · Meta strip · date | brand domain | brief id (mono)
14
- · Front-page band · all-caps banner headline (bento.title) +
15
- subdeck (bento.kicker) + hairline rule
16
- · Three-column editorial grid · each column is one milestone
17
- · LEFT col carries the inverted "BOTTOM LINE" callout
18
- (bento.conclusion) above its body
19
- · MIDDLE col carries an inverted date callout in the middle
20
- (footerTag stacked vertically)
21
- · RIGHT col carries the rankedBars chart in the "image slot"
22
- · Bottom band · 3-column "more from the room" continuations
23
- · LEFT · "MORE HEADINGS" stacked (verification bullets)
24
- · MIDDLE · editorial paragraphs (talkingPoints)
25
- · RIGHT · sign-off / brief id
26
- Reads BentoScaffold JSON from body_json. Same data shape as bento /
27
- magazine modes · only the renderer differs.
28
- ─────────────────────────────────────────────────────────────────── */
17
+ Newspaper · two distinct broadsheet templates picked deterministi-
18
+ cally from the brief id (so a refresh always shows the same look
19
+ for the same brief). Both render at LEAST 2 pages so the report
20
+ reads as a paper rather than a single sheet.
21
+
22
+ · POST · The Boardroom Post · Washington-Post register · clean
23
+ modern broadsheet · cream paper · deep ink · RED section
24
+ accents · sans-y kickers · big hero photo zone.
25
+ · TIMES · The Boardroom · New York Times register · classical
26
+ broadsheet · BLACKLETTER nameplate · "All the news that's
27
+ fit to print" tagline · star-ornament dividers · NAVY accent
28
+ · italic decks · 6-col grid front page.
29
+
30
+ Both variants share base typography and helpers; the masthead +
31
+ section system + accent palette differ to give each its own feel.
32
+ ═══════════════════════════════════════════════════════════════════ */
29
33
  :root {
30
- /* Default = Variant 1 (Broadsheet · WSJ / FT weekend register).
31
- Two sibling variants override these tokens at the body level
32
- (see the `[data-np-variant="2"]` and `="3"` blocks at the
33
- bottom of this stylesheet). */
34
-
35
- /* Page bg + soft radial accent that rides on top */
36
- --bg: #DCC9A1;
37
- --bg-radial: rgba(176, 160, 133, 0.10);
38
-
39
- /* Surfaces · cream paper + deep brown ink */
40
- --paper: #EEE2C6;
41
- --paper-soft: #F2E8D2;
42
- --paper-edge: #E5D8B7;
43
- --surface: #FFFFFF;
44
- --ink: #2A1D10;
45
- --ink-soft: #4A3725;
46
- --ink-mid: #6B5440;
47
- --ink-faint: #8E7A60;
48
- --ink-muted: #B0A085;
49
-
50
- /* Inverted register · dark callouts */
51
- --inv-bg: #2A1D10;
52
- --inv-bg-soft: #3A2A1A;
53
- --inv-ink: #F5EBD2;
54
- --inv-ink-soft: #C7B894;
55
-
56
- /* Rules · brown hairlines */
57
- --rule: #B5A483;
58
- --rule-soft: #C9BB9D;
34
+ /* Page bg + paper + ink palette · shared across variants */
35
+ --bg: #1F1E1A;
36
+ --paper: #F4EFDF;
37
+ --paper-soft: #FAF6E8;
38
+ --paper-edge: #E8DFC4;
39
+ --ink: #14110B;
40
+ --ink-soft: #3A2F1E;
41
+ --ink-mid: #5E5238;
42
+ --ink-faint: #8E8160;
43
+ --ink-muted: #B0A380;
44
+
45
+ --inv-bg: #1A2734;
46
+ --inv-ink: #F4EFDF;
47
+ --inv-ink-soft: #B7C0CB;
48
+
49
+ --rule: #C3B891;
50
+ --rule-soft: #D2C7A0;
59
51
  --rule-strong: #8C7A56;
60
52
 
61
- /* Accent · sparing · used for chart bars + small marks */
62
- --accent: #6B4515;
53
+ /* Default accents · POST variant (Washington Post · red) */
54
+ --accent: #C42126;
55
+ --accent-deep: #951C1F;
56
+ --accent-soft: #E6BFC1;
57
+ --accent-blue: #2C5282;
63
58
 
64
- /* Radii · zero everywhere · newspapers don't round */
65
- --r-0: 0;
66
-
67
- /* Shadow · only on the doc itself (lift it off the page) */
68
- --shadow-doc: 0 2px 8px rgba(40, 25, 10, 0.08),
69
- 0 12px 36px rgba(40, 25, 10, 0.10);
59
+ /* Shadow for paper stacks */
60
+ --shadow-page: 0 2px 8px rgba(20, 17, 11, 0.18),
61
+ 0 16px 48px rgba(20, 17, 11, 0.20);
70
62
 
71
63
  --serif-display: "Tiempos Headline", "Playfair Display", "Bodoni 72",
72
64
  "Didot", "Source Serif Pro", "Charter", Georgia,
@@ -79,17 +71,29 @@
79
71
  "Source Han Sans CN", "Noto Sans CJK SC", sans-serif;
80
72
  --mono: "SF Mono", "JetBrains Mono", "Menlo",
81
73
  "PingFang SC", "Source Han Sans CN", monospace;
74
+ --blackletter: "UnifrakturMaguntia", "Old English Text MT",
75
+ "Cloister Black", "Goudy Old Style",
76
+ "Tiempos Headline", Georgia, serif;
77
+ }
78
+
79
+ /* TIMES variant · accent shifts from red to navy, paper goes very
80
+ slightly cooler (NYT pages have a less yellowed tint than WaPo's). */
81
+ body[data-np-variant="times"] {
82
+ --paper: #F8F5E8;
83
+ --paper-soft: #FCF9EE;
84
+ --paper-edge: #ECE5CE;
85
+ --rule: #BCB29A;
86
+ --rule-soft: #C9C0AA;
87
+ --rule-strong: #6E6549;
88
+ --accent: #14366A;
89
+ --accent-deep: #0A2148;
90
+ --accent-soft: #B8C5D9;
91
+ --accent-blue: #14366A;
82
92
  }
83
93
 
84
94
  * { box-sizing: border-box; margin: 0; padding: 0; }
85
95
  html, body {
86
- /* Three variants override `--bg` and `--bg-radial` to retint the
87
- broadsheet · the radial gradient sits on top of the flat fill
88
- to give the page a slight printed-press hint without committing
89
- to a noise texture. */
90
- background:
91
- radial-gradient(120% 80% at 50% 0%, var(--bg-radial) 0%, transparent 60%),
92
- var(--bg);
96
+ background: var(--bg);
93
97
  color: var(--ink);
94
98
  font-family: var(--serif);
95
99
  font-size: 14px;
@@ -99,22 +103,22 @@
99
103
  min-height: 100vh;
100
104
  }
101
105
 
102
- /* ─── Top chrome · brand crumb + actions ────────────────────────── */
106
+ /* ─── Top chrome ───────────────────────────────────────────────── */
103
107
  .np-top-bar {
104
108
  display: flex;
105
109
  align-items: center;
106
110
  justify-content: space-between;
107
111
  gap: 14px;
108
- padding: 18px 32px;
109
- background: var(--top-bar-bg, rgba(220, 201, 161, 0.85));
112
+ padding: 14px 28px;
113
+ background: rgba(31, 30, 26, 0.92);
110
114
  backdrop-filter: blur(10px);
111
115
  -webkit-backdrop-filter: blur(10px);
112
- border-bottom: 1px solid var(--rule);
116
+ border-bottom: 1px solid rgba(244, 239, 223, 0.12);
113
117
  flex-wrap: wrap;
114
118
  position: sticky;
115
119
  top: 0;
116
120
  z-index: 10;
117
- font-family: var(--serif);
121
+ color: var(--paper);
118
122
  }
119
123
  .np-crumb {
120
124
  display: inline-flex;
@@ -123,862 +127,1077 @@
123
127
  font-family: var(--serif-display);
124
128
  font-size: 16px;
125
129
  font-weight: 700;
126
- color: var(--ink);
130
+ color: var(--paper);
127
131
  text-decoration: none;
128
132
  }
129
133
  .np-crumb::before {
130
134
  content: "";
131
- width: 10px;
132
- height: 10px;
133
- background: var(--ink);
135
+ width: 9px;
136
+ height: 9px;
137
+ background: var(--accent);
134
138
  flex: 0 0 auto;
139
+ border-radius: 50%;
135
140
  }
136
141
  .np-crumb-accent {
137
142
  color: var(--ink-faint);
138
143
  font-style: italic;
139
144
  font-weight: 400;
140
145
  }
141
- .np-actions { display: flex; gap: 8px; }
146
+ .np-actions { display: flex; gap: 8px; align-items: center; }
147
+ .np-page-nav {
148
+ display: inline-flex;
149
+ gap: 4px;
150
+ margin-right: 12px;
151
+ font-family: var(--mono);
152
+ font-size: 10px;
153
+ letter-spacing: 0.12em;
154
+ text-transform: uppercase;
155
+ }
156
+ .np-page-nav a {
157
+ color: var(--ink-muted);
158
+ padding: 4px 8px;
159
+ border: 1px solid rgba(244, 239, 223, 0.16);
160
+ text-decoration: none;
161
+ transition: all 0.12s;
162
+ }
163
+ .np-page-nav a:hover {
164
+ background: rgba(244, 239, 223, 0.10);
165
+ color: var(--paper);
166
+ }
167
+ .np-variant-badge {
168
+ font-family: var(--mono);
169
+ font-size: 10px;
170
+ letter-spacing: 0.16em;
171
+ text-transform: uppercase;
172
+ color: var(--accent-soft);
173
+ margin-right: 12px;
174
+ padding: 4px 10px;
175
+ background: rgba(244, 239, 223, 0.06);
176
+ border: 1px solid rgba(244, 239, 223, 0.16);
177
+ }
142
178
  .np-btn {
143
179
  font-family: var(--mono);
144
- font-size: 11px;
180
+ font-size: 10.5px;
145
181
  letter-spacing: 0.04em;
146
- padding: 8px 14px;
147
- background: var(--surface);
148
- border: 1px solid var(--rule-strong);
149
- color: var(--ink);
182
+ padding: 7px 12px;
183
+ background: transparent;
184
+ border: 1px solid rgba(244, 239, 223, 0.30);
185
+ color: var(--paper);
150
186
  cursor: pointer;
151
187
  text-decoration: none;
152
- transition: color 0.15s, background 0.15s, transform 0.15s;
188
+ text-transform: uppercase;
189
+ font-weight: 600;
190
+ transition: background 0.15s, color 0.15s;
153
191
  }
154
192
  .np-btn:hover {
155
- background: var(--ink);
156
- color: var(--paper);
157
- border-color: var(--ink);
158
- transform: translateY(-1px);
193
+ background: var(--paper);
194
+ color: var(--bg);
159
195
  }
160
- .np-btn:active { transform: translateY(0); }
161
196
  .np-btn .glyph { margin-right: 4px; }
162
197
 
163
- /* ─── Doc · the broadsheet sheet ───────────────────────────────── */
198
+ /* ─── Doc · paper-stack frame ──────────────────────────────────── */
164
199
  .np-doc {
165
- max-width: 880px;
166
- margin: 28px auto;
200
+ max-width: 920px;
201
+ margin: 0 auto;
202
+ padding: 28px 12px 56px;
203
+ }
204
+ .np-page {
167
205
  background: var(--paper);
168
- box-shadow: var(--shadow-doc);
169
- padding: 36px 44px 40px;
206
+ padding: 26px 32px 30px;
207
+ margin-bottom: 24px;
208
+ box-shadow: var(--shadow-page);
170
209
  position: relative;
171
210
  }
172
- /* Subtle paper edge · slight darkening at the very edge gives the
173
- impression of a printed sheet rather than a flat web rectangle. */
174
- .np-doc::before {
175
- content: "";
176
- position: absolute;
177
- inset: 0;
178
- pointer-events: none;
179
- background:
180
- linear-gradient(180deg, rgba(120, 90, 50, 0.04) 0%, transparent 30px),
181
- linear-gradient(0deg, rgba(120, 90, 50, 0.04) 0%, transparent 30px);
211
+ .np-page:last-child { margin-bottom: 0; }
212
+ body[data-np-variant="times"] .np-page {
213
+ padding: 28px 36px 30px;
182
214
  }
183
215
 
184
- /* ─── Rules · single + double hairlines ──────────────────────── */
185
- .np-rule {
186
- height: 1px;
187
- background: var(--ink);
188
- margin: 14px 0;
189
- }
216
+ /* ─── Rules · single + double · used as section dividers ─────── */
217
+ .np-rule { height: 1px; background: var(--ink); margin: 12px 0; }
218
+ .np-rule-thin { height: 1px; background: var(--rule); margin: 8px 0; }
190
219
  .np-rule-double {
191
- height: 6px;
220
+ height: 5px;
192
221
  background:
193
222
  linear-gradient(to bottom,
194
223
  var(--ink) 0,
195
224
  var(--ink) 1px,
196
225
  transparent 1px,
197
- transparent 5px,
198
- var(--ink) 5px,
199
- var(--ink) 6px);
200
- margin: 14px 0;
201
- }
202
- .np-rule-thick {
203
- height: 3px;
204
- background: var(--ink);
205
- margin: 18px 0;
226
+ transparent 4px,
227
+ var(--ink) 4px,
228
+ var(--ink) 5px);
229
+ margin: 12px 0;
206
230
  }
231
+ .np-rule-thick { height: 2px; background: var(--ink); margin: 14px 0; }
207
232
 
208
- /* ─── Masthead · flanks + nameplate ─────────────────────────── */
209
- .np-masthead {
210
- display: grid;
211
- grid-template-columns: 1fr auto 1fr;
212
- gap: 24px;
233
+ /* ═══════════════════════════════════════════════════════════════
234
+ POST variant · The Boardroom Post · Washington-Post register
235
+ ═══════════════════════════════════════════════════════════════ */
236
+
237
+ /* Section dots row · small accent dots above the masthead, each
238
+ paired with a section name. WaPo uses a colorful section nav
239
+ above the mast as a navigation aid. */
240
+ .post-sect-row {
241
+ display: flex;
242
+ justify-content: center;
243
+ gap: 22px;
244
+ padding: 2px 0 8px;
245
+ border-bottom: 1px solid var(--rule-soft);
246
+ margin-bottom: 10px;
247
+ flex-wrap: wrap;
248
+ }
249
+ .post-sect-item {
250
+ display: inline-flex;
213
251
  align-items: center;
214
- padding: 4px 0 18px;
252
+ gap: 6px;
253
+ font-family: var(--sans);
254
+ font-size: 10px;
255
+ letter-spacing: 0.14em;
256
+ text-transform: uppercase;
257
+ color: var(--ink-mid);
258
+ font-weight: 700;
215
259
  }
216
- .np-mast-flank {
217
- font-family: var(--serif);
218
- font-size: 9.5px;
219
- line-height: 1.45;
220
- color: var(--ink-soft);
221
- text-align: justify;
222
- text-justify: inter-word;
223
- /* Vertical hairlines bracket the flanks · one inside edge each ·
224
- evokes the nameplate "advertorial" boxes in classic broadsheets. */
225
- border-left: 1px solid var(--ink);
226
- border-right: 1px solid var(--ink);
227
- padding: 4px 8px;
228
- max-width: 160px;
229
- word-spacing: -0.03em;
260
+ .post-sect-item::before {
261
+ content: "";
262
+ width: 6px;
263
+ height: 6px;
264
+ border-radius: 50%;
265
+ background: var(--accent);
230
266
  }
231
- .np-mast-flank-left { justify-self: end; }
232
- .np-mast-flank-right { justify-self: start; }
233
- .np-mast-flank b {
234
- display: block;
235
- font-weight: 700;
236
- color: var(--ink);
237
- margin-bottom: 3px;
238
- font-size: 9.5px;
267
+ .post-sect-item.is-blue::before { background: var(--accent-blue); }
268
+ .post-sect-item.is-gold::before { background: #C8A36F; }
269
+ .post-sect-item.is-teal::before { background: #2C7B7A; }
270
+
271
+ /* Tagline + masthead (POST) · smaller / cleaner than NYT */
272
+ .post-tagline {
273
+ font-family: var(--serif);
274
+ font-style: italic;
275
+ font-size: 12px;
276
+ color: var(--ink-mid);
277
+ text-align: center;
278
+ margin-bottom: 6px;
239
279
  }
240
- .np-mast-name {
280
+ .post-mast-name {
241
281
  font-family: var(--serif-display);
242
- font-size: 64px;
243
- font-weight: 900;
244
- line-height: 1;
245
- letter-spacing: 0.04em;
246
- text-transform: uppercase;
282
+ font-size: 50px;
283
+ font-weight: 800;
284
+ line-height: 0.96;
285
+ letter-spacing: -0.018em;
247
286
  color: var(--ink);
248
287
  text-align: center;
249
- /* Optical centering · serif display fonts often want a tiny nudge */
250
- padding: 0 4px;
251
- white-space: nowrap;
288
+ margin: 2px 0 6px;
252
289
  }
253
290
  @media (max-width: 720px) {
254
- .np-mast-name { font-size: 44px; letter-spacing: 0.03em; }
255
- .np-mast-flank { display: none; }
256
- .np-masthead { grid-template-columns: 1fr; }
291
+ .post-mast-name { font-size: 36px; }
257
292
  }
258
293
 
259
- /* ─── Meta strip · date | url | id ──────────────────────────── */
260
- .np-meta-strip {
294
+ /* WaPo meta strip · 3-cell with date/edition/copy */
295
+ .post-meta {
261
296
  display: grid;
262
297
  grid-template-columns: 1fr auto 1fr;
263
- gap: 18px;
264
298
  align-items: center;
265
- font-family: var(--mono);
266
- font-size: 11px;
267
- letter-spacing: 0.05em;
268
- color: var(--ink);
269
- padding: 4px 0;
299
+ gap: 14px;
300
+ padding: 6px 0;
301
+ border-top: 2px solid var(--ink);
302
+ border-bottom: 1px solid var(--ink);
303
+ font-family: var(--sans);
304
+ font-size: 10.5px;
305
+ letter-spacing: 0.08em;
306
+ text-transform: uppercase;
307
+ color: var(--ink-mid);
308
+ font-weight: 600;
270
309
  }
271
- .np-meta-left { text-align: left; }
272
- .np-meta-center { text-align: center; }
273
- .np-meta-right { text-align: right; }
310
+ .post-meta-l { text-align: left; }
311
+ .post-meta-c { text-align: center; }
312
+ .post-meta-r { text-align: right; }
274
313
 
275
- /* ─── Front page · headline + deck ──────────────────────────── */
276
- .np-frontpage {
277
- text-align: center;
278
- padding: 18px 24px 14px;
314
+ /* Hero zone · big headline with deck + lead body + standfirst */
315
+ .post-hero {
316
+ padding: 14px 0 8px;
279
317
  }
280
- .np-frontpage-title {
281
- font-family: var(--serif-display);
282
- font-size: 38px;
318
+ .post-hero-flag {
319
+ display: inline-block;
320
+ background: var(--accent);
321
+ color: var(--paper);
322
+ font-family: var(--sans);
323
+ font-size: 10px;
283
324
  font-weight: 800;
284
- line-height: 1.05;
285
- letter-spacing: 0.005em;
325
+ letter-spacing: 0.18em;
286
326
  text-transform: uppercase;
287
- color: var(--ink);
288
- margin: 0 0 8px;
327
+ padding: 4px 10px;
328
+ margin-bottom: 8px;
289
329
  }
290
- .np-frontpage-deck {
330
+ .post-hero-title {
291
331
  font-family: var(--serif-display);
292
- font-size: 20px;
293
- font-weight: 500;
294
- line-height: 1.2;
295
- letter-spacing: 0.02em;
296
- text-transform: uppercase;
297
- color: var(--ink-soft);
298
- margin: 0;
332
+ font-size: 42px;
333
+ font-weight: 800;
334
+ line-height: 1.04;
335
+ letter-spacing: -0.012em;
336
+ color: var(--ink);
337
+ margin-bottom: 8px;
338
+ max-width: 760px;
299
339
  }
300
340
  @media (max-width: 720px) {
301
- .np-frontpage-title { font-size: 28px; }
302
- .np-frontpage-deck { font-size: 15px; }
303
- }
304
-
305
- /* ─── Editorial 3-column grid ───────────────────────────────── */
306
- .np-grid {
307
- display: grid;
308
- grid-template-columns: 1fr 1fr 1fr;
309
- gap: 22px;
310
- margin: 18px 0;
341
+ .post-hero-title { font-size: 30px; }
311
342
  }
312
- /* Vertical hairline column rules between adjacent columns · the
313
- classic newspaper spine. The rules are drawn via right border on
314
- non-last children so resizing reflows correctly. */
315
- .np-grid > .np-col {
316
- padding-right: 22px;
317
- border-right: 1px solid var(--rule);
343
+ .post-hero-deck {
344
+ font-family: var(--serif);
345
+ font-style: italic;
346
+ font-size: 16px;
347
+ line-height: 1.45;
348
+ color: var(--ink-mid);
349
+ max-width: 720px;
350
+ margin-bottom: 8px;
318
351
  }
319
- .np-grid > .np-col:last-child {
320
- padding-right: 0;
321
- border-right: 0;
352
+ .post-hero-byline {
353
+ font-family: var(--sans);
354
+ font-size: 11px;
355
+ letter-spacing: 0.06em;
356
+ text-transform: uppercase;
357
+ color: var(--ink-mid);
358
+ font-weight: 600;
359
+ margin-bottom: 4px;
322
360
  }
323
- @media (max-width: 720px) {
324
- .np-grid {
325
- grid-template-columns: 1fr;
326
- gap: 18px;
327
- }
328
- .np-grid > .np-col {
329
- padding-right: 0;
330
- border-right: 0;
331
- border-bottom: 1px solid var(--rule);
332
- padding-bottom: 18px;
333
- }
334
- .np-grid > .np-col:last-child { border-bottom: 0; }
361
+ .post-hero-byline em {
362
+ font-style: italic;
363
+ text-transform: none;
364
+ color: var(--ink-soft);
365
+ font-weight: 400;
366
+ letter-spacing: 0;
335
367
  }
336
368
 
337
- .np-col {
338
- display: flex;
339
- flex-direction: column;
340
- gap: 12px;
341
- min-width: 0;
369
+ /* Standfirst panel · solid colored block carrying a pull-quote
370
+ (replaces the prior gradient portrait fake-photo). */
371
+ .post-stand {
372
+ background: var(--inv-bg);
373
+ color: var(--paper);
374
+ padding: 18px 22px;
375
+ margin: 10px 0;
376
+ display: grid;
377
+ grid-template-columns: 48px 1fr;
378
+ gap: 14px;
379
+ align-items: center;
342
380
  }
343
- .np-col-heading {
381
+ .post-stand-mark {
344
382
  font-family: var(--serif-display);
345
- font-size: 16px;
346
- font-weight: 700;
383
+ font-style: italic;
384
+ font-size: 54px;
385
+ line-height: 0.6;
386
+ color: var(--accent-soft);
387
+ }
388
+ .post-stand-text {
389
+ font-family: var(--serif-display);
390
+ font-style: italic;
391
+ font-size: 19px;
392
+ line-height: 1.32;
393
+ color: var(--paper);
394
+ letter-spacing: -0.005em;
395
+ }
396
+ .post-stand-cite {
397
+ font-family: var(--sans);
398
+ font-size: 10px;
399
+ letter-spacing: 0.12em;
347
400
  text-transform: uppercase;
348
- letter-spacing: 0.02em;
349
- color: var(--ink);
350
- line-height: 1.15;
351
- margin: 0;
352
- /* Tight underline rule below section heading */
353
- padding-bottom: 6px;
354
- border-bottom: 1px solid var(--ink);
401
+ color: var(--inv-ink-soft);
402
+ margin-top: 8px;
403
+ display: block;
355
404
  }
356
- .np-col-heading-large {
357
- font-size: 22px;
358
- line-height: 1.1;
405
+ @media (max-width: 720px) {
406
+ .post-stand { grid-template-columns: 1fr; }
407
+ .post-stand-mark { display: none; }
408
+ .post-stand-text { font-size: 18px; }
359
409
  }
360
410
 
361
- /* Editorial body prose · justified, narrow, drop cap on first
362
- paragraph of the column. */
363
- .np-prose {
411
+ /* 2-col body grid (POST) · single-column main story (no internal
412
+ multi-column · the brief data isn't dense enough to fill a
413
+ 2-col body without leaving gaps) · sidebar with stacked tiles. */
414
+ .post-grid {
415
+ display: grid;
416
+ grid-template-columns: minmax(0, 1.7fr) minmax(180px, 1fr);
417
+ gap: 22px;
418
+ margin-top: 4px;
419
+ align-items: start;
420
+ }
421
+ @media (max-width: 720px) {
422
+ .post-grid { grid-template-columns: 1fr; gap: 18px; }
423
+ }
424
+ .post-body {
364
425
  font-family: var(--serif);
365
- font-size: 13px;
366
- line-height: 1.55;
426
+ font-size: 13.5px;
427
+ line-height: 1.62;
367
428
  color: var(--ink-soft);
368
429
  text-align: justify;
369
- text-justify: inter-word;
370
430
  hyphens: auto;
371
431
  -webkit-hyphens: auto;
372
432
  }
373
- .np-prose p + p { margin-top: 8px; }
374
- .np-prose-lead::first-letter {
433
+ .post-body p + p { margin-top: 8px; }
434
+ .post-body.has-drop::first-letter {
375
435
  font-family: var(--serif-display);
376
- font-size: 42px;
377
- line-height: 0.9;
378
- font-weight: 700;
379
- color: var(--ink);
436
+ font-size: 56px;
437
+ line-height: 0.85;
438
+ font-weight: 800;
439
+ color: var(--accent);
380
440
  float: left;
381
- margin: 4px 6px 0 0;
382
- padding: 0;
383
- }
384
- .np-prose strong, .np-prose b {
385
- color: var(--ink);
386
- font-weight: 700;
387
- }
388
- .np-pagehint {
389
- font-family: var(--serif);
390
- font-size: 11px;
391
- font-style: italic;
392
- color: var(--ink-mid);
393
- margin-top: 4px;
441
+ margin: 6px 8px 0 0;
394
442
  }
395
- .np-pagehint::before { content: "→ "; font-style: normal; color: var(--ink); }
396
443
 
397
- /* ─── Inverted callouts · IMPORTANT DETAILS / date stack ───── */
398
- .np-callout {
399
- background: var(--inv-bg);
400
- color: var(--inv-ink);
401
- padding: 14px 16px 16px;
444
+ .post-side {
445
+ display: flex;
446
+ flex-direction: column;
447
+ gap: 12px;
448
+ padding-left: 22px;
449
+ border-left: 1px solid var(--rule);
402
450
  }
403
- .np-callout-label {
404
- font-family: var(--serif-display);
405
- font-size: 12px;
406
- font-weight: 700;
407
- text-transform: uppercase;
451
+ @media (max-width: 720px) {
452
+ .post-side { border-left: 0; padding-left: 0; padding-top: 16px; border-top: 1px solid var(--rule); }
453
+ }
454
+ .post-side-flag {
455
+ display: inline-block;
456
+ font-family: var(--sans);
457
+ font-size: 10px;
458
+ font-weight: 800;
408
459
  letter-spacing: 0.18em;
409
- color: var(--inv-ink-soft);
410
- margin-bottom: 8px;
411
- line-height: 1.2;
460
+ text-transform: uppercase;
461
+ color: var(--accent);
462
+ padding-bottom: 6px;
463
+ border-bottom: 2px solid var(--accent);
464
+ margin-bottom: 6px;
465
+ align-self: flex-start;
412
466
  }
413
- .np-callout-text {
467
+ .post-side-head {
414
468
  font-family: var(--serif-display);
415
469
  font-size: 18px;
416
470
  font-weight: 700;
417
- line-height: 1.18;
418
- letter-spacing: -0.005em;
419
- color: var(--inv-ink);
420
- text-transform: uppercase;
471
+ line-height: 1.2;
472
+ letter-spacing: -0.008em;
473
+ color: var(--ink);
421
474
  }
422
- /* Vertical date stack · used in the middle column of the upper grid */
423
- .np-callout-date {
424
- text-align: center;
425
- padding: 18px 14px 16px;
475
+ .post-side-body {
476
+ font-family: var(--serif);
477
+ font-size: 12.5px;
478
+ line-height: 1.55;
479
+ color: var(--ink-soft);
426
480
  }
427
- .np-callout-date .np-date-day {
428
- font-family: var(--serif-display);
429
- font-size: 11px;
430
- font-weight: 700;
431
- letter-spacing: 0.18em;
432
- text-transform: uppercase;
433
- color: var(--inv-ink-soft);
434
- line-height: 1;
435
- margin-bottom: 8px;
481
+
482
+ /* Stat tile (POST register) · cream box with hairline border +
483
+ red bar accent. Numeric size sits at 36px to fit the narrow
484
+ sidebar (~200px) without wrapping awkwardly. word-break
485
+ ensures any 12-char callout still fits cleanly. */
486
+ .post-stat {
487
+ background: var(--paper-soft);
488
+ padding: 16px 16px 16px;
489
+ border-top: 4px solid var(--accent);
490
+ margin-top: 6px;
436
491
  }
437
- .np-callout-date .np-date-month {
438
- font-family: var(--serif-display);
439
- font-size: 16px;
440
- font-weight: 700;
492
+ .post-stat-eye {
493
+ font-family: var(--sans);
494
+ font-size: 10px;
495
+ font-weight: 800;
441
496
  letter-spacing: 0.18em;
442
497
  text-transform: uppercase;
443
- color: var(--inv-ink);
444
- line-height: 1;
445
- margin-bottom: 12px;
446
- }
447
- .np-callout-date .np-date-num {
448
- font-family: var(--serif-display);
449
- font-size: 56px;
450
- font-weight: 900;
451
- line-height: 1;
452
- color: var(--inv-ink);
453
- letter-spacing: -0.02em;
454
- margin-bottom: 8px;
498
+ color: var(--accent);
499
+ margin-bottom: 6px;
455
500
  }
456
- .np-callout-date .np-date-year {
501
+ .post-stat-num {
457
502
  font-family: var(--serif-display);
458
- font-size: 18px;
459
- font-weight: 600;
460
- line-height: 1;
461
- letter-spacing: 0.04em;
462
- color: var(--inv-ink);
463
- margin-bottom: 10px;
503
+ font-size: 36px;
504
+ font-weight: 800;
505
+ line-height: 1.0;
506
+ letter-spacing: -0.022em;
507
+ color: var(--ink);
508
+ word-break: break-word;
509
+ overflow-wrap: anywhere;
464
510
  }
465
- .np-callout-date .np-date-source {
511
+ .post-stat-cap {
466
512
  font-family: var(--serif);
467
- font-size: 11px;
468
513
  font-style: italic;
469
- letter-spacing: 0.04em;
470
- color: var(--inv-ink-soft);
514
+ font-size: 12.5px;
515
+ line-height: 1.4;
516
+ color: var(--ink-mid);
517
+ margin-top: 6px;
471
518
  }
472
519
 
473
- /* ─── Figure · ranked-bars chart in the image slot ──────────── */
474
- .np-figure {
475
- background: var(--paper-soft);
476
- border: 1px solid var(--ink);
477
- padding: 12px 14px 10px;
478
- }
479
- .np-figure-bars {
480
- display: flex;
481
- flex-direction: column;
482
- gap: 8px;
483
- margin-bottom: 10px;
520
+ /* Index strip at bottom of front page · "Inside this issue" */
521
+ .post-index {
522
+ display: grid;
523
+ grid-template-columns: auto 1fr;
524
+ gap: 18px;
525
+ align-items: start;
526
+ margin-top: 16px;
527
+ padding-top: 12px;
528
+ border-top: 2px solid var(--ink);
529
+ }
530
+ .post-index-flag {
531
+ font-family: var(--sans);
532
+ font-size: 10px;
533
+ font-weight: 800;
534
+ letter-spacing: 0.18em;
535
+ text-transform: uppercase;
536
+ color: var(--ink);
537
+ padding: 4px 10px;
538
+ border: 1px solid var(--ink);
539
+ background: var(--paper);
540
+ align-self: flex-start;
541
+ }
542
+ .post-index-list {
543
+ list-style: none;
544
+ margin: 0;
545
+ padding: 0;
546
+ display: grid;
547
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
548
+ gap: 8px 22px;
484
549
  }
485
- .np-bar {
550
+ .post-index-list li {
486
551
  display: grid;
487
552
  grid-template-columns: 1fr auto;
488
553
  gap: 6px;
489
554
  align-items: baseline;
490
555
  font-family: var(--serif);
491
- font-size: 11.5px;
556
+ font-size: 12.5px;
557
+ line-height: 1.4;
558
+ color: var(--ink-soft);
559
+ border-bottom: 1px dotted var(--rule-strong);
560
+ padding-bottom: 4px;
561
+ }
562
+ .post-index-list li b {
563
+ font-family: var(--serif-display);
564
+ font-weight: 700;
492
565
  color: var(--ink);
493
566
  }
494
- .np-bar-row {
495
- grid-column: 1 / -1;
496
- height: 10px;
497
- background:
498
- repeating-linear-gradient(
499
- 45deg,
500
- var(--paper-edge) 0,
501
- var(--paper-edge) 4px,
502
- var(--paper-soft) 4px,
503
- var(--paper-soft) 8px
504
- );
505
- border: 1px solid var(--ink);
506
- position: relative;
507
- margin-top: 2px;
567
+ .post-index-list li .pg {
568
+ font-family: var(--sans);
569
+ font-size: 10px;
570
+ letter-spacing: 0.1em;
571
+ text-transform: uppercase;
572
+ color: var(--accent);
573
+ font-weight: 700;
508
574
  }
509
- .np-bar-fill {
510
- position: absolute;
511
- inset: 0 auto 0 0;
512
- background: var(--ink);
575
+
576
+ /* POST inside running header · running mast + page indicator */
577
+ .post-running {
578
+ display: grid;
579
+ grid-template-columns: auto 1fr auto;
580
+ gap: 18px;
581
+ align-items: center;
582
+ padding-bottom: 8px;
583
+ border-bottom: 2px solid var(--ink);
513
584
  }
514
- .np-bar-value {
515
- font-family: var(--mono);
585
+ .post-running-mast {
586
+ font-family: var(--serif-display);
587
+ font-size: 18px;
588
+ font-weight: 800;
589
+ color: var(--ink);
590
+ letter-spacing: -0.005em;
591
+ }
592
+ .post-running-mast::before {
593
+ content: "";
594
+ display: inline-block;
595
+ width: 8px;
596
+ height: 8px;
597
+ background: var(--accent);
598
+ margin-right: 8px;
599
+ vertical-align: middle;
600
+ border-radius: 50%;
601
+ }
602
+ .post-running-meta {
603
+ text-align: center;
604
+ font-family: var(--sans);
516
605
  font-size: 10.5px;
517
- color: var(--ink-soft);
518
- letter-spacing: 0.02em;
606
+ letter-spacing: 0.06em;
607
+ text-transform: uppercase;
608
+ color: var(--ink-mid);
519
609
  }
520
- .np-figcaption {
521
- font-family: var(--serif);
610
+ .post-running-page {
611
+ font-family: var(--sans);
522
612
  font-size: 11px;
613
+ letter-spacing: 0.14em;
614
+ text-transform: uppercase;
615
+ color: var(--accent);
616
+ font-weight: 800;
617
+ }
618
+
619
+ /* Inside section banner */
620
+ .post-sect-banner {
621
+ display: flex;
622
+ align-items: center;
623
+ gap: 14px;
624
+ margin: 14px 0 12px;
625
+ }
626
+ .post-sect-banner-tag {
627
+ font-family: var(--sans);
628
+ font-size: 11px;
629
+ font-weight: 800;
630
+ letter-spacing: 0.18em;
631
+ text-transform: uppercase;
632
+ color: var(--paper);
633
+ background: var(--accent);
634
+ padding: 5px 12px;
635
+ }
636
+ .post-sect-banner-deck {
637
+ font-family: var(--serif);
523
638
  font-style: italic;
639
+ font-size: 13px;
524
640
  color: var(--ink-mid);
525
- text-align: right;
526
- margin-top: 4px;
527
- letter-spacing: 0.02em;
528
- }
529
- .np-figcaption::before {
530
- content: "Fig. ";
531
- font-style: normal;
532
- color: var(--ink);
533
- font-weight: 700;
534
641
  }
535
642
 
536
- /* ─── More headings sidebar list ────────────────────────────── */
537
- .np-list {
538
- list-style: none;
539
- margin: 0;
540
- padding: 0;
541
- display: flex;
542
- flex-direction: column;
543
- gap: 10px;
643
+ /* Inside article (POST) · headline + body */
644
+ .post-article {
645
+ display: grid;
646
+ grid-template-columns: minmax(0, 1.6fr) minmax(180px, 1fr);
647
+ gap: 28px;
648
+ margin-top: 6px;
649
+ }
650
+ @media (max-width: 720px) {
651
+ .post-article { grid-template-columns: 1fr; gap: 20px; }
544
652
  }
545
- .np-list li {
653
+ .post-article-head {
654
+ font-family: var(--serif-display);
655
+ font-size: 32px;
656
+ font-weight: 800;
657
+ line-height: 1.08;
658
+ letter-spacing: -0.012em;
659
+ color: var(--ink);
660
+ margin-bottom: 8px;
661
+ }
662
+ .post-article-deck {
546
663
  font-family: var(--serif);
547
- font-size: 12.5px;
664
+ font-style: italic;
665
+ font-size: 14px;
548
666
  line-height: 1.5;
549
- color: var(--ink-soft);
667
+ color: var(--ink-mid);
668
+ margin-bottom: 8px;
669
+ }
670
+ .post-article-byline {
671
+ font-family: var(--sans);
672
+ font-size: 10.5px;
673
+ letter-spacing: 0.08em;
674
+ text-transform: uppercase;
675
+ color: var(--ink-mid);
676
+ font-weight: 700;
550
677
  padding-bottom: 10px;
551
678
  border-bottom: 1px solid var(--rule);
679
+ margin-bottom: 12px;
680
+ }
681
+
682
+ /* Pull-quote inline (POST) · vertical red bar + italic */
683
+ .post-pull {
684
+ border-left: 4px solid var(--accent);
685
+ padding: 8px 18px;
686
+ margin: 14px 0;
552
687
  }
553
- .np-list li:last-child { border-bottom: 0; padding-bottom: 0; }
554
- .np-list li b {
688
+ .post-pull-text {
555
689
  font-family: var(--serif-display);
556
- font-size: 13px;
557
- font-weight: 700;
690
+ font-style: italic;
691
+ font-size: 19px;
692
+ line-height: 1.35;
558
693
  color: var(--ink);
559
- text-transform: uppercase;
560
- letter-spacing: 0.02em;
561
- margin-right: 4px;
694
+ letter-spacing: -0.005em;
562
695
  }
563
- .np-list li b::after {
564
- content: ":";
565
- color: var(--ink);
696
+ .post-pull-cite {
697
+ font-family: var(--sans);
698
+ font-size: 10px;
699
+ letter-spacing: 0.14em;
700
+ text-transform: uppercase;
701
+ color: var(--accent);
702
+ font-weight: 700;
703
+ margin-top: 6px;
566
704
  }
567
705
 
568
- /* ─── Pull quote · used in the bottom-middle column ─────────── */
569
- .np-pull {
570
- font-family: var(--serif-display);
571
- font-size: 18px;
572
- font-weight: 600;
706
+ /* ═══════════════════════════════════════════════════════════════
707
+ TIMES variant · The Boardroom · New York Times register
708
+ ═══════════════════════════════════════════════════════════════ */
709
+
710
+ .times-tagline {
711
+ font-family: var(--serif);
573
712
  font-style: italic;
574
- line-height: 1.3;
713
+ font-size: 11.5px;
714
+ color: var(--ink-mid);
715
+ text-align: center;
716
+ padding: 2px 0 6px;
717
+ }
718
+ .times-mast-name {
719
+ font-family: var(--blackletter);
720
+ font-weight: 400;
721
+ font-size: 72px;
722
+ line-height: 1;
723
+ letter-spacing: 0.005em;
575
724
  color: var(--ink);
576
725
  text-align: center;
577
- padding: 10px 0;
578
- border-top: 1px solid var(--ink);
579
- border-bottom: 1px solid var(--ink);
580
- margin: 8px 0;
726
+ margin: 4px 0 8px;
727
+ }
728
+ @media (max-width: 720px) {
729
+ .times-mast-name { font-size: 46px; }
581
730
  }
582
731
 
583
- /* ─── Footer ────────────────────────────────────────────────── */
584
- .np-footer {
732
+ /* TIMES volume strip · "VOL. CXC...No 12,345 ★ Late Edition ★
733
+ {Date} ★ $4.00" */
734
+ .times-vol-strip {
585
735
  display: flex;
586
- justify-content: space-between;
587
- align-items: baseline;
588
- gap: 14px;
589
- margin-top: 18px;
590
- padding-top: 12px;
591
- border-top: 1px solid var(--ink);
736
+ align-items: center;
737
+ justify-content: center;
738
+ gap: 18px;
739
+ padding: 6px 0;
740
+ border-top: 2px solid var(--ink);
741
+ border-bottom: 1px solid var(--ink);
592
742
  font-family: var(--serif);
593
- font-size: 11px;
594
- font-style: italic;
595
- color: var(--ink-mid);
743
+ font-size: 11.5px;
744
+ color: var(--ink-soft);
596
745
  flex-wrap: wrap;
597
746
  }
598
- .np-footer-stamp {
599
- font-family: var(--mono);
600
- font-size: 10.5px;
601
- font-style: normal;
747
+ .times-vol-strip .star {
748
+ color: var(--ink);
749
+ font-size: 12px;
750
+ line-height: 1;
751
+ }
752
+ .times-vol-strip em {
753
+ font-style: italic;
602
754
  color: var(--ink);
603
- letter-spacing: 0.04em;
604
755
  }
605
756
 
606
- /* ─── States · loading / error / empty ─────────────────────── */
607
- .np-state {
608
- max-width: 560px;
609
- margin: 80px auto;
610
- padding: 48px 36px;
611
- text-align: center;
612
- background: var(--paper);
613
- border: 1px solid var(--ink);
614
- box-shadow: var(--shadow-doc);
757
+ /* TIMES front · 4-col grid (1+2+1 spans) · packs better than the
758
+ classical 6-col when content is brief-sized; the lead is the
759
+ visual center, sidebars stay narrow but full of content. */
760
+ .times-front {
761
+ display: grid;
762
+ grid-template-columns: repeat(4, 1fr);
763
+ gap: 22px;
764
+ margin-top: 14px;
765
+ align-items: start;
615
766
  }
616
- .np-state-mark {
617
- font-family: var(--mono);
767
+ @media (max-width: 720px) {
768
+ .times-front { grid-template-columns: 1fr; }
769
+ }
770
+ .times-col {
771
+ display: flex;
772
+ flex-direction: column;
773
+ gap: 8px;
774
+ min-width: 0;
775
+ }
776
+ .times-col + .times-col {
777
+ padding-left: 22px;
778
+ border-left: 1px solid var(--rule);
779
+ }
780
+ @media (max-width: 720px) {
781
+ .times-col + .times-col {
782
+ padding-left: 0;
783
+ border-left: 0;
784
+ padding-top: 16px;
785
+ border-top: 1px solid var(--rule);
786
+ }
787
+ }
788
+ .times-col-1 { grid-column: span 1; }
789
+ .times-col-2 { grid-column: span 1; }
790
+ .times-col-3 { grid-column: span 2; }
791
+
792
+ .times-section-eye {
793
+ font-family: var(--serif);
618
794
  font-size: 10px;
619
- letter-spacing: 0.18em;
795
+ letter-spacing: 0.16em;
620
796
  text-transform: uppercase;
621
- color: var(--ink);
622
- border: 1px solid var(--ink);
623
- padding: 5px 14px;
624
- display: inline-block;
625
- margin-bottom: 16px;
797
+ color: var(--ink-mid);
626
798
  font-weight: 700;
799
+ text-align: center;
800
+ padding-bottom: 6px;
801
+ border-bottom: 1px solid var(--ink);
802
+ margin-bottom: 8px;
627
803
  }
628
- .np-state-title {
804
+ .times-section-eye::before { content: "★ "; color: var(--ink); margin-right: 4px; }
805
+ .times-section-eye::after { content: " ★"; color: var(--ink); margin-left: 4px; }
806
+
807
+ .times-head-large {
808
+ font-family: var(--serif-display);
809
+ font-size: 34px;
810
+ font-weight: 700;
811
+ line-height: 1.05;
812
+ letter-spacing: -0.014em;
813
+ color: var(--ink);
814
+ text-align: center;
815
+ }
816
+ @media (max-width: 720px) {
817
+ .times-head-large { font-size: 26px; }
818
+ }
819
+ .times-head-medium {
629
820
  font-family: var(--serif-display);
630
821
  font-size: 24px;
631
822
  font-weight: 700;
823
+ line-height: 1.12;
824
+ letter-spacing: -0.008em;
632
825
  color: var(--ink);
633
- margin-bottom: 10px;
634
- line-height: 1.2;
826
+ }
827
+ .times-head-small {
828
+ font-family: var(--serif-display);
829
+ font-size: 17px;
830
+ font-weight: 700;
831
+ line-height: 1.18;
832
+ letter-spacing: -0.005em;
833
+ color: var(--ink);
834
+ }
835
+ .times-deck {
836
+ font-family: var(--serif);
837
+ font-style: italic;
838
+ font-size: 14.5px;
839
+ line-height: 1.4;
840
+ color: var(--ink-mid);
841
+ text-align: center;
842
+ margin: 6px 0 10px;
843
+ }
844
+ .times-byline {
845
+ font-family: var(--serif);
846
+ font-size: 11px;
847
+ letter-spacing: 0.12em;
635
848
  text-transform: uppercase;
636
- letter-spacing: 0.02em;
849
+ color: var(--ink-mid);
850
+ text-align: center;
851
+ margin-bottom: 8px;
637
852
  }
638
- .np-state-body {
853
+ .times-byline em {
854
+ font-style: italic;
855
+ color: var(--ink);
856
+ }
857
+ .times-body {
639
858
  font-family: var(--serif);
640
- font-size: 13.5px;
859
+ font-size: 12.5px;
860
+ line-height: 1.55;
641
861
  color: var(--ink-soft);
642
- line-height: 1.6;
862
+ text-align: justify;
863
+ hyphens: auto;
864
+ -webkit-hyphens: auto;
643
865
  }
644
-
645
- /* ═════════════════════════════════════════════════════════════════
646
- LAYOUT-SPECIFIC GRIDS · variant 2 + variant 3 ship distinct
647
- structures, not just paint changes. Variant 1 keeps the original
648
- symmetric 3+3 broadsheet grid.
649
- ═════════════════════════════════════════════════════════════════ */
650
-
651
- /* Layout 2 · wide hero band · lead story 2/3 LEFT (drop-cap +
652
- bottom-line callout below) · sidebar 1/3 RIGHT (figure stacked
653
- above date-stack so the column never reads as short). The
654
- `:first-child` column gets the vertical column rule so the rule
655
- paints between the two regardless of which child sits first. */
656
- .np-hero-band {
657
- display: grid;
658
- grid-template-columns: 2fr 1fr;
659
- gap: 24px;
660
- margin: 8px 0 12px;
661
- align-items: stretch;
866
+ .times-body p + p { margin-top: 6px; text-indent: 1.2em; }
867
+ .times-body.has-drop::first-letter {
868
+ font-family: var(--serif-display);
869
+ font-size: 48px;
870
+ line-height: 0.85;
871
+ font-weight: 700;
872
+ color: var(--ink);
873
+ float: left;
874
+ margin: 4px 6px 0 0;
662
875
  }
663
- .np-hero-band > :first-child {
664
- padding-right: 24px;
665
- border-right: 1px solid var(--rule);
876
+
877
+ /* TIMES standfirst · solid block with serif italic quote · used
878
+ in the centered hero column */
879
+ .times-stand {
880
+ background: var(--ink);
881
+ color: var(--paper);
882
+ padding: 16px 20px;
883
+ margin: 10px 0;
884
+ text-align: center;
666
885
  }
667
- .np-hero-band > .np-hero-figure {
668
- display: flex;
669
- flex-direction: column;
670
- gap: 12px;
886
+ .times-stand-mark {
887
+ font-family: var(--blackletter);
888
+ font-size: 32px;
889
+ color: var(--paper);
890
+ line-height: 1;
891
+ margin-bottom: 8px;
671
892
  }
672
- .np-hero-band > .np-hero-lead {
673
- display: flex;
674
- flex-direction: column;
675
- gap: 12px;
893
+ .times-stand-text {
894
+ font-family: var(--serif-display);
895
+ font-style: italic;
896
+ font-size: 18px;
897
+ line-height: 1.35;
898
+ color: var(--paper);
899
+ letter-spacing: -0.005em;
676
900
  }
677
- @media (max-width: 720px) {
678
- .np-hero-band { grid-template-columns: 1fr; gap: 18px; }
679
- .np-hero-band > :first-child {
680
- border-right: 0;
681
- padding-right: 0;
682
- border-bottom: 1px solid var(--rule);
683
- padding-bottom: 18px;
684
- }
901
+ .times-stand-cite {
902
+ font-family: var(--serif);
903
+ font-size: 10.5px;
904
+ letter-spacing: 0.14em;
905
+ text-transform: uppercase;
906
+ color: var(--inv-ink-soft);
907
+ margin-top: 10px;
685
908
  }
686
909
 
687
- .np-grid-4 {
688
- display: grid;
689
- grid-template-columns: 1fr 1fr 1fr 1fr;
690
- gap: 18px;
691
- margin: 12px 0;
910
+ /* TIMES stat tile · serif numeric on cream · narrow sidebar
911
+ constraint forces 30px max for the numeric so 5-char strings
912
+ ("$120M" / "2,000") fit cleanly without breaking. */
913
+ .times-stat {
914
+ background: var(--paper-soft);
915
+ padding: 14px 14px;
916
+ border: 1px solid var(--ink);
917
+ text-align: center;
918
+ margin: 8px 0;
692
919
  }
693
- .np-grid-4 > .np-col {
694
- padding-right: 18px;
695
- border-right: 1px solid var(--rule);
920
+ .times-stat-eye {
921
+ font-family: var(--serif);
922
+ font-size: 10px;
923
+ letter-spacing: 0.16em;
924
+ text-transform: uppercase;
925
+ color: var(--ink-mid);
926
+ font-weight: 700;
927
+ padding-bottom: 6px;
928
+ border-bottom: 1px solid var(--rule);
929
+ margin-bottom: 8px;
696
930
  }
697
- .np-grid-4 > .np-col:last-child {
698
- padding-right: 0;
699
- border-right: 0;
931
+ .times-stat-eye::before { content: "★ "; color: var(--ink); }
932
+ .times-stat-eye::after { content: " ★"; color: var(--ink); }
933
+ .times-stat-num {
934
+ font-family: var(--serif-display);
935
+ font-size: 30px;
936
+ font-weight: 700;
937
+ line-height: 1.0;
938
+ letter-spacing: -0.018em;
939
+ color: var(--ink);
940
+ word-break: break-word;
941
+ overflow-wrap: anywhere;
700
942
  }
701
- @media (max-width: 720px) {
702
- .np-grid-4 { grid-template-columns: 1fr; }
703
- .np-grid-4 > .np-col {
704
- border-right: 0;
705
- padding-right: 0;
706
- padding-bottom: 18px;
707
- border-bottom: 1px solid var(--rule);
708
- }
709
- .np-grid-4 > .np-col:last-child { border-bottom: 0; padding-bottom: 0; }
943
+ .times-stat-cap {
944
+ font-family: var(--serif);
945
+ font-style: italic;
946
+ font-size: 12px;
947
+ line-height: 1.4;
948
+ color: var(--ink-mid);
949
+ margin-top: 6px;
710
950
  }
711
951
 
712
- /* Centered pull-quote band · used by layout 2 between the
713
- narrow-column body and the talking-points tail. */
714
- .np-pull-band {
715
- text-align: center;
952
+ /* TIMES inside running header · plain minimal */
953
+ .times-running {
954
+ display: grid;
955
+ grid-template-columns: auto 1fr auto;
956
+ align-items: center;
957
+ gap: 16px;
958
+ padding-bottom: 8px;
959
+ border-bottom: 1px solid var(--ink);
960
+ font-family: var(--serif);
961
+ font-size: 11px;
962
+ color: var(--ink);
963
+ }
964
+ .times-running-l {
716
965
  font-family: var(--serif-display);
717
- font-size: 22px;
966
+ font-size: 14px;
967
+ font-weight: 700;
968
+ letter-spacing: 0.005em;
969
+ }
970
+ .times-running-c {
971
+ text-align: center;
718
972
  font-style: italic;
719
- line-height: 1.35;
973
+ color: var(--ink-mid);
974
+ }
975
+ .times-running-r {
976
+ font-family: var(--serif);
977
+ font-size: 11px;
978
+ letter-spacing: 0.16em;
979
+ text-transform: uppercase;
720
980
  color: var(--ink);
721
- padding: 16px 36px;
722
- letter-spacing: -0.005em;
723
- margin: 10px auto;
724
- max-width: 720px;
725
981
  }
726
982
 
727
- /* 2-col grid · used by layout 2 + layout 3 for the talking-points
728
- tail (split into halves). */
729
- .np-grid-2 {
730
- display: grid;
731
- grid-template-columns: 1fr 1fr;
732
- gap: 22px;
733
- margin: 12px 0;
983
+ /* TIMES inside section banner · centered with rules + caps */
984
+ .times-section-banner {
985
+ text-align: center;
986
+ margin: 18px 0 14px;
734
987
  }
735
- .np-grid-2 > .np-col {
736
- padding-right: 22px;
737
- border-right: 1px solid var(--rule);
988
+ .times-section-banner-rule {
989
+ height: 1px;
990
+ background: var(--ink);
991
+ margin: 6px 0;
738
992
  }
739
- .np-grid-2 > .np-col:last-child {
740
- padding-right: 0;
741
- border-right: 0;
993
+ .times-section-banner-name {
994
+ font-family: var(--serif-display);
995
+ font-size: 22px;
996
+ font-weight: 700;
997
+ letter-spacing: 0.16em;
998
+ text-transform: uppercase;
999
+ color: var(--ink);
1000
+ padding: 0 4px;
742
1001
  }
743
- @media (max-width: 720px) {
744
- .np-grid-2 { grid-template-columns: 1fr; gap: 18px; }
745
- .np-grid-2 > .np-col {
746
- border-right: 0;
747
- padding-right: 0;
748
- border-bottom: 1px solid var(--rule);
749
- padding-bottom: 18px;
750
- }
751
- .np-grid-2 > .np-col:last-child { border-bottom: 0; padding-bottom: 0; }
1002
+ .times-section-banner-deck {
1003
+ font-family: var(--serif);
1004
+ font-style: italic;
1005
+ font-size: 12.5px;
1006
+ color: var(--ink-mid);
1007
+ margin-top: 4px;
752
1008
  }
753
1009
 
754
- /* Layout 3 · asymmetric 2/3 main + 1/3 sidebar. The sidebar
755
- stacks all the non-prose elements (callouts + figure/headings)
756
- while the main column carries the lead + secondary stories. */
757
- .np-hero-asym {
1010
+ /* TIMES inside · 4-col article spread */
1011
+ .times-inside {
758
1012
  display: grid;
759
- grid-template-columns: 2fr 1fr;
1013
+ grid-template-columns: minmax(0, 2.6fr) minmax(180px, 1fr);
760
1014
  gap: 28px;
761
- margin: 10px 0;
762
- align-items: start;
1015
+ margin-top: 4px;
763
1016
  }
764
- .np-hero-asym > .np-hero-main {
765
- padding-right: 28px;
766
- border-right: 1px solid var(--rule);
767
- display: flex;
768
- flex-direction: column;
769
- gap: 12px;
1017
+ @media (max-width: 720px) {
1018
+ .times-inside { grid-template-columns: 1fr; }
1019
+ }
1020
+ .times-inside-main {
1021
+ column-count: 2;
1022
+ column-gap: 22px;
1023
+ column-rule: 1px solid var(--rule);
1024
+ font-family: var(--serif);
1025
+ font-size: 12.5px;
1026
+ line-height: 1.6;
1027
+ color: var(--ink-soft);
1028
+ text-align: justify;
1029
+ hyphens: auto;
1030
+ -webkit-hyphens: auto;
1031
+ }
1032
+ @media (max-width: 540px) {
1033
+ .times-inside-main { column-count: 1; }
1034
+ }
1035
+ .times-inside-main p + p { margin-top: 6px; text-indent: 1.2em; }
1036
+ .times-inside-main.has-drop::first-letter {
1037
+ font-family: var(--serif-display);
1038
+ font-size: 52px;
1039
+ line-height: 0.85;
1040
+ font-weight: 700;
1041
+ color: var(--ink);
1042
+ float: left;
1043
+ margin: 4px 6px 0 0;
770
1044
  }
771
- .np-hero-asym > .np-hero-side {
1045
+ .times-inside-side {
772
1046
  display: flex;
773
1047
  flex-direction: column;
774
1048
  gap: 14px;
1049
+ padding-left: 22px;
1050
+ border-left: 1px solid var(--rule);
775
1051
  }
776
1052
  @media (max-width: 720px) {
777
- .np-hero-asym { grid-template-columns: 1fr; gap: 18px; }
778
- .np-hero-asym > .np-hero-main {
779
- padding-right: 0;
780
- border-right: 0;
781
- padding-bottom: 18px;
782
- border-bottom: 1px solid var(--rule);
783
- }
1053
+ .times-inside-side { border-left: 0; padding-left: 0; padding-top: 18px; border-top: 1px solid var(--rule); }
1054
+ }
1055
+
1056
+ /* TIMES "Continued from page A1" tag */
1057
+ .times-continued {
1058
+ font-family: var(--serif);
1059
+ font-style: italic;
1060
+ font-size: 11px;
1061
+ color: var(--accent);
1062
+ margin-bottom: 8px;
1063
+ }
1064
+ .times-continued::before { content: "→ "; font-style: normal; color: var(--ink); }
1065
+
1066
+ /* TIMES pull-quote inline · star ornaments */
1067
+ .times-pull {
1068
+ text-align: center;
1069
+ margin: 14px 0;
1070
+ padding: 10px 0;
1071
+ border-top: 1px solid var(--ink);
1072
+ border-bottom: 1px solid var(--ink);
784
1073
  }
785
-
786
- /* Framed pull-quote · used by layout 3 · double-rule top + bottom
787
- for an antique broadsheet pull-quote feel. */
788
- .np-pull-framed {
789
- text-align: center;
1074
+ .times-pull-text {
790
1075
  font-family: var(--serif-display);
791
- font-size: 22px;
792
1076
  font-style: italic;
1077
+ font-size: 19px;
793
1078
  line-height: 1.4;
794
1079
  color: var(--ink);
795
- padding: 24px 36px;
796
- border-top: 2px double var(--ink);
797
- border-bottom: 2px double var(--ink);
798
- margin: 12px auto;
799
- max-width: 760px;
800
1080
  letter-spacing: -0.005em;
801
1081
  }
802
-
803
- /* Centered figure · used by layout 3 at the bottom of the page
804
- (the chart sits as the closing visual element rather than tucked
805
- into a column). */
806
- .np-figure-centered {
807
- max-width: 480px;
808
- margin: 12px auto;
809
- }
810
-
811
- /* ═════════════════════════════════════════════════════════════════
812
- VARIANT 2 · Modern monochrome (Le Monde / Berliner register)
813
- Cooler off-white paper, near-black ink, deep red spot accent.
814
- Masthead nameplate flips to a CONDENSED SANS for the visual
815
- identity shift; the body stays serif for editorial readability.
816
- The dark callout fills become solid black with a red kicker.
817
- ═════════════════════════════════════════════════════════════════ */
818
- body[data-np-variant="2"] {
819
- --bg: #ECE7DA;
820
- --bg-radial: rgba(20, 20, 15, 0.04);
821
- --paper: #F8F4EA;
822
- --paper-soft: #F2EDE0;
823
- --paper-edge: #E8E2D2;
824
- --ink: #14140F;
825
- --ink-soft: #2C2A24;
826
- --ink-mid: #5A574F;
827
- --ink-faint: #8A867D;
828
- --ink-muted: #B0ABA0;
829
- --inv-bg: #14140F;
830
- --inv-bg-soft: #1F1E18;
831
- --inv-ink: #FFFFFF;
832
- --inv-ink-soft:#C4C0B6;
833
- --rule: #B0ABA0;
834
- --rule-soft: #C8C4B8;
835
- --rule-strong: #75716A;
836
- --accent: #B12B2B;
837
- --top-bar-bg: rgba(236, 231, 218, 0.92);
838
- }
839
- body[data-np-variant="2"] .np-mast-name {
840
- font-family: "Inter", "Helvetica Neue", -apple-system,
841
- "PingFang SC", "Hiragino Sans GB", sans-serif;
842
- font-weight: 900;
843
- font-stretch: 80%;
844
- letter-spacing: -0.04em;
845
- font-size: 70px;
846
- }
847
- @media (max-width: 720px) {
848
- body[data-np-variant="2"] .np-mast-name { font-size: 46px; }
849
- }
850
- body[data-np-variant="2"] .np-frontpage-title { letter-spacing: -0.005em; }
851
- body[data-np-variant="2"] .np-rule-double {
852
- height: 2px;
853
- background: var(--ink);
854
- }
855
- body[data-np-variant="2"] .np-callout-label {
856
- color: var(--accent);
857
- letter-spacing: 0.22em;
858
- }
859
- body[data-np-variant="2"] .np-callout-date .np-date-day {
860
- color: var(--accent);
1082
+ .times-pull-cite {
1083
+ font-family: var(--serif);
1084
+ font-size: 10.5px;
1085
+ letter-spacing: 0.16em;
1086
+ text-transform: uppercase;
1087
+ color: var(--ink-mid);
1088
+ margin-top: 6px;
861
1089
  }
862
- body[data-np-variant="2"] .np-conclusion-mark { color: var(--accent); }
863
- body[data-np-variant="2"] .np-flow-mark { color: var(--accent); }
864
- body[data-np-variant="2"] .np-prose-lead::first-letter {
865
- color: var(--accent);
1090
+ .times-pull-cite::before { content: "★ "; color: var(--accent); }
1091
+ .times-pull-cite::after { content: " ★"; color: var(--accent); }
1092
+
1093
+ /* TIMES sidebar · "What's inside" + verification list */
1094
+ .times-side-flag {
1095
+ font-family: var(--serif-display);
1096
+ font-size: 14px;
1097
+ font-weight: 700;
1098
+ letter-spacing: 0.04em;
1099
+ color: var(--ink);
1100
+ text-align: center;
1101
+ padding: 4px 0;
1102
+ border-top: 2px solid var(--ink);
1103
+ border-bottom: 1px solid var(--ink);
866
1104
  }
867
- body[data-np-variant="2"] .np-bar-fill { background: var(--ink); }
868
- body[data-np-variant="2"] .np-bar-row {
869
- background: repeating-linear-gradient(
870
- 45deg,
871
- #E8E2D2 0,
872
- #E8E2D2 4px,
873
- #F8F4EA 4px,
874
- #F8F4EA 8px
875
- );
1105
+ .times-side-list {
1106
+ list-style: none;
1107
+ margin: 0;
1108
+ padding: 0;
1109
+ display: flex;
1110
+ flex-direction: column;
876
1111
  }
877
- body[data-np-variant="2"] .np-grid > .np-col {
878
- border-right-color: var(--accent);
1112
+ .times-side-list li {
1113
+ padding: 10px 0;
1114
+ border-bottom: 1px solid var(--rule);
1115
+ font-family: var(--serif);
1116
+ font-size: 12px;
1117
+ line-height: 1.55;
1118
+ color: var(--ink-soft);
879
1119
  }
880
- body[data-np-variant="2"] .np-list li b {
881
- color: var(--accent);
1120
+ .times-side-list li:last-child { border-bottom: 0; }
1121
+ .times-side-list li b {
1122
+ display: block;
1123
+ font-family: var(--serif-display);
1124
+ font-size: 13.5px;
1125
+ font-weight: 700;
1126
+ color: var(--ink);
1127
+ letter-spacing: -0.004em;
1128
+ margin-bottom: 4px;
882
1129
  }
883
1130
 
884
- /* ═════════════════════════════════════════════════════════════════
885
- VARIANT 3 · Vintage / Sepia (penny-press · turn-of-the-century)
886
- Deeper aged-paper, warm sepia ink, amber accent. Masthead flips
887
- to an italic high-contrast Didot/Bodoni-style display serif.
888
- Section dividers become ornamental ◆◆◆ dingbat rows. Callouts
889
- pick up a 2px double border for a framed, antique feel.
890
- ═════════════════════════════════════════════════════════════════ */
891
- body[data-np-variant="3"] {
892
- --bg: #C9B286;
893
- --bg-radial: rgba(155, 100, 50, 0.10);
894
- --paper: #E5D2A4;
895
- --paper-soft: #EBDAB0;
896
- --paper-edge: #DCC68F;
897
- --ink: #3D2818;
898
- --ink-soft: #5C3F22;
899
- --ink-mid: #7A593A;
900
- --ink-faint: #9A7E5A;
901
- --ink-muted: #B89D7A;
902
- --inv-bg: #3D2818;
903
- --inv-bg-soft: #4F3622;
904
- --inv-ink: #F2DBB4;
905
- --inv-ink-soft:#C7AC7E;
906
- --rule: #A88D5F;
907
- --rule-soft: #BFA677;
908
- --rule-strong: #6B4A1F;
909
- --accent: #B5722E;
910
- --top-bar-bg: rgba(201, 178, 134, 0.92);
911
- }
912
- body[data-np-variant="3"] .np-mast-name {
913
- font-family: "Didot", "Bodoni 72", "Bodoni Moda",
914
- "Tiempos Headline", "Playfair Display", Georgia, serif;
915
- font-weight: 900;
916
- font-style: italic;
917
- letter-spacing: 0.02em;
918
- font-size: 60px;
919
- }
920
- @media (max-width: 720px) {
921
- body[data-np-variant="3"] .np-mast-name { font-size: 42px; }
1131
+ /* ═══════════════════════════════════════════════════════════════
1132
+ Shared · page footer + states + print
1133
+ ═══════════════════════════════════════════════════════════════ */
1134
+
1135
+ .np-page-foot {
1136
+ display: grid;
1137
+ grid-template-columns: 1fr auto 1fr;
1138
+ align-items: baseline;
1139
+ gap: 14px;
1140
+ padding-top: 14px;
1141
+ margin-top: 18px;
1142
+ border-top: 1px solid var(--rule);
1143
+ font-family: var(--mono);
1144
+ font-size: 10px;
1145
+ letter-spacing: 0.08em;
1146
+ text-transform: uppercase;
1147
+ color: var(--ink-faint);
922
1148
  }
923
- /* Replace the double-rule horizontal divider with a dingbat row.
924
- Keeps the visual rhythm but reads as engraved/antique. */
925
- body[data-np-variant="3"] .np-rule-double {
926
- height: auto;
927
- background: transparent;
928
- margin: 18px 0;
1149
+ .np-page-foot-left { text-align: left; }
1150
+ .np-page-foot-mid { text-align: center; color: var(--ink-mid); font-weight: 700; }
1151
+ .np-page-foot-right { text-align: right; font-style: italic; text-transform: none; }
1152
+
1153
+ .np-state {
1154
+ max-width: 560px;
1155
+ margin: 80px auto;
1156
+ padding: 48px 36px;
929
1157
  text-align: center;
930
- font-family: "Didot", "Bodoni 72", serif;
931
- color: var(--ink);
932
- font-size: 14px;
933
- letter-spacing: 0.6em;
934
- line-height: 1;
935
- }
936
- body[data-np-variant="3"] .np-rule-double::before {
937
- content: "◆ ◆ ◆";
938
- }
939
- body[data-np-variant="3"] .np-callout {
940
- border: 2px double var(--accent);
941
- }
942
- body[data-np-variant="3"] .np-callout-label,
943
- body[data-np-variant="3"] .np-callout-date .np-date-day {
944
- color: var(--accent);
945
- }
946
- body[data-np-variant="3"] .np-conclusion-mark::before {
947
- color: var(--accent);
1158
+ background: var(--paper);
1159
+ box-shadow: var(--shadow-page);
948
1160
  }
949
- body[data-np-variant="3"] .np-flow-mark::before { color: var(--accent); }
950
- body[data-np-variant="3"] .np-prose-lead::first-letter {
951
- color: var(--accent);
952
- font-family: "Didot", "Bodoni 72", "Tiempos Headline", Georgia, serif;
953
- font-style: italic;
954
- font-size: 48px;
1161
+ .np-state-mark {
1162
+ font-family: var(--mono);
1163
+ font-size: 10px;
1164
+ letter-spacing: 0.18em;
1165
+ text-transform: uppercase;
1166
+ color: var(--ink);
1167
+ border: 1px solid var(--ink);
1168
+ padding: 5px 14px;
1169
+ display: inline-block;
1170
+ margin-bottom: 16px;
1171
+ font-weight: 700;
955
1172
  }
956
- body[data-np-variant="3"] .np-bar-fill { background: var(--accent); }
957
- body[data-np-variant="3"] .np-figure { border: 2px double var(--ink); }
958
- body[data-np-variant="3"] .np-list li b {
959
- font-family: "Didot", "Bodoni 72", "Tiempos Headline", Georgia, serif;
960
- font-style: italic;
1173
+ .np-state-title {
1174
+ font-family: var(--serif-display);
1175
+ font-size: 24px;
1176
+ font-weight: 700;
1177
+ color: var(--ink);
1178
+ margin-bottom: 10px;
1179
+ line-height: 1.2;
1180
+ text-transform: uppercase;
1181
+ letter-spacing: 0.02em;
961
1182
  }
962
- body[data-np-variant="3"] .np-frontpage-title {
963
- font-style: italic;
1183
+ .np-state-body {
1184
+ font-family: var(--serif);
1185
+ font-size: 13.5px;
1186
+ color: var(--ink-soft);
1187
+ line-height: 1.6;
964
1188
  }
965
1189
 
966
- /* ─── Print · drop chrome ───────────────────────────────────── */
967
1190
  @media print {
968
1191
  .np-top-bar { display: none; }
969
1192
  body, html { background: white; }
970
- .np-doc {
971
- max-width: none;
972
- margin: 0;
1193
+ .np-doc { max-width: none; padding: 0; margin: 0; }
1194
+ .np-page {
973
1195
  box-shadow: none;
1196
+ margin: 0;
1197
+ padding: 16mm 18mm 18mm;
1198
+ page-break-after: always;
974
1199
  }
975
- .np-col, .np-callout, .np-figure,
976
- .np-hero-band > .np-hero-figure,
977
- .np-hero-asym > .np-hero-main,
978
- .np-pull-band, .np-pull-framed {
979
- break-inside: avoid;
980
- page-break-inside: avoid;
981
- }
1200
+ .np-page:last-child { page-break-after: auto; }
982
1201
  }
983
1202
  </style>
984
1203
  </head>
@@ -987,6 +1206,11 @@
987
1206
  <header class="np-top-bar" data-np-chrome>
988
1207
  <a href="/" class="np-crumb">PrivateBoard <span class="np-crumb-accent">· newspaper</span></a>
989
1208
  <div class="np-actions">
1209
+ <span class="np-variant-badge" data-np-variant-badge>Loading…</span>
1210
+ <nav class="np-page-nav" aria-label="Pages">
1211
+ <a href="#np-page-1">P. 1</a>
1212
+ <a href="#np-page-2">P. 2</a>
1213
+ </nav>
990
1214
  <button type="button" class="np-btn" data-np-png>
991
1215
  <span class="glyph">↓</span>PNG
992
1216
  </button>
@@ -1006,61 +1230,24 @@
1006
1230
 
1007
1231
  <script>
1008
1232
  /* ──────────────────────────────────────────────────────────────────
1009
- Newspaper renderer · reads the same BentoScaffold JSON that bento /
1010
- magazine modes produce, but maps the slots into a broadsheet layout.
1011
-
1012
- Slot mapping:
1013
- title → front-page banner headline
1014
- kicker → subdeck under the headline
1015
- source → masthead byline (used in flank decoration)
1016
- footerTag → meta-strip date / date-stack callout
1017
- milestones[3] 3 main column-stories
1018
- · LEFT: heading + BOTTOM-LINE callout + body
1019
- · MIDDLE: lead drop-cap body + date callout +
1020
- continuation prose
1021
- · RIGHT: figure (rankedBars chart) + heading + body
1022
- rankedBars → figure card in the right column's "image slot"
1023
- verification → "MORE HEADINGS" stacked sidebar in bottom-left
1024
- talkingPoints → bottom-middle / bottom-right editorial paragraphs
1025
- (with one card promoted to a centered pull-quote)
1026
- conclusion → BOTTOM-LINE inverted callout in the LEFT column
1027
- flow → bottom-right "FROM HERE" closing prose
1028
-
1029
- The renderer is defensive — it skips any slot that's missing/empty
1030
- instead of leaving placeholder boxes, so partial data still
1031
- produces a clean page.
1233
+ Newspaper renderer · two distinct templates picked deterministi-
1234
+ cally from the brief id:
1235
+ · POST · Washington-Post register (red accents · sans kickers ·
1236
+ display-serif nameplate · 3-col body grid)
1237
+ · TIMES · New York Times register (blackletter nameplate ·
1238
+ "All the news that's fit to print" tagline · navy accent ·
1239
+ star ornaments · 6-col grid front + 2-col inside)
1240
+
1241
+ Each variant renders 2 pages (front + inside) so the report
1242
+ reads as a paper, not a single sheet. The variant + page count
1243
+ are stable across refreshes (hash of brief id).
1032
1244
  ────────────────────────────────────────────────────────────── */
1033
1245
  (function () {
1034
1246
  const params = new URLSearchParams(location.search);
1035
1247
  const briefId = (params.get("b") || "").trim();
1036
1248
  const roomId = (params.get("r") || "").trim();
1037
1249
  const root = document.querySelector("[data-np-root]");
1038
-
1039
- /** Pick one of three newspaper variants deterministically from
1040
- * the brief id, so a refresh always shows the same look for the
1041
- * same brief. The `?v=1|2|3` URL parameter overrides the hash
1042
- * for previewing / debugging.
1043
- *
1044
- * · 1 · Broadsheet · the default WSJ / FT register · cream paper
1045
- * + deep brown ink + heavy serif nameplate
1046
- * · 2 · Modern monochrome · Le Monde register · cooler off-white
1047
- * + near-black + deep red accent · CONDENSED SANS nameplate
1048
- * · 3 · Vintage sepia · penny-press register · aged sepia paper
1049
- * + amber accent · italic Didot/Bodoni nameplate · ◆◆◆
1050
- * ornamental dingbats replace the double-rule dividers
1051
- */
1052
- function pickVariant(id) {
1053
- const force = (params.get("v") || "").trim();
1054
- if (force === "1" || force === "2" || force === "3") return parseInt(force, 10);
1055
- const s = String(id || "");
1056
- if (!s) return 1;
1057
- let h = 0;
1058
- for (let i = 0; i < s.length; i++) {
1059
- h = ((h << 5) - h) + s.charCodeAt(i);
1060
- h |= 0;
1061
- }
1062
- return (Math.abs(h) % 3) + 1;
1063
- }
1250
+ const variantBadge = document.querySelector("[data-np-variant-badge]");
1064
1251
 
1065
1252
  function escape(s) {
1066
1253
  return String(s == null ? "" : s)
@@ -1082,29 +1269,54 @@
1082
1269
 
1083
1270
  async function loadBrief() {
1084
1271
  let url;
1085
- if (briefId) {
1086
- url = `/api/briefs/${encodeURIComponent(briefId)}`;
1087
- } else if (roomId) {
1088
- url = `/api/rooms/${encodeURIComponent(roomId)}/brief`;
1089
- } else {
1090
- showState("Missing query", "No brief specified",
1091
- "Add ?b=<briefId> or ?r=<roomId> to the URL.");
1092
- return null;
1093
- }
1272
+ if (briefId) url = `/api/briefs/${encodeURIComponent(briefId)}`;
1273
+ else if (roomId) url = `/api/rooms/${encodeURIComponent(roomId)}/brief`;
1274
+ else { showState("Missing query", "No brief specified", "Add ?b=<briefId> or ?r=<roomId> to the URL."); return null; }
1094
1275
  const res = await fetch(url);
1095
1276
  if (!res.ok) {
1096
1277
  const e = await res.json().catch(() => ({}));
1097
- showState("Not found", "Brief not found",
1098
- e.error || "The requested brief doesn't exist or is no longer available.");
1278
+ showState("Not found", "Brief not found", e.error || "The requested brief doesn't exist or is no longer available.");
1099
1279
  return null;
1100
1280
  }
1101
1281
  return await res.json();
1102
1282
  }
1103
1283
 
1104
- /** Split a "Heading: body." style verification bullet on the
1105
- * first colon. Falls back to em-dash / middle-dot, then to
1106
- * whole-bullet-as-heading. The newspaper prompt asks for colon
1107
- * separators specifically. */
1284
+ /** Pick "post" or "times" deterministically from the brief id ·
1285
+ * same brief id always renders the same template so refreshes /
1286
+ * share-links / PNG exports stay stable. The `?v=post|times`
1287
+ * URL parameter forces a specific template (debug / preview). */
1288
+ function pickVariant(id) {
1289
+ const force = (params.get("v") || "").trim().toLowerCase();
1290
+ if (force === "post" || force === "times") return force;
1291
+ const s = String(id || "");
1292
+ if (!s) return "post";
1293
+ let h = 0;
1294
+ for (let i = 0; i < s.length; i++) {
1295
+ h = ((h << 5) - h) + s.charCodeAt(i);
1296
+ h |= 0;
1297
+ }
1298
+ return (Math.abs(h) % 2) === 0 ? "post" : "times";
1299
+ }
1300
+
1301
+ /** Format date for masthead · returns formatted strings used by
1302
+ * both variants but in slightly different shapes. */
1303
+ function formatDate(footerTag) {
1304
+ const months = ["January","February","March","April","May","June","July","August","September","October","November","December"];
1305
+ const monthsShort = ["Jan.","Feb.","March","April","May","June","July","Aug.","Sept.","Oct.","Nov.","Dec."];
1306
+ const days = ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];
1307
+ const m = String(footerTag || "").match(/(\d{4})-(\d{2})-(\d{2})/);
1308
+ const target = m ? new Date(`${m[1]}-${m[2]}-${m[3]}T00:00:00`) : new Date();
1309
+ const d = isNaN(target.getTime()) ? new Date() : target;
1310
+ return {
1311
+ weekday: days[d.getDay()],
1312
+ weekdayShort: days[d.getDay()].toUpperCase().slice(0, 3),
1313
+ long: `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`,
1314
+ short: `${monthsShort[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`,
1315
+ year: String(d.getFullYear()),
1316
+ nytDate: `${days[d.getDay()]}, ${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`,
1317
+ };
1318
+ }
1319
+
1108
1320
  function splitHeading(raw) {
1109
1321
  const s = String(raw || "").trim();
1110
1322
  if (!s) return { heading: "", body: "" };
@@ -1112,512 +1324,451 @@
1112
1324
  for (const sep of seps) {
1113
1325
  const idx = s.indexOf(sep);
1114
1326
  if (idx > 0 && idx < 60) {
1115
- return {
1116
- heading: s.slice(0, idx).trim(),
1117
- body: s.slice(idx + sep.length).trim(),
1118
- };
1327
+ return { heading: s.slice(0, idx).trim(), body: s.slice(idx + sep.length).trim() };
1119
1328
  }
1120
1329
  }
1121
1330
  return { heading: s, body: "" };
1122
1331
  }
1123
1332
 
1124
- /** Render the rankedBars chart inside the right column's figure
1125
- * slot · diagonal-hatched track + solid ink fill, mono labels,
1126
- * newspaper-graphic register. Skipped when the brief has no
1127
- * ranked-numeric material. */
1128
- function renderFigure(rankedBars, fallbackCaption) {
1129
- if (!rankedBars || !Array.isArray(rankedBars.entries) || rankedBars.entries.length === 0) {
1130
- return "";
1333
+ /** Pick a stat tile's numeric + caption from the brief data.
1334
+ * Strict: only returns a tile when a milestone has a SHORT
1335
+ * numeric-y callout (≤ 14 chars, the "$120M" / "10×" / "Q4"
1336
+ * shape). The previous version fell back to verification
1337
+ * headings as the "num" slot, which rendered long phrases
1338
+ * ("Compliance review remains the gating constraint") at
1339
+ * 38-48px display-serif in a 200px-wide column · severely
1340
+ * cramped. Skipping is honest. */
1341
+ function pickStat(m) {
1342
+ const ms = m.milestones || [];
1343
+ const milestoneWithCallout = ms.find((x) => x && x.callout && String(x.callout).trim().length > 0 && String(x.callout).trim().length <= 14);
1344
+ if (milestoneWithCallout) {
1345
+ return {
1346
+ num: String(milestoneWithCallout.callout).trim(),
1347
+ cap: milestoneWithCallout.title || milestoneWithCallout.period || "",
1348
+ };
1131
1349
  }
1132
- const rows = rankedBars.entries.slice(0, 5).map((e) => {
1133
- const ratio = Math.max(0, Math.min(1, Number(e.ratio) || 0));
1134
- const pct = (ratio * 100).toFixed(1);
1135
- return `
1136
- <div class="np-bar">
1137
- <span>${escape(e.label || "")}</span>
1138
- <span class="np-bar-value">${escape(e.value || "")}</span>
1139
- <div class="np-bar-row"><span class="np-bar-fill" style="width: ${pct}%"></span></div>
1140
- </div>`;
1141
- }).join("");
1142
- const caption = rankedBars.title || fallbackCaption || "Reading the room";
1143
- return `
1144
- <figure class="np-figure">
1145
- <div class="np-figure-bars">${rows}</div>
1146
- <figcaption class="np-figcaption">${escape(caption)}</figcaption>
1147
- </figure>`;
1350
+ return null;
1148
1351
  }
1149
1352
 
1150
- function renderBottomLineCallout(conclusion) {
1151
- if (!conclusion) return "";
1353
+ function pageFoot(pageNum, totalPages, briefIdShort) {
1152
1354
  return `
1153
- <aside class="np-callout">
1154
- <div class="np-callout-label">Bottom line</div>
1155
- <div class="np-callout-text">${escape(conclusion)}</div>
1156
- </aside>`;
1355
+ <div class="np-page-foot">
1356
+ <span class="np-page-foot-left">${escape(briefIdShort)}</span>
1357
+ <span class="np-page-foot-mid">Page ${pageNum} of ${totalPages}</span>
1358
+ <span class="np-page-foot-right">privateboard.ai</span>
1359
+ </div>`;
1157
1360
  }
1158
1361
 
1159
- /** Render the inverted date-stack callout. Pulls a date out of the
1160
- * footerTag if one is present (matching `\d{4}` or `YYYY-MM-DD`);
1161
- * otherwise uses today's date. */
1162
- function renderDateCallout(footerTag) {
1163
- const today = new Date();
1164
- const months = ["JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"];
1165
- const days = ["SUNDAY","MONDAY","TUESDAY","WEDNESDAY","THURSDAY","FRIDAY","SATURDAY"];
1166
- // Try to parse a date out of footerTag — supports YYYY-MM-DD anywhere in the string.
1167
- let target = today;
1168
- const m = String(footerTag || "").match(/(\d{4})-(\d{2})-(\d{2})/);
1169
- if (m) {
1170
- const parsed = new Date(`${m[1]}-${m[2]}-${m[3]}T00:00:00`);
1171
- if (!isNaN(parsed.getTime())) target = parsed;
1362
+ /* ═════════════════════════════════════════════════════════════
1363
+ POST · The Boardroom Post · Washington Post register
1364
+ ═════════════════════════════════════════════════════════════ */
1365
+
1366
+ function renderPostPage1(brief, parts) {
1367
+ const { m, ms0, ms1, ms2, dateInfo, briefIdShort, totalPages } = parts;
1368
+ const tpAll = (m.talkingPoints && Array.isArray(m.talkingPoints.bullets)) ? m.talkingPoints.bullets : [];
1369
+ const tpFirst = tpAll[0] || "";
1370
+ const tpSecond = tpAll[1] || "";
1371
+ const stat = pickStat(m);
1372
+ const indexItems = [
1373
+ { name: ms1.title || ms1.period || "Inside the room", page: "A2" },
1374
+ { name: ms2.title || ms2.period || "More from the desk", page: "A2" },
1375
+ ];
1376
+ if (m.verification && m.verification.title) {
1377
+ indexItems.push({ name: m.verification.title, page: "A2" });
1172
1378
  }
1173
- const day = days[target.getDay()];
1174
- const month = months[target.getMonth()];
1175
- const num = String(target.getDate());
1176
- const year = String(target.getFullYear());
1379
+ const indexHtml = indexItems.map((it) => `
1380
+ <li><b>${escape(it.name)}</b><span class="pg">Page ${escape(it.page)}</span></li>
1381
+ `).join("");
1382
+
1383
+ // Pack the body cell with extra content so it doesn't end
1384
+ // short relative to the sidebar (which has flag + head +
1385
+ // body + stat + pull = 4-5 stacked items). Mid-article
1386
+ // sub-headline + tail talking-point keeps body height in
1387
+ // reach of the sidebar.
1388
+ const bodySubHead = ms2.title || ms2.period || "";
1389
+ const bodySubBody = ms2.body || "";
1390
+
1177
1391
  return `
1178
- <aside class="np-callout np-callout-date">
1179
- <div class="np-date-day">${day}</div>
1180
- <div class="np-date-month">${month}</div>
1181
- <div class="np-date-num">${num}</div>
1182
- <div class="np-date-year">${year}</div>
1183
- <div class="np-date-source">privateboard.app</div>
1184
- </aside>`;
1185
- }
1392
+ <section class="np-page" id="np-page-1">
1393
+ <!-- Section dots row · WaPo nav strip above the mast -->
1394
+ <div class="post-sect-row">
1395
+ <span class="post-sect-item">Top Story</span>
1396
+ <span class="post-sect-item is-blue">Markets</span>
1397
+ <span class="post-sect-item is-gold">Opinion</span>
1398
+ <span class="post-sect-item is-teal">Inside</span>
1399
+ </div>
1186
1400
 
1187
- function renderColumnHeading(period, title) {
1188
- // Use period as the small section banner; promote title to a
1189
- // larger column heading when both are present and meaningfully
1190
- // different.
1191
- const p = String(period || "").trim();
1192
- const t = String(title || "").trim();
1193
- if (!p && !t) return "";
1194
- if (p && t && p.toLowerCase() !== t.toLowerCase()) {
1195
- return `
1196
- <h4 class="np-col-heading">${escape(p)}</h4>
1197
- <h3 class="np-col-heading np-col-heading-large">${escape(t)}</h3>`;
1198
- }
1199
- return `<h4 class="np-col-heading np-col-heading-large">${escape(t || p)}</h4>`;
1200
- }
1401
+ <div class="post-tagline">From the boardroom — insights, every issue.</div>
1402
+ <h1 class="post-mast-name">The Boardroom Post</h1>
1403
+
1404
+ <div class="post-meta">
1405
+ <span class="post-meta-l">${escape(dateInfo.weekday)}, ${escape(dateInfo.long)}</span>
1406
+ <span class="post-meta-c">privateboard.ai</span>
1407
+ <span class="post-meta-r">${escape(briefIdShort)}</span>
1408
+ </div>
1201
1409
 
1202
- function renderProse(text, options) {
1203
- const opts = options || {};
1204
- const t = String(text || "").trim();
1205
- if (!t) return "";
1206
- const klass = opts.lead ? "np-prose np-prose-lead" : "np-prose";
1207
- return `<div class="${klass}">${escape(t)}</div>`;
1410
+ <div class="post-hero">
1411
+ <span class="post-hero-flag">Top Story</span>
1412
+ <h2 class="post-hero-title">${escape(m.title || "")}</h2>
1413
+ ${m.kicker ? `<div class="post-hero-deck">${escape(m.kicker)}</div>` : ""}
1414
+ <div class="post-hero-byline">By the chair · <em>From the boardroom</em></div>
1415
+ </div>
1416
+
1417
+ ${m.conclusion ? `
1418
+ <aside class="post-stand">
1419
+ <div class="post-stand-mark">"</div>
1420
+ <div>
1421
+ <div class="post-stand-text">${escape(m.conclusion)}</div>
1422
+ <span class="post-stand-cite">— Bottom line · ${escape(m.title || "")}</span>
1423
+ </div>
1424
+ </aside>` : ""}
1425
+
1426
+ <div class="post-grid">
1427
+ <div>
1428
+ ${ms0.body ? `
1429
+ <div class="post-body has-drop">
1430
+ <p>${escape(ms0.body)}</p>
1431
+ ${ms1.body ? `<p>${escape(ms1.body)}</p>` : ""}
1432
+ </div>` : ""}
1433
+ ${tpSecond ? `
1434
+ <div class="post-pull" style="margin: 14px 0 8px;">
1435
+ <div class="post-pull-text">"${escape(tpSecond)}"</div>
1436
+ <div class="post-pull-cite">Editorial · second take</div>
1437
+ </div>` : ""}
1438
+ ${bodySubHead || bodySubBody ? `
1439
+ <div style="margin-top: 12px;">
1440
+ ${bodySubHead ? `<h4 style="font-family: var(--serif-display); font-size: 18px; font-weight: 700; line-height: 1.2; color: var(--ink); margin-bottom: 6px; padding-top: 10px; border-top: 1px solid var(--rule);">${escape(bodySubHead)}</h4>` : ""}
1441
+ ${bodySubBody ? `<p class="post-body" style="margin: 0;">${escape(bodySubBody)}</p>` : ""}
1442
+ </div>` : ""}
1443
+ </div>
1444
+ <aside class="post-side">
1445
+ <span class="post-side-flag">Breaking</span>
1446
+ <h3 class="post-side-head">${escape(ms1.title || ms1.period || "Late dispatches")}</h3>
1447
+ ${ms1.body ? `<p class="post-side-body">${escape(ms1.body)}</p>` : ""}
1448
+ ${stat ? `
1449
+ <div class="post-stat">
1450
+ <div class="post-stat-eye">By the numbers</div>
1451
+ <div class="post-stat-num">${escape(stat.num)}</div>
1452
+ <div class="post-stat-cap">${escape(stat.cap)}</div>
1453
+ </div>` : ""}
1454
+ ${tpFirst ? `
1455
+ <div class="post-pull">
1456
+ <div class="post-pull-text">"${escape(tpFirst)}"</div>
1457
+ <div class="post-pull-cite">From the editorial</div>
1458
+ </div>` : ""}
1459
+ </aside>
1460
+ </div>
1461
+
1462
+ ${indexHtml ? `
1463
+ <div class="post-index">
1464
+ <span class="post-index-flag">Inside this issue</span>
1465
+ <ul class="post-index-list">${indexHtml}</ul>
1466
+ </div>` : ""}
1467
+
1468
+ ${pageFoot(1, totalPages, briefIdShort)}
1469
+ </section>`;
1208
1470
  }
1209
1471
 
1210
- function renderBullets(verification) {
1211
- if (!verification || !Array.isArray(verification.bullets) || verification.bullets.length === 0) {
1212
- return "";
1213
- }
1214
- const items = verification.bullets.slice(0, 5).map((b) => {
1472
+ function renderPostPage2(brief, parts) {
1473
+ const { m, ms1, ms2, dateInfo, briefIdShort, totalPages } = parts;
1474
+ const tpAll = (m.talkingPoints && Array.isArray(m.talkingPoints.bullets)) ? m.talkingPoints.bullets : [];
1475
+ const pullQuote = tpAll[1] || tpAll[0] || "";
1476
+ const verifs = (m.verification && Array.isArray(m.verification.bullets)) ? m.verification.bullets : [];
1477
+ const verifsHtml = verifs.slice(0, 4).map((b) => {
1215
1478
  const parts = splitHeading(b);
1216
1479
  const inner = parts.body
1217
1480
  ? `<b>${escape(parts.heading)}</b>${escape(parts.body)}`
1218
1481
  : escape(parts.heading);
1219
1482
  return `<li>${inner}</li>`;
1220
1483
  }).join("");
1221
- return `<ul class="np-list">${items}</ul>`;
1222
- }
1223
-
1224
- /** Render talking points · first one becomes a centered pull-quote
1225
- * (the bottom-middle column's signature element); the rest become
1226
- * paragraph prose for the right column. */
1227
- function renderTalkingMiddle(talking) {
1228
- if (!talking || !Array.isArray(talking.bullets) || talking.bullets.length === 0) {
1229
- return "";
1230
- }
1231
- const first = talking.bullets[0];
1232
- const rest = talking.bullets.slice(1, 3).map((b) => `<p>${escape(b)}</p>`).join("");
1233
- return `
1234
- <p class="np-pull">"${escape(first)}"</p>
1235
- ${rest ? `<div class="np-prose">${rest}</div>` : ""}`;
1236
- }
1237
- function renderTalkingTail(talking) {
1238
- if (!talking || !Array.isArray(talking.bullets) || talking.bullets.length === 0) {
1239
- return "";
1240
- }
1241
- const tail = talking.bullets.slice(3, 6).map((b) => `<p>${escape(b)}</p>`).join("");
1242
- if (!tail) return "";
1243
- return `<div class="np-prose">${tail}</div>`;
1244
- }
1245
-
1246
- function render(brief) {
1247
- if (brief.mode !== "newspaper") {
1248
- showState("Wrong mode", "This brief isn't a newspaper",
1249
- "Open it in the matching viewer instead. Newspaper, magazine, bento, and research-note are separate output modes.");
1250
- return;
1251
- }
1252
- const m = brief.bodyJson;
1253
- if (!m || typeof m !== "object") {
1254
- if (brief.isGenerating) {
1255
- showState("Generating",
1256
- "Newspaper is still being prepared",
1257
- "The chair is currently composing the front page. Refresh in a few seconds.");
1258
- } else {
1259
- showState("Empty",
1260
- "This brief has no newspaper data",
1261
- "It may have failed to generate. Try regenerating from the room view.");
1262
- }
1263
- return;
1264
- }
1265
-
1266
- if (m.title) document.title = `${m.title} · Newspaper`;
1267
-
1268
- // Apply the variant attribute · drives the broadsheet / modern
1269
- // / vintage skin via CSS. Setting on body so the page bg and
1270
- // top-bar bg also retint per variant. Cleared / overwritten on
1271
- // every render so re-renders within the same page (e.g. retry
1272
- // path) pick up the right look.
1273
- const variant = pickVariant(brief.id);
1274
- document.body.setAttribute("data-np-variant", String(variant));
1275
-
1276
- // Masthead flanks · use the source byline as the left flank's
1277
- // "advertorial" decoration and a stable static line on the right
1278
- // so the masthead reads as balanced regardless of brief length.
1279
- const sourceTxt = m.source || "From the editor's desk";
1280
- const leftFlankBody = m.kicker || sourceTxt;
1281
- const flankLeft = `<b>${escape((sourceTxt || "Boardroom").toUpperCase())}</b>${escape(leftFlankBody.slice(0, 180))}`;
1282
- const flankRight = `<b>EDITORIAL NOTE</b>An issue from the room · pour a coffee, take it column by column, and trust the numbers more than the slogans.`;
1283
-
1284
- // Meta strip · prefer footerTag for the date format; fall back
1285
- // to today.
1286
- const today = new Date();
1287
- const monthsShort = ["JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"];
1288
- const daysShort = ["SUN","MON","TUE","WED","THU","FRI","SAT"];
1289
- const dateMatch = String(m.footerTag || "").match(/(\d{4})-(\d{2})-(\d{2})/);
1290
- const dispDate = (() => {
1291
- const d = dateMatch ? new Date(`${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}T00:00:00`) : today;
1292
- const dd = String(d.getDate()).padStart(2, "0");
1293
- const mm = String(d.getMonth() + 1).padStart(2, "0");
1294
- const yyyy = d.getFullYear();
1295
- const wk = daysShort[d.getDay()];
1296
- return `${dd}.${mm}.${yyyy} / ${wk}`;
1297
- })();
1298
- const briefIdShort = brief.id ? `#${String(brief.id).slice(0, 10).toUpperCase()}` : "";
1299
-
1300
- const milestones = (m.milestones || []).slice(0, 3);
1301
- const ms0 = milestones[0] || {};
1302
- const ms1 = milestones[1] || {};
1303
- const ms2 = milestones[2] || {};
1304
-
1305
- const figure = renderFigure(m.rankedBars, ms2.title);
1306
- const bottomLine = renderBottomLineCallout(m.conclusion);
1307
- const dateStack = renderDateCallout(m.footerTag);
1308
- const moreHeadings = renderBullets(m.verification);
1309
-
1310
- // Bottom band · 3 cols · MORE HEADINGS / pull quote + tail / closing
1311
- const flowText = (m.flow && Array.isArray(m.flow.nodes) && m.flow.nodes.length >= 2)
1312
- ? `<div class="np-prose">${escape(m.flow.nodes.join(" → "))}${m.flow.caption ? `<p style="font-style:italic;margin-top:6px;">${escape(m.flow.caption)}</p>` : ""}</div>`
1313
- : "";
1314
-
1315
- const parts = {
1316
- m, ms0, ms1, ms2,
1317
- flankLeft, flankRight, dispDate, briefIdShort,
1318
- bottomLine, dateStack, figure, moreHeadings, flowText,
1319
- };
1320
-
1321
- // Dispatch to the layout matching the picked variant. Each
1322
- // layout owns its full <article> tree · they share helpers
1323
- // (renderProse, renderFigure, renderColumnHeading, etc.) but
1324
- // arrange the blocks differently.
1325
- let html;
1326
- if (variant === 2) html = layoutVariant2(parts);
1327
- else if (variant === 3) html = layoutVariant3(parts);
1328
- else html = layoutVariant1(parts);
1329
- root.innerHTML = html;
1330
- }
1331
-
1332
- /* ─────────────────────── Layout dispatch ───────────────────────
1333
- * Three structurally distinct broadsheet layouts. Each takes the
1334
- * pre-rendered field parts and composes them into its own <article>
1335
- * tree. Variant 1 keeps the symmetric 3+3 broadsheet feel · variant
1336
- * 2 leans modular with a 2:1 hero band, 4-narrow-column body, and
1337
- * a centered pull-quote band · variant 3 goes asymmetric (2/3 main
1338
- * + 1/3 sidebar), framed pull-quote, 2-col tail, and a centered
1339
- * closing figure.
1340
- * ─────────────────────────────────────────────────────────────── */
1341
-
1342
- /** Layout 1 · classic 3+3 broadsheet (WSJ / FT register).
1343
- * TOP grid · heading+callout+body / heading+lead-drop+date / figure+heading+body
1344
- * BOTTOM grid · MORE HEADINGS / pull-quote + tail / closing prose */
1345
- function layoutVariant1(p) {
1346
- const { m, ms0, ms1, ms2, flankLeft, flankRight, dispDate, briefIdShort,
1347
- bottomLine, dateStack, figure, moreHeadings, flowText } = p;
1348
- const middlePull = renderTalkingMiddle(m.talkingPoints);
1349
- const tailProse = renderTalkingTail(m.talkingPoints);
1350
- const tailFallback = tailProse || flowText
1351
- || (m.conclusion ? `<h4 class="np-col-heading">From here</h4><div class="np-prose">${escape(m.conclusion)}</div>` : "");
1484
+ const sideHtml = verifsHtml ? `
1485
+ <span class="post-side-flag">More headings</span>
1486
+ <ul style="list-style:none;margin:0;padding:0;display:flex;flex-direction:column;">
1487
+ ${verifs.slice(0, 5).map((b) => {
1488
+ const p = splitHeading(b);
1489
+ return `
1490
+ <li style="font-family:var(--serif);font-size:12.5px;line-height:1.55;color:var(--ink-soft);padding:10px 0;border-bottom:1px solid var(--rule);">
1491
+ ${p.body
1492
+ ? `<b style="display:block;font-family:var(--serif-display);font-size:13.5px;font-weight:700;color:var(--ink);margin-bottom:4px;">${escape(p.heading)}</b>${escape(p.body)}`
1493
+ : escape(p.heading)}
1494
+ </li>`;
1495
+ }).join("")}
1496
+ </ul>` : "";
1497
+
1498
+ const bodyParts = [];
1499
+ if (ms1.body) bodyParts.push(`<p>${escape(ms1.body)}</p>`);
1500
+ if (ms2.body) bodyParts.push(`<p>${escape(ms2.body)}</p>`);
1501
+ const bodyHtml = bodyParts.join("");
1502
+
1503
+ const headline = ms1.title || ms2.title || "Inside the room";
1504
+ const deck = m.kicker || "";
1352
1505
 
1353
1506
  return `
1354
- <article class="np-doc np-layout-1" data-np-paper>
1355
- <div class="np-rule"></div>
1356
-
1357
- <header class="np-masthead">
1358
- <div class="np-mast-flank np-mast-flank-left">${flankLeft}</div>
1359
- <h1 class="np-mast-name">PrivateBoard</h1>
1360
- <div class="np-mast-flank np-mast-flank-right">${flankRight}</div>
1361
- </header>
1362
-
1363
- <div class="np-rule"></div>
1364
-
1365
- <div class="np-meta-strip">
1366
- <span class="np-meta-left">${escape(dispDate)}</span>
1367
- <span class="np-meta-center">www.privateboard.app</span>
1368
- <span class="np-meta-right">${escape(briefIdShort)}</span>
1507
+ <section class="np-page" id="np-page-2">
1508
+ <div class="post-running">
1509
+ <span class="post-running-mast">The Boardroom Post</span>
1510
+ <span class="post-running-meta">${escape(dateInfo.weekday)}, ${escape(dateInfo.long)} · privateboard.ai</span>
1511
+ <span class="post-running-page">A2</span>
1369
1512
  </div>
1370
1513
 
1371
- <div class="np-rule-double"></div>
1372
-
1373
- <section class="np-frontpage">
1374
- <h2 class="np-frontpage-title">${escape(m.title || "")}</h2>
1375
- ${m.kicker ? `<div class="np-frontpage-deck">${escape(m.kicker)}</div>` : ""}
1376
- </section>
1377
-
1378
- <div class="np-rule"></div>
1514
+ <div class="post-sect-banner">
1515
+ <span class="post-sect-banner-tag">${escape(ms1.period || ms2.period || "Continued")}</span>
1516
+ <span class="post-sect-banner-deck">From the room · continued from the front page</span>
1517
+ </div>
1379
1518
 
1380
- <section class="np-grid">
1381
- <div class="np-col">
1382
- ${renderColumnHeading(ms0.period, ms0.title)}
1383
- ${bottomLine}
1384
- ${renderProse(ms0.body)}
1385
- ${m.talkingPoints && m.talkingPoints.title ? `<div class="np-pagehint">See ${escape(m.talkingPoints.title)}</div>` : ""}
1386
- </div>
1387
- <div class="np-col">
1388
- ${renderColumnHeading(ms1.period, ms1.title)}
1389
- ${renderProse(ms1.body, { lead: true })}
1390
- ${dateStack}
1519
+ <div class="post-article">
1520
+ <div>
1521
+ <h2 class="post-article-head">${escape(headline)}</h2>
1522
+ ${deck ? `<div class="post-article-deck">${escape(deck)}</div>` : ""}
1523
+ <div class="post-article-byline">By the chair · Continued from A1</div>
1524
+ ${bodyHtml ? `<div class="post-body has-drop">${bodyHtml}</div>` : ""}
1525
+ ${pullQuote ? `
1526
+ <div class="post-pull">
1527
+ <div class="post-pull-text">"${escape(pullQuote)}"</div>
1528
+ <div class="post-pull-cite">Editorial</div>
1529
+ </div>` : ""}
1391
1530
  </div>
1392
- <div class="np-col">
1393
- ${figure || ""}
1394
- ${renderColumnHeading(ms2.period, ms2.title)}
1395
- ${renderProse(ms2.body)}
1396
- </div>
1397
- </section>
1398
-
1399
- <div class="np-rule"></div>
1531
+ <aside class="post-side">
1532
+ ${sideHtml}
1533
+ ${m.flow && Array.isArray(m.flow.nodes) && m.flow.nodes.length >= 2 ? `
1534
+ <div class="post-stat">
1535
+ <div class="post-stat-eye">Transformation</div>
1536
+ <div style="font-family: var(--serif-display); font-size: 17px; font-weight: 700; color: var(--ink); line-height: 1.25;">
1537
+ ${m.flow.nodes.map(escape).join('<span style="color: var(--accent); margin: 0 6px;">→</span>')}
1538
+ </div>
1539
+ ${m.flow.caption ? `<div class="post-stat-cap">${escape(m.flow.caption)}</div>` : ""}
1540
+ </div>` : ""}
1541
+ </aside>
1542
+ </div>
1400
1543
 
1401
- <section class="np-grid">
1402
- <div class="np-col">
1403
- <h4 class="np-col-heading">${escape((m.verification && m.verification.title) || "More headings")}</h4>
1404
- ${moreHeadings}
1405
- </div>
1406
- <div class="np-col">${middlePull}</div>
1407
- <div class="np-col">${tailFallback}</div>
1408
- </section>
1409
-
1410
- <footer class="np-footer">
1411
- <span>${escape(m.footerTag || "")}</span>
1412
- <span class="np-footer-stamp">PrivateBoard · ${escape(briefIdShort)}</span>
1413
- </footer>
1414
- </article>`;
1544
+ ${pageFoot(2, totalPages, briefIdShort)}
1545
+ </section>`;
1415
1546
  }
1416
1547
 
1417
- /** Layout 2 · modular Le Monde feel.
1418
- * · LEAD STORY 2/3 wide on the LEFT (drop-cap body + bottom-
1419
- * line callout below) · SIDEBAR 1/3 on the RIGHT stacking
1420
- * figure ABOVE date-stack so the column always packs 2 items
1421
- * (no short-figure-leaves-whitespace problem)
1422
- * · Centered pull-quote band drawn from talking[0]
1423
- * · 3-col tail · ms1 / ms2 / "more from the room" (verification
1424
- * bullets + tail talking points + flow stacked dense in the
1425
- * third column so it always reads full)
1426
- */
1427
- function layoutVariant2(p) {
1428
- const { m, ms0, ms1, ms2, flankLeft, flankRight, dispDate, briefIdShort,
1429
- bottomLine, dateStack, figure, moreHeadings, flowText } = p;
1430
-
1431
- const tp = (m.talkingPoints && Array.isArray(m.talkingPoints.bullets)) ? m.talkingPoints.bullets : [];
1432
- const pullText = tp[0] || "";
1433
- const tail = tp.slice(1, 5).map((b) => `<p>${escape(b)}</p>`).join("");
1434
-
1435
- // Sidebar always packs 2 stacked elements so the 1fr column
1436
- // matches the lead story's height. When rankedBars is missing
1437
- // we substitute a "From the desk" callout so the slot still
1438
- // carries weight rather than going blank.
1439
- const sidebarTop = figure
1440
- || `<aside class="np-callout"><div class="np-callout-label">From the desk</div><div class="np-callout-text">${escape((m.source || "Boardroom").toUpperCase())}</div></aside>`;
1548
+ /* ═════════════════════════════════════════════════════════════
1549
+ TIMES · The Boardroom · New York Times register
1550
+ ═════════════════════════════════════════════════════════════ */
1551
+
1552
+ function renderTimesPage1(brief, parts) {
1553
+ const { m, ms0, ms1, ms2, dateInfo, briefIdShort, totalPages } = parts;
1554
+ const tpAll = (m.talkingPoints && Array.isArray(m.talkingPoints.bullets)) ? m.talkingPoints.bullets : [];
1555
+ const tpFirst = tpAll[0] || "";
1556
+ const tpSecond = tpAll[1] || "";
1557
+ const stat = pickStat(m);
1558
+
1559
+ // Verification preview · 2 short bullets at the bottom of the
1560
+ // lead column so the lead extends to roughly match the side
1561
+ // cols' height (the lead is twice as wide so its body wraps
1562
+ // half as tall · packing more content here keeps the columns
1563
+ // visually balanced).
1564
+ const vArr = (m.verification && Array.isArray(m.verification.bullets)) ? m.verification.bullets : [];
1565
+ const vPreview = vArr.slice(0, 2).map((b) => {
1566
+ const p = splitHeading(b);
1567
+ return `<li>${p.body
1568
+ ? `<b>${escape(p.heading)}</b> ${escape(p.body)}`
1569
+ : escape(p.heading)}</li>`;
1570
+ }).join("");
1441
1571
 
1442
- return `
1443
- <article class="np-doc np-layout-2" data-np-paper>
1444
- <div class="np-rule"></div>
1572
+ // Volume info · classic NYT pattern · "VOL. CXC..No. {n} ★ Late
1573
+ // City Edition ★ {date} ★ {price/id}". We use the brief id
1574
+ // as a serial number and "Late City Edition" as the standard
1575
+ // copy.
1576
+ const volStrip = `
1577
+ <div class="times-vol-strip">
1578
+ <span><em>VOL. CXC . . No.</em> ${escape(briefIdShort.replace(/^#/, ""))}</span>
1579
+ <span class="star">★</span>
1580
+ <span>Late City Edition</span>
1581
+ <span class="star">★</span>
1582
+ <span>${escape(dateInfo.nytDate)}</span>
1583
+ <span class="star">★</span>
1584
+ <span><em>${escape(briefIdShort)}</em></span>
1585
+ </div>`;
1445
1586
 
1446
- <header class="np-masthead">
1447
- <div class="np-mast-flank np-mast-flank-left">${flankLeft}</div>
1448
- <h1 class="np-mast-name">PrivateBoard</h1>
1449
- <div class="np-mast-flank np-mast-flank-right">${flankRight}</div>
1450
- </header>
1587
+ return `
1588
+ <section class="np-page" id="np-page-1">
1589
+ <div class="times-tagline">"All the analysis that's fit to print"</div>
1590
+ <h1 class="times-mast-name">The Boardroom</h1>
1591
+ ${volStrip}
1592
+
1593
+ <div class="times-front">
1594
+ <!-- Col 1 · sidebar story (ms2) -->
1595
+ <div class="times-col times-col-1">
1596
+ <div class="times-section-eye">Inside</div>
1597
+ <h3 class="times-head-small">${escape(ms2.title || ms2.period || "Late Dispatches")}</h3>
1598
+ ${ms2.body ? `<div class="times-body">${escape(ms2.body)}</div>` : ""}
1599
+ ${stat ? `
1600
+ <div class="times-stat">
1601
+ <div class="times-stat-eye">By the numbers</div>
1602
+ <div class="times-stat-num">${escape(stat.num)}</div>
1603
+ <div class="times-stat-cap">${escape(stat.cap)}</div>
1604
+ </div>` : ""}
1605
+ </div>
1451
1606
 
1452
- <div class="np-rule-double"></div>
1607
+ <!-- Col 2-3 · the lead story · centered headline + body
1608
+ + standfirst + verification preview · packed so the
1609
+ lead matches the side cols' height -->
1610
+ <div class="times-col times-col-3">
1611
+ <div class="times-section-eye">The Boardroom</div>
1612
+ <h2 class="times-head-large">${escape(m.title || "")}</h2>
1613
+ ${m.kicker ? `<div class="times-deck">${escape(m.kicker)}</div>` : ""}
1614
+ <div class="times-byline">By <em>The Chair</em></div>
1615
+ ${ms0.body ? `<div class="times-body has-drop">${escape(ms0.body)}</div>` : ""}
1616
+ ${m.conclusion ? `
1617
+ <aside class="times-stand">
1618
+ <div class="times-stand-mark">¶</div>
1619
+ <div class="times-stand-text">${escape(m.conclusion)}</div>
1620
+ <div class="times-stand-cite">Bottom line</div>
1621
+ </aside>` : ""}
1622
+ ${tpSecond ? `
1623
+ <div class="times-pull">
1624
+ <div class="times-pull-text">"${escape(tpSecond)}"</div>
1625
+ <div class="times-pull-cite">Editorial · second take</div>
1626
+ </div>` : ""}
1627
+ ${vPreview ? `
1628
+ <div style="margin-top:6px;">
1629
+ <div class="times-section-eye" style="text-align:left;border:0;padding-bottom:0;margin-bottom:6px;">More headings</div>
1630
+ <ul class="times-side-list" style="font-size:12px;">${vPreview}</ul>
1631
+ </div>` : ""}
1632
+ </div>
1453
1633
 
1454
- <div class="np-meta-strip">
1455
- <span class="np-meta-left">${escape(dispDate)}</span>
1456
- <span class="np-meta-center">www.privateboard.app</span>
1457
- <span class="np-meta-right">${escape(briefIdShort)}</span>
1634
+ <!-- Col 5-6 · secondary stories stacked -->
1635
+ <div class="times-col times-col-2">
1636
+ <div class="times-section-eye">Markets</div>
1637
+ <h3 class="times-head-medium">${escape(ms1.title || ms1.period || "On the markets")}</h3>
1638
+ ${ms1.body ? `<div class="times-body">${escape(ms1.body)}</div>` : ""}
1639
+ ${tpFirst ? `
1640
+ <div class="times-pull">
1641
+ <div class="times-pull-text">"${escape(tpFirst)}"</div>
1642
+ <div class="times-pull-cite">From the editorial</div>
1643
+ </div>` : ""}
1644
+ <div class="times-continued">Continued on Page A2</div>
1645
+ </div>
1458
1646
  </div>
1459
1647
 
1460
- <div class="np-rule"></div>
1461
-
1462
- <section class="np-frontpage">
1463
- <h2 class="np-frontpage-title">${escape(m.title || "")}</h2>
1464
- ${m.kicker ? `<div class="np-frontpage-deck">${escape(m.kicker)}</div>` : ""}
1465
- </section>
1466
-
1467
- <div class="np-rule"></div>
1648
+ ${pageFoot(1, totalPages, briefIdShort)}
1649
+ </section>`;
1650
+ }
1468
1651
 
1469
- <!-- Lead story 2fr LEFT, dense sidebar 1fr RIGHT -->
1470
- <section class="np-hero-band">
1471
- <div class="np-hero-lead">
1472
- ${renderColumnHeading(ms0.period, ms0.title)}
1473
- ${renderProse(ms0.body, { lead: true })}
1474
- ${bottomLine}
1475
- </div>
1476
- <div class="np-hero-figure">
1477
- ${sidebarTop}
1478
- ${dateStack}
1479
- </div>
1480
- </section>
1652
+ function renderTimesPage2(brief, parts) {
1653
+ const { m, ms1, ms2, dateInfo, briefIdShort, totalPages } = parts;
1654
+ const tpAll = (m.talkingPoints && Array.isArray(m.talkingPoints.bullets)) ? m.talkingPoints.bullets : [];
1655
+ const pullQuote = tpAll[1] || tpAll[0] || "";
1656
+ const verifs = (m.verification && Array.isArray(m.verification.bullets)) ? m.verification.bullets : [];
1657
+ const verifsHtml = verifs.slice(0, 5).map((b) => {
1658
+ const p = splitHeading(b);
1659
+ return `<li>${p.body
1660
+ ? `<b>${escape(p.heading)}</b>${escape(p.body)}`
1661
+ : escape(p.heading)}</li>`;
1662
+ }).join("");
1481
1663
 
1482
- ${pullText ? `
1483
- <div class="np-rule"></div>
1484
- <section class="np-pull-band">"${escape(pullText)}"</section>
1485
- ` : ""}
1664
+ const bodyParts = [];
1665
+ if (ms1.body) bodyParts.push(`<p>${escape(ms1.body)}</p>`);
1666
+ if (ms2.body) bodyParts.push(`<p>${escape(ms2.body)}</p>`);
1667
+ const bodyHtml = bodyParts.join("");
1486
1668
 
1487
- <div class="np-rule"></div>
1669
+ const headline = ms1.title || ms2.title || "Continued from the front page";
1670
+ const deck = m.kicker || "";
1671
+ const sectionName = (ms1.period || ms2.period || "International").toUpperCase();
1488
1672
 
1489
- <!-- 3-col tail · column 3 packs verification + talking +
1490
- flow dense so it never reads as a short empty cell. -->
1491
- <section class="np-grid">
1492
- <div class="np-col">
1493
- ${renderColumnHeading(ms1.period, ms1.title)}
1494
- ${renderProse(ms1.body)}
1495
- </div>
1496
- <div class="np-col">
1497
- ${renderColumnHeading(ms2.period, ms2.title)}
1498
- ${renderProse(ms2.body)}
1673
+ const flowHtml = (m.flow && Array.isArray(m.flow.nodes) && m.flow.nodes.length >= 2)
1674
+ ? `<div class="times-stat">
1675
+ <div class="times-stat-eye">Transformation</div>
1676
+ <div style="font-family: var(--serif-display); font-size: 17px; font-weight: 700; color: var(--ink); line-height: 1.3; padding: 6px 0;">
1677
+ ${m.flow.nodes.map(escape).join('<span style="color: var(--accent); margin: 0 8px;">→</span>')}
1499
1678
  </div>
1500
- <div class="np-col">
1501
- <h4 class="np-col-heading">${escape((m.verification && m.verification.title) || "More headings")}</h4>
1502
- ${moreHeadings}
1503
- ${tail ? `<div class="np-prose" style="margin-top:14px;">${tail}</div>` : ""}
1504
- ${flowText}
1505
- </div>
1506
- </section>
1507
-
1508
- <footer class="np-footer">
1509
- <span>${escape(m.footerTag || "")}</span>
1510
- <span class="np-footer-stamp">PrivateBoard · ${escape(briefIdShort)}</span>
1511
- </footer>
1512
- </article>`;
1513
- }
1514
-
1515
- /** Layout 3 · 2x2 framed-quote spread (penny-press feel).
1516
- * Each quadrant carries a milestone body + a callout/figure so
1517
- * the four cells are roughly the same height — the prior 2/3 +
1518
- * 1/3 asymmetric layout left the sidebar short relative to the
1519
- * main column's two stacked bodies.
1520
- *
1521
- * · TOP 2-col · LEFT: ms0 lead-drop + bottom-line · RIGHT: ms1
1522
- * + figure (chart inline)
1523
- * · CENTER · framed full-width pull-quote drawn from talking[0]
1524
- * · BOTTOM 2-col · LEFT: ms2 + date-stack · RIGHT: more-headings
1525
- * + talking tail + flow
1526
- *
1527
- * Result: 4 quadrants bookending a centered framed quote ·
1528
- * reads as a magazine spread on broadsheet paper, not a
1529
- * half-empty asymmetric grid.
1530
- */
1531
- function layoutVariant3(p) {
1532
- const { m, ms0, ms1, ms2, flankLeft, flankRight, dispDate, briefIdShort,
1533
- bottomLine, dateStack, figure, moreHeadings, flowText } = p;
1534
-
1535
- const tp = (m.talkingPoints && Array.isArray(m.talkingPoints.bullets)) ? m.talkingPoints.bullets : [];
1536
- const pullText = tp[0] || "";
1537
- const tail = tp.slice(1, 5).map((b) => `<p>${escape(b)}</p>`).join("");
1538
-
1539
- // Quadrant fillers · right-top carries the figure when
1540
- // present, otherwise the date-stack steps in so that cell
1541
- // never reads as prose-only. Left-bottom carries the
1542
- // date-stack ONLY when right-top got the figure (avoids
1543
- // double-rendering); when figure is missing the date-stack
1544
- // already sat in right-top, so left-bottom stays bare prose.
1545
- const rightTopAside = figure || dateStack;
1546
- const leftBottomAside = figure ? dateStack : "";
1679
+ ${m.flow.caption ? `<div class="times-stat-cap">${escape(m.flow.caption)}</div>` : ""}
1680
+ </div>` : "";
1547
1681
 
1548
1682
  return `
1549
- <article class="np-doc np-layout-3" data-np-paper>
1550
- <div class="np-rule"></div>
1683
+ <section class="np-page" id="np-page-2">
1684
+ <div class="times-running">
1685
+ <span class="times-running-l">The Boardroom</span>
1686
+ <span class="times-running-c">${escape(dateInfo.long)}</span>
1687
+ <span class="times-running-r">A2</span>
1688
+ </div>
1551
1689
 
1552
- <header class="np-masthead">
1553
- <div class="np-mast-flank np-mast-flank-left">${flankLeft}</div>
1554
- <h1 class="np-mast-name">PrivateBoard</h1>
1555
- <div class="np-mast-flank np-mast-flank-right">${flankRight}</div>
1556
- </header>
1690
+ <!-- Section banner · drops the rule ABOVE the section
1691
+ name because the running header already provides a
1692
+ horizontal line right above this block (its
1693
+ border-bottom). Two close-together rules read as a
1694
+ doubled border. -->
1695
+ <div class="times-section-banner">
1696
+ <div class="times-section-banner-name">${escape(sectionName)}</div>
1697
+ <div class="times-section-banner-rule"></div>
1698
+ <div class="times-section-banner-deck">Continued from the front page</div>
1699
+ </div>
1557
1700
 
1558
- <div class="np-rule-double"></div>
1701
+ <div class="times-inside">
1702
+ <div>
1703
+ <h2 class="times-head-medium" style="text-align:left;">${escape(headline)}</h2>
1704
+ ${deck ? `<div class="times-deck" style="text-align:left;">${escape(deck)}</div>` : ""}
1705
+ <div class="times-byline" style="text-align:left;">By <em>The Chair</em> · Continued from A1</div>
1706
+ <div class="times-continued">Continued from Page A1</div>
1707
+ ${bodyHtml ? `<div class="times-inside-main has-drop">${bodyHtml}</div>` : ""}
1708
+ ${pullQuote ? `
1709
+ <div class="times-pull">
1710
+ <div class="times-pull-text">"${escape(pullQuote)}"</div>
1711
+ <div class="times-pull-cite">From the editorial</div>
1712
+ </div>` : ""}
1713
+ </div>
1559
1714
 
1560
- <div class="np-meta-strip">
1561
- <span class="np-meta-left">${escape(dispDate)}</span>
1562
- <span class="np-meta-center">www.privateboard.app</span>
1563
- <span class="np-meta-right">${escape(briefIdShort)}</span>
1715
+ <aside class="times-inside-side">
1716
+ <div class="times-side-flag">★ More Headings ★</div>
1717
+ ${verifsHtml ? `<ul class="times-side-list">${verifsHtml}</ul>` : ""}
1718
+ ${flowHtml}
1719
+ </aside>
1564
1720
  </div>
1565
1721
 
1566
- <div class="np-rule-double"></div>
1722
+ ${pageFoot(2, totalPages, briefIdShort)}
1723
+ </section>`;
1724
+ }
1567
1725
 
1568
- <section class="np-frontpage">
1569
- <h2 class="np-frontpage-title">${escape(m.title || "")}</h2>
1570
- ${m.kicker ? `<div class="np-frontpage-deck">${escape(m.kicker)}</div>` : ""}
1571
- </section>
1726
+ /* ═════════════════════════════════════════════════════════════ */
1572
1727
 
1573
- <div class="np-rule-double"></div>
1728
+ function render(brief) {
1729
+ if (brief.mode !== "newspaper") {
1730
+ showState("Wrong mode", "This brief isn't a newspaper",
1731
+ "Open it in the matching viewer instead. Newspaper, magazine, and research-note are separate output modes.");
1732
+ return;
1733
+ }
1734
+ const m = brief.bodyJson;
1735
+ if (!m || typeof m !== "object") {
1736
+ if (brief.isGenerating) {
1737
+ showState("Generating", "Newspaper is still being prepared",
1738
+ "The chair is currently composing the front page. Refresh in a few seconds.");
1739
+ } else {
1740
+ showState("Empty", "This brief has no newspaper data",
1741
+ "It may have failed to generate. Try regenerating from the room view.");
1742
+ }
1743
+ return;
1744
+ }
1574
1745
 
1575
- <!-- TOP quadrants · ms0+bottomLine | ms1+figure -->
1576
- <section class="np-grid-2">
1577
- <div class="np-col">
1578
- ${renderColumnHeading(ms0.period, ms0.title)}
1579
- ${renderProse(ms0.body, { lead: true })}
1580
- ${bottomLine}
1581
- </div>
1582
- <div class="np-col">
1583
- ${renderColumnHeading(ms1.period, ms1.title)}
1584
- ${renderProse(ms1.body)}
1585
- ${rightTopAside}
1586
- </div>
1587
- </section>
1746
+ if (m.title) document.title = `${m.title} · Newspaper`;
1588
1747
 
1589
- ${pullText ? `
1590
- <div class="np-rule-double"></div>
1591
- <section class="np-pull-framed">"${escape(pullText)}"</section>
1592
- ` : ""}
1748
+ const variant = pickVariant(brief.id);
1749
+ document.body.setAttribute("data-np-variant", variant);
1750
+ if (variantBadge) {
1751
+ variantBadge.textContent = variant === "times" ? "Times Edition" : "Post Edition";
1752
+ }
1593
1753
 
1594
- <div class="np-rule-double"></div>
1754
+ const milestones = (m.milestones || []).slice(0, 3);
1755
+ const ms0 = milestones[0] || {};
1756
+ const ms1 = milestones[1] || {};
1757
+ const ms2 = milestones[2] || {};
1758
+ const dateInfo = formatDate(m.footerTag);
1759
+ const briefIdShort = brief.id ? `#${String(brief.id).slice(0, 10).toUpperCase()}` : "";
1760
+ const totalPages = 2;
1595
1761
 
1596
- <!-- BOTTOM quadrants · ms2+dateStack | more-headings + tail + flow -->
1597
- <section class="np-grid-2">
1598
- <div class="np-col">
1599
- ${renderColumnHeading(ms2.period, ms2.title)}
1600
- ${renderProse(ms2.body)}
1601
- ${leftBottomAside}
1602
- </div>
1603
- <div class="np-col">
1604
- <h4 class="np-col-heading">${escape((m.verification && m.verification.title) || "More headings")}</h4>
1605
- ${moreHeadings}
1606
- ${tail ? `<div class="np-prose" style="margin-top:14px;">${tail}</div>` : ""}
1607
- ${flowText}
1608
- </div>
1609
- </section>
1762
+ const parts = { m, ms0, ms1, ms2, dateInfo, briefIdShort, totalPages };
1610
1763
 
1611
- <div class="np-rule"></div>
1764
+ const pages = variant === "times"
1765
+ ? [renderTimesPage1(brief, parts), renderTimesPage2(brief, parts)]
1766
+ : [renderPostPage1(brief, parts), renderPostPage2(brief, parts)];
1612
1767
 
1613
- <footer class="np-footer">
1614
- <span>${escape(m.footerTag || "")}</span>
1615
- <span class="np-footer-stamp">PrivateBoard · ${escape(briefIdShort)}</span>
1616
- </footer>
1617
- </article>`;
1768
+ root.innerHTML = `<article class="np-doc" data-np-paper>${pages.join("")}</article>`;
1618
1769
  }
1619
1770
 
1620
- /* ─── Export wiring · same PNG-as-PDF strategy as bento/magazine ── */
1771
+ /* ─── Export wiring · same PNG-as-PDF strategy ─────────── */
1621
1772
  let _h2iLoaded = null;
1622
1773
  async function ensureHtmlToImage() {
1623
1774
  if (window.htmlToImage) return;
@@ -1644,17 +1795,13 @@
1644
1795
  const height = Math.max(el.scrollHeight, el.offsetHeight, el.clientHeight);
1645
1796
  return window.htmlToImage.toPng(el, {
1646
1797
  pixelRatio: 2,
1647
- backgroundColor: "#EEE2C6",
1798
+ backgroundColor: "#1F1E1A",
1648
1799
  cacheBust: true,
1649
1800
  width,
1650
1801
  height,
1651
1802
  canvasWidth: width,
1652
1803
  canvasHeight: height,
1653
- style: {
1654
- margin: "0",
1655
- width: `${width}px`,
1656
- height: `${height}px`,
1657
- },
1804
+ style: { margin: "0", width: `${width}px`, height: `${height}px` },
1658
1805
  });
1659
1806
  }
1660
1807
 
@@ -1692,33 +1839,17 @@
1692
1839
  @page { size: auto; margin: 10mm; }
1693
1840
  * { box-sizing: border-box; margin: 0; padding: 0; }
1694
1841
  html, body { background: #FFFFFF; }
1695
- body {
1696
- display: flex;
1697
- align-items: flex-start;
1698
- justify-content: center;
1699
- padding: 20px;
1700
- min-height: 100vh;
1701
- }
1702
- img {
1703
- display: block;
1704
- width: 100%;
1705
- max-width: 880px;
1706
- height: auto;
1707
- box-shadow: 0 0 24px rgba(0, 0, 0, 0.08);
1708
- }
1842
+ body { display: flex; align-items: flex-start; justify-content: center; padding: 20px; min-height: 100vh; }
1843
+ img { display: block; width: 100%; max-width: 880px; height: auto; box-shadow: 0 0 24px rgba(0, 0, 0, 0.08); }
1709
1844
  @media print {
1710
1845
  body { padding: 0; }
1711
1846
  img { box-shadow: none; max-width: none; width: 100%; }
1712
1847
  }
1713
1848
  .hint {
1714
- position: fixed;
1715
- top: 12px;
1716
- left: 12px;
1849
+ position: fixed; top: 12px; left: 12px;
1717
1850
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
1718
- font-size: 11px;
1719
- color: #8E8B83;
1720
- background: rgba(255,255,255,0.9);
1721
- padding: 6px 10px;
1851
+ font-size: 11px; color: #8E8B83;
1852
+ background: rgba(255,255,255,0.9); padding: 6px 10px;
1722
1853
  border: 1px solid #E5E2DA;
1723
1854
  }
1724
1855
  @media print { .hint { display: none; } }
@@ -1743,16 +1874,8 @@
1743
1874
  }
1744
1875
 
1745
1876
  document.addEventListener("click", (e) => {
1746
- if (e.target.closest("[data-np-png]")) {
1747
- e.preventDefault();
1748
- exportPng();
1749
- return;
1750
- }
1751
- if (e.target.closest("[data-np-print]")) {
1752
- e.preventDefault();
1753
- exportPdf();
1754
- return;
1755
- }
1877
+ if (e.target.closest("[data-np-png]")) { e.preventDefault(); exportPng(); return; }
1878
+ if (e.target.closest("[data-np-print]")) { e.preventDefault(); exportPdf(); return; }
1756
1879
  });
1757
1880
 
1758
1881
  /* ─── Boot ──────────────────────────────────────────────────── */
@@ -1760,8 +1883,7 @@
1760
1883
  if (brief) render(brief);
1761
1884
  }).catch((e) => {
1762
1885
  console.error("[newspaper] load failed:", e);
1763
- showState("Error", "Couldn't load this brief",
1764
- e instanceof Error ? e.message : String(e));
1886
+ showState("Error", "Couldn't load this brief", e instanceof Error ? e.message : String(e));
1765
1887
  });
1766
1888
  })();
1767
1889
  </script>