ideasbugs 0.5.1 → 0.5.3

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: 04077c3c3deaf5d2f5591f78dd1e1de8086474289b9a31d4168c84fb0eb9f1f6
4
- data.tar.gz: 2882e9f33d9664088738c5b6bf953df30b3da4a1ac7416316b7c2fdeabd91759
3
+ metadata.gz: 29449ba188f3e7da8299dd22ff9f03795cd5b645734596619eb5318535c8a809
4
+ data.tar.gz: 6d5853b6e7db868f7caa5203575d09fef1d1fde2a9823ee32e09e6dbf82f9f05
5
5
  SHA512:
6
- metadata.gz: c397a6cd994a8bd4af419a91433db814da8520e7c627e697dfac5ae7225214c4751eee22fa5260017d2e3cda335a8ef5014e4be6310aa2bd0919553ee0cbc2c3
7
- data.tar.gz: b566747ad2b0fbda18e9d572ff031ff1c0f3f0142134d9af38e270ba41c94439ff3aca94fdbf5e8ae990aca706630a5a4bb7716b4d320ae03184d50e90042543
6
+ metadata.gz: 7fbd87765283a0c53e058c50d8a94318b0ddd79b4abb43c14fcf4782b1041c6b984bb4740e927f5d4fb8a9451718a3676afa42dfb4d1ffb686cb20afb981cee2
7
+ data.tar.gz: 60fc9116964d2035b5640201eedbfa4ffefd766c517f89fb0a1aab03e2178365bfc53856f220706631638be6f6b09e9e345fc881a82daf869c8774c694ca1a59
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.3 (2026-07-25)
4
+
5
+ - The widget's dynamic messages are now announced to screen readers: the
6
+ validation/save error paragraph is a `role="alert"` region created before
7
+ any text lands in it, and the post-submit thanks note is an
8
+ `aria-live="polite"` status region inserted empty and then filled, so the
9
+ announcement actually fires.
10
+ - The page behind the open widget dialog no longer scrolls: opening the
11
+ dialog locks `<html>` overflow and closing it restores the previous value.
12
+ Because the lock lives on `<html>` (which survives Turbo body swaps), the
13
+ per-visit render step also releases a stale lock whenever the overlay is
14
+ no longer in the DOM.
15
+
16
+ ## 0.5.2 (2026-07-25)
17
+
18
+ - **Fixed: the widget went dead after Turbo Drive navigations under a
19
+ nonce-based CSP.** The widget code used to be inlined as a nonce'd
20
+ `<script>` block. Turbo Drive soft visits swap the `<body>` and re-execute
21
+ body scripts against the *original* page's CSP header, so the freshly
22
+ minted inline nonce was refused and the widget never booted — until the
23
+ user hard-reloaded. The code is now served by the engine as a same-origin,
24
+ content-fingerprinted script (`<mount_path>/widget.js?v=<md5>`), which
25
+ `script-src 'self'` covers on every visit, first or soft. The fingerprinted
26
+ URL is immutable (new code means a new URL) and is cached publicly for a
27
+ year; any other `?v` only ETag-revalidates. The `nonce` attribute is still
28
+ stamped on the tag for hosts whose `script-src` has no `'self'`. The config
29
+ `type="application/json"` block is unchanged and stays inline — it is data,
30
+ not code, and needs no nonce.
31
+
3
32
  ## 0.5.1 (2026-07-25)
4
33
 
