bible270 1.11.0 → 1.12.0

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.
@@ -48,7 +48,7 @@
48
48
  <path d="M0 181Q150 174 294 181T601 184M728 182Q874 175 1028 181T1370 180M1442 183H1600" stroke-width="1.4" />
49
49
  <path d="M43 216Q193 203 353 214T691 213M821 220Q970 208 1128 218T1510 215" stroke-width="2" />
50
50
  </g>
51
- <g fill="none" stroke="url(#b270-ocean-shimmer)" stroke-linecap="round">
51
+ <g id="b270-ocean-glints" fill="none" stroke="url(#b270-ocean-shimmer)" stroke-linecap="round">
52
52
  <path d="M880 170H1360M808 196H1434M751 230H1507" stroke-width="3" />
53
53
  <path d="M685 269Q1050 253 1560 272" stroke-width="5" />
54
54
  </g>
@@ -61,7 +61,7 @@
61
61
  <path d="M0 292C208 270 381 310 593 289S973 254 1181 266 1444 288 1600 257" stroke-width="2" opacity=".38" />
62
62
  <path d="M50 365Q174 352 282 366M370 381Q499 389 611 374M745 352Q887 326 1012 328M1194 339Q1335 359 1450 344" stroke-width="1.4" opacity=".38" />
63
63
  </g>
64
- <g fill="none" stroke="#b4a88c" stroke-linecap="round" opacity=".17">
64
+ <g id="b270-ocean-sand-detail" fill="none" stroke="#b4a88c" stroke-linecap="round" opacity=".17">
65
65
  <path d="M105 414Q228 401 347 413M530 424Q669 412 777 417M1007 386Q1110 379 1227 391M1337 420Q1450 406 1562 411" />
66
66
  </g>
67
67
  </svg>
@@ -238,12 +238,12 @@
238
238
  <path d="M372 0 359 104 371 193 356 283 369 369 358 460 372 547 359 635 373 723 362 815 372 900H385L375 814 387 723 373 635 386 547 372 459 383 369 370 282 385 193 373 105 385 0Z" />
239
239
  <path d="M529 0 538 92 523 184 536 276 522 362 533 451 518 542 531 636 520 726 534 813 529 900H540L547 813 533 726 544 636 532 542 546 452 535 362 549 276 537 184 551 92 540 0Z" />
240
240
  </g>
241
- <g fill="none" stroke="#ad8359" stroke-linecap="round" opacity=".17">
241
+ <g id="b270-cedar-fine-grain" fill="none" stroke="#ad8359" stroke-linecap="round" opacity=".17">
242
242
  <path stroke-width="2" d="M75 0C51 134 85 209 65 330S79 525 62 653 82 811 75 900M241 0C260 112 231 202 250 314S229 511 247 629 225 802 241 900M402 0C383 127 413 223 393 345S418 534 398 665 414 810 402 900M565 0C581 123 552 223 574 348S552 548 571 677 552 807 565 900" />
243
243
  <path d="M97 51 88 147 99 232M95 468 84 556 94 631 85 710M272 79 264 164 276 245 267 320M272 502 261 588 273 667 264 757 272 830M455 43 446 132 458 219 449 295M471 408 459 486 471 568M472 767 461 842" />
244
244
  <path d="M163 47C150 163 167 221 150 271M150 416C165 481 151 539 160 617M316 171 307 262 318 347 308 429M311 671 302 753 313 845M491 109 481 195 492 281 482 359" />
245
245
  </g>
246
- <g fill="none" stroke="#9b744e" stroke-width="1.5" opacity=".2">
246
+ <g id="b270-cedar-knots" fill="none" stroke="#9b744e" stroke-width="1.5" opacity=".2">
247
247
  <path d="M143 261C113 290 108 342 126 385L143 425C158 390 175 346 162 302Z" />
248
248
  <path d="M142 288C126 310 124 340 135 365L144 387C152 359 158 329 149 307Z" />
