bible270 1.9.1 → 1.10.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: 5e8a5fd512d3d6da6e59a4ecbbb112717b13eeb00b4b6fa0c4033c7c61ace730
4
- data.tar.gz: 190694fc67180501f5e991aaf7640c7577b90f636fdea9dfca742941f9577841
3
+ metadata.gz: 43fc7b160b27ec7e20797964b1e7a61244f85b2475ba61e5b8b16e0bfb48c491
4
+ data.tar.gz: df5de443f785eed07c04db5efdbee1184a0dc39cbf2862a6feec79c3bb83fb3d
5
5
  SHA512:
6
- metadata.gz: 78a6b2c66b69dc0f6fb268ff1669b968567a4fdedbcf749ba34d31daec6853119a30da545c129c0f77d0571ef488a43a24b0c6f10dc7b5b7738e2c6d2e60e366
7
- data.tar.gz: a132a769bef838b993cac33603fd2d5ef6b66796a3f9d86f40d257436040732d73ebe154d92054ca5d724b88899abf6970d41a122438286afe60dedf52a128b4
6
+ metadata.gz: 047001d13b3e85efb775b5abcb63431ce22bf02e0a6e853b4cf63bcf472e5ea5d2e6d95f2af56d8a0004275d343aeaea4c13df1d54d1ae49d7145f79e16d55b8
7
+ data.tar.gz: 06aa22e9592f1603f1136babc07b29657c3190f874d645f4e89deb66b0895e200fb155560f0cb628caa79e3c3fac1c8d2d40997e3a5ed6f18998b519fe13e3d0
data/README.md CHANGED
@@ -284,7 +284,8 @@ From a reader's page you can:
284
284
  The Reflections page lists conversations by their latest approved reply, supports day and author
285
285
  filters, and paginates older threads. Signed-in readers see which conversations became active since
286
286
  their previous visit. Reflection and reply drafts are retained for up to 30 days in that browser and
287
- cleared after a successful post. On a shared device, clearing browser storage also removes saved drafts.
287
+ cleared after a successful post. If browser storage is blocked or full, the composer warns that the draft
288
+ could not be saved; posting still works. On a shared device, clearing browser storage also removes saved drafts.
288
289
  Typing `@` in the composer offers up to five unambiguous reader handles; manual mentions continue to
289
290
  work without JavaScript. A quiet **Formatting** disclosure opens a toolbar for bold, italic,
290
291
  quotations, bulleted and numbered lists, links, and YouTube videos without requiring readers to know Markdown syntax.
@@ -368,13 +369,17 @@ Psalm 35: []
368
369
  Psalm 78: [20, 39, 55]
369
370
  ```
370
371
 
371
- ## Display preference
372
+ ## Theme
372
373
 
373
- Readers can choose **Light**, **Dark**, or **System** under **Profile → Display**, then save.
374
- The preference is stored with their account and applies throughout Bible270 on every device.
375
- System is the default for existing and new readers, follows the device's current appearance,
374
+ Readers can choose **Parchment** (light), **Earth** (dark brown), **Forest** (dark green),
375
+ **Midnight** (dark blue), or **System** under **Profile → Theme**, then save. The preference is stored
376
+ with their account and applies throughout Bible270 on every device. System is the default for existing
377
+ and new readers, follows the device's current appearance using Parchment for light or Earth for dark,
376
378
  and responds to changes without reloading. Signed-out visitors also use System.
377
- The completion dove retains its bright colors in either theme.
379
+ The completion dove retains its bright colors in every theme.
380
+
381
+ Existing preferences are preserved under the new names. Forest and Midnight require no additional
382
+ migration once display preferences are installed.
378
383
 
379
384
  Upgrading hosts must install and run migration `20260101000023`:
380
385
 
@@ -420,7 +425,8 @@ Daily reminders are optional at both levels: the host enables the feature, then
420
425
  whether to receive it and at what local time from their profile. The feature and reader opt-in both
421
426
  default to off; each reader's time defaults to `08:00`. Profile times use quarter-hour choices so they
422
427
  match the recommended 15-minute scheduler interval. An existing off-quarter preference remains
423
- available until that reader chooses a new time.
428
+ available until that reader chooses a new time. The profile shows the plan's effective time zone beside
429
+ the reminder settings; this is not automatically the reader's device time zone.
424
430
 
425
431
  ```ruby