5
34
  - The widget dialog is now full-screen on mobile (≤ 480px): edge-to-edge,
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ideasbugs
4
+ # Serves the widget JavaScript as a plain same-origin script. Same-origin
5
+ # matters: under a `script-src 'self'` (or nonce-based) CSP, an external
6
+ # script from the app's own host is always allowed — including when Turbo
7
+ # Drive swaps the <body> and re-runs body scripts under the *original*
8
+ # page's CSP header, where a freshly minted inline nonce would be refused.
9
+ class WidgetsController < ApplicationController
10
+ # The script is static and carries no user data; without this, Rails'
11
+ # cross-origin JavaScript guard refuses to serve it to a plain
12
+ # <script src> request.
13
+ skip_forgery_protection
14
+
15
+ # The script URL carries a content fingerprint (?v=<md5>), so stale code
16
+ # is structurally impossible: new code means a new URL. The canonical
17
+ # fingerprinted URL is immutable and gets long-lived caching; anything
18
+ # else only ETag-revalidates.
19
+ def show
20
+ expires_in 1.year, public: true if params[:v] == Widget.fingerprint
21
+ return unless stale?(etag: [Ideasbugs::VERSION, Widget.fingerprint])
22
+
23
+ render plain: Widget.javascript, content_type: 'text/javascript'
24
+ end
25
+ end
26
+ end
data/config/routes.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Ideasbugs::Engine.routes.draw do
4
+ # The widget code, served same-origin (see WidgetsController for why).
5
+ get 'widget.js', to: 'widgets#show', as: :widget
6
+
4
7
  # create is the public widget endpoint; the rest is the triage dashboard.
5
8
  resources :feedbacks, only: %i[create index show update destroy] do
6
9
  # Screenshots stream through the dashboard's own gate, never via public
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ideasbugs
4
- VERSION = '0.5.1'
4
+ VERSION = '0.5.3'
5
5
  end
@@ -20,6 +20,20 @@
20
20
  var Z = 2147483000;
21
21
  var overlay = null;
22
22
  var lastFocused = null;
23
+ var savedOverflow = null;
24
+
25
+ // The lock lives on <html>, which survives Turbo body swaps — so a swap can
26
+ // drop the overlay without closeForm() running; render() releases it then.
27
+ function lockScroll() {
28
+ savedOverflow = document.documentElement.style.overflow;
29
+ document.documentElement.style.overflow = "hidden";
30
+ }
31
+
32
+ function unlockScroll() {
33
+ if (savedOverflow === null) return;
34
+ document.documentElement.style.overflow = savedOverflow;
35
+ savedOverflow = null;
36
+ }
23
37
 
24
38
  function ready(fn) {
25
39
  if (document.readyState === "loading") {
@@ -55,6 +69,10 @@
55
69
  // Re-read on each visit: the config block is data (not an executed
56
70
  // script), so it reflects the page Turbo just rendered.
57
71
  config = readConfig() || config;
72
+ if (overlay && !document.body.contains(overlay)) {
73
+ overlay = null;
74
+ unlockScroll();
75
+ }
58
76
  injectStyles();
59
77
  if (config.showButton !== false) buildButton();
60
78
  }
@@ -108,6 +126,7 @@
108
126
  dialog.appendChild(form());
109
127
  overlay.appendChild(dialog);
110
128
  document.body.appendChild(overlay);
129
+ lockScroll();
111
130
 
112
131
  // Keep Tab (and Shift+Tab) cycling inside the dialog while it is open.
113
132
  overlay.addEventListener("keydown", trapFocus);
@@ -120,6 +139,7 @@
120
139
  if (!overlay) return;
121
140
  overlay.remove();
122
141
  overlay = null;
142
+ unlockScroll();
123
143
  if (lastFocused && lastFocused.focus) lastFocused.focus();
124
144
  }
125
145
 
@@ -172,6 +192,9 @@
172
192
 
173
193
  var error = document.createElement("p");
174
194
  error.className = "idb-error";
195
+ // The alert region exists (empty) before any text lands in it, so
196
+ // screen readers announce the message showError() later drops in.
197
+ error.setAttribute("role", "alert");
175
198
  error.hidden = true;
176
199
  form.appendChild(error);
177
200
 
@@ -331,8 +354,14 @@
331
354
  dialog.textContent = "";
332
355
  var note = document.createElement("p");
333
356
  note.className = "idb-thanks";
334
- note.textContent = config.labels.thanks;
357
+ // Success is a status, not an alert. Insert the region empty and fill it
358
+ // afterwards: screen readers announce text landing in an existing polite
359
+ // region, but may skip a node that arrives already carrying its text.
360
+ note.setAttribute("aria-live", "polite");
335
361
  dialog.appendChild(note);