249
249
  <path d="M141 315Q134 335 144 352Q150 332 141 315Z" />
@@ -264,11 +264,11 @@
264
264
  <rect width="100%" height="100%" fill="url(#b270-cedar-grain)" mask="url(#b270-cedar-mask)" />
265
265
  </svg>
266
266
 
267
+ <%# Direct paths avoid SVG use-instance repainting when a hidden theme is revealed. %>
268
+ <% spruce_tree_path = "M0 0-9 29-5 27-20 55-11 52-29 85-18 81-39 119-26 113-48 154-32 146-57 194-6 181-6 218H6V181L57 194 32 146 48 154 26 113 39 119 18 81 29 85 11 52 20 55 5 27 9 29Z" %>
267
269
  <svg class="b270-theme-scenery" data-scenery="dark_green" viewBox="0 0 1600 440" preserveAspectRatio="xMidYMax slice"
268
270
  xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
269
271
  <defs>
270
- <path id="b270-spruce-tree"
271
- d="M0 0-9 29-5 27-20 55-11 52-29 85-18 81-39 119-26 113-48 154-32 146-57 194-6 181-6 218H6V181L57 194 32 146 48 154 26 113 39 119 18 81 29 85 11 52 20 55 5 27 9 29Z" />
272
272
  <linearGradient id="b270-spruce-fade" x1="0" y1="0" x2="0" y2="1">
273
273
  <stop offset="0" stop-color="white" />
274
274
  <stop offset=".75" stop-color="white" stop-opacity=".9" />
@@ -281,70 +281,70 @@
281
281
  <g id="b270-spruce-treeline" transform="translate(0 -24)">
282
282
  <g id="b270-spruce-distant" fill="#66816b" opacity=".19" mask="url(#b270-spruce-mask)">
283
283
  <rect id="b270-spruce-distant-ground" y="300" width="1600" height="164" />
