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,1396 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=840, initial-scale=1">
6
+ <title>Bento · PrivateBoard</title>
7
+ <link rel="icon" href="/avatars/chair.svg" type="image/svg+xml">
8
+ <style>
9
+ /* ═══════════════════════════════════════════════════════════════════
10
+ Bento · single-page POSTER. Off-white paper, flat bordered tiles,
11
+ display-grade typography, full-width hero blocks on near-black.
12
+
13
+ Poster register (not blog register) · the visual energy comes
14
+ from oversized type (60px+ display title, 64px+ milestone callouts,
15
+ 32px+ pull-quote), high-contrast inverse blocks (near-black + cream
16
+ + bright lime), and a confident horizontal 3-tile milestone row
17
+ with the middle tile flipped to dark for visual rhythm.
18
+
19
+ Tiles are bordered + flat instead of floating-with-shadow · the
20
+ prior "soft floating cards on cream" register read as a personal
21
+ blog. The new register reads as printed editorial poster.
22
+ ─────────────────────────────────────────────────────────────────── */
23
+ :root {
24
+ /* Surfaces · warm off-white paper, plain not gradient · cards are
25
+ bordered tiles, not floating shadow surfaces */
26
+ --bg: #ECE5D2;
27
+ --paper: #F6F1E0;
28
+ --paper-soft: #FBF8EC;
29
+ --paper-warm: #F2ECD8;
30
+
31
+ /* Ink · pushed near-black for poster contrast */
32
+ --ink: #0F0D08;
33
+ --ink-soft: #2C2618;
34
+ --ink-mid: #5C5340;
35
+ --ink-faint: #8E8470;
36
+ --ink-muted: #B8AE96;
37
+ --cream: #FFFCEE;
38
+
39
+ /* Rules */
40
+ --rule: #D4C9AC;
41
+ --rule-soft: #E2D9BE;
42
+ --rule-strong: #A89C7B;
43
+
44
+ /* Accent · sage-lime kept, plus a brighter variant for use on
45
+ the dark inverse blocks where #6FB572 reads too subdued. */
46
+ --accent: #6FB572;
47
+ --accent-deep: #4F8E54;
48
+ --accent-bright:#B7E84A;
49
+ --accent-soft: #DDEED1;
50
+ --accent-tint: #EEF6E4;
51
+
52
+ --gold: #C9A46B;
53
+ --gold-deep: #8E6B3A;
54
+ --gold-pale: #EFE0BD;
55
+
56
+ /* Radius scale · tighter than before; poster typography wants
57
+ crisper edges than rounded blog cards. */
58
+ --r-xs: 2px;
59
+ --r-sm: 4px;
60
+ --r-md: 8px;
61
+
62
+ /* Type stack · display serif elevated to Tiempos / Playfair so
63
+ the title and callouts read as masthead-grade, not blog-grade. */
64
+ --serif-display: "Tiempos Headline", "Playfair Display", "Source Serif Pro",
65
+ "Charter", Georgia, "Source Han Serif SC",
66
+ "Songti SC", "STSong", serif;
67
+ --serif: "Charter", "Source Serif Pro", "Iowan Old Style",
68
+ "Source Han Serif SC", Georgia,
69
+ "Songti SC", "STSong", serif;
70
+ --sans: "Inter", "Helvetica Neue", -apple-system, BlinkMacSystemFont,
71
+ "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
72
+ "Source Han Sans CN", "Noto Sans CJK SC", sans-serif;
73
+ --mono: "SF Mono", "JetBrains Mono", "Menlo",
74
+ "PingFang SC", "Source Han Sans CN", monospace;
75
+ }
76
+
77
+ * { box-sizing: border-box; margin: 0; padding: 0; }
78
+ html, body {
79
+ background: var(--bg);
80
+ color: var(--ink);
81
+ font-family: var(--sans);
82
+ font-size: 14px;
83
+ line-height: 1.5;
84
+ -webkit-font-smoothing: antialiased;
85
+ text-rendering: optimizeLegibility;
86
+ min-height: 100vh;
87
+ }
88
+
89
+ /* ─── Top chrome · brand crumb + actions ────────────────────────── */
90
+ .bento-top-bar {
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: space-between;
94
+ gap: 14px;
95
+ padding: 18px 32px;
96
+ background: rgba(236, 229, 210, 0.92);
97
+ backdrop-filter: blur(10px);
98
+ -webkit-backdrop-filter: blur(10px);
99
+ border-bottom: 1px solid var(--ink);
100
+ flex-wrap: wrap;
101
+ position: sticky;
102
+ top: 0;
103
+ z-index: 10;
104
+ }
105
+ .bento-crumb {
106
+ display: inline-flex;
107
+ align-items: center;
108
+ gap: 12px;
109
+ font-family: var(--serif-display);
110
+ font-size: 17px;
111
+ font-weight: 700;
112
+ color: var(--ink);
113
+ text-decoration: none;
114
+ letter-spacing: -0.005em;
115
+ }
116
+ .bento-crumb::before {
117
+ content: "";
118
+ width: 10px;
119
+ height: 10px;
120
+ background: var(--ink);
121
+ flex: 0 0 auto;
122
+ }
123
+ .bento-crumb-accent {
124
+ color: var(--ink-mid);
125
+ font-style: italic;
126
+ font-weight: 500;
127
+ }
128
+ .bento-actions { display: flex; gap: 8px; }
129
+ .bento-btn {
130
+ font-family: var(--mono);
131
+ font-size: 11px;
132
+ letter-spacing: 0.06em;
133
+ padding: 8px 14px;
134
+ background: var(--paper);
135
+ border: 1px solid var(--ink);
136
+ color: var(--ink);
137
+ cursor: pointer;
138
+ text-decoration: none;
139
+ text-transform: uppercase;
140
+ font-weight: 600;
141
+ transition: background 0.15s, color 0.15s;
142
+ }
143
+ .bento-btn:hover {
144
+ background: var(--ink);
145
+ color: var(--paper);
146
+ }
147
+ .bento-btn .glyph { margin-right: 4px; }
148
+
149
+ /* ─── Doc · the poster sheet ────────────────────────────────────
150
+ Plain warm paper bg + crisp 1px ink edge. No floating cards · the
151
+ poster IS one composition with bordered tile zones inside it. */
152
+ .bento-doc {
153
+ max-width: 920px;
154
+ margin: 28px auto 56px;
155
+ padding: 36px 36px 32px;
156
+ background: var(--paper);
157
+ border: 1px solid var(--ink);
158
+ }
159
+
160
+ /* ─── Header · meta strip + huge display title + serif kicker ─── */
161
+ .bento-header {
162
+ margin-bottom: 28px;
163
+ }
164
+ .bento-header-meta {
165
+ display: flex;
166
+ align-items: baseline;
167
+ justify-content: space-between;
168
+ gap: 18px;
169
+ margin-bottom: 18px;
170
+ padding-bottom: 12px;
171
+ border-bottom: 1px solid var(--ink);
172
+ }
173
+ .bento-eyebrow {
174
+ display: inline-flex;
175
+ align-items: center;
176
+ gap: 8px;
177
+ font-family: var(--mono);
178
+ font-size: 11px;
179
+ letter-spacing: 0.18em;
180
+ text-transform: uppercase;
181
+ color: var(--ink);
182
+ font-weight: 700;
183
+ }
184
+ .bento-eyebrow-dot {
185
+ width: 8px;
186
+ height: 8px;
187
+ background: var(--accent-deep);
188
+ border-radius: 50%;
189
+ }
190
+ .bento-source {
191
+ font-family: var(--mono);
192
+ font-size: 11px;
193
+ letter-spacing: 0.06em;
194
+ color: var(--ink-mid);
195
+ text-align: right;
196
+ white-space: nowrap;
197
+ max-width: 320px;
198
+ overflow: hidden;
199
+ text-overflow: ellipsis;
200
+ }
201
+ .bento-title {
202
+ font-family: var(--serif-display);
203
+ font-size: 60px;
204
+ font-weight: 700;
205
+ line-height: 0.98;
206
+ letter-spacing: -0.025em;
207
+ color: var(--ink);
208
+ margin: 0 0 14px;
209
+ }
210
+ @media (max-width: 720px) {
211
+ .bento-title { font-size: 38px; }
212
+ }
213
+ .bento-kicker {
214
+ font-family: var(--serif);
215
+ font-size: 17px;
216
+ line-height: 1.45;
217
+ color: var(--ink-soft);
218
+ font-style: italic;
219
+ max-width: 720px;
220
+ letter-spacing: -0.005em;
221
+ }
222
+
223
+ /* ─── Hero conclusion · full-width near-black pull-quote ──────── */
224
+ .bento-conclusion {
225
+ background: var(--ink);
226
+ color: var(--cream);
227
+ padding: 32px 36px;
228
+ margin-bottom: 16px;
229
+ position: relative;
230
+ }
231
+ .bento-conclusion-mark {
232
+ display: inline-flex;
233
+ align-items: center;
234
+ gap: 10px;
235
+ font-family: var(--mono);
236
+ font-size: 11px;
237
+ letter-spacing: 0.2em;
238
+ text-transform: uppercase;
239
+ color: var(--accent-bright);
240
+ font-weight: 700;
241
+ margin-bottom: 14px;
242
+ }
243
+ .bento-conclusion-mark::before {
244
+ content: "";
245
+ width: 18px;
246
+ height: 1px;
247
+ background: var(--accent-bright);
248
+ }
249
+ .bento-conclusion-inner { width: 100%; }
250
+ .bento-conclusion-text {
251
+ font-family: var(--serif-display);
252
+ font-size: 32px;
253
+ line-height: 1.18;
254
+ color: var(--cream);
255
+ font-weight: 500;
256
+ letter-spacing: -0.012em;
257
+ max-width: 800px;
258
+ }
259
+ @media (max-width: 720px) {
260
+ .bento-conclusion { padding: 24px 22px; }
261
+ .bento-conclusion-text { font-size: 22px; }
262
+ }
263
+
264
+ /* ─── Milestones row · 3 horizontal tiles, middle one inverted
265
+ for poster rhythm. Each tile leads with a HUGE callout. ─────── */
266
+ .bento-milestones-row {
267
+ display: grid;
268
+ grid-template-columns: 1fr 1fr 1fr;
269
+ gap: 12px;
270
+ margin-bottom: 16px;
271
+ }
272
+ @media (max-width: 720px) {
273
+ .bento-milestones-row { grid-template-columns: 1fr; }
274
+ }
275
+
276
+ .bento-milestone {
277
+ background: var(--paper-soft);
278
+ border: 1px solid var(--ink);
279
+ padding: 22px 22px 22px;
280
+ display: flex;
281
+ flex-direction: column;
282
+ gap: 10px;
283
+ min-height: 280px;
284
+ }
285
+ /* Middle tile · inverse treatment for poster rhythm. SCOPED TO
286
+ LAYOUT 1 only · layouts 2 + 3 don't use the alternating rhythm
287
+ (layout 2 has its own dedicated featured-hero tile · layout 3's
288
+ mosaic uses a wide dark conclusion block as its dark element). */
289
+ .bento-doc.bento-layout-1 .bento-milestone.bento-milestone-1 {
290
+ background: var(--ink);
291
+ color: var(--cream);
292
+ }
293
+ .bento-doc.bento-layout-1 .bento-milestone.bento-milestone-1 .bento-milestone-period {
294
+ color: var(--accent-bright);
295
+ }
296
+ .bento-doc.bento-layout-1 .bento-milestone.bento-milestone-1 .bento-milestone-callout {
297
+ color: var(--accent-bright);
298
+ }
299
+ .bento-doc.bento-layout-1 .bento-milestone.bento-milestone-1 .bento-milestone-title {
300
+ color: var(--cream);
301
+ }
302
+ .bento-doc.bento-layout-1 .bento-milestone.bento-milestone-1 .bento-milestone-body {
303
+ color: rgba(255, 252, 238, 0.8);
304
+ }
305
+ .bento-doc.bento-layout-1 .bento-milestone.bento-milestone-1 .bento-tag {
306
+ background: rgba(255, 252, 238, 0.10);
307
+ border-color: rgba(255, 252, 238, 0.22);
308
+ color: rgba(255, 252, 238, 0.88);
309
+ }
310
+
311
+ .bento-milestone-period {
312
+ font-family: var(--mono);
313
+ font-size: 10px;
314
+ letter-spacing: 0.16em;
315
+ text-transform: uppercase;
316
+ color: var(--accent-deep);
317
+ font-weight: 700;
318
+ }
319
+ .bento-milestone-callout {
320
+ font-family: var(--serif-display);
321
+ font-size: 64px;
322
+ font-weight: 700;
323
+ line-height: 0.95;
324
+ letter-spacing: -0.04em;
325
+ color: var(--ink);
326
+ margin: 2px 0 4px;
327
+ word-break: break-word;
328
+ }
329
+ @media (max-width: 720px) {
330
+ .bento-milestone-callout { font-size: 48px; }
331
+ }
332
+ .bento-milestone-title {
333
+ font-family: var(--serif-display);
334
+ font-size: 19px;
335
+ font-weight: 700;
336
+ line-height: 1.22;
337
+ letter-spacing: -0.008em;
338
+ color: var(--ink);
339
+ }
340
+ .bento-milestone-body {
341
+ font-family: var(--serif);
342
+ font-size: 13.5px;
343
+ line-height: 1.5;
344
+ color: var(--ink-mid);
345
+ }
346
+ .bento-milestone-tags {
347
+ display: flex;
348
+ flex-wrap: wrap;
349
+ gap: 4px;
350
+ margin-top: auto;
351
+ padding-top: 8px;
352
+ }
353
+ .bento-tag {
354
+ font-family: var(--mono);
355
+ font-size: 9.5px;
356
+ letter-spacing: 0.06em;
357
+ text-transform: uppercase;
358
+ color: var(--ink-mid);
359
+ background: var(--paper-warm);
360
+ border: 1px solid var(--rule-strong);
361
+ padding: 2px 8px;
362
+ font-weight: 600;
363
+ }
364
+
365
+ /* ─── Lower grid · ranked / verification / talking points ───── */
366
+ .bento-lower-grid {
367
+ display: grid;
368
+ grid-template-columns: 1fr 1fr 1fr;
369
+ gap: 12px;
370
+ margin-bottom: 16px;
371
+ }
372
+ @media (max-width: 720px) {
373
+ .bento-lower-grid { grid-template-columns: 1fr; }
374
+ }
375
+ .bento-card {
376
+ background: var(--paper-soft);
377
+ border: 1px solid var(--ink);
378
+ padding: 22px 22px;
379
+ display: flex;
380
+ flex-direction: column;
381
+ gap: 14px;
382
+ }
383
+ .bento-card-title {
384
+ font-family: var(--serif-display);
385
+ font-size: 18px;
386
+ font-weight: 700;
387
+ line-height: 1.2;
388
+ letter-spacing: -0.008em;
389
+ color: var(--ink);
390
+ padding-bottom: 8px;
391
+ border-bottom: 2px solid var(--ink);
392
+ }
393
+
394
+ /* Ranked bars · grid-row layout · big bold solid bars */
395
+ .bento-bar {
396
+ display: grid;
397
+ grid-template-columns: 1fr auto;
398
+ gap: 4px 8px;
399
+ margin-bottom: 14px;
400
+ font-family: var(--sans);
401
+ }
402
+ .bento-bar:last-child { margin-bottom: 0; }
403
+ .bento-bar-label {
404
+ font-size: 13px;
405
+ color: var(--ink);
406
+ font-weight: 600;
407
+ letter-spacing: -0.005em;
408
+ }
409
+ .bento-bar-value {
410
+ font-family: var(--mono);
411
+ font-size: 11px;
412
+ color: var(--ink-mid);
413
+ font-weight: 600;
414
+ letter-spacing: 0.02em;
415
+ }
416
+ .bento-bar-track {
417
+ grid-column: 1 / -1;
418
+ height: 8px;
419
+ background: var(--paper-warm);
420
+ border: 1px solid var(--ink);
421
+ position: relative;
422
+ margin-top: 2px;
423
+ }
424
+ .bento-bar-fill {
425
+ position: absolute;
426
+ top: 0;
427
+ left: 0;
428
+ bottom: 0;
429
+ background: var(--ink);
430
+ }
431
+
432
+ /* Bullet lists · default (verification) · ▸ markers */
433
+ .bento-bullets {
434
+ list-style: none;
435
+ margin: 0;
436
+ padding: 0;
437
+ }
438
+ .bento-bullets li {
439
+ display: grid;
440
+ grid-template-columns: 14px 1fr;
441
+ gap: 8px;
442
+ align-items: baseline;
443
+ font-family: var(--serif);
444
+ font-size: 13px;
445
+ line-height: 1.5;
446
+ color: var(--ink-soft);
447
+ padding-bottom: 10px;
448
+ margin-bottom: 10px;
449
+ border-bottom: 1px solid var(--rule);
450
+ }
451
+ .bento-bullets li:last-child {
452
+ padding-bottom: 0;
453
+ margin-bottom: 0;
454
+ border-bottom: 0;
455
+ }
456
+ .bento-bullets li::before {
457
+ content: "▸";
458
+ color: var(--accent-deep);
459
+ font-weight: 700;
460
+ font-size: 12px;
461
+ line-height: 1;
462
+ }
463
+ /* Talking points · numbered display-serif italic numerals */
464
+ .bento-card.bento-talking .bento-bullets {
465
+ counter-reset: tp;
466
+ }
467
+ .bento-card.bento-talking .bento-bullets li {
468
+ counter-increment: tp;
469
+ grid-template-columns: 32px 1fr;
470
+ gap: 12px;
471
+ }
472
+ .bento-card.bento-talking .bento-bullets li::before {
473
+ content: counter(tp, decimal-leading-zero);
474
+ font-family: var(--serif-display);
475
+ font-size: 22px;
476
+ font-weight: 700;
477
+ font-style: italic;
478
+ color: var(--accent-deep);
479
+ line-height: 1;
480
+ letter-spacing: -0.02em;
481
+ }
482
+ .bento-empty {
483
+ font-family: var(--mono);
484
+ font-size: 11px;
485
+ color: var(--ink-faint);
486
+ font-style: italic;
487
+ padding: 4px 0;
488
+ }
489
+
490
+ /* ─── Flow band · full-width near-black band with display arrows ─ */
491
+ .bento-flow {
492
+ background: var(--ink);
493
+ color: var(--cream);
494
+ padding: 26px 36px;
495
+ margin-bottom: 16px;
496
+ text-align: center;
497
+ }
498
+ .bento-flow-mark {
499
+ display: inline-flex;
500
+ align-items: center;
501
+ gap: 10px;
502
+ font-family: var(--mono);
503
+ font-size: 11px;
504
+ letter-spacing: 0.2em;
505
+ text-transform: uppercase;
506
+ color: var(--accent-bright);
507
+ font-weight: 700;
508
+ margin-bottom: 14px;
509
+ }
510
+ .bento-flow-mark::before {
511
+ content: "";
512
+ width: 18px;
513
+ height: 1px;
514
+ background: var(--accent-bright);
515
+ }
516
+ .bento-flow-chain {
517
+ font-family: var(--serif-display);
518
+ font-size: 28px;
519
+ font-weight: 600;
520
+ color: var(--cream);
521
+ letter-spacing: -0.014em;
522
+ line-height: 1.2;
523
+ }
524
+ @media (max-width: 720px) {
525
+ .bento-flow-chain { font-size: 22px; }
526
+ }
527
+ .bento-flow-chain .arrow {
528
+ color: var(--accent-bright);
529
+ margin: 0 14px;
530
+ font-weight: 700;
531
+ }
532
+ .bento-flow-caption {
533
+ font-family: var(--serif);
534
+ font-style: italic;
535
+ font-size: 13px;
536
+ color: rgba(255, 252, 238, 0.7);
537
+ margin-top: 10px;
538
+ letter-spacing: -0.005em;
539
+ }
540
+
541
+ /* ─── Footer · mono brand stamp ────────────────────────────────── */
542
+ .bento-footer {
543
+ display: flex;
544
+ justify-content: space-between;
545
+ align-items: baseline;
546
+ gap: 14px;
547
+ padding-top: 16px;
548
+ margin-top: 4px;
549
+ border-top: 2px solid var(--ink);
550
+ font-family: var(--mono);
551
+ font-size: 10px;
552
+ letter-spacing: 0.16em;
553
+ text-transform: uppercase;
554
+ color: var(--ink-mid);
555
+ font-weight: 600;
556
+ flex-wrap: wrap;
557
+ }
558
+ .bento-footer-stamp {
559
+ color: var(--ink);
560
+ font-weight: 700;
561
+ }
562
+
563
+ /* ─── States · loading / error / empty ─────────────────────────── */
564
+ .bento-state {
565
+ max-width: 560px;
566
+ margin: 80px auto;
567
+ padding: 48px 36px;
568
+ text-align: center;
569
+ background: var(--paper);
570
+ border: 1px solid var(--ink);
571
+ }
572
+ .bento-state-mark {
573
+ font-family: var(--mono);
574
+ font-size: 10px;
575
+ letter-spacing: 0.18em;
576
+ text-transform: uppercase;
577
+ color: var(--ink);
578
+ background: var(--accent-tint);
579
+ border: 1px solid var(--ink);
580
+ padding: 5px 14px;
581
+ display: inline-block;
582
+ margin-bottom: 16px;
583
+ font-weight: 700;
584
+ }
585
+ .bento-state-title {
586
+ font-family: var(--serif-display);
587
+ font-size: 26px;
588
+ font-weight: 700;
589
+ color: var(--ink);
590
+ margin-bottom: 10px;
591
+ line-height: 1.2;
592
+ letter-spacing: -0.014em;
593
+ }
594
+ .bento-state-body {
595
+ font-family: var(--serif);
596
+ font-size: 13.5px;
597
+ color: var(--ink-soft);
598
+ line-height: 1.6;
599
+ }
600
+
601
+ /* ═════════════════════════════════════════════════════════════════
602
+ LAYOUT 2 · Featured-hero · ms0 takes a full-width inverted dark
603
+ tile at the top with a MASSIVE callout numeral; ms1 + ms2 sit
604
+ below in a 2-tile row; conclusion lands in the middle as a
605
+ centered framed pull-quote (NOT dark, italic serif).
606
+ ═════════════════════════════════════════════════════════════════ */
607
+ .bento-feature-hero {
608
+ background: var(--ink);
609
+ color: var(--cream);
610
+ padding: 36px 36px;
611
+ margin-bottom: 16px;
612
+ display: grid;
613
+ grid-template-columns: 1.2fr 1fr;
614
+ gap: 32px;
615
+ align-items: center;
616
+ }
617
+ @media (max-width: 720px) {
618
+ .bento-feature-hero { grid-template-columns: 1fr; padding: 24px 22px; }
619
+ }
620
+ .bento-feature-callout {
621
+ font-family: var(--serif-display);
622
+ font-size: 112px;
623
+ font-weight: 700;
624
+ line-height: 0.88;
625
+ letter-spacing: -0.045em;
626
+ color: var(--accent-bright);
627
+ word-break: break-word;
628
+ }
629
+ @media (max-width: 720px) {
630
+ .bento-feature-callout { font-size: 72px; }
631
+ }
632
+ .bento-feature-content {
633
+ display: flex;
634
+ flex-direction: column;
635
+ gap: 10px;
636
+ }
637
+ .bento-feature-period {
638
+ display: inline-flex;
639
+ align-items: center;
640
+ gap: 8px;
641
+ font-family: var(--mono);
642
+ font-size: 10px;
643
+ letter-spacing: 0.2em;
644
+ text-transform: uppercase;
645
+ color: var(--accent-bright);
646
+ font-weight: 700;
647
+ margin-bottom: 4px;
648
+ }
649
+ .bento-feature-period::before {
650
+ content: "";
651
+ width: 16px;
652
+ height: 1px;
653
+ background: var(--accent-bright);
654
+ }
655
+ .bento-feature-title {
656
+ font-family: var(--serif-display);
657
+ font-size: 26px;
658
+ font-weight: 700;
659
+ line-height: 1.18;
660
+ letter-spacing: -0.014em;
661
+ color: var(--cream);
662
+ }
663
+ .bento-feature-body {
664
+ font-family: var(--serif);
665
+ font-size: 14.5px;
666
+ line-height: 1.5;
667
+ color: rgba(255, 252, 238, 0.82);
668
+ }
669
+
670
+ /* 2-tile milestone row · used by layout 2 (after the featured
671
+ hero, ms1 + ms2 sit side-by-side). */
672
+ .bento-milestones-2 {
673
+ display: grid;
674
+ grid-template-columns: 1fr 1fr;
675
+ gap: 12px;
676
+ margin-bottom: 16px;
677
+ }
678
+ @media (max-width: 720px) {
679
+ .bento-milestones-2 { grid-template-columns: 1fr; }
680
+ }
681
+
682
+ /* Centered pull-quote conclusion · used by layout 2 in place of
683
+ the full-width dark conclusion block. Italic serif, framed top
684
+ + bottom by 2px ink rules — magazine pull-quote register. */
685
+ .bento-conclusion-pull {
686
+ text-align: center;
687
+ padding: 28px 32px;
688
+ margin: 0 auto 16px;
689
+ max-width: 760px;
690
+ border-top: 2px solid var(--ink);
691
+ border-bottom: 2px solid var(--ink);
692
+ }
693
+ .bento-conclusion-pull .bento-conclusion-mark {
694
+ font-family: var(--mono);
695
+ font-size: 11px;
696
+ letter-spacing: 0.2em;
697
+ text-transform: uppercase;
698
+ color: var(--accent-deep);
699
+ font-weight: 700;
700
+ margin-bottom: 10px;
701
+ display: inline-block;
702
+ }
703
+ .bento-conclusion-pull .bento-conclusion-text {
704
+ font-family: var(--serif-display);
705
+ font-size: 28px;
706
+ line-height: 1.22;
707
+ color: var(--ink);
708
+ font-weight: 500;
709
+ font-style: italic;
710
+ letter-spacing: -0.012em;
711
+ }
712
+ @media (max-width: 720px) {
713
+ .bento-conclusion-pull .bento-conclusion-text { font-size: 20px; }
714
+ }
715
+
716
+ /* ═════════════════════════════════════════════════════════════════
717
+ LAYOUT 3 · Mosaic · mixed-size tiles in an asymmetric grid:
718
+ · ms0 spans column 1 across 2 rows (tall narrow tile)
719
+ · conclusion spans columns 2-3 row 1 (wide dark hero)
720
+ · ms1 in column 2 row 2 · ms2 in column 3 row 2
721
+ The lower 3-col grid (bars / verify / talking) and flow band
722
+ follow as in layout 1. The mosaic feels like a magazine spread
723
+ rather than a uniform horizontal stack.
724
+ ═════════════════════════════════════════════════════════════════ */
725
+ .bento-mosaic {
726
+ display: grid;
727
+ grid-template-columns: 1fr 1fr 1fr;
728
+ grid-template-rows: 1fr 1fr;
729
+ gap: 12px;
730
+ margin-bottom: 16px;
731
+ /* Set a min height so the tall ms0 tile has room to breathe ·
732
+ the row tracks divide this between the conclusion (top-right)
733
+ and ms1/ms2 (bottom-right). */
734
+ min-height: 440px;
735
+ }
736
+ .bento-mosaic > .bento-mosaic-ms0 {
737
+ grid-column: 1;
738
+ grid-row: 1 / span 2;
739
+ }
740
+ .bento-mosaic > .bento-mosaic-concl {
741
+ grid-column: 2 / span 2;
742
+ grid-row: 1;
743
+ background: var(--ink);
744
+ color: var(--cream);
745
+ padding: 28px 32px;
746
+ display: flex;
747
+ flex-direction: column;
748
+ justify-content: center;
749
+ gap: 12px;
750
+ }
751
+ .bento-mosaic > .bento-mosaic-concl .bento-conclusion-mark {
752
+ display: inline-flex;
753
+ align-items: center;
754
+ gap: 10px;
755
+ font-family: var(--mono);
756
+ font-size: 11px;
757
+ letter-spacing: 0.2em;
758
+ text-transform: uppercase;
759
+ color: var(--accent-bright);
760
+ font-weight: 700;
761
+ }
762
+ .bento-mosaic > .bento-mosaic-concl .bento-conclusion-mark::before {
763
+ content: "";
764
+ width: 18px;
765
+ height: 1px;
766
+ background: var(--accent-bright);
767
+ }
768
+ .bento-mosaic > .bento-mosaic-concl .bento-conclusion-text {
769
+ font-family: var(--serif-display);
770
+ font-size: 28px;
771
+ line-height: 1.18;
772
+ color: var(--cream);
773
+ font-weight: 500;
774
+ letter-spacing: -0.012em;
775
+ }
776
+ .bento-mosaic > .bento-mosaic-ms1 {
777
+ grid-column: 2;
778
+ grid-row: 2;
779
+ }
780
+ .bento-mosaic > .bento-mosaic-ms2 {
781
+ grid-column: 3;
782
+ grid-row: 2;
783
+ }
784
+ @media (max-width: 720px) {
785
+ .bento-mosaic {
786
+ grid-template-columns: 1fr;
787
+ grid-template-rows: auto;
788
+ min-height: 0;
789
+ }
790
+ .bento-mosaic > .bento-mosaic-ms0,
791
+ .bento-mosaic > .bento-mosaic-concl,
792
+ .bento-mosaic > .bento-mosaic-ms1,
793
+ .bento-mosaic > .bento-mosaic-ms2 {
794
+ grid-column: 1;
795
+ grid-row: auto;
796
+ }
797
+ }
798
+
799
+ /* ─── Print · drop chrome ─────────────────────────────────────── */
800
+ @media print {
801
+ .bento-top-bar { display: none; }
802
+ .bento-doc {
803
+ padding: 12mm;
804
+ max-width: none;
805
+ border: 0;
806
+ }
807
+ body, html { background: white; }
808
+ .bento-milestone,
809
+ .bento-card,
810
+ .bento-conclusion,
811
+ .bento-flow {
812
+ break-inside: avoid;
813
+ page-break-inside: avoid;
814
+ }
815
+ }
816
+ </style>
817
+ </head>
818
+ <body>
819
+
820
+ <header class="bento-top-bar" data-bento-chrome>
821
+ <a href="/" class="bento-crumb">PrivateBoard <span class="bento-crumb-accent">· bento</span></a>
822
+ <div class="bento-actions">
823
+ <button type="button" class="bento-btn" data-bento-png>
824
+ <span class="glyph">↓</span>PNG
825
+ </button>
826
+ <button type="button" class="bento-btn" data-bento-print>
827
+ <span class="glyph">↓</span>PDF
828
+ </button>
829
+ </div>
830
+ </header>
831
+
832
+ <main data-bento-root>
833
+ <div class="bento-state" data-bento-loading>
834
+ <div class="bento-state-mark">Loading</div>
835
+ <div class="bento-state-title">Loading bento…</div>
836
+ <div class="bento-state-body">Fetching the structured infographic for this brief.</div>
837
+ </div>
838
+ </main>
839
+
840
+ <script>
841
+ /* ──────────────────────────────────────────────────────────────────
842
+ Bento renderer · deterministic templating from BentoScaffold JSON.
843
+ Reads the brief id from the URL query string (`?b=<id>` or
844
+ `?r=<roomId>` for the latest brief on a room), fetches via the
845
+ existing /api/briefs/:id endpoint, validates the brief is in
846
+ bento mode, and templates the body_json into the layout.
847
+ ────────────────────────────────────────────────────────────── */
848
+ (function () {
849
+ const params = new URLSearchParams(location.search);
850
+ const briefId = (params.get("b") || "").trim();
851
+ const roomId = (params.get("r") || "").trim();
852
+ const root = document.querySelector("[data-bento-root]");
853
+
854
+ function escape(s) {
855
+ return String(s == null ? "" : s)
856
+ .replace(/&/g, "&amp;")
857
+ .replace(/</g, "&lt;")
858
+ .replace(/>/g, "&gt;")
859
+ .replace(/"/g, "&quot;")
860
+ .replace(/'/g, "&#39;");
861
+ }
862
+
863
+ function showState(mark, title, body) {
864
+ root.innerHTML = `
865
+ <div class="bento-state">
866
+ <div class="bento-state-mark">${escape(mark)}</div>
867
+ <div class="bento-state-title">${escape(title)}</div>
868
+ <div class="bento-state-body">${escape(body)}</div>
869
+ </div>`;
870
+ }
871
+
872
+ async function loadBrief() {
873
+ let url;
874
+ if (briefId) {
875
+ url = `/api/briefs/${encodeURIComponent(briefId)}`;
876
+ } else if (roomId) {
877
+ url = `/api/rooms/${encodeURIComponent(roomId)}/brief`;
878
+ } else {
879
+ showState("Missing query", "No brief specified",
880
+ "Add ?b=<briefId> or ?r=<roomId> to the URL.");
881
+ return null;
882
+ }
883
+ const res = await fetch(url);
884
+ if (!res.ok) {
885
+ const e = await res.json().catch(() => ({}));
886
+ showState("Not found", "Brief not found",
887
+ e.error || "The requested brief doesn't exist or is no longer available.");
888
+ return null;
889
+ }
890
+ return await res.json();
891
+ }
892
+
893
+ /** Render one milestone tile. The index determines which tile in
894
+ * the row it is · in layout 1 the middle tile (i === 1) gets
895
+ * an inverse near-black treatment via CSS scoped to
896
+ * `.bento-doc.bento-layout-1`. The data layout (period · big
897
+ * callout · title · body · tags) flows top-to-bottom inside the
898
+ * tile. `extraClass` is appended to the article's classList ·
899
+ * used by layout 3's mosaic to set grid-position classes
900
+ * (bento-mosaic-ms0 · ms1 · ms2). */
901
+ function renderMilestone(m, i, extraClass) {
902
+ const tagsHtml = (m.tags || []).map((t) =>
903
+ `<span class="bento-tag">${escape(t)}</span>`).join("");
904
+ const period = m.period
905
+ ? `<div class="bento-milestone-period">${escape(m.period)}</div>`
906
+ : "";
907
+ const callout = m.callout
908
+ ? `<div class="bento-milestone-callout">${escape(m.callout)}</div>`
909
+ : "";
910
+ const title = m.title
911
+ ? `<div class="bento-milestone-title">${escape(m.title)}</div>`
912
+ : "";
913
+ const body = m.body
914
+ ? `<div class="bento-milestone-body">${escape(m.body)}</div>`
915
+ : "";
916
+ const tags = tagsHtml
917
+ ? `<div class="bento-milestone-tags">${tagsHtml}</div>`
918
+ : "";
919
+ const cls = `bento-milestone bento-milestone-${i}${extraClass ? " " + extraClass : ""}`;
920
+ return `<article class="${cls}">${period}${callout}${title}${body}${tags}</article>`;
921
+ }
922
+
923
+ /** Featured-hero tile · layout 2's full-width inverted dark
924
+ * block that promotes ms0 to a poster-cover element. The
925
+ * callout is scaled to 112px display serif (or 72px on small
926
+ * screens) and pairs with the title + body in the right half.
927
+ * Falls back to using the period as the big numeral when the
928
+ * milestone has no explicit callout. */
929
+ function renderFeatureHero(m0) {
930
+ if (!m0) return "";
931
+ const big = m0.callout || m0.period || "";
932
+ return `
933
+ <section class="bento-feature-hero">
934
+ ${big ? `<div class="bento-feature-callout">${escape(big)}</div>` : ""}
935
+ <div class="bento-feature-content">
936
+ ${m0.callout && m0.period ? `<div class="bento-feature-period">${escape(m0.period)}</div>` : ""}
937
+ ${m0.title ? `<div class="bento-feature-title">${escape(m0.title)}</div>` : ""}
938
+ ${m0.body ? `<div class="bento-feature-body">${escape(m0.body)}</div>` : ""}
939
+ </div>
940
+ </section>`;
941
+ }
942
+
943
+ /** Pull-quote conclusion · layout 2's centered framed conclusion
944
+ * block (italic serif top + bottom rules) that replaces the
945
+ * full-width dark conclusion of layout 1. Skipped when there
946
+ * is no conclusion text. */
947
+ function renderConclusionPull(text) {
948
+ if (!text) return "";
949
+ return `
950
+ <section class="bento-conclusion-pull">
951
+ <div class="bento-conclusion-mark">The takeaway</div>
952
+ <div class="bento-conclusion-text">"${escape(text)}"</div>
953
+ </section>`;
954
+ }
955
+
956
+ /** Pick one of three bento layouts deterministically from the
957
+ * brief id, so a refresh always shows the same look for the
958
+ * same brief. The `?v=1|2|3` URL parameter overrides the hash
959
+ * for previewing / debugging.
960
+ *
961
+ * · 1 · Linear · current uniform horizontal stack · conclusion
962
+ * hero on top, 3 milestones row, lower 3-col grid, flow
963
+ * · 2 · Featured-hero · ms0 promoted to a full-width inverted
964
+ * dark feature tile with a HUGE callout · ms1 + ms2 in a
965
+ * 2-tile row · conclusion as a centered framed pull-quote
966
+ * · 3 · Mosaic · asymmetric grid · ms0 tall on the left
967
+ * spanning 2 rows, conclusion wide on the top-right,
968
+ * ms1 + ms2 in the bottom-right cells
969
+ */
970
+ function pickVariant(id) {
971
+ const force = (params.get("v") || "").trim();
972
+ if (force === "1" || force === "2" || force === "3") return parseInt(force, 10);
973
+ const s = String(id || "");
974
+ if (!s) return 1;
975
+ let h = 0;
976
+ for (let i = 0; i < s.length; i++) {
977
+ h = ((h << 5) - h) + s.charCodeAt(i);
978
+ h |= 0;
979
+ }
980
+ return (Math.abs(h) % 3) + 1;
981
+ }
982
+
983
+ function renderRankedBars(rb) {
984
+ if (!rb || !rb.entries || rb.entries.length === 0) return "";
985
+ const items = rb.entries.map((e) => {
986
+ const ratio = Math.max(0, Math.min(1, Number(e.ratio) || 0));
987
+ const pct = (ratio * 100).toFixed(1);
988
+ return `
989
+ <div class="bento-bar">
990
+ <span class="bento-bar-label">${escape(e.label || "")}</span>
991
+ <span class="bento-bar-track"><span class="bento-bar-fill" style="width: ${pct}%"></span></span>
992
+ <span class="bento-bar-value">${escape(e.value || "")}</span>
993
+ </div>`;
994
+ }).join("");
995
+ return `
996
+ <section class="bento-card bento-ranked">
997
+ <div class="bento-card-title">${escape(rb.title || "By the numbers")}</div>
998
+ ${items}
999
+ </section>`;
1000
+ }
1001
+
1002
+ function renderBullets(bd, klass) {
1003
+ if (!bd || !bd.bullets || bd.bullets.length === 0) {
1004
+ if (!bd || !bd.title) return "";
1005
+ return `
1006
+ <section class="bento-card ${klass}">
1007
+ <div class="bento-card-title">${escape(bd.title)}</div>
1008
+ <div class="bento-empty">— awaiting</div>
1009
+ </section>`;
1010
+ }
1011
+ const items = bd.bullets.map((b) => `<li>${escape(b)}</li>`).join("");
1012
+ return `
1013
+ <section class="bento-card ${klass}">
1014
+ <div class="bento-card-title">${escape(bd.title)}</div>
1015
+ <ul class="bento-bullets">${items}</ul>
1016
+ </section>`;
1017
+ }
1018
+ // Note: the `<div class="bento-empty">— awaiting</div>` branch above
1019
+ // ran when a card had a title but no bullets · we now hide such
1020
+ // cards entirely (the empty pill read as broken). Rendering nothing
1021
+ // for an unfilled section is more honest than a placeholder.
1022
+
1023
+ function renderFlow(flow) {
1024
+ if (!flow || !Array.isArray(flow.nodes) || flow.nodes.length < 2) return "";
1025
+ const chain = flow.nodes.map(escape).join('<span class="arrow">→</span>');
1026
+ const cap = flow.caption ? `<div class="bento-flow-caption">${escape(flow.caption)}</div>` : "";
1027
+ return `
1028
+ <aside class="bento-flow">
1029
+ <div class="bento-flow-mark">Transformation</div>
1030
+ <div class="bento-flow-chain">${chain}</div>
1031
+ ${cap}
1032
+ </aside>`;
1033
+ }
1034
+
1035
+ function render(brief) {
1036
+ if (brief.mode !== "bento") {
1037
+ showState("Wrong mode", "This brief is a research note",
1038
+ "Open it in the report viewer instead. Bento is a separate output mode.");
1039
+ return;
1040
+ }
1041
+ const bento = brief.bodyJson;
1042
+ if (!bento || typeof bento !== "object") {
1043
+ if (brief.isGenerating) {
1044
+ showState("Generating",
1045
+ "Bento is still being prepared",
1046
+ "The chair is currently composing the infographic. Refresh in a few seconds.");
1047
+ } else {
1048
+ showState("Empty",
1049
+ "This brief has no bento data",
1050
+ "It may have failed to generate. Try regenerating from the room view.");
1051
+ }
1052
+ return;
1053
+ }
1054
+
1055
+ // Set page title to the bento headline.
1056
+ if (bento.title) document.title = `${bento.title} · Bento`;
1057
+
1058
+ // Pre-render shared parts · the 3 layouts compose them into
1059
+ // different DOM structures.
1060
+ const rankedBars = renderRankedBars(bento.rankedBars);
1061
+ const verification = renderBullets(bento.verification, "bento-verify");
1062
+ const talkingPoints = renderBullets(bento.talkingPoints, "bento-talking");
1063
+ const flow = renderFlow(bento.flow);
1064
+
1065
+ const parts = { bento, rankedBars, verification, talkingPoints, flow };
1066
+
1067
+ // Pick the variant deterministically from the brief id and
1068
+ // dispatch to the matching layout function.
1069
+ const variant = pickVariant(brief.id);
1070
+ const html = (variant === 2) ? layoutVariant2(parts)
1071
+ : (variant === 3) ? layoutVariant3(parts)
1072
+ : layoutVariant1(parts);
1073
+ root.innerHTML = html;
1074
+ }
1075
+
1076
+ /* ─────────────────────── Layout dispatch ───────────────────────
1077
+ * Three structurally distinct bento layouts. All share the same
1078
+ * BentoScaffold data + tile renderers (renderMilestone,
1079
+ * renderRankedBars, etc.). Layouts differ in DOM structure +
1080
+ * scoped CSS classes (.bento-layout-1 / -2 / -3 on the doc).
1081
+ * ─────────────────────────────────────────────────────────────── */
1082
+
1083
+ /** Helper · render the doc's header band identically across
1084
+ * layouts (eyebrow + source + title + kicker). */
1085
+ function bentoHeaderHTML(bento) {
1086
+ return `
1087
+ <header class="bento-header">
1088
+ <div class="bento-header-meta">
1089
+ <span class="bento-eyebrow"><span class="bento-eyebrow-dot"></span>Bento brief</span>
1090
+ ${bento.source ? `<span class="bento-source">${escape(bento.source)}</span>` : ""}
1091
+ </div>
1092
+ <h1 class="bento-title">${escape(bento.title || "")}</h1>
1093
+ ${bento.kicker ? `<div class="bento-kicker">${escape(bento.kicker)}</div>` : ""}
1094
+ </header>`;
1095
+ }
1096
+
1097
+ /** Helper · footer stamp · identical across layouts. */
1098
+ function bentoFooterHTML(bento) {
1099
+ if (!bento.footerTag) return "";
1100
+ return `
1101
+ <footer class="bento-footer">
1102
+ <span class="bento-footer-stamp">PrivateBoard · Bento</span>
1103
+ <span>${escape(bento.footerTag)}</span>
1104
+ </footer>`;
1105
+ }
1106
+
1107
+ /** Layout 1 · Linear stack.
1108
+ * Header → conclusion (full-width dark hero) → 3 milestones
1109
+ * horizontal row (middle tile inverted) → 3-col lower grid →
1110
+ * flow band → footer. */
1111
+ function layoutVariant1(p) {
1112
+ const { bento, rankedBars, verification, talkingPoints, flow } = p;
1113
+ const milestones = (bento.milestones || []).slice(0, 3)
1114
+ .map((m, i) => renderMilestone(m, i)).join("");
1115
+ const conclusion = bento.conclusion
1116
+ ? `<section class="bento-conclusion">
1117
+ <div class="bento-conclusion-inner">
1118
+ <div class="bento-conclusion-mark">The takeaway</div>
1119
+ <div class="bento-conclusion-text">${escape(bento.conclusion)}</div>
1120
+ </div>
1121
+ </section>`
1122
+ : "";
1123
+
1124
+ return `
1125
+ <article class="bento-doc bento-layout-1" data-bento-paper>
1126
+ ${bentoHeaderHTML(bento)}
1127
+ ${conclusion}
1128
+ ${milestones ? `<section class="bento-milestones-row">${milestones}</section>` : ""}
1129
+ <section class="bento-lower-grid">
1130
+ ${rankedBars}
1131
+ ${verification}
1132
+ ${talkingPoints}
1133
+ </section>
1134
+ ${flow}
1135
+ ${bentoFooterHTML(bento)}
1136
+ </article>`;
1137
+ }
1138
+
1139
+ /** Layout 2 · Featured-hero.
1140
+ * Header → ms0 promoted to full-width inverted dark feature
1141
+ * with HUGE callout → 2-tile row (ms1 | ms2) → centered framed
1142
+ * pull-quote conclusion → 3-col lower grid → flow → footer.
1143
+ * Reading order shifts: detail first, takeaway in the middle. */
1144
+ function layoutVariant2(p) {
1145
+ const { bento, rankedBars, verification, talkingPoints, flow } = p;
1146
+ const ms = bento.milestones || [];
1147
+ const featureHero = renderFeatureHero(ms[0]);
1148
+ // ms1 + ms2 · render at index 0 to skip the layout-1 dark
1149
+ // inverse treatment (it's scoped to .bento-layout-1 anyway,
1150
+ // but staying at 0 also avoids the "middle tile dark" rhythm).
1151
+ const milestones2 = [ms[1], ms[2]].filter(Boolean)
1152
+ .map((m, i) => renderMilestone(m, i)).join("");
1153
+
1154
+ return `
1155
+ <article class="bento-doc bento-layout-2" data-bento-paper>
1156
+ ${bentoHeaderHTML(bento)}
1157
+ ${featureHero}
1158
+ ${milestones2 ? `<section class="bento-milestones-2">${milestones2}</section>` : ""}
1159
+ ${renderConclusionPull(bento.conclusion)}
1160
+ <section class="bento-lower-grid">
1161
+ ${rankedBars}
1162
+ ${verification}
1163
+ ${talkingPoints}
1164
+ </section>
1165
+ ${flow}
1166
+ ${bentoFooterHTML(bento)}
1167
+ </article>`;
1168
+ }
1169
+
1170
+ /** Layout 3 · Mosaic asymmetric grid.
1171
+ * Header → mosaic block where ms0 takes a tall narrow column
1172
+ * spanning 2 rows on the left, conclusion sits wide on the
1173
+ * top-right (cols 2-3 row 1) as a dark hero, ms1 + ms2 fill
1174
+ * the bottom-right cells (col 2 row 2 · col 3 row 2) → 3-col
1175
+ * lower grid → flow → footer. The mosaic feels like a
1176
+ * magazine spread rather than a uniform horizontal stack. */
1177
+ function layoutVariant3(p) {
1178
+ const { bento, rankedBars, verification, talkingPoints, flow } = p;
1179
+ const ms = bento.milestones || [];
1180
+ const ms0 = ms[0] ? renderMilestone(ms[0], 0, "bento-mosaic-ms0") : "";
1181
+ const ms1 = ms[1] ? renderMilestone(ms[1], 0, "bento-mosaic-ms1") : "";
1182
+ const ms2 = ms[2] ? renderMilestone(ms[2], 0, "bento-mosaic-ms2") : "";
1183
+ const conclusionTile = bento.conclusion
1184
+ ? `<div class="bento-mosaic-concl">
1185
+ <div class="bento-conclusion-mark">The takeaway</div>
1186
+ <div class="bento-conclusion-text">${escape(bento.conclusion)}</div>
1187
+ </div>`
1188
+ : "";
1189
+
1190
+ return `
1191
+ <article class="bento-doc bento-layout-3" data-bento-paper>
1192
+ ${bentoHeaderHTML(bento)}
1193
+ <section class="bento-mosaic">
1194
+ ${ms0}
1195
+ ${conclusionTile}
1196
+ ${ms1}
1197
+ ${ms2}
1198
+ </section>
1199
+ <section class="bento-lower-grid">
1200
+ ${rankedBars}
1201
+ ${verification}
1202
+ ${talkingPoints}
1203
+ </section>
1204
+ ${flow}
1205
+ ${bentoFooterHTML(bento)}
1206
+ </article>`;
1207
+ }
1208
+
1209
+ /* ─── Export wiring ──────────────────────────────────────────────
1210
+ Both PNG and PDF go through the SAME canvas-snapshot path · the
1211
+ browser's native print engine handles CSS Grid / absolute
1212
+ positioning unreliably (grid items break across pages, the
1213
+ `.bento-source` absolute element gets misplaced, the document
1214
+ reflows when max-width is removed for print). So we capture the
1215
+ bento as a single PNG once, then:
1216
+ · PNG · download directly
1217
+ · PDF · open a trivial print page that contains JUST the PNG +
1218
+ `@page` sizing, then `window.print()`. The print engine sees
1219
+ no grid / no absolute positioning, just a single image —
1220
+ always renders pixel-perfect.
1221
+
1222
+ html-to-image is lazy-loaded from jsdelivr on first export click
1223
+ so it's free until used. */
1224
+ let _h2iLoaded = null;
1225
+ async function ensureHtmlToImage() {
1226
+ if (window.htmlToImage) return;
1227
+ if (!_h2iLoaded) {
1228
+ _h2iLoaded = new Promise((res, rej) => {
1229
+ const s = document.createElement("script");
1230
+ s.src = "https://cdn.jsdelivr.net/npm/html-to-image@1.11.13/dist/html-to-image.min.js";
1231
+ s.onload = res;
1232
+ s.onerror = rej;
1233
+ document.head.appendChild(s);
1234
+ });
1235
+ }
1236
+ await _h2iLoaded;
1237
+ }
1238
+
1239
+ /** Capture .bento-doc as a PNG data URL. Two reliability fixes
1240
+ * over the naive `toPng(el)` call:
1241
+ * 1. Pass explicit width/height from `scrollWidth/Height` ·
1242
+ * html-to-image defaults to `getBoundingClientRect()` size,
1243
+ * which can be smaller than the actual content height when
1244
+ * the element sits inside a flex/grid container or a
1245
+ * scroll viewport. Result: bottom half of the document
1246
+ * gets clipped (the user's "PNG truncated" report).
1247
+ * 2. Wait for `document.fonts.ready` · web fonts settle
1248
+ * asynchronously after layout. Capturing before they're
1249
+ * loaded snapshots fallback glyphs, then the page
1250
+ * re-renders with web fonts AFTER the snapshot — leaving
1251
+ * the PNG looking different from what's on screen. */
1252
+ async function captureBentoPng() {
1253
+ const el = document.querySelector("[data-bento-paper]");
1254
+ if (!el) throw new Error("bento doc not found");
1255
+ await ensureHtmlToImage();
1256
+ if (document.fonts && document.fonts.ready) {
1257
+ try { await document.fonts.ready; } catch { /* best-effort */ }
1258
+ }
1259
+ const width = Math.max(el.scrollWidth, el.offsetWidth, el.clientWidth);
1260
+ const height = Math.max(el.scrollHeight, el.offsetHeight, el.clientHeight);
1261
+ return window.htmlToImage.toPng(el, {
1262
+ pixelRatio: 2,
1263
+ backgroundColor: "#FFFFFF",
1264
+ cacheBust: true,
1265
+ width,
1266
+ height,
1267
+ canvasWidth: width,
1268
+ canvasHeight: height,
1269
+ // Pin the captured element to its natural width so flex/grid
1270
+ // ancestors don't squeeze it during snapshot.
1271
+ style: {
1272
+ margin: "0",
1273
+ width: `${width}px`,
1274
+ height: `${height}px`,
1275
+ },
1276
+ });
1277
+ }
1278
+
1279
+ function slugTitle() {
1280
+ return (document.title || "bento").replace(/[^a-z0-9]+/gi, "-").slice(0, 60) || "bento";
1281
+ }
1282
+
1283
+ async function exportPng() {
1284
+ try {
1285
+ const dataUrl = await captureBentoPng();
1286
+ const a = document.createElement("a");
1287
+ a.download = `${slugTitle()}.png`;
1288
+ a.href = dataUrl;
1289
+ a.click();
1290
+ } catch (e) {
1291
+ console.warn("[bento] PNG export failed:", e);
1292
+ alert("PNG export failed · see browser console.");
1293
+ }
1294
+ }
1295
+
1296
+ /** PDF export · capture as PNG, embed in a minimal print-only page,
1297
+ * trigger window.print(). The print engine sees a single `<img>`
1298
+ * filling the page — no grid, no absolute positioning, no font
1299
+ * loading races · always pixel-perfect. User picks "Save as PDF"
1300
+ * in the print dialog to get the PDF file. */
1301
+ async function exportPdf() {
1302
+ try {
1303
+ const dataUrl = await captureBentoPng();
1304
+ const win = window.open("", "_blank", "width=1024,height=720");
1305
+ if (!win) {
1306
+ alert("PDF export needs to open a new window — please allow popups for this site.");
1307
+ return;
1308
+ }
1309
+ const slug = slugTitle();
1310
+ win.document.open();
1311
+ win.document.write(`<!doctype html><html lang="en"><head>
1312
+ <meta charset="utf-8">
1313
+ <title>${slug}</title>
1314
+ <style>
1315
+ @page { size: auto; margin: 10mm; }
1316
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1317
+ html, body { background: #FFFFFF; }
1318
+ body {
1319
+ display: flex;
1320
+ align-items: flex-start;
1321
+ justify-content: center;
1322
+ padding: 20px;
1323
+ min-height: 100vh;
1324
+ }
1325
+ img {
1326
+ display: block;
1327
+ width: 100%;
1328
+ max-width: 840px;
1329
+ height: auto;
1330
+ box-shadow: 0 0 24px rgba(0, 0, 0, 0.08);
1331
+ }
1332
+ @media print {
1333
+ body { padding: 0; }
1334
+ img { box-shadow: none; max-width: none; width: 100%; }
1335
+ }
1336
+ .hint {
1337
+ position: fixed;
1338
+ top: 12px;
1339
+ left: 12px;
1340
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
1341
+ font-size: 11px;
1342
+ color: #8E8B83;
1343
+ background: rgba(255,255,255,0.9);
1344
+ padding: 6px 10px;
1345
+ border: 0.5px solid #E5E2DA;
1346
+ }
1347
+ @media print { .hint { display: none; } }
1348
+ </style>
1349
+ </head><body>
1350
+ <div class="hint">// press <kbd>⌘P</kbd> / <kbd>Ctrl+P</kbd> · save as PDF</div>
1351
+ <img alt="${slug}" src="${dataUrl}">
1352
+ <script>
1353
+ // Auto-fire the print dialog once the image has decoded.
1354
+ // Some browsers (Safari) ignore window.print() before the
1355
+ // image is rendered, so wait for the load event.
1356
+ (function () {
1357
+ var img = document.querySelector("img");
1358
+ function go() { setTimeout(function () { window.print(); }, 200); }
1359
+ if (img && img.complete) { go(); }
1360
+ else if (img) { img.addEventListener("load", go, { once: true }); }
1361
+ })();
1362
+ <\/script>
1363
+ </body></html>`);
1364
+ win.document.close();
1365
+ } catch (e) {
1366
+ console.warn("[bento] PDF export failed:", e);
1367
+ alert("PDF export failed · see browser console.");
1368
+ }
1369
+ }
1370
+
1371
+ document.addEventListener("click", (e) => {
1372
+ if (e.target.closest("[data-bento-png]")) {
1373
+ e.preventDefault();
1374
+ exportPng();
1375
+ return;
1376
+ }
1377
+ if (e.target.closest("[data-bento-print]")) {
1378
+ e.preventDefault();
1379
+ exportPdf();
1380
+ return;
1381
+ }
1382
+ });
1383
+
1384
+ /* ─── Boot ──────────────────────────────────────────────────── */
1385
+ loadBrief().then((brief) => {
1386
+ if (brief) render(brief);
1387
+ }).catch((e) => {
1388
+ console.error("[bento] load failed:", e);
1389
+ showState("Error", "Couldn't load this brief",
1390
+ e instanceof Error ? e.message : String(e));
1391
+ });
1392
+ })();
1393
+ </script>
1394
+
1395
+ </body>
1396
+ </html>