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.
- package/dist/cli.js +694 -16
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/public/adjourn-overlay.css +194 -0
- package/public/app.js +426 -66
- package/public/bento.html +1396 -0
- package/public/home.html +389 -17
- package/public/index.html +22 -5
- package/public/magazine.html +1266 -0
- package/public/newspaper.html +1770 -0
- package/public/report.html +366 -52
- package/public/user-settings.css +94 -47
- package/public/user-settings.js +76 -42
|
@@ -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, "&")
|
|
857
|
+
.replace(/</g, "<")
|
|
858
|
+
.replace(/>/g, ">")
|
|
859
|
+
.replace(/"/g, """)
|
|
860
|
+
.replace(/'/g, "'");
|
|
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>
|