284
- <use href="#b270-spruce-tree" transform="translate(15 145) scale(.64 .95)" />
285
- <use href="#b270-spruce-tree" transform="translate(90 171) scale(.55 .82)" />
286
- <use href="#b270-spruce-tree" transform="translate(164 112) scale(.7 1.1)" />
287
- <use href="#b270-spruce-tree" transform="translate(244 157) scale(.6 .92)" />
288
- <use href="#b270-spruce-tree" transform="translate(321 137) scale(.66 1.02)" />
289
- <use href="#b270-spruce-tree" transform="translate(405 186) scale(.55 .82)" />
290
- <use href="#b270-spruce-tree" transform="translate(478 163) scale(.67 .96)" />
291
- <use href="#b270-spruce-tree" transform="translate(560 194) scale(.58 .83)" />
292
- <use href="#b270-spruce-tree" transform="translate(643 180) scale(.6 .91)" />
293
- <use href="#b270-spruce-tree" transform="translate(719 212) scale(.5 .77)" />
294
- <use href="#b270-spruce-tree" transform="translate(790 192) scale(.58 .88)" />
295
- <use href="#b270-spruce-tree" transform="translate(875 211) scale(.52 .78)" />
296
- <use href="#b270-spruce-tree" transform="translate(950 174) scale(.62 .96)" />
297
- <use href="#b270-spruce-tree" transform="translate(1030 193) scale(.54 .84)" />
298
- <use href="#b270-spruce-tree" transform="translate(1110 148) scale(.7 1.02)" />
299
- <use href="#b270-spruce-tree" transform="translate(1195 166) scale(.58 .91)" />
300
- <use href="#b270-spruce-tree" transform="translate(1270 108) scale(.7 1.15)" />
301
- <use href="#b270-spruce-tree" transform="translate(1350 153) scale(.6 .94)" />
302
- <use href="#b270-spruce-tree" transform="translate(1435 129) scale(.64 1.05)" />
303
- <use href="#b270-spruce-tree" transform="translate(1515 168) scale(.58 .88)" />
304
- <use href="#b270-spruce-tree" transform="translate(1590 139) scale(.7 1)" />
305
- <use href="#b270-spruce-tree" transform="translate(53 237) scale(.3 .36)" />
306
- <use href="#b270-spruce-tree" transform="translate(205 225) scale(.34 .43)" />
307
- <use href="#b270-spruce-tree" transform="translate(365 247) scale(.28 .31)" />
308
- <use href="#b270-spruce-tree" transform="translate(520 231) scale(.33 .4)" />
309
- <use href="#b270-spruce-tree" transform="translate(681 241) scale(.29 .34)" />
310
- <use href="#b270-spruce-tree" transform="translate(834 218) scale(.36 .47)" />
311
- <use href="#b270-spruce-tree" transform="translate(1068 237) scale(.31 .37)" />
312
- <use href="#b270-spruce-tree" transform="translate(1235 244) scale(.28 .33)" />
313
- <use href="#b270-spruce-tree" transform="translate(1394 228) scale(.34 .42)" />
284
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(15 145) scale(.64 .95)" />
285
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(90 171) scale(.55 .82)" />
286
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(164 112) scale(.7 1.1)" />
287
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(244 157) scale(.6 .92)" />
288
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(321 137) scale(.66 1.02)" />
289
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(405 186) scale(.55 .82)" />
290
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(478 163) scale(.67 .96)" />
291
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(560 194) scale(.58 .83)" />
292
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(643 180) scale(.6 .91)" />
293
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(719 212) scale(.5 .77)" />
294
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(790 192) scale(.58 .88)" />
295
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(875 211) scale(.52 .78)" />
296
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(950 174) scale(.62 .96)" />
297
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1030 193) scale(.54 .84)" />
298
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1110 148) scale(.7 1.02)" />
299
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1195 166) scale(.58 .91)" />
300
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1270 108) scale(.7 1.15)" />
301
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1350 153) scale(.6 .94)" />
302
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1435 129) scale(.64 1.05)" />
303
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1515 168) scale(.58 .88)" />
304
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1590 139) scale(.7 1)" />
305
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(53 237) scale(.3 .36)" />
306
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(205 225) scale(.34 .43)" />
307
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(365 247) scale(.28 .31)" />
308
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(520 231) scale(.33 .4)" />
309
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(681 241) scale(.29 .34)" />
310
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(834 218) scale(.36 .47)" />
311
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1068 237) scale(.31 .37)" />
312
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1235 244) scale(.28 .33)" />
313
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#66816b" transform="translate(1394 228) scale(.34 .42)" />
314
314
  </g>
315
315
  <%# Keep the foreground outside the distant haze mask so no trees show through it. %>
316
316
  <g id="b270-spruce-near" fill="#0c2017">
317
317
  <rect id="b270-spruce-near-ground" y="350" width="1600" height="114" />
