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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 31871047e1cb4638eee7d513efd350d9acbca88a02e58370c91f8506811ca5ca
4
- data.tar.gz: a5fffa5b4ec2aa31addf33ff2dddd5f09ca34f2986bd8aec470cd045a87c04be
3
+ metadata.gz: a699484a74364bfd2ca703e50a1ab634d9449691f9242acee726d2fbc5a27afc
4
+ data.tar.gz: 17711f1bf0c60d00bb7b1cb0545e218a808e93226da8627c10c16f20c3f4473a
5
5
  SHA512:
6
- metadata.gz: 8fe16a2b159bbe1390b23ee0634e12c6bfe00e6c9e3aef711c9e8be35ac4b244b8908f4b7e11e35300d18e4211c2d02df2edb44f4d8c62834ff78bd3ee8692c1
7
- data.tar.gz: c7963aa88e83095ee877211b5e192ac58da38abc880dd67323d0008b3044549cdd3a5e6ec76616046f66298030774c5dc454b864e4c6902f51f6b28cc8dd96d5
6
+ metadata.gz: 66ed48de38122303a4fe50cfbdbf6251334ee9c67bb82be501a95c3c5d14fdcaa63f5356d4db1d7cf518307870fdc0e08b9045ea23e956a55f20ce7d46e771e9
7
+ data.tar.gz: 6230d31ac89feb6a6d830c9ca2009edcb266ca9886be87a57bc6b1fed378f0580d0e76ae5ec4c51353b3751b1a52c214eca5dbf016eddbeecaa740abed162626
data/CHANGELOG.md CHANGED
@@ -4,7 +4,66 @@ All notable changes to bible270. Format follows [Keep a Changelog](https://keepa
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
- **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.10.2...main)**
7
+ ### Fixed
8
+
9
+ - load avatars eagerly without forced asynchronous decoding, while retaining attachment preloading and bounded database queries
10
+ - recover failed avatar requests with up to two automatic retries, refreshing same-origin uploaded-image redirects without modifying signed provider URLs
11
+ - show accessible initials instead of broken images when avatars fail, including cached failures and images inserted during navigation or conversations
12
+
13
+ ### Added
14
+
15
+ - automatically create 256×256, center-cropped static WebP avatars on Profile and Admin uploads, with orientation correction, color conversion, transparency preservation, and metadata stripping; animated images use their first frame
16
+ - preserve the current original upload separately and display the optimized thumbnail only after successful processing and storage, without request-time image processing
17
+ - validate actual image data and enforce a 40-megapixel limit in addition to the existing configurable upload byte limit (2 MiB by default)
18
+ - add `bible270:avatars:optimize` to backfill existing uploaded avatars, with `DRY_RUN=1`, `FORCE=1`, bounded batches, rerun-safe version tracking, and protection against concurrent picture changes
19
+
20
+ Avatar processing requires libvips with PNG, JPEG, GIF and WebP support on upload/backfill hosts; the gem now depends on `image_processing` and `ruby-vips`. No additional database migration is required for hosts already using Active Storage. Existing avatars continue displaying their originals until optimized; remote provider images are unchanged.
21
+
22
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.11.1...main)**
23
+
24
+ ## [1.11.1] - 2026-10-22
25
+
26
+ ### Changed
27
+
28
+ - simplify artwork on phone-sized screens: hide decorative scripture and word reflections, lower landscape scenes, and reduce fine detail while preserving desktop artwork
29
+ - further mute Ocean's mobile colors and wave lines while keeping the shoreline and gulls
30
+ - use a soft moonlight reflection on phones and retain opaque foreground trees in the simplified Forest scene
31
+ - draw Forest trees as direct SVG paths with explicit green fills instead of reused SVG instances
32
+ - increase the space between the app title and compact theme selector
33
+
34
+ ### Fixed
35
+
36
+ - declare the selected color scheme early in the page and synchronize root settings and metadata on theme changes and Turbo navigation
37
+ - request `color-scheme: only light` for Papyrus, Meadow, and Ocean while preserving System's device-following behavior
38
+
39
+ Browser website-darkening features can still override theme colors. In iOS Brave, disabling Night Mode resolved the reported darkened light themes and white Forest trees.
40
+
41
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.11.0...v1.11.1)**
42
+
43
+ ## [1.11.0] - 2026-10-22
44
+
45
+ ### Added
46
+
47
+ - add Meadow and Ocean light themes alongside Papyrus, Cedar, Forest, and Moonlight
48
+ - add muted artwork for each theme: textured papyrus with ragged edges and curled corners, a sunrise meadow, an afternoon coast with gulls, cedar grain, layered forest silhouettes, and a moonlit lake
49
+ - add shared Hebrew and Greek scripture backgrounds, with rippled reflections in Moonlight
50
+ - add a compact theme selector beside the app title on every signed-in page
51
+
52
+ ### Changed
53
+
54
+ - save and apply theme selections immediately from the header, Profile, and Admin without submitting unrelated edits
55
+ - synchronize controls for the same reader while keeping an admin's own theme independent of another reader's setting
56
+ - use short theme names, list light themes first, and retain Light/Dark descriptions in Profile and Admin
57
+ - keep the header icon, title, spacing, and theme selector consistently compact across desktop and mobile, without wrapping the navigation
58
+ - let System follow the device appearance using Papyrus for light and Cedar for dark, including their artwork
59
+
60
+ ### Fixed
61
+
62
+ - restore the previous theme selection and show an error when saving fails; prevent overlapping saves for the same reader
63
+
64
+ Existing saved theme preferences are preserved; no additional migration is required for installations with display preferences already enabled.
65
+
66
+ **[Full Changelog](https://github.com/avonderluft/bible270/compare/v1.10.2...v1.11.0)**
8
67
 
9
68
  ## [1.10.2] - 2026-10-18
10
69
 
@@ -872,7 +931,9 @@ This is the initial public release.
872
931
 
873
932
  * Email sign-in requires working Action Mailer delivery in the host application. Set `config.mailer_from`; delivery is inline by default, or set `email_sign_in_deliver_later` when a queue backend is available.
874
933
 
875
- [Unreleased]: https://github.com/avonderluft/bible270/compare/v1.10.2...HEAD
934
+ [Unreleased]: https://github.com/avonderluft/bible270/compare/v1.11.1...HEAD
935
+ [1.11.1]: https://github.com/avonderluft/bible270/releases/tag/v1.11.1
936
+ [1.11.0]: https://github.com/avonderluft/bible270/releases/tag/v1.11.0
876
937
  [1.10.2]: https://github.com/avonderluft/bible270/releases/tag/v1.10.2
877
938
  [1.10.1]: https://github.com/avonderluft/bible270/releases/tag/v1.10.1
878
939
  [1.10.0]: https://github.com/avonderluft/bible270/releases/tag/v1.10.0
data/README.md CHANGED
@@ -21,14 +21,40 @@ The schedule is pure deterministic Ruby (`Bible270::Plan`) — none of it is sto
21
21
 
22
22
  ## Requirements
23
23
 
24
- - **Ruby** >= 3.2. Plain Ruby, no C extensions. Runs on 4.0.6 — it avoids everything 4.0 dropped or unbundled (notably `cgi`; URL escaping uses `URI.encode_www_form_component`).
24
+ - **Ruby** >= 3.2. Engine code is plain Ruby; avatar processing uses native libvips through `ruby-vips`. Runs on 4.0.6 — it avoids everything 4.0 dropped or unbundled (notably `cgi`; URL escaping uses `URI.encode_www_form_component`).
25
25
  - **Rails** >= 7.2. On Ruby 4.0 your host app is the real constraint, not this engine — check your lockfile with RailsBump first.
26
26
  - A host app, and Turbo if you want check-offs without a page reload.
27
+ - For picture uploads and avatar backfills: **libvips** with PNG, JPEG, GIF and WebP support. The gem supplies the `image_processing` and `ruby-vips` Ruby dependencies. Install libvips on every upload/backfill host (for example, `sudo apt-get install libvips-tools` on Ubuntu or `sudo pacman -S libvips` on Arch). Native processing is loaded only when optimizing an image; existing pages and provider avatars do not require it.
27
28
 
28
29
  Mutation controls expose pending, success, and retry feedback when JavaScript runs, while ordinary HTML
29
30
  forms remain the fallback. Repeated checkoff and like requests converge on the intended state rather
30
31
  than accidentally reversing it.
31
32
 
33
+ ## Avatar optimization
34
+
35
+ Profile and Admin picture uploads produce a **256×256 static WebP thumbnail at quality 85** before switching the displayed avatar. Images are auto-oriented, center-cropped, converted to sRGB, and stripped of metadata; transparency is preserved. Animated GIF/WebP uploads use the first frame. Small images are upscaled to the same dimensions. Invalid or corrupt image data is rejected, even if its filename or declared type looks valid.
36
+
37
+ Uploads are limited to **2 MiB by default** (`config.avatar_max_bytes`) and **40 million pixels** in the selected frame/container canvas. The pixel limit bounds dimensions, not total processor memory. A processing or storage failure leaves the previous picture and profile details unchanged.
38
+
39
+ The current original remains in the `avatar` attachment, including its original metadata; the display image is stored separately as `avatar_thumbnail`. This preserves the source for future reprocessing, not a history of past pictures. Removing/replacing a picture detaches the old pair; Active Storage's purge jobs clean up their files. Keep the host's Active Job worker running for that cleanup. Original retention increases storage use even though avatar downloads become smaller. No new database migration is needed if Active Storage is already installed.
40
+
41
+ Existing uploaded avatars continue displaying their originals until optimized. Run the following **from the host Rails application**, with its normal database/storage credentials and production environment when appropriate:
42
+
43
+ ```sh
44
+ # Metadata-only preview: no image downloads or attachment changes
45
+ RAILS_ENV=production bundle exec rake bible270:avatars:optimize DRY_RUN=1
46
+
47
+ # Process existing uploaded reader avatars, sequentially in bounded batches
48
+ RAILS_ENV=production bundle exec rake bible270:avatars:optimize BATCH_SIZE=100
49
+
50
+ # Rebuild even current thumbnails from the retained originals
51
+ RAILS_ENV=production bundle exec rake bible270:avatars:optimize FORCE=1
52
+ ```
53
+
54
+ The task skips current thumbnails, protects concurrent avatar changes, continues after per-image failures, and exits nonzero if any fail. It prints reader IDs, counts, and original/optimized display-byte totals (not total storage savings). Correct failed files, limits, storage access, or processor dependencies and rerun. `BATCH_SIZE` defaults to 100 and must be between 1 and 1000. Dry-run checks eligibility only; it does not verify file availability or decodability. Take a normal database/storage backup before a production backfill. The task reads originals and writes thumbnails to the configured storage service; remote storage can incur request and transfer costs. It does **not** download remote sign-in-provider avatars or modify unrelated host attachments.
55
+
56
+ For host code that uploads reader pictures, use `reader.attach_avatar(upload)` or `Bible270::AvatarImages.update!(reader, attributes, upload: upload)` rather than attaching directly with Active Storage, which bypasses this processing pipeline. Uploads and backfills must run **outside a caller-owned database transaction**: the service owns the commit boundary so a later outer rollback cannot orphan already-uploaded files. Failed cleanup retains unattached blob records with their storage keys for retry; check logged blob IDs and include these in the host's unattached-blob maintenance.
57
+
32
58
  ## Install
33
59
 
34
60
  ```ruby
@@ -229,23 +229,20 @@ module Bible270
229
229
  # Name and picture together, mirroring what a reader can change about
230
230
  # themselves on their own profile.
231
231
  def update_profile
232
- problems = []
233
- @reader.transaction do
234
- if (params[:first_name].present? || params[:last_name].present?) && !@reader.update_names(params[:first_name],
235
- params[:last_name])
236
- problems << 'both a first and last name'
232
+ attributes = {}
233
+ if params[:first_name].present? || params[:last_name].present?
234
+ attributes = Names.normalize(params[:first_name], params[:last_name])
235
+ unless attributes
236
+ return redirect_to admin_reader_path(@reader), alert: 'Needs both a first and last name.'
237
237
  end
238
- if problems.empty? && params[:avatar].present? && !@reader.attach_avatar(params[:avatar])
239
- problems << (@reader.errors[:avatar].first || 'a valid image')
240
- end
241
- raise ActiveRecord::Rollback if problems.any?
242
238
  end
243
239
 
244
- if problems.empty?
245
- redirect_to admin_reader_path(@reader), notice: 'Updated.'
246
- else
247
- redirect_to admin_reader_path(@reader), alert: "Needs #{problems.to_sentence}."
248
- end
240
+ AvatarImages.update!(@reader, attributes, upload: params[:avatar].presence)
241
+ redirect_to admin_reader_path(@reader), notice: 'Updated.'
242
+ rescue AvatarOptimizer::Error, AvatarImages::Error => e
243
+ redirect_to admin_reader_path(@reader), alert: e.message
244
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved
245
+ redirect_to admin_reader_path(@reader), alert: 'The profile could not be saved. Please try again.'
249
246
  end
250
247
 
251
248
  def update_theme
@@ -35,6 +35,10 @@ module Bible270
35
35
 
36
36
  save_profile(names.merge(passage_source: source))
37
37
  redirect_to profile_path, notice: 'Your profile has been updated.'
38
+ rescue AvatarOptimizer::Error, AvatarImages::Error => e
39
+ current_reader.attachment_changes.clear if Reader.avatar_uploads?
40
+ current_reader.reload
41
+ render_profile_error(e.message)
38
42
  rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
39
43
  message = e.record ? e.record.errors.full_messages.to_sentence : nil
40
44
  # Reload does not discard Active Storage's deferred attachment changes.
@@ -103,13 +107,7 @@ module Bible270
103
107
  attributes[:daily_reminder_time] = @daily_reminder_time
104
108
  end
105
109
 
106
- Reader.transaction do
107
- current_reader.assign_attributes(attributes)
108
- # Unlike attach, assignment never saves early. Active Storage persists the
109
- # attachment with the reader and uploads only after a successful commit.
110
- current_reader.avatar = params[:avatar] if params[:avatar].present?
111
- current_reader.save!
112
- end
110
+ AvatarImages.update!(current_reader, attributes, upload: params[:avatar].presence)
113
111
  end
114
112
 
115
113
  def render_profile_error(message)
@@ -154,19 +154,25 @@ module Bible270
154
154
  end
155
155
 
156
156
  def b270_avatar_src(reader)
157
- return main_app.rails_blob_path(reader.avatar, only_path: true) if reader.avatar_uploaded?
157
+ return main_app.rails_blob_path(reader.display_avatar, only_path: true) if reader.avatar_uploaded?
158
158
 
159
159
  reader.avatar_url.presence
160
160
  end
161
161
 
162
- def b270_avatar(reader, size: 72, loading: 'lazy')
162
+ def b270_avatar(reader, size: 72, loading: 'eager')
163
163
  src = b270_avatar_src(reader)
164
- if src
165
- image_tag src, class: 'b270-avatar', width: size, height: size, alt: reader.display_name,
166
- loading: loading, decoding: 'async'
167
- else
168
- content_tag :span, reader.initials, class: 'b270-avatar b270-avatar-fallback',
169
- style: "width:#{size}px;height:#{size}px;line-height:#{size}px"
164
+ dimensions = "width:#{size}px;height:#{size}px;line-height:#{size}px"
165
+ unless src
166
+ return content_tag :span, reader.initials, class: 'b270-avatar b270-avatar-fallback',
167
+ style: dimensions, role: 'img', 'aria-label': reader.display_name
168
+ end
169
+
170
+ content_tag :span, class: 'b270-avatar-frame', style: dimensions,
171
+ role: 'img', 'aria-label': reader.display_name do
172
+ image = image_tag src, class: 'b270-avatar', width: size, height: size, alt: reader.display_name,
173
+ loading: loading, data: { b270_avatar: true, b270_avatar_uploaded: reader.avatar_uploaded? }
174
+ fallback = content_tag :span, reader.initials, class: 'b270-avatar b270-avatar-fallback', 'aria-hidden': true
175
+ safe_join([image, fallback])
170
176
  end
171
177
  end
172
178
 
@@ -53,10 +53,10 @@ module Bible270
53
53
  # listing them again at the top level would show each twice.
54
54
  scope :for_day, ->(d) { approved.where(day: d, parent_id: nil).order(created_at: :asc) }
55
55
  scope :with_reader_avatars, -> {
56
- Reader.avatar_uploads? ? includes(reader: { avatar_attachment: :blob }) : includes(:reader)
56
+ Reader.avatar_uploads? ? includes(reader: Reader::AVATAR_ASSOCIATIONS) : includes(:reader)
57
57
  }
58
58
  scope :with_thread_details, -> {
59
- reply_reader = Reader.avatar_uploads? ? { reader: { avatar_attachment: :blob } } : :reader
59
+ reply_reader = Reader.avatar_uploads? ? { reader: Reader::AVATAR_ASSOCIATIONS } : :reader
60
60
  with_reader_avatars.includes({ likes: :reader }, { replies: [reply_reader, { likes: :reader }] })
61
61
  }
62
62
  scope :threads_for_day, ->(d) { for_day(d).with_thread_details }
@@ -15,11 +15,16 @@ module Bible270
15
15
  # to keep working there — readers simply can't upload, and any avatar from
16
16
  # their sign-in provider is used instead.
17
17
  AVATAR_UPLOADS = respond_to?(:has_one_attached)
18
- has_one_attached :avatar if AVATAR_UPLOADS
18
+ if AVATAR_UPLOADS
19
+ has_one_attached :avatar
20
+ has_one_attached :avatar_thumbnail
21
+ end
22
+
23
+ AVATAR_ASSOCIATIONS = { avatar_attachment: :blob, avatar_thumbnail_attachment: :blob }.freeze
19
24
 
20
25
  def self.avatar_uploads? = AVATAR_UPLOADS
21
26
 
22
- scope :with_avatar, -> { avatar_uploads? ? includes(avatar_attachment: :blob) : all }
27
+ scope :with_avatar, -> { avatar_uploads? ? includes(AVATAR_ASSOCIATIONS) : all }
23
28
 
24
29
  # Fires for every way a reader comes into existence — email sign-in, OmniAuth,
25
30
  # or a bridged host user — rather than in each controller, so no path can
@@ -426,22 +431,29 @@ module Bible270
426
431
  self.class.avatar_uploads? && avatar.attached?
427
432
  end
428
433
 
434
+ def avatar_optimized?
435
+ avatar_uploaded? && avatar_thumbnail.attached? &&
436
+ avatar_thumbnail.blob.metadata['bible270_avatar_source_id'] == avatar.blob.id &&
437
+ avatar_thumbnail.blob.metadata['bible270_avatar_version'] == AvatarOptimizer::VERSION
438
+ end
439
+
440
+ def display_avatar
441
+ avatar_optimized? ? avatar_thumbnail : avatar
442
+ end
443
+
429
444
  # Returns false and sets an error when the upload isn't acceptable.
430
445
  def attach_avatar(upload)
431
446
  return false unless self.class.avatar_uploads?
432
447
 
433
- problem = Avatars.problem_with(content_type: upload.content_type, byte_size: upload.size)
434
- if problem
435
- errors.add(:avatar, problem)
436
- return false
437
- end
438
-
439
- avatar.attach(upload)
448
+ AvatarImages.update!(self, {}, upload: upload)
440
449
  true
450
+ rescue AvatarOptimizer::Error, AvatarImages::Error => e
451
+ errors.add(:avatar, e.message)
452
+ false
441
453
  end
442
454
 
443
455
  def remove_avatar!
444
- avatar.purge if avatar_uploaded?
456
+ AvatarImages.remove!(self)
445
457
  true
446
458
  end
447
459
 
@@ -23,7 +23,8 @@
23
23
  <span>Picture</span>
24
24
  <%= file_field_tag :avatar, accept: Bible270::Avatars.content_types.join(',') %>
25
25
  <span class="b270-hint" style="margin:0">
26
- PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes) %>.
26
+ PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes) %> and <%= Bible270::AvatarOptimizer::MAX_PIXELS / 1_000_000 %> megapixels.
27
+ Automatically center-cropped to a square. Animated pictures use the first frame.
27
28
  </span>
28
29
  </label>
29
30
  <% if @reader.avatar_uploaded? %>
@@ -15,7 +15,8 @@
15
15
  <span>Picture</span>
16
16
  <%= file_field_tag :avatar, accept: Bible270::Avatars.content_types.join(',') %>
17
17
  <span class="b270-hint" style="margin:0">
18
- PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes) %>.
18
+ PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes) %> and <%= Bible270::AvatarOptimizer::MAX_PIXELS / 1_000_000 %> megapixels.
19
+ Automatically center-cropped to a square. Animated pictures use the first frame.
19
20
  </span>
20
21
  </label>
21
22
  <% if current_reader.avatar_uploaded? %>
@@ -0,0 +1,85 @@
1
+ <script nonce="<%= content_security_policy_nonce %>">
2
+ (() => {
3
+ if (window.Bible270AvatarUI) {
4
+ window.Bible270AvatarUI.initialize();
5
+ return;
6
+ }
7
+
8
+ const selector = "img[data-b270-avatar]";
9
+ const states = new WeakMap();
10
+ const delays = [500, 1500];
11
+
12
+ function stateFor(image) {
13
+ const source = image.getAttribute("src");
14
+ let state = states.get(image);
15
+ if (!state || state.current !== source) {
16
+ if (state) clearTimeout(state.timer);
17
+ state = { original: source, current: source, attempts: 0, timer: null };
18
+ states.set(image, state);
19
+ }
20
+ return state;
21
+ }
22
+
23
+ function loaded(image) {
24
+ const state = stateFor(image);
25
+ clearTimeout(state.timer);
26
+ state.timer = null;
27
+ image.removeAttribute("data-b270-avatar-failed");
28
+ }
29
+
30
+ function failed(image) {
31
+ image.setAttribute("data-b270-avatar-failed", "");
32
+ const state = stateFor(image);
33
+ if (!state.original || state.timer !== null || state.attempts >= delays.length) return;
34
+
35
+ state.timer = setTimeout(() => {
36
+ state.timer = null;
37
+ if (!image.isConnected || image.getAttribute("src") !== state.current) return;
38
+ if (image.complete && image.naturalWidth > 0) {
39
+ loaded(image);
40
+ return;
41
+ }
42
+ state.attempts += 1;
43
+ let source = state.original;
44
+ // Refresh our blob redirect, which may have cached an expired storage URL.
45
+ // Never alter provider URLs: their query parameters may be signed.
46
+ const url = new URL(source, document.baseURI);
47
+ if (image.dataset.b270AvatarUploaded === "true" && url.origin === window.location.origin) {
48
+ url.searchParams.set("_b270_avatar_retry", `${Date.now()}-${state.attempts}`);
49
+ source = url.href;
50
+ }
51
+ state.current = source;
52
+ image.setAttribute("src", source);
53
+ }, delays[state.attempts]);
54
+ }
55
+
56
+ function inspect(image) {
57
+ if (!image.complete) return;
58
+ if (image.naturalWidth > 0) loaded(image);
59
+ else failed(image);
60
+ }
61
+
62
+ function initialize(root = document) {
63
+ if (root.matches?.(selector)) inspect(root);
64
+ root.querySelectorAll?.(selector).forEach(inspect);
65
+ }
66
+
67
+ // Image events do not bubble. Capture also handles images inserted by replies
68
+ // and Turbo; the scan catches cached failures before this script was installed.
69
+ document.addEventListener("load", (event) => {
70
+ if (event.target.matches?.(selector)) loaded(event.target);
71
+ }, true);
72
+ document.addEventListener("error", (event) => {
73
+ if (event.target.matches?.(selector)) failed(event.target);
74
+ }, true);
75
+ document.addEventListener("turbo:load", () => initialize());
76
+ document.addEventListener("turbo:render", () => initialize());
77
+ window.addEventListener("pageshow", () => initialize());
78
+ new MutationObserver((records) => {
79
+ records.forEach((record) => record.addedNodes.forEach((node) => initialize(node)));
80
+ }).observe(document.documentElement, { childList: true, subtree: true });
81
+
82
+ window.Bible270AvatarUI = { initialize };
83
+ initialize();
84
+ })();
85
+ </script>
@@ -18,13 +18,19 @@
18
18
  margin:0; background:radial-gradient(120% 80% at 100% 0%,rgba(165,129,44,.06),transparent 55%),var(--paper);
19
19
  color:var(--ink); font-family:"Inter",system-ui,sans-serif; line-height:1.2; -webkit-font-smoothing:antialiased;
20
20
  }
21
- /* Explicit light keeps native controls light even when the OS is dark.
22
- Only system mode follows the media query, including live OS changes. */
21
+ /* Explicit light also opts out of automatic browser darkening. System still follows the OS. */
22
+ :root[data-b270-display-mode="light"],
23
+ :root[data-b270-display-mode="sage"],
24
+ :root[data-b270-display-mode="mist"]{color-scheme:only light}
25
+ :root[data-b270-display-mode="dark"],
26
+ :root[data-b270-display-mode="dark_green"],
27
+ :root[data-b270-display-mode="dark_blue"]{color-scheme:dark}
28
+ :root[data-b270-display-mode="system"]{color-scheme:light dark}
23
29
  .b270-reader-theme[data-display-mode="light"],
24
- .b270[data-display-mode="light"]{color-scheme:light}
30
+ .b270[data-display-mode="light"]{color-scheme:only light}
25
31
  .b270-reader-theme[data-display-mode="sage"],
26
32
  .b270[data-display-mode="sage"]{
27
- color-scheme:light;
33
+ color-scheme:only light;
28
34
  background-image:none;
29
35
  --paper:#e1e8df; --card:#edf0e9; --ink:#29382d; --muted:#52634f; --faint:#5b6c58;
30
36
  --line:#cbd6c7; --line-soft:#dce4d8; --teal:#356b60; --oxblood:#905466; --gold:#776023;
@@ -39,7 +45,7 @@
39
45
  }
40
46
  .b270-reader-theme[data-display-mode="mist"],
41
47
  .b270[data-display-mode="mist"]{
42
- color-scheme:light;
48
+ color-scheme:only light;
43
49
  background-image:none;
44
50
  --paper:#e2e7ed; --card:#ecf0f5; --ink:#293746; --muted:#4f6074; --faint:#58687b;
45
51
  --line:#ced7e2; --line-soft:#dfe5ed; --teal:#316977; --oxblood:#86566f; --gold:#7b602b;
@@ -155,6 +161,38 @@
155
161
  .b270[data-display-mode="system"] > .b270-theme-scenery[data-scenery="light"]{display:none}
156
162
  .b270[data-display-mode="system"] > .b270-theme-scenery[data-scenery="dark"]{display:block}
157
163
  }
164
+ .b270-mobile-scenery{display:none}
165
+ /* Phone artwork is a quiet accent below the reading area, not a cropped desktop scene. */
166
+ @media (max-width:600px){
167
+ .b270-theme-scenery{--b270-scene-height:clamp(180px,30vh,260px)}
168
+ .b270[data-display-mode] > .b270-theme-scripture,
169
+ .b270[data-display-mode] > .b270-theme-scenery[data-scenery="hebrew_water"]{display:none}
170
+ .b270-mobile-scenery{display:block}
171
+ .b270-papyrus-grid{opacity:.2}
172
+ .b270-papyrus-edges{opacity:.55}
173
+ .b270-theme-scenery[data-scenery="sage_sky"]{opacity:.3}
174
+ #b270-meadow-clouds{opacity:.5}
175
+ #b270-meadow-clouds path:nth-child(even),
176
+ #b270-meadow-grass-distant,
177
+ #b270-meadow-grass-near path:not(:nth-child(3n + 1)){display:none}
178
+ #b270-meadow-grass-near{opacity:.15}
179
+ .b270-theme-scenery[data-scenery="sage"] [fill="url(#b270-meadow-rays)"]{opacity:.25}
180
+ .b270-theme-scenery[data-scenery="mist_sky"]{opacity:.2}
181
+ .b270-theme-scenery[data-scenery="mist"]{opacity:.45}
182
+ #b270-ocean-clouds,
183
+ #b270-ocean-distant-waves,
184
+ #b270-ocean-glints,
185
+ #b270-ocean-sand-detail,
186
+ #b270-ocean-surf path:not(:first-child){display:none}
187
+ .b270-theme-scenery[data-scenery="dark"]{height:clamp(240px,42vh,380px);opacity:.45}
188
+ #b270-cedar-fine-grain path:not(:first-child),
189
+ #b270-cedar-knots path:nth-child(n + 4){display:none}
190
+ #b270-spruce-distant path:nth-of-type(even),
191
+ #b270-spruce-near path:nth-of-type(even),
192
+ #b270-spruce-distant path:nth-of-type(n + 22),
193
+ #b270-spruce-near path:nth-of-type(n + 22),
194
+ #b270-lake-reflection{display:none}
195
+ }
158
196
  @media print{
159
197
  .b270[data-display-mode] > .b270-theme-scenery[data-scenery]{display:none}
160
198
  }
@@ -168,7 +206,7 @@
168
206
  .b270-wrap{max-width:820px;margin:0 auto;padding:clamp(14px,3.5vw,36px) clamp(14px,4vw,40px) 64px}
169
207
 
170
208
  .b270-topbar{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;padding-bottom:20px;border-bottom:1px solid var(--line-soft);margin-bottom:26px}
171
- .b270-brand{display:flex;align-items:baseline;gap:4px;margin-right:auto;min-width:0}
209
+ .b270-brand{display:flex;align-items:baseline;gap:14px;margin-right:auto;min-width:0}
172
210
  .b270-header-theme{position:relative;display:flex;align-items:baseline;gap:4px;margin:0;flex:none}
173
211
  .b270-header-theme-select{font:inherit;font-size:12px;width:6.5em;height:28px;padding:2px;margin:0;
174
212
  border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--ink);cursor:pointer}
@@ -386,6 +424,11 @@
386
424
  .b270-completers{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:16px;font-size:13px;color:var(--muted)}
387
425
  .b270-avatar{border-radius:50%;object-fit:cover;display:inline-block;text-align:center;background:var(--teal-fill);color:var(--on-accent);font-size:12px;font-weight:600}
388
426
  .b270-avatar-fallback{border-radius:50%}
427
+ .b270-avatar-frame{position:relative;display:inline-block;vertical-align:middle;flex-shrink:0}
428
+ .b270-avatar-frame>img{display:block}
429
+ .b270-avatar-frame>.b270-avatar-fallback{position:absolute;inset:0;visibility:hidden}
430
+ .b270-avatar-frame>img[data-b270-avatar-failed]{visibility:hidden}
431
+ .b270-avatar-frame>img[data-b270-avatar-failed]+.b270-avatar-fallback{visibility:visible}
389
432
 
390
433
  /* comments */
391
434
  .b270-section-h{font-family:"Spectral",serif;font-size:20px;font-weight:600;margin:34px 0 14px}