426
432
  # config/initializers/bible270.rb
@@ -67,7 +67,7 @@ module Bible270
67
67
  @days_completed = Reader.completed_days_by_id
68
68
  @reader_sort_options = READER_SORT_OPTIONS
69
69
  @reader_sort = READER_SORT_OPTIONS.key?(params[:sort]) ? params[:sort] : 'first_name'
70
- readers = Reader.all.load
70
+ readers = Reader.with_avatar.load
71
71
  @recent_reader_activities = Checkoff.recent_activity_by_reader(readers.map(&:id))
72
72
  @reader_report = ReaderProgressReport.new(
73
73
  readers: readers,
@@ -105,7 +105,7 @@ module Bible270
105
105
  # Everything is visible by default; this lists it all so anything unsuitable
106
106
  # can be taken down.
107
107
  def comments
108
- scope = Comment.includes(:reader, parent: :reader).order(created_at: :desc)
108
+ scope = Comment.with_reader_avatars.includes(parent: :reader).order(created_at: :desc)
109
109
  @filter = params[:filter].to_s
110
110
  scope = scope.hidden if @filter == 'hidden'
111
111
  scope = scope.approved if @filter == 'visible'
@@ -17,39 +17,30 @@ module Bible270
17
17
  @comment_notification_level = requested_comment_notification_level
18
18
  @daily_reminders_available = daily_reminders_available?
19
19
  @display_mode = params.fetch(:display_mode, current_reader.effective_display_mode).to_s
20
- problems = [comment_notification_problem, display_mode_problem].compact
21
- problems << 'both a first and last name' unless current_reader.update_names(params[:first_name],
22
- params[:last_name])
23
- if params[:bible_version].present? && !current_reader.update_bible_version(params[:bible_version])
20
+ @names = { first_name: params[:first_name], last_name: params[:last_name] }
21
+ @daily_reminders = params[:daily_reminders] == '1'
22
+ @daily_reminder_time = daily_reminder_time_param
23
+ names = Names.normalize(params[:first_name], params[:last_name])
24
+ source = (params[:passage_source].presence || Reader::DEFAULT_PASSAGE_SOURCE).to_s
25
+ problems = [comment_notification_problem, display_mode_problem, avatar_problem].compact
26
+ problems << 'both a first and last name' unless names
27
+ if params[:bible_version].present? && !Translations.valid?(params[:bible_version])
24
28
  problems << 'a translation from the list'
25
29
  end
26
- source = params[:passage_source].presence || Reader::DEFAULT_PASSAGE_SOURCE
27
- problems << 'a reading-link source from the list' unless current_reader.update_passage_source(source)
28
- if params[:avatar].present? && !current_reader.attach_avatar(params[:avatar])
29
- problems << (current_reader.errors[:avatar].first || 'a valid image')
30
- end
31
-
32
- requested_daily_reminders = params[:daily_reminders] == '1'
33
- requested_daily_reminder_time = daily_reminder_time_param
34
- if @daily_reminders_available && !Reader.valid_daily_reminder_time?(requested_daily_reminder_time)
30
+ problems << 'a reading-link source from the list' unless Reader::PASSAGE_SOURCES.include?(source)
31
+ if @daily_reminders_available && !Reader.valid_daily_reminder_time?(@daily_reminder_time)
35
32
  problems << 'a reminder time in 24-hour HH:MM format'
36
33
  end
37
-
38
- if problems.empty?
39
- save_display_mode
40
- current_reader.update_comment_notification_level!(@comment_notification_level) if @comment_notifications_available
41
- if @daily_reminders_available
42
- current_reader.update!(daily_reminders: requested_daily_reminders,
43
- daily_reminder_time: requested_daily_reminder_time)
44
- end
45
- redirect_to profile_path, notice: 'Your profile has been updated.'
46
- else
47
- @names = { first_name: params[:first_name], last_name: params[:last_name] }
48
- @daily_reminders = requested_daily_reminders
49
- @daily_reminder_time = requested_daily_reminder_time
50
- flash.now[:alert] = "Please give #{problems.to_sentence}."
51
- render :edit, status: :unprocessable_entity
52
- end
34
+ return render_profile_error("Please give #{problems.to_sentence}.") if problems.any?
35
+
36
+ save_profile(names.merge(passage_source: source))
37
+ redirect_to profile_path, notice: 'Your profile has been updated.'
38
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
39
+ message = e.record ? e.record.errors.full_messages.to_sentence : nil
40
+ # Reload does not discard Active Storage's deferred attachment changes.
41
+ current_reader.attachment_changes.clear if Reader.avatar_uploads?
42
+ current_reader.reload
43
+ render_profile_error("Your profile could not be saved. #{message.presence || 'Please try again.'}")
53
44
  end
54
45
 
55
46
  def remove_avatar
@@ -63,11 +54,41 @@ module Bible270
63
54
  return unless Reader.display_mode_column?
64
55
  return if Reader::DISPLAY_MODES.include?(@display_mode)
65
56
 
66
- 'a display preference from the list'
57
+ 'a theme from the list'
58
+ end
59
+
60
+ def avatar_problem
61
+ upload = params[:avatar]
62
+ return if upload.blank?
63
+ return 'a valid image' unless Reader.avatar_uploads?
64
+
65
+ Avatars.problem_with(content_type: upload.content_type, byte_size: upload.size)
66
+ end
67
+
68
+ def save_profile(attributes)
69
+ attributes[:bible_version] = Translations.normalize(params[:bible_version]) if params[:bible_version].present?
70
+ attributes[:display_mode] = @display_mode if Reader.display_mode_column?
71
+ if @comment_notifications_available
72
+ attributes[:notify_on_mention] = @comment_notification_level != 'none'
73
+ attributes[:notify_on_all_comments] = @comment_notification_level == 'all'
74
+ end
75
+ if @daily_reminders_available
76
+ attributes[:daily_reminders] = @daily_reminders
77
+ attributes[:daily_reminder_time] = @daily_reminder_time
78
+ end
79
+
80
+ Reader.transaction do
81
+ current_reader.assign_attributes(attributes)
82
+ # Unlike attach, assignment never saves early. Active Storage persists the
83
+ # attachment with the reader and uploads only after a successful commit.
84
+ current_reader.avatar = params[:avatar] if params[:avatar].present?
85
+ current_reader.save!
86
+ end
67
87
  end
68
88
 
69
- def save_display_mode
70
- current_reader.update!(display_mode: @display_mode) if Reader.display_mode_column?
89
+ def render_profile_error(message)
90
+ flash.now[:alert] = message
91
+ render :edit, status: :unprocessable_entity
71
92
  end
72
93
 
73
94
  def load_comment_notification_state
@@ -5,7 +5,7 @@ module Bible270
5
5
  def index
6
6
  # Community is a directory of fellow readers, not a leaderboard. Keep the
7
7
  # order predictable without implying that progress determines standing.
8
- @readers = Reader.all.to_a.sort_by(&:sort_name)
8
+ @readers = Reader.with_avatar.to_a.sort_by(&:sort_name)
9
9
  @today_day = current_reader&.today_day
10
10
  @show_admin_progress = Bible270.config.admin?(current_reader)
11
11
  @days_completed = Reader.completed_days_by_id if @show_admin_progress
@@ -47,9 +47,7 @@ module Bible270
47
47
  private
48
48
 
49
49
  def recent_reflections(reader, limit:)
50
- comments = reader.comments.recent
51
- .includes(:reader, { likes: :reader }, { replies: [:reader, { likes: :reader }] })
52
- .limit(limit).to_a
50
+ comments = reader.comments.recent.with_thread_details.limit(limit).to_a
53
51
  ids = comments.map(&:id)
54
52
  # A reader's own replies already appear beneath any listed parent reflection.
55
53
  comments.reject { |comment| ids.include?(comment.parent_id) }
@@ -159,10 +159,11 @@ module Bible270
159
159
  reader.avatar_url.presence
160
160
  end
161
161
 
162
- def b270_avatar(reader, size: 72)
162
+ def b270_avatar(reader, size: 72, loading: 'lazy')
163
163
  src = b270_avatar_src(reader)
164
164
  if src
165
- image_tag src, class: 'b270-avatar', width: size, height: size, alt: reader.display_name
165
+ image_tag src, class: 'b270-avatar', width: size, height: size, alt: reader.display_name,
166
+ loading: loading, decoding: 'async'
166
167
  else
167
168
  content_tag :span, reader.initials, class: 'b270-avatar b270-avatar-fallback',
168
169
  style: "width:#{size}px;height:#{size}px;line-height:#{size}px"
@@ -52,9 +52,14 @@ module Bible270
52
52
  # Only the reflections themselves: replies are rendered under their parent, so
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
- scope :threads_for_day, ->(d) {
56
- for_day(d).includes(:reader, { likes: :reader }, { replies: [:reader, { likes: :reader }] })
55
+ scope :with_reader_avatars, -> {
56
+ Reader.avatar_uploads? ? includes(reader: { avatar_attachment: :blob }) : includes(:reader)
57
57
  }
58
+ scope :with_thread_details, -> {
59
+ reply_reader = Reader.avatar_uploads? ? { reader: { avatar_attachment: :blob } } : :reader
60
+ with_reader_avatars.includes({ likes: :reader }, { replies: [reply_reader, { likes: :reader }] })
61
+ }
62
+ scope :threads_for_day, ->(d) { for_day(d).with_thread_details }
58
63
  scope :recent, -> { approved.order(created_at: :desc) }
59
64
 
60
65
  def self.body_format_column?
@@ -95,9 +100,7 @@ module Bible270
95
100
  page = page.to_i.clamp(1, pages)
96
101
  page_rows = ordered.slice((page - 1) * per_page, per_page) || []
97
102
  ids = page_rows.map(&:first)
98
- loaded = approved.where(id: ids)
99
- .includes(:reader, { likes: :reader }, { replies: [:reader, { likes: :reader }] })
100
- .index_by(&:id)
103
+ loaded = approved.where(id: ids).with_thread_details.index_by(&:id)
101
104
 
102
105
  ThreadPage.new(
103
106
  threads: ids.filter_map { |id| loaded[id] },
@@ -19,6 +19,8 @@ module Bible270
19
19
 
20
20
  def self.avatar_uploads? = AVATAR_UPLOADS
21
21
 
22
+ scope :with_avatar, -> { avatar_uploads? ? includes(avatar_attachment: :blob) : all }
23
+
22
24
  # Fires for every way a reader comes into existence — email sign-in, OmniAuth,
23
25
  # or a bridged host user — rather than in each controller, so no path can
24
26
  # quietly skip it. after_create_commit, so nothing is sent for a reader whose
@@ -27,7 +29,7 @@ module Bible270
27
29
 
28
30
  PASSAGE_SOURCES = %w[bible_com bible_gateway blue_letter_bible].freeze
29
31
  DEFAULT_PASSAGE_SOURCE = 'bible_com'
30
- DISPLAY_MODES = %w[light dark system].freeze
32
+ DISPLAY_MODES = %w[light dark dark_green dark_blue system].freeze
31
33
  DEFAULT_DISPLAY_MODE = 'system'
32
34
  DAILY_REMINDER_TIME_FORMAT = %r{\A(?:[01]\d|2[0-3]):[0-5]\d\z}
33
35
  DAILY_REMINDER_MINUTES = %w[00 15 30 45].freeze
@@ -87,7 +89,7 @@ module Bible270
87
89
  .having('COUNT(*) >= ?', Plan.total_parts(day))
88
90
  .order(Checkoff.arel_table[:created_at].maximum.asc, :reader_id)
89
91
  .pluck(:reader_id)
90
- readers_by_id = where(id: reader_ids).index_by(&:id)
92
+ readers_by_id = with_avatar.where(id: reader_ids).index_by(&:id)
91
93
  reader_ids.filter_map { |reader_id| readers_by_id[reader_id] }
92
94
  end
93
95
 
@@ -16,7 +16,7 @@
16
16
  <%= form_with url: admin_reader_profile_path(@reader), method: :patch, class: "b270-emailform",
17
17
  html: { multipart: true } do %>
18
18
  <div class="b270-avatarrow">
19
- <%= b270_avatar(@reader, size: 72) %>
19
+ <%= b270_avatar(@reader, size: 72, loading: 'eager') %>
20
20
  <% if Bible270::Reader.avatar_uploads? %>
21
21
  <label class="b270-field" style="flex:1">
22
22
  <span>Picture</span>
@@ -20,7 +20,7 @@
20
20
  <%= f.select :track,
21
21
  options_for_select([["The whole day", ""]] + Bible270::Plan::TRACKS.map { |k, v| [v[:label], k] },
22
22
  comment.track.to_s),
23
- {}, class: "b270-select", style: "width:auto" %>
23
+ {}, class: "b270-select", style: "width:auto", aria: { label: "Reading this reflection relates to" } %>
24
24
  <% end %>
25
25
  <%= f.submit "Save", class: "b270-btn" %>
26
26
  <%= link_to "Cancel", day_path(comment.day, anchor: "comment-#{comment.id}"), class: "b270-linkbtn" %>
@@ -1,6 +1,6 @@
1
1
  <%# locals: (comment:) %>
2
2
  <div class="b270-comment b270-editing" id="comment-<%= comment.id %>">
3
- <%= b270_avatar(comment.reader) %>
3
+ <%= b270_avatar(comment.reader, loading: 'eager') %>
4
4
  <div style="flex:1">
5
5
  <%= render partial: "bible270/comments/edit_fields", locals: { comment: comment } %>
6
6
  </div>
@@ -25,7 +25,8 @@
25
25
  <div class="row">
26
26
  <%= f.select :track,
27
27
  options_for_select([["The whole day", ""]] + Bible270::Plan::TRACKS.map { |k, v| [v[:label], k] }),
28
- {}, style: "font:inherit;padding:8px;border:1px solid var(--line);border-radius:8px;background:#fff" %>
28
+ {}, aria: { label: "Reading this reflection relates to" },
29
+ style: "font:inherit;padding:8px;border:1px solid var(--line);border-radius:8px;background:#fff" %>
29
30
  <%= f.submit "Post reflection", class: "b270-btn" %>
30
31
  </div>
31
32
  <% end %>
@@ -8,15 +8,21 @@
8
8
  <%= form_with url: profile_path, method: :patch, class: "b270-emailform",
9
9
  html: { multipart: true } do %>
10
10
  <div class="b270-avatarrow">
11
- <%= b270_avatar(current_reader, size: 72) %>
11
+ <%= b270_avatar(current_reader, size: 72, loading: 'eager') %>
12
12
  <% if Bible270::Reader.avatar_uploads? %>
13
- <label class="b270-field" style="flex:1">
14
- <span>Picture</span>
15
- <%= file_field_tag :avatar, accept: Bible270::Avatars.content_types.join(',') %>
16
- <span class="b270-hint" style="margin:0">
17
- PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes) %>.
18
- </span>
19
- </label>
13
+ <div class="b270-avatarcontrols" style="flex:1;min-width:0">
14
+ <label class="b270-field">
15
+ <span>Picture</span>
16
+ <%= file_field_tag :avatar, accept: Bible270::Avatars.content_types.join(',') %>
17
+ <span class="b270-hint" style="margin:0">
18
+ PNG, JPEG, GIF or WebP, up to <%= Bible270::Avatars.human_size(Bible270::Avatars.max_bytes) %>.
19
+ </span>
20
+ </label>
21
+ <% if current_reader.avatar_uploaded? %>
22
+ <%= button_tag "Remove picture", type: "submit", class: "b270-linkbtn",
23
+ form: "b270-remove-picture" %>
24
+ <% end %>
25
+ </div>
20
26
  <% end %>
21
27
  </div>
22
28
 
@@ -40,15 +46,16 @@
40
46
 
41
47
  <% if Bible270::Reader.display_mode_column? %>
42
48
  <label class="b270-field">
43
- <span>Display</span>
49
+ <span>Theme</span>
44
50
  <%= select_tag :display_mode,
45
- options_for_select([["Light", "light"], ["Dark", "dark"], ["System", "system"]], @display_mode),
51
+ options_for_select([["Parchment", "light"], ["Earth", "dark"], ["Forest", "dark_green"],
52
+ ["Midnight", "dark_blue"], ["System", "system"]], @display_mode),
46
53
  class: "b270-select", aria: { describedby: "b270-display-hint" } %>
47
- <span id="b270-display-hint" class="b270-hint" style="margin:0">System follows your device's light or dark setting.</span>
54
+ <span id="b270-display-hint" class="b270-hint" style="margin:0">System follows your device's setting: Parchment for light, Earth for dark.</span>
48
55
  </label>
49
56
  <% else %>
50
57
  <p class="b270-flash alert" style="margin:0">
51
- Display settings are temporarily unavailable while this installation finishes a database update.
58
+ Theme settings are temporarily unavailable while this installation finishes a database update.
52
59
  </p>
53
60
  <% end %>
54
61
 
@@ -63,6 +70,13 @@
63
70
  <% end %>
64
71
 
65
72
  <% if @daily_reminders_available %>
73
+ <% reminder_local_time = Bible270.local_time(Time.now) %>
74
+ <% reminder_zone = if reminder_local_time.respond_to?(:time_zone)
75
+ reminder_local_time.time_zone.tzinfo.name
76
+ else
77
+ ["Server time zone", reminder_local_time.zone.presence].compact.join(" — ")
78
+ end %>
79
+ <% reminder_tooltip = "Uses the plan's time zone: #{reminder_zone}, not your device's time zone. You can turn reminders off here at any time." %>
66
80
  <% reminder_hour, reminder_minute = @daily_reminder_time.to_s.split(":", 2) %>
67
81
  <% reminder_minutes = (Bible270::Reader::DAILY_REMINDER_MINUTES + [reminder_minute]).compact.uniq.sort %>
68
82
  <div class="b270-field">
@@ -73,7 +87,7 @@
73
87
  aria: { describedby: "daily-reminder-optin-hint" } %>
74
88
  <span>Email me each day's readings</span>
75
89
  <span class="b270-reminder-tooltip" id="daily-reminder-optin-hint" role="tooltip">
76
- Uses the plan's configured time zone. You can turn reminders off here at any time.
90
+ <%= reminder_tooltip %>
77
91
  </span>
78
92
  </label>
79
93
  <div class="b270-reminder-time">
@@ -88,11 +102,14 @@
88
102
  options_for_select(reminder_minutes, reminder_minute),
89
103
  class: "b270-time-select", required: true, aria: { label: "Minute" } %>
90
104
  <span class="b270-reminder-tooltip" id="daily-reminder-time-hint" role="tooltip">
91
- Uses the plan's configured time zone. You can turn reminders off here at any time.
105
+ <%= reminder_tooltip %>
92
106
  </span>
93
107
  </span>
94
108
  </div>
95
109
  </div>
110
+ <span id="daily-reminder-zone" class="b270-hint" style="margin:0;overflow-wrap:anywhere">
111
+ Plan time zone: <%= reminder_zone %>
112
+ </span>
96
113
  </div>
97
114
  <% elsif Bible270.config.daily_reminders? %>
98
115
  <p class="b270-flash alert" style="margin:0">
@@ -104,8 +121,11 @@
104
121
  <% end %>
105
122
 
106
123
  <% if current_reader.avatar_uploaded? %>
107
- <%= button_to "Remove picture", profile_avatar_path, method: :delete, class: "b270-linkbtn",
108
- form: { data: { turbo_confirm: "Remove your picture?" } } %>
124
+ <%# The picture button targets this separate form so it never submits profile edits. %>
125
+ <%= form_with url: profile_avatar_path, method: :delete,
126
+ html: { id: "b270-remove-picture", hidden: true },
127
+ data: { turbo_confirm: "Remove your picture?" } do %>
128
+ <% end %>
109
129
  <% end %>
110
130
 
111
131
  <% unless Bible270::Reader.avatar_uploads? %>
@@ -1,7 +1,7 @@
1
1
  <% content_for :title, "#{@reader.display_name} — #{Bible270.config.app_name}" %>
2
2
  <% content_for :hide_day_index, true %><%# this page has its own grid %>
3
3
  <div style="display:flex;align-items:center;gap:16px;margin-bottom:8px">
4
- <%= b270_avatar(@reader, size: 72) %>
4
+ <%= b270_avatar(@reader, size: 72, loading: 'eager') %>
5
5
  <div>
6
6
  <h1 style="margin:0"><%= @reader.display_name %></h1>
7
7
  <% if signed_in? && current_reader.id == @reader.id %>
@@ -20,8 +20,13 @@
20
20
  try { return area.getItem(key); } catch (_error) { return null; }
21
21
  },
22
22
  set(area, key, value) {
23
- if (!area) return;
24
- try { area.setItem(key, value); } catch (_error) { /* Drafts degrade safely. */ }
23
+ if (!area) return false;
24
+ try {
25
+ area.setItem(key, value);
26
+ return true;
27
+ } catch (_error) {
28
+ return false;
29
+ }
25
30
  },
26
31
  remove(area, key) {
27
32
  if (!area) return;
@@ -49,7 +54,7 @@
49
54
 
50
55
  function setStatus(form, message) {
51
56
  const status = form.querySelector("[data-b270-draft-status]");
52
- if (status) status.textContent = message;
57
+ if (status && status.textContent !== message) status.textContent = message;
53
58
  }
54
59
 
55
60
  function save(form) {
@@ -81,8 +86,9 @@
81
86
  savedAt: Date.now(),
82
87
  revision: `${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}`
83
88
  };
84
- storage.set(local, key, JSON.stringify(draft));
85
- setStatus(form, "Draft saved in this browser.");
89
+ const saved = storage.set(local, key, JSON.stringify(draft));
90
+ setStatus(form, saved ? "Draft saved in this browser." :
91
+ "Draft couldn’t be saved. Keep this page open until posting.");
86
92
  }
87
93
 
88
94
  function settlePending(key = null) {
@@ -29,18 +29,38 @@
29
29
  (() => {
30
30
  if (window.Bible270NavigationMenu) return;
31
31
 
32
+ function setExpanded(menu, expanded) {
33
+ const toggle = menu && menu.querySelector("button.b270-navtoggle");
34
+ const links = menu && menu.querySelector(".b270-navlinks");
35
+ if (!toggle || !links) return;
36
+
37
+ toggle.setAttribute("aria-expanded", String(expanded));
38
+ links.hidden = !expanded;
39
+ }
40
+
32
41
  document.addEventListener("click", (event) => {
33
42
  const target = event.target;
43
+ document.querySelectorAll(".b270-navmenu").forEach((menu) => {
44
+ if (!menu.contains(target)) setExpanded(menu, false);
45
+ });
46
+
34
47
  const toggle = target.closest && target.closest("button.b270-navtoggle");
35
48
  if (!toggle) return;
36
49
 
37
- const menu = toggle.closest(".b270-navmenu");
38
- const links = menu && menu.querySelector(".b270-navlinks");
39
- if (!links) return;
50
+ setExpanded(toggle.closest(".b270-navmenu"), toggle.getAttribute("aria-expanded") !== "true");
51
+ });
52
+
53
+ document.addEventListener("keydown", (event) => {
54
+ if (event.key !== "Escape" || event.defaultPrevented) return;
55
+
56
+ const menu = event.target.closest && event.target.closest(".b270-navmenu");
57
+ const toggle = menu && menu.querySelector("button.b270-navtoggle");
58
+ if (!toggle || toggle.getAttribute("aria-expanded") !== "true") return;
40
59
 
41
- const opening = toggle.getAttribute("aria-expanded") !== "true";
42
- toggle.setAttribute("aria-expanded", String(opening));
43
- links.hidden = !opening;
60
+ const restoreFocus = menu.contains(document.activeElement);
61
+ setExpanded(menu, false);
62
+ event.preventDefault();
63
+ if (restoreFocus) toggle.focus();
44
64
  });
45
65
 
46
66
  window.Bible270NavigationMenu = true;
@@ -32,6 +32,36 @@
32
32
  --alert-surface:#42282a; --alert-ink:#ffc0b8; --alert-line:#a66d68;
33
33
  --notice-surface:#25382a; --notice-ink:#b6dfb6; --notice-line:#6d956e;
34
34
  }
35
+ .b270[data-display-mode="dark_green"]{
36
+ color-scheme:dark;
37
+ background-image:none;
38
+ --control-line:#7c9280;
39
+ --paper:#1c3027; --card:#17291f; --ink:#d9decb; --muted:#bdc9b5; --faint:#a6b69e;
40
+ --line:#3c5045; --line-soft:#304638; --teal:#83c4b3; --oxblood:#df9aa6; --gold:#d8c184;
41
+ --gold-lite:#62501e; --shadow:0 1px 0 rgba(0,0,0,.1);
42
+ --control:#101c17; --task-surface:rgba(255,255,255,.025); --button-hover:#1e3829;
43
+ --on-accent:#e3e7d5; --complete-ink:#e3e7d5; --complete-dot:#e3e7d5;
44
+ --button-surface:#102219; --button-ink:#e3e7d5;
45
+ --teal-fill:#28554b; --oxblood-fill:#6a2c3c; --gold-fill:#62501e;
46
+ --teal-mark:#4d9d89; --oxblood-mark:#c46c7f; --gold-mark:#b79b45;
47
+ --alert-surface:#3e282c; --alert-ink:#ffc1c2; --alert-line:#a87479;
48
+ --notice-surface:#243b2b; --notice-ink:#c1e2b9; --notice-line:#779c70;
49
+ }
50
+ .b270[data-display-mode="dark_blue"]{
51
+ color-scheme:dark;
52
+ background-image:none;
53
+ --control-line:#7c8da7;
54
+ --paper:#182537; --card:#121d2e; --ink:#d1dbe8; --muted:#b6c3d5; --faint:#9dadc3;
55
+ --line:#36465d; --line-soft:#29394e; --teal:#81bfca; --oxblood:#df9caf; --gold:#d5bd82;
56
+ --gold-lite:#5d4c25; --shadow:0 1px 0 rgba(0,0,0,.1);
57
+ --control:#0b1422; --task-surface:rgba(255,255,255,.025); --button-hover:#20334d;
58
+ --on-accent:#e0e7ef; --complete-ink:#e0e7ef; --complete-dot:#e0e7ef;
59
+ --button-surface:#0c1829; --button-ink:#e0e7ef;
60
+ --teal-fill:#245363; --oxblood-fill:#622d49; --gold-fill:#5d4c25;
61
+ --teal-mark:#4c9eae; --oxblood-mark:#b86382; --gold-mark:#b59a54;
62
+ --alert-surface:#3b2432; --alert-ink:#ffc0cd; --alert-line:#a66c80;
63
+ --notice-surface:#1e3430; --notice-ink:#b7decc; --notice-line:#6b9787;
64
+ }
35
65
  @media (prefers-color-scheme:dark){
36
66
  .b270[data-display-mode="system"]{
37
67
  color-scheme:dark;
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bible270
4
- VERSION = '1.9.1'
4
+ VERSION = '1.10.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bible270
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.1
4
+ version: 1.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew vonderLuft