318
- <use href="#b270-spruce-tree" transform="translate(-15 104) scale(1.1 1.65)" />
319
- <use href="#b270-spruce-tree" transform="translate(115 64) scale(1.08 1.78)" />
320
- <use href="#b270-spruce-tree" transform="translate(228 153) scale(.87 1.4)" />
321
- <use href="#b270-spruce-tree" transform="translate(343 121) scale(1 1.56)" />
322
- <use href="#b270-spruce-tree" transform="translate(455 189) scale(.83 1.26)" />
323
- <use href="#b270-spruce-tree" transform="translate(561 161) scale(.94 1.4)" />
324
- <use href="#b270-spruce-tree" transform="translate(676 227) scale(.75 1.12)" />
325
- <use href="#b270-spruce-tree" transform="translate(781 202) scale(.88 1.25)" />
326
- <use href="#b270-spruce-tree" transform="translate(890 231) scale(.76 1.1)" />
327
- <use href="#b270-spruce-tree" transform="translate(998 182) scale(.89 1.32)" />
328
- <use href="#b270-spruce-tree" transform="translate(1112 153) scale(.98 1.46)" />
329
- <use href="#b270-spruce-tree" transform="translate(1227 106) scale(1.03 1.65)" />
330
- <use href="#b270-spruce-tree" transform="translate(1343 162) scale(.9 1.38)" />
331
- <use href="#b270-spruce-tree" transform="translate(1460 73) scale(1.12 1.78)" />
332
- <use href="#b270-spruce-tree" transform="translate(1580 133) scale(.97 1.5)" />
333
- <use href="#b270-spruce-tree" transform="translate(55 213) scale(.62 1.08)" />
334
- <use href="#b270-spruce-tree" transform="translate(394 245) scale(.58 .94)" />
335
- <use href="#b270-spruce-tree" transform="translate(622 132) scale(.78 1.52)" />
336
- <use href="#b270-spruce-tree" transform="translate(944 263) scale(.55 .88)" />
337
- <use href="#b270-spruce-tree" transform="translate(1171 230) scale(.65 1.04)" />
338
- <use href="#b270-spruce-tree" transform="translate(1524 201) scale(.7 1.17)" />
339
- <use href="#b270-spruce-tree" transform="translate(173 282) scale(.34 .4)" />
340
- <use href="#b270-spruce-tree" transform="translate(283 265) scale(.4 .49)" />
341
- <use href="#b270-spruce-tree" transform="translate(506 290) scale(.32 .35)" />
342
- <use href="#b270-spruce-tree" transform="translate(727 271) scale(.38 .46)" />
343
- <use href="#b270-spruce-tree" transform="translate(835 297) scale(.3 .31)" />
344
- <use href="#b270-spruce-tree" transform="translate(1054 277) scale(.36 .43)" />
345
- <use href="#b270-spruce-tree" transform="translate(1284 288) scale(.33 .36)" />
346
- <use href="#b270-spruce-tree" transform="translate(1398 260) scale(.42 .52)" />
347
- <use href="#b270-spruce-tree" transform="translate(1552 294) scale(.31 .33)" />
318
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(-15 104) scale(1.1 1.65)" />
319
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(115 64) scale(1.08 1.78)" />
320
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(228 153) scale(.87 1.4)" />
321
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(343 121) scale(1 1.56)" />
322
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(455 189) scale(.83 1.26)" />
323
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(561 161) scale(.94 1.4)" />
324
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(676 227) scale(.75 1.12)" />
325
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(781 202) scale(.88 1.25)" />
326
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(890 231) scale(.76 1.1)" />
327
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(998 182) scale(.89 1.32)" />
328
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1112 153) scale(.98 1.46)" />
329
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1227 106) scale(1.03 1.65)" />
330
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1343 162) scale(.9 1.38)" />
331
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1460 73) scale(1.12 1.78)" />
332
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1580 133) scale(.97 1.5)" />
333
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(55 213) scale(.62 1.08)" />
334
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(394 245) scale(.58 .94)" />
335
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(622 132) scale(.78 1.52)" />
336
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(944 263) scale(.55 .88)" />
337
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1171 230) scale(.65 1.04)" />
338
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1524 201) scale(.7 1.17)" />
339
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(173 282) scale(.34 .4)" />
340
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(283 265) scale(.4 .49)" />
341
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(506 290) scale(.32 .35)" />
342
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(727 271) scale(.38 .46)" />
343
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(835 297) scale(.3 .31)" />
344
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1054 277) scale(.36 .43)" />
345
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1284 288) scale(.33 .36)" />
346
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1398 260) scale(.42 .52)" />
347
+ <path class="b270-spruce-tree" d="<%= spruce_tree_path %>" fill="#0c2017" transform="translate(1552 294) scale(.31 .33)" />
348
348
  </g>
349
349
  </g>
350
350
  </svg>
@@ -398,6 +398,11 @@
398
398
  </g>
399
399
  </g>
400
400
  </mask>
