privateboard 0.1.7 → 0.1.8

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,1770 @@
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
+ <style>
9
+ /* ═══════════════════════════════════════════════════════════════════
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
+ ─────────────────────────────────────────────────────────────────── */
29
+ :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;
59
+ --rule-strong: #8C7A56;
60
+
61
+ /* Accent · sparing · used for chart bars + small marks */
62
+ --accent: #6B4515;
63
+
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);
70
+
71
+ --serif-display: "Tiempos Headline", "Playfair Display", "Bodoni 72",
72
+ "Didot", "Source Serif Pro", "Charter", Georgia,
73
+ "Source Han Serif SC", "Songti SC", "STSong", serif;
74
+ --serif: "Charter", "Source Serif Pro", "Iowan Old Style",
75
+ "Tiempos Text", "Source Han Serif SC", Georgia,
76
+ "Songti SC", "STSong", serif;
77
+ --sans: "Inter", "Helvetica Neue", -apple-system, BlinkMacSystemFont,
78
+ "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
79
+ "Source Han Sans CN", "Noto Sans CJK SC", sans-serif;
80
+ --mono: "SF Mono", "JetBrains Mono", "Menlo",
81
+ "PingFang SC", "Source Han Sans CN", monospace;
82
+ }
83
+
84
+ * { box-sizing: border-box; margin: 0; padding: 0; }
85
+ 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);
93
+ color: var(--ink);
94
+ font-family: var(--serif);
95
+ font-size: 14px;
96
+ line-height: 1.55;
97
+ -webkit-font-smoothing: antialiased;
98
+ text-rendering: optimizeLegibility;
99
+ min-height: 100vh;
100
+ }
101
+
102
+ /* ─── Top chrome · brand crumb + actions ────────────────────────── */
103
+ .np-top-bar {
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: space-between;
107
+ gap: 14px;
108
+ padding: 18px 32px;
109
+ background: var(--top-bar-bg, rgba(220, 201, 161, 0.85));
110
+ backdrop-filter: blur(10px);
111
+ -webkit-backdrop-filter: blur(10px);
112
+ border-bottom: 1px solid var(--rule);
113
+ flex-wrap: wrap;
114
+ position: sticky;
115
+ top: 0;
116
+ z-index: 10;
117
+ font-family: var(--serif);
118
+ }
119
+ .np-crumb {
120
+ display: inline-flex;
121
+ align-items: center;
122
+ gap: 12px;
123
+ font-family: var(--serif-display);
124
+ font-size: 16px;
125
+ font-weight: 700;
126
+ color: var(--ink);
127
+ text-decoration: none;
128
+ }
129
+ .np-crumb::before {
130
+ content: "";
131
+ width: 10px;
132
+ height: 10px;
133
+ background: var(--ink);
134
+ flex: 0 0 auto;
135
+ }
136
+ .np-crumb-accent {
137
+ color: var(--ink-faint);
138
+ font-style: italic;
139
+ font-weight: 400;
140
+ }
141
+ .np-actions { display: flex; gap: 8px; }
142
+ .np-btn {
143
+ font-family: var(--mono);
144
+ font-size: 11px;
145
+ letter-spacing: 0.04em;
146
+ padding: 8px 14px;
147
+ background: var(--surface);
148
+ border: 1px solid var(--rule-strong);
149
+ color: var(--ink);
150
+ cursor: pointer;
151
+ text-decoration: none;
152
+ transition: color 0.15s, background 0.15s, transform 0.15s;
153
+ }
154
+ .np-btn:hover {
155
+ background: var(--ink);
156
+ color: var(--paper);
157
+ border-color: var(--ink);
158
+ transform: translateY(-1px);
159
+ }
160
+ .np-btn:active { transform: translateY(0); }
161
+ .np-btn .glyph { margin-right: 4px; }
162
+
163
+ /* ─── Doc · the broadsheet sheet ───────────────────────────────── */
164
+ .np-doc {
165
+ max-width: 880px;
166
+ margin: 28px auto;
167
+ background: var(--paper);
168
+ box-shadow: var(--shadow-doc);
169
+ padding: 36px 44px 40px;
170
+ position: relative;
171
+ }
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);
182
+ }
183
+
184
+ /* ─── Rules · single + double hairlines ──────────────────────── */
185
+ .np-rule {
186
+ height: 1px;
187
+ background: var(--ink);
188
+ margin: 14px 0;
189
+ }
190
+ .np-rule-double {
191
+ height: 6px;
192
+ background:
193
+ linear-gradient(to bottom,
194
+ var(--ink) 0,
195
+ var(--ink) 1px,
196
+ 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;
206
+ }
207
+
208
+ /* ─── Masthead · flanks + nameplate ─────────────────────────── */
209
+ .np-masthead {
210
+ display: grid;
211
+ grid-template-columns: 1fr auto 1fr;
212
+ gap: 24px;
213
+ align-items: center;
214
+ padding: 4px 0 18px;
215
+ }
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;
230
+ }
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;
239
+ }
240
+ .np-mast-name {
241
+ 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;
247
+ color: var(--ink);
248
+ text-align: center;
249
+ /* Optical centering · serif display fonts often want a tiny nudge */
250
+ padding: 0 4px;
251
+ white-space: nowrap;
252
+ }
253
+ @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; }
257
+ }
258
+
259
+ /* ─── Meta strip · date | url | id ──────────────────────────── */
260
+ .np-meta-strip {
261
+ display: grid;
262
+ grid-template-columns: 1fr auto 1fr;
263
+ gap: 18px;
264
+ 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;
270
+ }
271
+ .np-meta-left { text-align: left; }
272
+ .np-meta-center { text-align: center; }
273
+ .np-meta-right { text-align: right; }
274
+
275
+ /* ─── Front page · headline + deck ──────────────────────────── */
276
+ .np-frontpage {
277
+ text-align: center;
278
+ padding: 18px 24px 14px;
279
+ }
280
+ .np-frontpage-title {
281
+ font-family: var(--serif-display);
282
+ font-size: 38px;
283
+ font-weight: 800;
284
+ line-height: 1.05;
285
+ letter-spacing: 0.005em;
286
+ text-transform: uppercase;
287
+ color: var(--ink);
288
+ margin: 0 0 8px;
289
+ }
290
+ .np-frontpage-deck {
291
+ 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;
299
+ }
300
+ @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;
311
+ }
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);
318
+ }
319
+ .np-grid > .np-col:last-child {
320
+ padding-right: 0;
321
+ border-right: 0;
322
+ }
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; }
335
+ }
336
+
337
+ .np-col {
338
+ display: flex;
339
+ flex-direction: column;
340
+ gap: 12px;
341
+ min-width: 0;
342
+ }
343
+ .np-col-heading {
344
+ font-family: var(--serif-display);
345
+ font-size: 16px;
346
+ font-weight: 700;
347
+ 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);
355
+ }
356
+ .np-col-heading-large {
357
+ font-size: 22px;
358
+ line-height: 1.1;
359
+ }
360
+
361
+ /* Editorial body prose · justified, narrow, drop cap on first
362
+ paragraph of the column. */
363
+ .np-prose {
364
+ font-family: var(--serif);
365
+ font-size: 13px;
366
+ line-height: 1.55;
367
+ color: var(--ink-soft);
368
+ text-align: justify;
369
+ text-justify: inter-word;
370
+ hyphens: auto;
371
+ -webkit-hyphens: auto;
372
+ }
373
+ .np-prose p + p { margin-top: 8px; }
374
+ .np-prose-lead::first-letter {
375
+ font-family: var(--serif-display);
376
+ font-size: 42px;
377
+ line-height: 0.9;
378
+ font-weight: 700;
379
+ color: var(--ink);
380
+ 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;
394
+ }
395
+ .np-pagehint::before { content: "→ "; font-style: normal; color: var(--ink); }
396
+
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;
402
+ }
403
+ .np-callout-label {
404
+ font-family: var(--serif-display);
405
+ font-size: 12px;
406
+ font-weight: 700;
407
+ text-transform: uppercase;
408
+ letter-spacing: 0.18em;
409
+ color: var(--inv-ink-soft);
410
+ margin-bottom: 8px;
411
+ line-height: 1.2;
412
+ }
413
+ .np-callout-text {
414
+ font-family: var(--serif-display);
415
+ font-size: 18px;
416
+ font-weight: 700;
417
+ line-height: 1.18;
418
+ letter-spacing: -0.005em;
419
+ color: var(--inv-ink);
420
+ text-transform: uppercase;
421
+ }
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;
426
+ }
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;
436
+ }
437
+ .np-callout-date .np-date-month {
438
+ font-family: var(--serif-display);
439
+ font-size: 16px;
440
+ font-weight: 700;
441
+ letter-spacing: 0.18em;
442
+ 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;
455
+ }
456
+ .np-callout-date .np-date-year {
457
+ 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;
464
+ }
465
+ .np-callout-date .np-date-source {
466
+ font-family: var(--serif);
467
+ font-size: 11px;
468
+ font-style: italic;
469
+ letter-spacing: 0.04em;
470
+ color: var(--inv-ink-soft);
471
+ }
472
+
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;
484
+ }
485
+ .np-bar {
486
+ display: grid;
487
+ grid-template-columns: 1fr auto;
488
+ gap: 6px;
489
+ align-items: baseline;
490
+ font-family: var(--serif);
491
+ font-size: 11.5px;
492
+ color: var(--ink);
493
+ }
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;
508
+ }
509
+ .np-bar-fill {
510
+ position: absolute;
511
+ inset: 0 auto 0 0;
512
+ background: var(--ink);
513
+ }
514
+ .np-bar-value {
515
+ font-family: var(--mono);
516
+ font-size: 10.5px;
517
+ color: var(--ink-soft);
518
+ letter-spacing: 0.02em;
519
+ }
520
+ .np-figcaption {
521
+ font-family: var(--serif);
522
+ font-size: 11px;
523
+ font-style: italic;
524
+ 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
+ }
535
+
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;
544
+ }
545
+ .np-list li {
546
+ font-family: var(--serif);
547
+ font-size: 12.5px;
548
+ line-height: 1.5;
549
+ color: var(--ink-soft);
550
+ padding-bottom: 10px;
551
+ border-bottom: 1px solid var(--rule);
552
+ }
553
+ .np-list li:last-child { border-bottom: 0; padding-bottom: 0; }
554
+ .np-list li b {
555
+ font-family: var(--serif-display);
556
+ font-size: 13px;
557
+ font-weight: 700;
558
+ color: var(--ink);
559
+ text-transform: uppercase;
560
+ letter-spacing: 0.02em;
561
+ margin-right: 4px;
562
+ }
563
+ .np-list li b::after {
564
+ content: ":";
565
+ color: var(--ink);
566
+ }
567
+
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;
573
+ font-style: italic;
574
+ line-height: 1.3;
575
+ color: var(--ink);
576
+ 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;
581
+ }
582
+
583
+ /* ─── Footer ────────────────────────────────────────────────── */
584
+ .np-footer {
585
+ 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);
592
+ font-family: var(--serif);
593
+ font-size: 11px;
594
+ font-style: italic;
595
+ color: var(--ink-mid);
596
+ flex-wrap: wrap;
597
+ }
598
+ .np-footer-stamp {
599
+ font-family: var(--mono);
600
+ font-size: 10.5px;
601
+ font-style: normal;
602
+ color: var(--ink);
603
+ letter-spacing: 0.04em;
604
+ }
605
+
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);
615
+ }
616
+ .np-state-mark {
617
+ font-family: var(--mono);
618
+ font-size: 10px;
619
+ letter-spacing: 0.18em;
620
+ 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;
626
+ font-weight: 700;
627
+ }
628
+ .np-state-title {
629
+ font-family: var(--serif-display);
630
+ font-size: 24px;
631
+ font-weight: 700;
632
+ color: var(--ink);
633
+ margin-bottom: 10px;
634
+ line-height: 1.2;
635
+ text-transform: uppercase;
636
+ letter-spacing: 0.02em;
637
+ }
638
+ .np-state-body {
639
+ font-family: var(--serif);
640
+ font-size: 13.5px;
641
+ color: var(--ink-soft);
642
+ line-height: 1.6;
643
+ }
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;
662
+ }
663
+ .np-hero-band > :first-child {
664
+ padding-right: 24px;
665
+ border-right: 1px solid var(--rule);
666
+ }
667
+ .np-hero-band > .np-hero-figure {
668
+ display: flex;
669
+ flex-direction: column;
670
+ gap: 12px;
671
+ }
672
+ .np-hero-band > .np-hero-lead {
673
+ display: flex;
674
+ flex-direction: column;
675
+ gap: 12px;
676
+ }
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
+ }
685
+ }
686
+
687
+ .np-grid-4 {
688
+ display: grid;
689
+ grid-template-columns: 1fr 1fr 1fr 1fr;
690
+ gap: 18px;
691
+ margin: 12px 0;
692
+ }
693
+ .np-grid-4 > .np-col {
694
+ padding-right: 18px;
695
+ border-right: 1px solid var(--rule);
696
+ }
697
+ .np-grid-4 > .np-col:last-child {
698
+ padding-right: 0;
699
+ border-right: 0;
700
+ }
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; }
710
+ }
711
+
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;
716
+ font-family: var(--serif-display);
717
+ font-size: 22px;
718
+ font-style: italic;
719
+ line-height: 1.35;
720
+ color: var(--ink);
721
+ padding: 16px 36px;
722
+ letter-spacing: -0.005em;
723
+ margin: 10px auto;
724
+ max-width: 720px;
725
+ }
726
+
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;
734
+ }
735
+ .np-grid-2 > .np-col {
736
+ padding-right: 22px;
737
+ border-right: 1px solid var(--rule);
738
+ }
739
+ .np-grid-2 > .np-col:last-child {
740
+ padding-right: 0;
741
+ border-right: 0;
742
+ }
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; }
752
+ }
753
+
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 {
758
+ display: grid;
759
+ grid-template-columns: 2fr 1fr;
760
+ gap: 28px;
761
+ margin: 10px 0;
762
+ align-items: start;
763
+ }
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;
770
+ }
771
+ .np-hero-asym > .np-hero-side {
772
+ display: flex;
773
+ flex-direction: column;
774
+ gap: 14px;
775
+ }
776
+ @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
+ }
784
+ }
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;
790
+ font-family: var(--serif-display);
791
+ font-size: 22px;
792
+ font-style: italic;
793
+ line-height: 1.4;
794
+ 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
+ letter-spacing: -0.005em;
801
+ }
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);
861
+ }
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);
866
+ }
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
+ );
876
+ }
877
+ body[data-np-variant="2"] .np-grid > .np-col {
878
+ border-right-color: var(--accent);
879
+ }
880
+ body[data-np-variant="2"] .np-list li b {
881
+ color: var(--accent);
882
+ }
883
+
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; }
922
+ }
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;
929
+ 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);
948
+ }
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;
955
+ }
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;
961
+ }
962
+ body[data-np-variant="3"] .np-frontpage-title {
963
+ font-style: italic;
964
+ }
965
+
966
+ /* ─── Print · drop chrome ───────────────────────────────────── */
967
+ @media print {
968
+ .np-top-bar { display: none; }
969
+ body, html { background: white; }
970
+ .np-doc {
971
+ max-width: none;
972
+ margin: 0;
973
+ box-shadow: none;
974
+ }
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
+ }
982
+ }
983
+ </style>
984
+ </head>
985
+ <body>
986
+
987
+ <header class="np-top-bar" data-np-chrome>
988
+ <a href="/" class="np-crumb">PrivateBoard <span class="np-crumb-accent">· newspaper</span></a>
989
+ <div class="np-actions">
990
+ <button type="button" class="np-btn" data-np-png>
991
+ <span class="glyph">↓</span>PNG
992
+ </button>
993
+ <button type="button" class="np-btn" data-np-print>
994
+ <span class="glyph">↓</span>PDF
995
+ </button>
996
+ </div>
997
+ </header>
998
+
999
+ <main data-np-root>
1000
+ <div class="np-state">
1001
+ <div class="np-state-mark">Loading</div>
1002
+ <div class="np-state-title">Loading newspaper…</div>
1003
+ <div class="np-state-body">Fetching the broadsheet for this brief.</div>
1004
+ </div>
1005
+ </main>
1006
+
1007
+ <script>
1008
+ /* ──────────────────────────────────────────────────────────────────
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.
1032
+ ────────────────────────────────────────────────────────────── */
1033
+ (function () {
1034
+ const params = new URLSearchParams(location.search);
1035
+ const briefId = (params.get("b") || "").trim();
1036
+ const roomId = (params.get("r") || "").trim();
1037
+ 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
+ }
1064
+
1065
+ function escape(s) {
1066
+ return String(s == null ? "" : s)
1067
+ .replace(/&/g, "&amp;")
1068
+ .replace(/</g, "&lt;")
1069
+ .replace(/>/g, "&gt;")
1070
+ .replace(/"/g, "&quot;")
1071
+ .replace(/'/g, "&#39;");
1072
+ }
1073
+
1074
+ function showState(mark, title, body) {
1075
+ root.innerHTML = `
1076
+ <div class="np-state">
1077
+ <div class="np-state-mark">${escape(mark)}</div>
1078
+ <div class="np-state-title">${escape(title)}</div>
1079
+ <div class="np-state-body">${escape(body)}</div>
1080
+ </div>`;
1081
+ }
1082
+
1083
+ async function loadBrief() {
1084
+ 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
+ }
1094
+ const res = await fetch(url);
1095
+ if (!res.ok) {
1096
+ 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.");
1099
+ return null;
1100
+ }
1101
+ return await res.json();
1102
+ }
1103
+
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. */
1108
+ function splitHeading(raw) {
1109
+ const s = String(raw || "").trim();
1110
+ if (!s) return { heading: "", body: "" };
1111
+ const seps = [": ", " · ", " — ", " - "];
1112
+ for (const sep of seps) {
1113
+ const idx = s.indexOf(sep);
1114
+ if (idx > 0 && idx < 60) {
1115
+ return {
1116
+ heading: s.slice(0, idx).trim(),
1117
+ body: s.slice(idx + sep.length).trim(),
1118
+ };
1119
+ }
1120
+ }
1121
+ return { heading: s, body: "" };
1122
+ }
1123
+
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 "";
1131
+ }
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>`;
1148
+ }
1149
+
1150
+ function renderBottomLineCallout(conclusion) {
1151
+ if (!conclusion) return "";
1152
+ 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>`;
1157
+ }
1158
+
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;
1172
+ }
1173
+ const day = days[target.getDay()];
1174
+ const month = months[target.getMonth()];
1175
+ const num = String(target.getDate());
1176
+ const year = String(target.getFullYear());
1177
+ 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
+ }
1186
+
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
+ }
1201
+
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>`;
1208
+ }
1209
+
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) => {
1215
+ const parts = splitHeading(b);
1216
+ const inner = parts.body
1217
+ ? `<b>${escape(parts.heading)}</b>${escape(parts.body)}`
1218
+ : escape(parts.heading);
1219
+ return `<li>${inner}</li>`;
1220
+ }).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>` : "");
1352
+
1353
+ 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>
1369
+ </div>
1370
+
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>
1379
+
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}
1391
+ </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>
1400
+
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>`;
1415
+ }
1416
+
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>`;
1441
+
1442
+ return `
1443
+ <article class="np-doc np-layout-2" data-np-paper>
1444
+ <div class="np-rule"></div>
1445
+
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>
1451
+
1452
+ <div class="np-rule-double"></div>
1453
+
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>
1458
+ </div>
1459
+
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>
1468
+
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>
1481
+
1482
+ ${pullText ? `
1483
+ <div class="np-rule"></div>
1484
+ <section class="np-pull-band">"${escape(pullText)}"</section>
1485
+ ` : ""}
1486
+
1487
+ <div class="np-rule"></div>
1488
+
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)}
1499
+ </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 : "";
1547
+
1548
+ return `
1549
+ <article class="np-doc np-layout-3" data-np-paper>
1550
+ <div class="np-rule"></div>
1551
+
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>
1557
+
1558
+ <div class="np-rule-double"></div>
1559
+
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>
1564
+ </div>
1565
+
1566
+ <div class="np-rule-double"></div>
1567
+
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>
1572
+
1573
+ <div class="np-rule-double"></div>
1574
+
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>
1588
+
1589
+ ${pullText ? `
1590
+ <div class="np-rule-double"></div>
1591
+ <section class="np-pull-framed">"${escape(pullText)}"</section>
1592
+ ` : ""}
1593
+
1594
+ <div class="np-rule-double"></div>
1595
+
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>
1610
+
1611
+ <div class="np-rule"></div>
1612
+
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>`;
1618
+ }
1619
+
1620
+ /* ─── Export wiring · same PNG-as-PDF strategy as bento/magazine ── */
1621
+ let _h2iLoaded = null;
1622
+ async function ensureHtmlToImage() {
1623
+ if (window.htmlToImage) return;
1624
+ if (!_h2iLoaded) {
1625
+ _h2iLoaded = new Promise((res, rej) => {
1626
+ const s = document.createElement("script");
1627
+ s.src = "https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.min.js";
1628
+ s.onload = res;
1629
+ s.onerror = rej;
1630
+ document.head.appendChild(s);
1631
+ });
1632
+ }
1633
+ await _h2iLoaded;
1634
+ }
1635
+
1636
+ async function captureNpPng() {
1637
+ const el = document.querySelector("[data-np-paper]");
1638
+ if (!el) throw new Error("newspaper doc not found");
1639
+ await ensureHtmlToImage();
1640
+ if (document.fonts && document.fonts.ready) {
1641
+ try { await document.fonts.ready; } catch { /* best-effort */ }
1642
+ }
1643
+ const width = Math.max(el.scrollWidth, el.offsetWidth, el.clientWidth);
1644
+ const height = Math.max(el.scrollHeight, el.offsetHeight, el.clientHeight);
1645
+ return window.htmlToImage.toPng(el, {
1646
+ pixelRatio: 2,
1647
+ backgroundColor: "#EEE2C6",
1648
+ cacheBust: true,
1649
+ width,
1650
+ height,
1651
+ canvasWidth: width,
1652
+ canvasHeight: height,
1653
+ style: {
1654
+ margin: "0",
1655
+ width: `${width}px`,
1656
+ height: `${height}px`,
1657
+ },
1658
+ });
1659
+ }
1660
+
1661
+ function slugTitle() {
1662
+ return (document.title || "newspaper").replace(/[^a-z0-9]+/gi, "-").slice(0, 60) || "newspaper";
1663
+ }
1664
+
1665
+ async function exportPng() {
1666
+ try {
1667
+ const dataUrl = await captureNpPng();
1668
+ const a = document.createElement("a");
1669
+ a.download = `${slugTitle()}.png`;
1670
+ a.href = dataUrl;
1671
+ a.click();
1672
+ } catch (e) {
1673
+ console.warn("[newspaper] PNG export failed:", e);
1674
+ alert("PNG export failed · see browser console.");
1675
+ }
1676
+ }
1677
+
1678
+ async function exportPdf() {
1679
+ try {
1680
+ const dataUrl = await captureNpPng();
1681
+ const win = window.open("", "_blank", "width=1024,height=720");
1682
+ if (!win) {
1683
+ alert("PDF export needs to open a new window — please allow popups for this site.");
1684
+ return;
1685
+ }
1686
+ const slug = slugTitle();
1687
+ win.document.open();
1688
+ win.document.write(`<!doctype html><html lang="en"><head>
1689
+ <meta charset="utf-8">
1690
+ <title>${slug}</title>
1691
+ <style>
1692
+ @page { size: auto; margin: 10mm; }
1693
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1694
+ 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
+ }
1709
+ @media print {
1710
+ body { padding: 0; }
1711
+ img { box-shadow: none; max-width: none; width: 100%; }
1712
+ }
1713
+ .hint {
1714
+ position: fixed;
1715
+ top: 12px;
1716
+ left: 12px;
1717
+ 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;
1722
+ border: 1px solid #E5E2DA;
1723
+ }
1724
+ @media print { .hint { display: none; } }
1725
+ </style>
1726
+ </head><body>
1727
+ <div class="hint">// press <kbd>⌘P</kbd> / <kbd>Ctrl+P</kbd> · save as PDF</div>
1728
+ <img alt="${slug}" src="${dataUrl}">
1729
+ <script>
1730
+ (function () {
1731
+ var img = document.querySelector("img");
1732
+ function go() { setTimeout(function () { window.print(); }, 200); }
1733
+ if (img && img.complete) { go(); }
1734
+ else if (img) { img.addEventListener("load", go, { once: true }); }
1735
+ })();
1736
+ <\/script>
1737
+ </body></html>`);
1738
+ win.document.close();
1739
+ } catch (e) {
1740
+ console.warn("[newspaper] PDF export failed:", e);
1741
+ alert("PDF export failed · see browser console.");
1742
+ }
1743
+ }
1744
+
1745
+ 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
+ }
1756
+ });
1757
+
1758
+ /* ─── Boot ──────────────────────────────────────────────────── */
1759
+ loadBrief().then((brief) => {
1760
+ if (brief) render(brief);
1761
+ }).catch((e) => {
1762
+ console.error("[newspaper] load failed:", e);
1763
+ showState("Error", "Couldn't load this brief",
1764
+ e instanceof Error ? e.message : String(e));
1765
+ });
1766
+ })();
1767
+ </script>
1768
+
1769
+ </body>
1770
+ </html>