362
+ setTimeout(function () {
363
+ note.textContent = config.labels.thanks;
364
+ }, 0);
336
365
  setTimeout(closeForm, 1600);
337
366
  }
338
367
 
@@ -1,15 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'digest'
4
5
 
5
6
  module Ideasbugs
6
7
  # Serves the self-contained browser widget. The JavaScript is plain ES (no
7
8
  # framework, no build step) and styles itself inline, so it drops into any
8
- # Rails app regardless of its CSS or JS setup. It is inlined into the page
9
- # rather than served as a separate asset to avoid any dependency on the
10
- # host's asset pipeline — and it lives under lib/ (not app/assets/) so a
11
- # host that *does* run a pipeline never ingests it either: nothing lands in
12
- # the host's asset namespace or precompiled output.
9
+ # Rails app regardless of its CSS or JS setup. It is served by the engine's
10
+ # own controller rather than through the host's asset pipeline, so there is
11
+ # no dependency on it — and it lives under lib/ (not app/assets/) so a host
12
+ # that *does* run a pipeline never ingests it either: nothing lands in the
13
+ # host's asset namespace or precompiled output.
13
14
  module Widget
14
15
  SOURCE = File.expand_path('widget.js', __dir__)
15
16
 
@@ -22,6 +23,13 @@ module Ideasbugs
22
23
  @javascript ||= File.read(SOURCE)
23
24
  end
24
25
 
26
+ # Content fingerprint for the cache-busting script URL: a changed file
27
+ # is a changed URL, so no browser can ever run stale widget code —
28
+ # Safari has been caught ignoring must-revalidate on same-URL scripts.
29
+ def fingerprint
30
+ @fingerprint ||= Digest::MD5.hexdigest(javascript)
31
+ end
32
+
25
33
  # The two <script> tags the helper renders.
26
34
  #
27
35
  # The config rides in a `type="application/json"` block: it is *data*,
@@ -29,8 +37,12 @@ module Ideasbugs
29
37
  # re-run it on a soft visit — which means it needs no CSP nonce and the
30
38
  # widget can re-read the *current* page's config on every `turbo:load`.
31
39
  #
32
- # `nonce:` stamps only the widget script (the code), so it runs under a
33
- # nonce-based Content-Security-Policy; pass nil when the app has no nonce.
40
+ # The code is a same-origin `src` script served by the engine NOT
41
+ # inlined. Under a nonce-based CSP, Turbo Drive body swaps re-run body
42
+ # scripts against the *original* page's CSP header, so a fresh inline
43
+ # nonce gets refused; a same-origin src is covered by `'self'` on every
44
+ # visit. `nonce:` is still stamped for hosts whose script-src has no
45
+ # 'self'; pass nil when the app has no nonce.
34
46
  def snippet(endpoint:, locale:, nonce: nil)
35
47
  config = {
36
48
  endpoint: endpoint,
@@ -46,9 +58,10 @@ module Ideasbugs
46
58
  # Escape "</" so a value can't close the <script> block early.
47
59
  json = config.to_json.gsub('</', '<\/')
48
60
  nonce_attr = nonce ? %( nonce="#{nonce}") : ''
61
+ src = "#{Ideasbugs.config.mount_path.chomp('/')}/widget.js?v=#{fingerprint}"
49
62
 
50
63
  %(<script type="application/json" data-ideasbugs-config>#{json}</script>) +
51
- %(<script data-ideasbugs-widget#{nonce_attr}>#{javascript}</script>)
64
+ %(<script src="#{src}" defer#{nonce_attr} data-ideasbugs-widget></script>)
52
65
  end
53
66
 
54
67
  private
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ideasbugs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -41,6 +41,7 @@ files:
41
41
  - app/controllers/ideasbugs/application_controller.rb
42
42
  - app/controllers/ideasbugs/feedbacks_controller.rb
43
43
  - app/controllers/ideasbugs/screenshots_controller.rb
44
+ - app/controllers/ideasbugs/widgets_controller.rb
44
45
  - app/helpers/ideasbugs/widget_helper.rb
45
46
  - app/models/ideasbugs/application_record.rb
46
47
  - app/models/ideasbugs/feedback.rb