401
+ <radialGradient id="b270-moon-mobile-glow">
402
+ <stop offset="0" stop-color="#c6d6e9" stop-opacity=".18" />
403
+ <stop offset=".5" stop-color="#abc3df" stop-opacity=".07" />
404
+ <stop offset="1" stop-color="#abc3df" stop-opacity="0" />
405
+ </radialGradient>
401
406
  <clipPath id="b270-lake-clip">
402
407
  <rect y="240" width="1600" height="200" />
403
408
  </clipPath>
@@ -416,7 +421,9 @@
416
421
  <ellipse cx="1510" cy="305" rx="145" ry="125" fill="url(#b270-moon-halo)" opacity=".6" />
417
422
  <rect id="b270-lake-reflection" fill="url(#b270-moon-reflection)"
418
423
  x="0" y="240" width="1600" height="200" mask="url(#b270-greek-reflection)" />
419
- <%# The rippled letters supply the water texture without straight lines crossing the reflections. %>
424
+ <%# Phones use a quiet pool of moonlight instead of the reflected scripture. %>
425
+ <ellipse class="b270-mobile-scenery" cx="1510" cy="332" rx="65" ry="92" fill="url(#b270-moon-mobile-glow)" />
426
+ <%# The rippled letters supply the desktop water texture without straight lines crossing the reflections. %>
420
427
  </g>
421
428
  </svg>
422
429
 
@@ -16,7 +16,18 @@
16
16
  }
17
17
  }
18
18
 
19
+ function syncColorScheme() {
20
+ const mode = document.body.dataset.displayMode || "system";
21
+ document.documentElement.dataset.b270DisplayMode = mode;
22
+ const meta = document.getElementById("b270-color-scheme");
23
+ if (meta) {
24
+ meta.content = ["light", "sage", "mist"].includes(mode) ? "only light" :
25
+ ["dark", "dark_green", "dark_blue"].includes(mode) ? "dark" : "light dark";
26
+ }
27
+ }
28
+
19
29
  function initialize() {
30
+ syncColorScheme();
20
31
  document.querySelectorAll("[data-b270-theme-fallback]").forEach((button) => { button.hidden = true; });
21
32
  }
22
33
 
@@ -65,7 +76,10 @@
65
76
  other.dataset.b270SavedTheme = mode;
66
77
  });
67
78
  if (!select.isConnected) return;
68
- if (select.dataset.b270ThemeApply === "true") document.body.dataset.displayMode = mode;
79
+ if (select.dataset.b270ThemeApply === "true") {
80
+ document.body.dataset.displayMode = mode;
81
+ syncColorScheme();
82
+ }
69
83
  status(select, "Theme saved.");
