privateboard 0.1.7 → 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.
@@ -0,0 +1,1892 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=900, initial-scale=1">
6
+ <title>Newspaper · PrivateBoard</title>
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">
15
+ <style>
16
+ /* ═══════════════════════════════════════════════════════════════════
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
+ ═══════════════════════════════════════════════════════════════════ */
33
+ :root {
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;
51
+ --rule-strong: #8C7A56;
52
+
53
+ /* Default accents · POST variant (Washington Post · red) */
54
+ --accent: #C42126;
55
+ --accent-deep: #951C1F;
56
+ --accent-soft: #E6BFC1;
57
+ --accent-blue: #2C5282;
58
+
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);
62
+
63
+ --serif-display: "Tiempos Headline", "Playfair Display", "Bodoni 72",
64
+ "Didot", "Source Serif Pro", "Charter", Georgia,
65
+ "Source Han Serif SC", "Songti SC", "STSong", serif;
66
+ --serif: "Charter", "Source Serif Pro", "Iowan Old Style",
67
+ "Tiempos Text", "Source Han Serif SC", Georgia,
68
+ "Songti SC", "STSong", serif;
69
+ --sans: "Inter", "Helvetica Neue", -apple-system, BlinkMacSystemFont,
70
+ "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
71
+ "Source Han Sans CN", "Noto Sans CJK SC", sans-serif;
72
+ --mono: "SF Mono", "JetBrains Mono", "Menlo",
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;
92
+ }
93
+
94
+ * { box-sizing: border-box; margin: 0; padding: 0; }
95
+ html, body {
96
+ background: var(--bg);
97
+ color: var(--ink);
98
+ font-family: var(--serif);
99
+ font-size: 14px;
100
+ line-height: 1.55;
101
+ -webkit-font-smoothing: antialiased;
102
+ text-rendering: optimizeLegibility;
103
+ min-height: 100vh;
104
+ }
105
+
106
+ /* ─── Top chrome ───────────────────────────────────────────────── */
107
+ .np-top-bar {
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: space-between;
111
+ gap: 14px;
112
+ padding: 14px 28px;
113
+ background: rgba(31, 30, 26, 0.92);
114
+ backdrop-filter: blur(10px);
115
+ -webkit-backdrop-filter: blur(10px);
116
+ border-bottom: 1px solid rgba(244, 239, 223, 0.12);
117
+ flex-wrap: wrap;
118
+ position: sticky;
119
+ top: 0;
120
+ z-index: 10;
121
+ color: var(--paper);
122
+ }
123
+ .np-crumb {
124
+ display: inline-flex;
125
+ align-items: center;
126
+ gap: 12px;
127
+ font-family: var(--serif-display);
128
+ font-size: 16px;
129
+ font-weight: 700;
130
+ color: var(--paper);
131
+ text-decoration: none;
132
+ }
133
+ .np-crumb::before {
134
+ content: "";
135
+ width: 9px;
136
+ height: 9px;
137
+ background: var(--accent);
138
+ flex: 0 0 auto;
139
+ border-radius: 50%;
140
+ }
141
+ .np-crumb-accent {
142
+ color: var(--ink-faint);
143
+ font-style: italic;
144
+ font-weight: 400;
145
+ }
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
+ }
178
+ .np-btn {
179
+ font-family: var(--mono);
180
+ font-size: 10.5px;
181
+ letter-spacing: 0.04em;
182
+ padding: 7px 12px;
183
+ background: transparent;
184
+ border: 1px solid rgba(244, 239, 223, 0.30);
185
+ color: var(--paper);
186
+ cursor: pointer;
187
+ text-decoration: none;
188
+ text-transform: uppercase;
189
+ font-weight: 600;
190
+ transition: background 0.15s, color 0.15s;
191
+ }
192
+ .np-btn:hover {
193
+ background: var(--paper);
194
+ color: var(--bg);
195
+ }
196
+ .np-btn .glyph { margin-right: 4px; }
197
+
198
+ /* ─── Doc · paper-stack frame ──────────────────────────────────── */
199
+ .np-doc {
200
+ max-width: 920px;
201
+ margin: 0 auto;
202
+ padding: 28px 12px 56px;
203
+ }
204
+ .np-page {
205
+ background: var(--paper);
206
+ padding: 26px 32px 30px;
207
+ margin-bottom: 24px;
208
+ box-shadow: var(--shadow-page);
209
+ position: relative;
210
+ }
211
+ .np-page:last-child { margin-bottom: 0; }
212
+ body[data-np-variant="times"] .np-page {
213
+ padding: 28px 36px 30px;
214
+ }
215
+
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; }
219
+ .np-rule-double {
220
+ height: 5px;
221
+ background:
222
+ linear-gradient(to bottom,
223
+ var(--ink) 0,
224
+ var(--ink) 1px,
225
+ transparent 1px,
226
+ transparent 4px,
227
+ var(--ink) 4px,
228
+ var(--ink) 5px);
229
+ margin: 12px 0;
230
+ }
231
+ .np-rule-thick { height: 2px; background: var(--ink); margin: 14px 0; }
232
+
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;
251
+ align-items: center;
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;
259
+ }
260
+ .post-sect-item::before {
261
+ content: "";
262
+ width: 6px;
263
+ height: 6px;
264
+ border-radius: 50%;
265
+ background: var(--accent);
266
+ }
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;
279
+ }
280
+ .post-mast-name {
281
+ font-family: var(--serif-display);
282
+ font-size: 50px;
283
+ font-weight: 800;
284
+ line-height: 0.96;
285
+ letter-spacing: -0.018em;
286
+ color: var(--ink);
287
+ text-align: center;
288
+ margin: 2px 0 6px;
289
+ }
290
+ @media (max-width: 720px) {
291
+ .post-mast-name { font-size: 36px; }
292
+ }
293
+
294
+ /* WaPo meta strip · 3-cell with date/edition/copy */
295
+ .post-meta {
296
+ display: grid;
297
+ grid-template-columns: 1fr auto 1fr;
298
+ align-items: center;
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;
309
+ }
310
+ .post-meta-l { text-align: left; }
311
+ .post-meta-c { text-align: center; }
312
+ .post-meta-r { text-align: right; }
313
+
314
+ /* Hero zone · big headline with deck + lead body + standfirst */
315
+ .post-hero {
316
+ padding: 14px 0 8px;
317
+ }
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;
324
+ font-weight: 800;
325
+ letter-spacing: 0.18em;
326
+ text-transform: uppercase;
327
+ padding: 4px 10px;
328
+ margin-bottom: 8px;
329
+ }
330
+ .post-hero-title {
331
+ font-family: var(--serif-display);
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;
339
+ }
340
+ @media (max-width: 720px) {
341
+ .post-hero-title { font-size: 30px; }
342
+ }
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;
351
+ }
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;
360
+ }
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;
367
+ }
368
+
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;
380
+ }
381
+ .post-stand-mark {
382
+ font-family: var(--serif-display);
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;
400
+ text-transform: uppercase;
401
+ color: var(--inv-ink-soft);
402
+ margin-top: 8px;
403
+ display: block;
404
+ }
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; }
409
+ }
410
+
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 {
425
+ font-family: var(--serif);
426
+ font-size: 13.5px;
427
+ line-height: 1.62;
428
+ color: var(--ink-soft);
429
+ text-align: justify;
430
+ hyphens: auto;
431
+ -webkit-hyphens: auto;
432
+ }
433
+ .post-body p + p { margin-top: 8px; }
434
+ .post-body.has-drop::first-letter {
435
+ font-family: var(--serif-display);
436
+ font-size: 56px;
437
+ line-height: 0.85;
438
+ font-weight: 800;
439
+ color: var(--accent);
440
+ float: left;
441
+ margin: 6px 8px 0 0;
442
+ }
443
+
444
+ .post-side {
445
+ display: flex;
446
+ flex-direction: column;
447
+ gap: 12px;
448
+ padding-left: 22px;
449
+ border-left: 1px solid var(--rule);
450
+ }
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;
459
+ letter-spacing: 0.18em;
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;
466
+ }
467
+ .post-side-head {
468
+ font-family: var(--serif-display);
469
+ font-size: 18px;
470
+ font-weight: 700;
471
+ line-height: 1.2;
472
+ letter-spacing: -0.008em;
473
+ color: var(--ink);
474
+ }
475
+ .post-side-body {
476
+ font-family: var(--serif);
477
+ font-size: 12.5px;
478
+ line-height: 1.55;
479
+ color: var(--ink-soft);
480
+ }
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;
491
+ }
492
+ .post-stat-eye {
493
+ font-family: var(--sans);
494
+ font-size: 10px;
495
+ font-weight: 800;
496
+ letter-spacing: 0.18em;
497
+ text-transform: uppercase;
498
+ color: var(--accent);
499
+ margin-bottom: 6px;
500
+ }
501
+ .post-stat-num {
502
+ font-family: var(--serif-display);
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;
510
+ }
511
+ .post-stat-cap {
512
+ font-family: var(--serif);
513
+ font-style: italic;
514
+ font-size: 12.5px;
515
+ line-height: 1.4;
516
+ color: var(--ink-mid);
517
+ margin-top: 6px;
518
+ }
519
+
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;
549
+ }
550
+ .post-index-list li {
551
+ display: grid;
552
+ grid-template-columns: 1fr auto;
553
+ gap: 6px;
554
+ align-items: baseline;
555
+ font-family: var(--serif);
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;
565
+ color: var(--ink);
566
+ }
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;
574
+ }
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);
584
+ }
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);
605
+ font-size: 10.5px;
606
+ letter-spacing: 0.06em;
607
+ text-transform: uppercase;
608
+ color: var(--ink-mid);
609
+ }
610
+ .post-running-page {
611
+ font-family: var(--sans);
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);
638
+ font-style: italic;
639
+ font-size: 13px;
640
+ color: var(--ink-mid);
641
+ }
642
+
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; }
652
+ }
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 {
663
+ font-family: var(--serif);
664
+ font-style: italic;
665
+ font-size: 14px;
666
+ line-height: 1.5;
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;
677
+ padding-bottom: 10px;
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;
687
+ }
688
+ .post-pull-text {
689
+ font-family: var(--serif-display);
690
+ font-style: italic;
691
+ font-size: 19px;
692
+ line-height: 1.35;
693
+ color: var(--ink);
694
+ letter-spacing: -0.005em;
695
+ }
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;
704
+ }
705
+
706
+ /* ═══════════════════════════════════════════════════════════════
707
+ TIMES variant · The Boardroom · New York Times register
708
+ ═══════════════════════════════════════════════════════════════ */
709
+
710
+ .times-tagline {
711
+ font-family: var(--serif);
712
+ font-style: italic;
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;
724
+ color: var(--ink);
725
+ text-align: center;
726
+ margin: 4px 0 8px;
727
+ }
728
+ @media (max-width: 720px) {
729
+ .times-mast-name { font-size: 46px; }
730
+ }
731
+
732
+ /* TIMES volume strip · "VOL. CXC...No 12,345 ★ Late Edition ★
733
+ {Date} ★ $4.00" */
734
+ .times-vol-strip {
735
+ display: flex;
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);
742
+ font-family: var(--serif);
743
+ font-size: 11.5px;
744
+ color: var(--ink-soft);
745
+ flex-wrap: wrap;
746
+ }
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;
754
+ color: var(--ink);
755
+ }
756
+
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;
766
+ }
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);
794
+ font-size: 10px;
795
+ letter-spacing: 0.16em;
796
+ text-transform: uppercase;
797
+ color: var(--ink-mid);
798
+ font-weight: 700;
799
+ text-align: center;
800
+ padding-bottom: 6px;
801
+ border-bottom: 1px solid var(--ink);
802
+ margin-bottom: 8px;
803
+ }
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 {
820
+ font-family: var(--serif-display);
821
+ font-size: 24px;
822
+ font-weight: 700;
823
+ line-height: 1.12;
824
+ letter-spacing: -0.008em;
825
+ color: var(--ink);
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;
848
+ text-transform: uppercase;
849
+ color: var(--ink-mid);
850
+ text-align: center;
851
+ margin-bottom: 8px;
852
+ }
853
+ .times-byline em {
854
+ font-style: italic;
855
+ color: var(--ink);
856
+ }
857
+ .times-body {
858
+ font-family: var(--serif);
859
+ font-size: 12.5px;
860
+ line-height: 1.55;
861
+ color: var(--ink-soft);
862
+ text-align: justify;
863
+ hyphens: auto;
864
+ -webkit-hyphens: auto;
865
+ }
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;
875
+ }
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;
885
+ }
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;
892
+ }
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;
900
+ }
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;
908
+ }
909
+
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;
919
+ }
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;
930
+ }
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;
942
+ }
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;
950
+ }
951
+
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 {
965
+ font-family: var(--serif-display);
966
+ font-size: 14px;
967
+ font-weight: 700;
968
+ letter-spacing: 0.005em;
969
+ }
970
+ .times-running-c {
971
+ text-align: center;
972
+ font-style: italic;
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;
980
+ color: var(--ink);
981
+ }
982
+
983
+ /* TIMES inside section banner · centered with rules + caps */
984
+ .times-section-banner {
985
+ text-align: center;
986
+ margin: 18px 0 14px;
987
+ }
988
+ .times-section-banner-rule {
989
+ height: 1px;
990
+ background: var(--ink);
991
+ margin: 6px 0;
992
+ }
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;
1001
+ }
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;
1008
+ }
1009
+
1010
+ /* TIMES inside · 4-col article spread */
1011
+ .times-inside {
1012
+ display: grid;
1013
+ grid-template-columns: minmax(0, 2.6fr) minmax(180px, 1fr);
1014
+ gap: 28px;
1015
+ margin-top: 4px;
1016
+ }
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;
1044
+ }
1045
+ .times-inside-side {
1046
+ display: flex;
1047
+ flex-direction: column;
1048
+ gap: 14px;
1049
+ padding-left: 22px;
1050
+ border-left: 1px solid var(--rule);
1051
+ }
1052
+ @media (max-width: 720px) {
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);
1073
+ }
1074
+ .times-pull-text {
1075
+ font-family: var(--serif-display);
1076
+ font-style: italic;
1077
+ font-size: 19px;
1078
+ line-height: 1.4;
1079
+ color: var(--ink);
1080
+ letter-spacing: -0.005em;
1081
+ }
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;
1089
+ }
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);
1104
+ }
1105
+ .times-side-list {
1106
+ list-style: none;
1107
+ margin: 0;
1108
+ padding: 0;
1109
+ display: flex;
1110
+ flex-direction: column;
1111
+ }
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);
1119
+ }
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;
1129
+ }
1130
+
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);
1148
+ }
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;
1157
+ text-align: center;
1158
+ background: var(--paper);
1159
+ box-shadow: var(--shadow-page);
1160
+ }
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;
1172
+ }
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;
1182
+ }
1183
+ .np-state-body {
1184
+ font-family: var(--serif);
1185
+ font-size: 13.5px;
1186
+ color: var(--ink-soft);
1187
+ line-height: 1.6;
1188
+ }
1189
+
1190
+ @media print {
1191
+ .np-top-bar { display: none; }
1192
+ body, html { background: white; }
1193
+ .np-doc { max-width: none; padding: 0; margin: 0; }
1194
+ .np-page {
1195
+ box-shadow: none;
1196
+ margin: 0;
1197
+ padding: 16mm 18mm 18mm;
1198
+ page-break-after: always;
1199
+ }
1200
+ .np-page:last-child { page-break-after: auto; }
1201
+ }
1202
+ </style>
1203
+ </head>
1204
+ <body>
1205
+
1206
+ <header class="np-top-bar" data-np-chrome>
1207
+ <a href="/" class="np-crumb">PrivateBoard <span class="np-crumb-accent">· newspaper</span></a>
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>
1214
+ <button type="button" class="np-btn" data-np-png>
1215
+ <span class="glyph">↓</span>PNG
1216
+ </button>
1217
+ <button type="button" class="np-btn" data-np-print>
1218
+ <span class="glyph">↓</span>PDF
1219
+ </button>
1220
+ </div>
1221
+ </header>
1222
+
1223
+ <main data-np-root>
1224
+ <div class="np-state">
1225
+ <div class="np-state-mark">Loading</div>
1226
+ <div class="np-state-title">Loading newspaper…</div>
1227
+ <div class="np-state-body">Fetching the broadsheet for this brief.</div>
1228
+ </div>
1229
+ </main>
1230
+
1231
+ <script>
1232
+ /* ──────────────────────────────────────────────────────────────────
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).
1244
+ ────────────────────────────────────────────────────────────── */
1245
+ (function () {
1246
+ const params = new URLSearchParams(location.search);
1247
+ const briefId = (params.get("b") || "").trim();
1248
+ const roomId = (params.get("r") || "").trim();
1249
+ const root = document.querySelector("[data-np-root]");
1250
+ const variantBadge = document.querySelector("[data-np-variant-badge]");
1251
+
1252
+ function escape(s) {
1253
+ return String(s == null ? "" : s)
1254
+ .replace(/&/g, "&amp;")
1255
+ .replace(/</g, "&lt;")
1256
+ .replace(/>/g, "&gt;")
1257
+ .replace(/"/g, "&quot;")
1258
+ .replace(/'/g, "&#39;");
1259
+ }
1260
+
1261
+ function showState(mark, title, body) {
1262
+ root.innerHTML = `
1263
+ <div class="np-state">
1264
+ <div class="np-state-mark">${escape(mark)}</div>
1265
+ <div class="np-state-title">${escape(title)}</div>
1266
+ <div class="np-state-body">${escape(body)}</div>
1267
+ </div>`;
1268
+ }
1269
+
1270
+ async function loadBrief() {
1271
+ let url;
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; }
1275
+ const res = await fetch(url);
1276
+ if (!res.ok) {
1277
+ const e = await res.json().catch(() => ({}));
1278
+ showState("Not found", "Brief not found", e.error || "The requested brief doesn't exist or is no longer available.");
1279
+ return null;
1280
+ }
1281
+ return await res.json();
1282
+ }
1283
+
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
+
1320
+ function splitHeading(raw) {
1321
+ const s = String(raw || "").trim();
1322
+ if (!s) return { heading: "", body: "" };
1323
+ const seps = [": ", " · ", " — ", " - "];
1324
+ for (const sep of seps) {
1325
+ const idx = s.indexOf(sep);
1326
+ if (idx > 0 && idx < 60) {
1327
+ return { heading: s.slice(0, idx).trim(), body: s.slice(idx + sep.length).trim() };
1328
+ }
1329
+ }
1330
+ return { heading: s, body: "" };
1331
+ }
1332
+
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
+ };
1349
+ }
1350
+ return null;
1351
+ }
1352
+
1353
+ function pageFoot(pageNum, totalPages, briefIdShort) {
1354
+ return `
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>`;
1360
+ }
1361
+
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" });
1378
+ }
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
+
1391
+ return `
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>
1400
+
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>
1409
+
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>`;
1470
+ }
1471
+
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) => {
1478
+ const parts = splitHeading(b);
1479
+ const inner = parts.body
1480
+ ? `<b>${escape(parts.heading)}</b>${escape(parts.body)}`
1481
+ : escape(parts.heading);
1482
+ return `<li>${inner}</li>`;
1483
+ }).join("");
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 || "";
1505
+
1506
+ return `
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>
1512
+ </div>
1513
+
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>
1518
+
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>` : ""}
1530
+ </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>
1543
+
1544
+ ${pageFoot(2, totalPages, briefIdShort)}
1545
+ </section>`;
1546
+ }
1547
+
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("");
1571
+
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>`;
1586
+
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>
1606
+
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>
1633
+
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>
1646
+ </div>
1647
+
1648
+ ${pageFoot(1, totalPages, briefIdShort)}
1649
+ </section>`;
1650
+ }
1651
+
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("");
1663
+
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("");
1668
+
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();
1672
+
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>')}
1678
+ </div>
1679
+ ${m.flow.caption ? `<div class="times-stat-cap">${escape(m.flow.caption)}</div>` : ""}
1680
+ </div>` : "";
1681
+
1682
+ return `
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>
1689
+
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>
1700
+
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>
1714
+
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>
1720
+ </div>
1721
+
1722
+ ${pageFoot(2, totalPages, briefIdShort)}
1723
+ </section>`;
1724
+ }
1725
+
1726
+ /* ═════════════════════════════════════════════════════════════ */
1727
+
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
+ }
1745
+
1746
+ if (m.title) document.title = `${m.title} · Newspaper`;
1747
+
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
+ }
1753
+
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;
1761
+
1762
+ const parts = { m, ms0, ms1, ms2, dateInfo, briefIdShort, totalPages };
1763
+
1764
+ const pages = variant === "times"
1765
+ ? [renderTimesPage1(brief, parts), renderTimesPage2(brief, parts)]
1766
+ : [renderPostPage1(brief, parts), renderPostPage2(brief, parts)];
1767
+
1768
+ root.innerHTML = `<article class="np-doc" data-np-paper>${pages.join("")}</article>`;
1769
+ }
1770
+
1771
+ /* ─── Export wiring · same PNG-as-PDF strategy ─────────── */
1772
+ let _h2iLoaded = null;
1773
+ async function ensureHtmlToImage() {
1774
+ if (window.htmlToImage) return;
1775
+ if (!_h2iLoaded) {
1776
+ _h2iLoaded = new Promise((res, rej) => {
1777
+ const s = document.createElement("script");
1778
+ s.src = "https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.min.js";
1779
+ s.onload = res;
1780
+ s.onerror = rej;
1781
+ document.head.appendChild(s);
1782
+ });
1783
+ }
1784
+ await _h2iLoaded;
1785
+ }
1786
+
1787
+ async function captureNpPng() {
1788
+ const el = document.querySelector("[data-np-paper]");
1789
+ if (!el) throw new Error("newspaper doc not found");
1790
+ await ensureHtmlToImage();
1791
+ if (document.fonts && document.fonts.ready) {
1792
+ try { await document.fonts.ready; } catch { /* best-effort */ }
1793
+ }
1794
+ const width = Math.max(el.scrollWidth, el.offsetWidth, el.clientWidth);
1795
+ const height = Math.max(el.scrollHeight, el.offsetHeight, el.clientHeight);
1796
+ return window.htmlToImage.toPng(el, {
1797
+ pixelRatio: 2,
1798
+ backgroundColor: "#1F1E1A",
1799
+ cacheBust: true,
1800
+ width,
1801
+ height,
1802
+ canvasWidth: width,
1803
+ canvasHeight: height,
1804
+ style: { margin: "0", width: `${width}px`, height: `${height}px` },
1805
+ });
1806
+ }
1807
+
1808
+ function slugTitle() {
1809
+ return (document.title || "newspaper").replace(/[^a-z0-9]+/gi, "-").slice(0, 60) || "newspaper";
1810
+ }
1811
+
1812
+ async function exportPng() {
1813
+ try {
1814
+ const dataUrl = await captureNpPng();
1815
+ const a = document.createElement("a");
1816
+ a.download = `${slugTitle()}.png`;
1817
+ a.href = dataUrl;
1818
+ a.click();
1819
+ } catch (e) {
1820
+ console.warn("[newspaper] PNG export failed:", e);
1821
+ alert("PNG export failed · see browser console.");
1822
+ }
1823
+ }
1824
+
1825
+ async function exportPdf() {
1826
+ try {
1827
+ const dataUrl = await captureNpPng();
1828
+ const win = window.open("", "_blank", "width=1024,height=720");
1829
+ if (!win) {
1830
+ alert("PDF export needs to open a new window — please allow popups for this site.");
1831
+ return;
1832
+ }
1833
+ const slug = slugTitle();
1834
+ win.document.open();
1835
+ win.document.write(`<!doctype html><html lang="en"><head>
1836
+ <meta charset="utf-8">
1837
+ <title>${slug}</title>
1838
+ <style>
1839
+ @page { size: auto; margin: 10mm; }
1840
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1841
+ html, body { background: #FFFFFF; }
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); }
1844
+ @media print {
1845
+ body { padding: 0; }
1846
+ img { box-shadow: none; max-width: none; width: 100%; }
1847
+ }
1848
+ .hint {
1849
+ position: fixed; top: 12px; left: 12px;
1850
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
1851
+ font-size: 11px; color: #8E8B83;
1852
+ background: rgba(255,255,255,0.9); padding: 6px 10px;
1853
+ border: 1px solid #E5E2DA;
1854
+ }
1855
+ @media print { .hint { display: none; } }
1856
+ </style>
1857
+ </head><body>
1858
+ <div class="hint">// press <kbd>⌘P</kbd> / <kbd>Ctrl+P</kbd> · save as PDF</div>
1859
+ <img alt="${slug}" src="${dataUrl}">
1860
+ <script>
1861
+ (function () {
1862
+ var img = document.querySelector("img");
1863
+ function go() { setTimeout(function () { window.print(); }, 200); }
1864
+ if (img && img.complete) { go(); }
1865
+ else if (img) { img.addEventListener("load", go, { once: true }); }
1866
+ })();
1867
+ <\/script>
1868
+ </body></html>`);
1869
+ win.document.close();
1870
+ } catch (e) {
1871
+ console.warn("[newspaper] PDF export failed:", e);
1872
+ alert("PDF export failed · see browser console.");
1873
+ }
1874
+ }
1875
+
1876
+ document.addEventListener("click", (e) => {
1877
+ if (e.target.closest("[data-np-png]")) { e.preventDefault(); exportPng(); return; }
1878
+ if (e.target.closest("[data-np-print]")) { e.preventDefault(); exportPdf(); return; }
1879
+ });
1880
+
1881
+ /* ─── Boot ──────────────────────────────────────────────────── */
1882
+ loadBrief().then((brief) => {
1883
+ if (brief) render(brief);
1884
+ }).catch((e) => {
1885
+ console.error("[newspaper] load failed:", e);
1886
+ showState("Error", "Couldn't load this brief", e instanceof Error ? e.message : String(e));
1887
+ });
1888
+ })();
1889
+ </script>
1890
+
1891
+ </body>
1892
+ </html>