70
84
  } catch (_error) {
71
85
  group.forEach(({ select: other, previous }) => { other.value = previous; });
@@ -1,7 +1,16 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <%
3
+ display_mode = current_reader&.effective_display_mode || Bible270::Reader::DEFAULT_DISPLAY_MODE
4
+ color_scheme = case display_mode
5
+ when "light", "sage", "mist" then "only light"
6
+ when "system" then "light dark"
7
+ else "dark"
8
+ end
9
+ %>
10
+ <html lang="en" data-b270-display-mode="<%= display_mode %>">
3
11
  <head>
4
12
  <meta charset="utf-8">
13
+ <meta id="b270-color-scheme" name="color-scheme" content="<%= color_scheme %>">
5
14
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
15
  <title><%= content_for?(:title) ? yield(:title) : Bible270.config.app_name %></title>
7
16
  <%= csrf_meta_tags %>
@@ -13,7 +22,7 @@
13
22
  <%= render "bible270/shared/styles" %>
14
23
  <%= yield :head %>
15
24
  </head>
16
- <body class="b270" data-display-mode="<%= current_reader&.effective_display_mode || Bible270::Reader::DEFAULT_DISPLAY_MODE %>">
25
+ <body class="b270" data-display-mode="<%= display_mode %>">
17
26
  <%= render "bible270/shared/theme_scenery" %>
18
27
  <div class="b270-wrap">
19
28
  <%= render "bible270/shared/header" %>
@@ -24,6 +33,7 @@
24
33
  <%= render 'bible270/shared/day_index', expanded: content_for?(:expand_day_index) unless content_for?(:hide_day_index) %>
25
34
  <%= b270_footer %>
26
35
  </div>
36
+ <%= render "bible270/shared/avatar_ui" %>
27
37
  <%= render "bible270/shared/theme_ui" %>
28
38
  <%= render "bible270/shared/scripture_popups" %>
29
39
  <%= render "bible270/shared/video_embeds" %>
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bible270
4
+ class AvatarBackfill
5
+ DEFAULT_BATCH_SIZE = 100
6
+ MAX_BATCH_SIZE = 1000
7
+
8
+ class Error < StandardError; end
9
+
10
+ def initialize(dry_run: false, force: false, batch_size: DEFAULT_BATCH_SIZE, output: $stdout)
11
+ @dry_run = dry_run
12
+ @force = force
13
+ @batch_size = validate_batch_size(batch_size)
14
+ @output = output
15
+ end
16
+
17
+ # processed counts successful optimizations, not all visited readers. Byte
18
+ # totals cover successful/current derivatives only, never failed or raced work.
19
+ def run
20
+ check_storage!
21
+ summary = { processed: 0, skipped: 0, changed: 0, failed: 0, eligible: 0,
22
+ original_bytes: 0, optimized_bytes: 0 }
23
+
24
+ Reader.with_avatar.joins(:avatar_attachment).find_each(batch_size: @batch_size) do |reader|
25
+ if @dry_run
26
+ eligible = @force || !reader.avatar_optimized?
27
+ summary[eligible ? :eligible : :skipped] += 1
28
+ @output.puts "reader_id=#{reader.id} #{eligible ? 'eligible' : 'skipped'}"
29
+ else
30
+ process(reader, summary)
31
+ end
32
+ end
33
+
34
+ if @dry_run
35
+ @output.puts "DRY RUN: eligible=#{summary[:eligible]} skipped=#{summary[:skipped]} " \
36
+ 'processed=0 changed=0 failed=0'
37
+ else
38
+ @output.puts "processed=#{summary[:processed]} skipped=#{summary[:skipped]} " \
39
+ "changed=#{summary[:changed]} failed=#{summary[:failed]}"
40
+ @output.puts "Display bytes (optimized/skipped readers): original=#{summary[:original_bytes]} " \
41
+ "optimized=#{summary[:optimized_bytes]}. Originals retained; not total storage savings."
42
+ end
43
+ summary
44
+ end
45
+
46
+ private
47
+
48
+ def validate_batch_size(value)
49
+ valid = value.is_a?(Integer) || (value.is_a?(String) && value.match?(%r{\A[0-9]+\z}))
50
+ size = Integer(value, 10) if valid && value.is_a?(String)
51
+ size = value if value.is_a?(Integer)
52
+ return size if valid && size.between?(1, MAX_BATCH_SIZE)
53
+
54
+ raise Error, "BATCH_SIZE must be a positive integer between 1 and #{MAX_BATCH_SIZE}."
55
+ end
56
+
57
+ def check_storage!
58
+ return if Reader.avatar_uploads? && defined?(ActiveStorage::Blob) &&
59
+ defined?(ActiveStorage::Attachment) && ActiveStorage::Blob.table_exists? &&
60
+ ActiveStorage::Attachment.table_exists?
61
+
62
+ raise Error, 'Avatar backfill requires Active Storage. Enable active_storage/engine, ' \
63
+ 'configure a storage service, and run bin/rails active_storage:install and bin/rails db:migrate in the host app.'
64
+ end
65
+
66
+ def process(reader, summary)
67
+ result = AvatarImages.optimize_existing!(reader, force: @force)
68
+ case result.fetch(:status)
69
+ when :optimized, :skipped
70
+ summary[result[:status] == :optimized ? :processed : :skipped] += 1
71
+ summary[:original_bytes] += result.fetch(:before_bytes)
72
+ summary[:optimized_bytes] += result.fetch(:after_bytes)
73
+ when :changed
74
+ summary[:changed] += 1
75
+ else
76
+ raise Error, 'Avatar optimizer returned an unsupported status.'
77
+ end
78
+ @output.puts "reader_id=#{reader.id} #{result[:status]}"
79
+ rescue AvatarOptimizer::Error
80
+ record_failure(reader, summary, 'image processing failed; check the source image and libvips codec support')
81
+ rescue AvatarImages::Error
82
+ record_failure(reader, summary, 'storage failed; check storage service configuration and access')
83
+ rescue ActiveRecord::ActiveRecordError
84
+ record_failure(reader, summary, 'database failed; check database availability and record integrity')
85
+ end
86
+
87
+ def record_failure(reader, summary, reason)
88
+ summary[:failed] += 1
89
+ # Exception messages, causes and record inspections can expose private data.
90
+ @output.puts "reader_id=#{reader.id} failed: #{reason}"
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'avatar_optimizer'
4
+
5
+ module Bible270
6
+ # Originals and their display thumbnails are separate, immutable blobs. All
7
+ # storage work finishes before attachment changes become visible to readers.
8
+ class AvatarImages
9
+ class Error < StandardError; end
10
+
11
+ class << self
12
+ def update!(reader, attributes, upload: nil)
13
+ raise Error, 'Picture uploads require Active Storage.' if upload && !reader.class.avatar_uploads?
14
+
15
+ require_own_transaction!(reader) if upload
16
+
17
+ reader.assign_attributes(attributes)
18
+ raise ActiveRecord::RecordInvalid, reader unless reader.valid?
19
+ return reader.save! unless upload
20
+
21
+ blobs = []
22
+ AvatarOptimizer.open(upload.tempfile.path, content_type: upload.content_type, byte_size: upload.size) do |output|
23
+ # Do not let Active Storage decode the unsanitized original again in
24
+ # an analysis job. Validation/conversion already ran through our safe
25
+ # pipeline, and originals are retained for reprocessing, not variants.
26
+ original = store_blob(blobs, io: upload.tempfile, filename: upload.original_filename,
27
+ content_type: upload.content_type.to_s.downcase.split(';').first.to_s.strip,
28
+ metadata: { 'analyzed' => true })
29
+ thumbnail = store_thumbnail(blobs, output, original)
30
+ pending_attributes = reader.changes.transform_values(&:last)
31
+ reader.class.transaction do
32
+ # Reload under the row lock: cached (even nil) attachments may have
33
+ # changed during processing. Replacing a stale association can leave
34
+ # multiple attachments with the same has_one_attached name.
35
+ reader.class.lock.find(reader.id)
36
+ reader.attachment_changes.delete('avatar')
37
+ reader.attachment_changes.delete('avatar_thumbnail')
38
+ reader.reload
39
+ reader.assign_attributes(pending_attributes)
40
+ reader.avatar = original
41
+ reader.avatar_thumbnail = thumbnail
42
+ reader.save!
43
+ end
44
+ end
45
+ ensure
46
+ discard_unattached(blobs)
47
+ end
48
+
49
+ def optimize_existing!(reader, force: false)
50
+ return result(:skipped) unless reader.avatar_uploaded?
51
+
52
+ require_own_transaction!(reader)
53
+ original = reader.avatar.blob
54
+ if !force && reader.avatar_optimized?
55
+ return result(:skipped, original.byte_size, reader.avatar_thumbnail.blob.byte_size)
56
+ end
57
+
58
+ problem = Avatars.problem_with(content_type: original.content_type, byte_size: original.byte_size)
59
+ raise AvatarOptimizer::Error, "Avatar #{problem}." if problem
60
+
61
+ blobs = []
62
+ thumbnail = download_original(original) do |input|
63
+ AvatarOptimizer.open(input.path, content_type: original.content_type, byte_size: original.byte_size) do |output|
64
+ store_thumbnail(blobs, output, original)
65
+ end
66
+ end
67
+ reader.with_lock do
68
+ unless reader.avatar_uploaded? && reader.avatar.blob.id == original.id
69
+ return result(:changed, original.byte_size)
70
+ end
71
+ if !force && reader.avatar_optimized?
72
+ return result(:skipped, original.byte_size, reader.avatar_thumbnail.blob.byte_size)
73
+ end
74
+
75
+ reader.avatar_thumbnail = thumbnail
76
+ reader.save!
77
+ end
78
+ result(:optimized, original.byte_size, thumbnail.byte_size)
79
+ ensure
80
+ discard_unattached(blobs)
81
+ end
82
+
83
+ def remove!(reader)
84
+ return unless reader.class.avatar_uploads?
85
+
86
+ # Detach atomically; Active Storage purges both old blobs after commit.
87
+ reader.with_lock do
88
+ reader.avatar = nil
89
+ reader.avatar_thumbnail = nil
90
+ reader.save!
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def require_own_transaction!(reader)
97
+ return unless [reader.class, ActiveStorage::Blob].any? do |model|
98
+ model.connection_pool.with_connection(&:transaction_open?)
99
+ end
100
+
101
+ # Files cannot roll back with an outer database transaction. Reject that
102
+ # usage before creating blobs rather than leaking files on a later abort.
103
+ raise Error, 'Avatar uploads and optimization must run outside an existing database transaction.'
104
+ end
105
+
106
+ def download_original(original, &)
107
+ original.open(&)
108
+ rescue AvatarOptimizer::Error, Error
109
+ raise
110
+ rescue StandardError => e
111
+ Rails.logger.error("Bible270 avatar download failed (#{e.class}).")
112
+ raise Error, 'The original picture could not be read. Check storage availability and file integrity.'
113
+ end
114
+
115
+ def result(status, before_bytes = 0, after_bytes = 0)
116
+ { status: status, before_bytes: before_bytes, after_bytes: after_bytes }
117
+ end
118
+
119
+ def store_thumbnail(blobs, output, original)
120
+ store_blob(blobs, io: output, filename: 'avatar.webp', content_type: 'image/webp',
121
+ service_name: original.service_name,
122
+ metadata: { 'bible270_avatar_version' => AvatarOptimizer::VERSION,
123
+ 'bible270_avatar_source_id' => original.id,
124
+ 'width' => AvatarOptimizer::SIZE, 'height' => AvatarOptimizer::SIZE,
125
+ 'analyzed' => true })
126
+ end
127
+
128
+ def store_blob(blobs, **attributes)
129
+ attributes.fetch(:io).rewind
130
+ # Register the blob before uploading so a partial storage failure can be
131
+ # cleaned up too. create_and_upload! would not return that failed blob.
132
+ blob = ActiveStorage::Blob.create_after_unfurling!(**attributes, identify: false)
133
+ blobs << blob
134
+ blob.upload_without_unfurling(attributes.fetch(:io))
135
+ blob
136
+ rescue StandardError => e
137
+ Rails.logger.error("Bible270 avatar storage failed (#{e.class}).")
138
+ raise Error, 'Your picture could not be stored. Please try again.'
139
+ end
140
+
141
+ def discard_unattached(blobs)
142
+ Array(blobs).each do |blob|
143
+ # An after-commit callback may raise after a successful save. Never
144
+ # remove blobs that have already become somebody's live attachment.
145
+ blob.with_lock do
146
+ next if blob.attachments.exists?
147
+
148
+ # Unlike purge, delete storage first. If deletion fails, retain the
149
+ # unattached row (service/key) so maintenance can retry cleanup.
150
+ blob.delete
151
+ blob.destroy!
152
+ end
153
+ rescue StandardError => e
154
+ Rails.logger.error("Bible270 unused avatar blob #{blob.id} cleanup failed (#{e.class}); retry unattached-blob cleanup.")
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end