i18n_proofreading 0.9.7 → 0.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: 71c3ef13d9eb7688953deaa41e98490084aef787abe305c4cac15bcad61cfe6d
4
- data.tar.gz: 7c8d967ece7913135833ca2848e02851a3f54487e380ec6add21002373fca9bc
3
+ metadata.gz: 0d4eb6649e4d744fc7a9e2374bf649a7968110d39d0a65b9c8fcb179776f4699
4
+ data.tar.gz: ea706e9e99fd29a0b5acc78d5c43bd5f41858d48311d1c6eb33b8821e4ba9522
5
5
  SHA512:
6
- metadata.gz: 140f6f111eb68a553b1bc394b76e198e855dd55d742dcb77008282610634bc95135a6a4a41406c029d3d58a0fd0c538cb8ff2370cd6d478c5b9e8567342d2063
7
- data.tar.gz: 999d1079f6e426fe8b92a9ffc37f3779621992ff40cd28c3f36b76999581506bbfaa89ec613b5792ef26ebb76852d1c1361280dd7411a232f6245343ec1e3713
6
+ metadata.gz: 47a2414c8c08b22c761034de75a39600c3f86bf3af81afe3782f68de5e27e3cbb8dd5cc933399b6232c796b4fe7bd8bcf119417df3536990150a5fd8ce713b2e
7
+ data.tar.gz: 9c9d0a69aad3f89f87df5a88ea38d7d0141af267acaaab6f94c87027cb444a82b3a458247e278319127baad1a05ddfc5673dcd1ce87ad679d9036142202762f8
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.10.0]
6
+
7
+ - The full-screen mobile suggestion dialog now survives the on-screen keyboard.
8
+ On phones the panel is a `100dvh` fixed element, and iOS Safari opens the
9
+ keyboard *over* it without resizing — hiding the Save/Cancel row behind the
10
+ keyboard. While the dialog is open (and only when the `max-width:480px`
11
+ full-screen media query matches), the widget uses the `visualViewport` API to
12
+ pin the panel's `height`/`top` to the actually-visible area, so its action row
13
+ stays just above the keyboard as it slides in. Listeners are removed and the
14
+ inline styles cleared on close; desktop is untouched, and browsers without
15
+ `visualViewport` keep the plain CSS behaviour.
16
+
5
17
  ## [0.9.7]
6
18
 
7
19
  - The widget's injected stylesheet now refreshes when its content changes
data/README.md CHANGED
@@ -5,56 +5,15 @@
5
5
  [![CI](https://github.com/yshmarov/i18n_proofreading/actions/workflows/ci.yml/badge.svg)](https://github.com/yshmarov/i18n_proofreading/actions/workflows/ci.yml)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](MIT-LICENSE)
7
7
 
8
- In-context translation proofreading for Rails.
8
+ **In-context translation proofreading for Rails.** Your reviewer clicks any
9
+ string in the running app and suggests a better wording. You get the i18n key,
10
+ the old text, and the proposal — without anyone opening a YAML file.
9
11
 
10
- `i18n_proofreading` renders every translated string alongside its i18n key in the
11
- environments you choose, lets a reviewer click any string in the running app and
12
- suggest a better wording, and stores those suggestions for a developer to apply.
13
- It is meant for development and staging, never production.
12
+ ![Click any string in your running app and suggest a better wording](docs/demo.gif)
14
13
 
15
- ![Click any string in your running app and suggest a better wording](i18n-proofreading-demo.gif)
14
+ <sub>[Watch as MP4](https://github.com/yshmarov/i18n_proofreading/raw/main/docs/demo-640.mp4) (sharper, 3 MB)</sub>
16
15
 
17
- <video src="https://github.com/yshmarov/i18n_proofreading/raw/main/i18n-proofreading-demo-640-high.mp4" controls muted playsinline width="640">
18
- Your browser can't play this video —
19
- <a href="https://github.com/yshmarov/i18n_proofreading/raw/main/i18n-proofreading-demo-640-high.mp4">download it here</a>.
20
- </video>
21
-
22
- - **Zero UI dependencies.** The widget is plain JavaScript and styles itself. No
23
- Tailwind, no daisyUI, no Stimulus, no importmap, no build step.
24
- - **Zero layout changes.** The widget is injected into HTML responses
25
- automatically (opt out and place it yourself if you prefer).
26
- - **Trigger it your way.** Use the built-in floating pill, or hide it and switch
27
- suggest mode on from your own link (a nav item, a menu, anywhere).
28
- - **Pluggable gating and attribution.** You decide which environments and which
29
- users see the tool, and how a suggestion is attributed.
30
-
31
- ## How it works
32
-
33
- 1. In an enabled environment, the I18n backend appends a hidden `⟦some.key⟧`
34
- marker to each translated string. Markers are only emitted while a reviewer
35
- has the tool switched on (a cookie), so pages are clean by default.
36
- 2. The browser widget strips every marker out of the DOM on load and remembers
37
- which key produced each piece of text.
38
- 3. Clicking a string opens a popover showing the current text, any pending
39
- suggestions, and a field to propose a new wording.
40
- 4. Suggestions are `POST`ed to the mounted engine and stored in the
41
- `i18n_proofreading_suggestions` table for you to review and apply.
42
-
43
- ## Turbo
44
-
45
- Works with Turbo Drive out of the box. Turbo replaces `<body>` on every visit,
46
- which would take the pill and the active-mode highlighting with it, so the
47
- widget registers its document-level listeners once and re-renders on
48
- `turbo:load`. The pill survives navigation without a full reload.
49
-
50
- ## Requirements
51
-
52
- - Ruby >= 3.2
53
- - Rails >= 7.1
54
-
55
- ## Installation
56
-
57
- Add the gem:
16
+ ## Install
58
17
 
59
18
  ```ruby
60
19
  # Gemfile
@@ -67,75 +26,109 @@ bin/rails generate i18n_proofreading:install
67
26
  bin/rails db:migrate
68
27
  ```
69
28
 
70
- The generator:
71
-
72
- - writes `config/initializers/i18n_proofreading.rb`,
73
- - creates the `i18n_proofreading_suggestions` migration,
74
- - mounts the engine in `config/routes.rb`:
75
-
76
- ```ruby
77
- mount I18nProofreading::Engine => "/i18n_proofreading"
78
- ```
29
+ Boot the app in development and look for the **"Suggest edits"** pill in the
30
+ bottom-left. Click it, then click any text. `Esc` exits.
31
+
32
+ No layout change needed — the widget injects itself into HTML responses.
33
+
34
+ > [!IMPORTANT]
35
+ > Development and staging only by design. `enabled_environments` defaults to
36
+ > `%w[development staging]`, and the review dashboard defaults to development
37
+ > only. This is not a production tool.
38
+
39
+ Ruby >= 3.2 · Rails >= 7.1 · CSRF token comes from `csrf_meta_tags`, already in
40
+ a standard Rails layout.
41
+
42
+ ## What you get
43
+
44
+ | | |
45
+ | -------------- | ------------------------------------------------------------------- |
46
+ | **Highlight** | Every translated string outlined in the live app, mapped to its key |
47
+ | **Suggest** | Click a string → current text, your proposal, an optional comment |
48
+ | **Review** | Built-in board: pending / applied / rejected, filtered by locale |
49
+ | **Storage** | `i18n_proofreading_suggestions` — ordinary Active Record rows |
50
+ | **Deps** | None. Plain JS — no Tailwind, no Stimulus, no importmap, no build step |
51
+ | **Layout** | Auto-injected. Opt out and place the tag yourself if you prefer |
52
+ | **Auth** | Lambdas over the raw request — Devise, Rails 8 auth, feature flags |
53
+ | **i18n** | The tool's own UI ships in 26 languages, RTL mirrored |
54
+ | **Theme** | Follows system light/dark |
55
+ | **Turbo/CSP** | Turbo Drive and strict nonce-based CSP (incl. `strict-dynamic`) |
56
+
57
+ ## The flow
58
+
59
+ | 1. Turn it on — every translated string is outlined |
60
+ | --- |
61
+ | ![Proofreading mode: every translated string outlined in place](docs/screenshots/01-highlight.png) |
62
+ | Markers are only emitted while a reviewer has the tool switched on, so pages are clean by default. |
63
+ | **2. Click any string and propose a wording** |
64
+ | ![The suggestion popover showing the i18n key, current text, proposal and comment](docs/screenshots/02-suggest.png) |
65
+ | The popover shows the i18n key, the current text, any pending suggestions, and a comment field. |
66
+ | **3. Triage what came in** |
67
+ | ![The review board: pending, applied and rejected tabs with a locale filter](docs/screenshots/03-review-board.png) |
68
+ | Read-only by design — the gem never writes to your locale files. You make the edit. |
79
69
 
80
- Boot the app in development and look for the **“Suggest edits”** pill in the
81
- bottom-left corner. Click it to turn on suggest mode, then click any text to
82
- propose a fix. Press `Esc` (or the pill) to exit.
83
-
84
- > The widget reads the CSRF token from `<meta name="csrf-token">`, which
85
- > `csrf_meta_tags` in your layout already provides in a standard Rails app.
86
-
87
- ## Configuration
70
+ ## How it works
88
71
 
89
- Everything is optional; the defaults work out of the box in development.
72
+ 1. In an enabled environment, the I18n backend appends a hidden `⟦some.key⟧`
73
+ marker to each translated string — only while a reviewer has the tool on
74
+ (a cookie), so pages are clean by default.
75
+ 2. The widget strips every marker out of the DOM on load and remembers which
76
+ key produced each piece of text.
77
+ 3. Clicking a string opens a popover: current text, pending suggestions, and a
78
+ field to propose new wording.
79
+ 4. Suggestions `POST` to the mounted engine and land in
80
+ `i18n_proofreading_suggestions`.
81
+
82
+ ## Configure
83
+
84
+ Everything is optional — the defaults work out of the box in development. In
85
+ `config/initializers/i18n_proofreading.rb`:
86
+
87
+ | Option | Default | What it does |
88
+ | --- | --- | --- |
89
+ | `enabled_environments` | `%w[development staging]` | Environments the tool is active in |
90
+ | `enabled` | everyone | Extra per-request gate. `false` hides the tool |
91
+ | `authorize_admin` | development only | **Who can open the review board.** Independent of the gates above |
92
+ | `current_user` | `nil` | Attribute a suggestion to a user. Receives the request |
93
+ | `author_label` | the user's `email` | Label shown for the author |
94
+ | `available_locales` | `I18n.available_locales` | Which locales a suggestion may target |
95
+ | `auto_inject` | `true` | Inject the widget into HTML responses |
96
+ | `show_pill` | `true` | The floating "Suggest edits" pill |
97
+ | `pill_label` | `nil` | Fixed pill text. `nil` uses the localized default |
98
+ | `toggle_param` | `"i18n_proofreading"` | Query param that toggles suggest mode |
99
+ | `mount_path` | `"/i18n_proofreading"` | Keep in sync with `mount` in `routes.rb` |
100
+ | `on_submit` | no-op | Runs inline after each save — Slack, email, tickets |
101
+ | `rate_limit` | `{ to: 30, within: 1.minute }` | Per-IP throttle (Rails 7.2+). `nil` disables |
102
+
103
+ Gates receive the **raw request**, so they work with any auth:
90
104
 
91
105
  ```ruby
92
- # config/initializers/i18n_proofreading.rb
93
- I18nProofreading.configure do |config|
94
- # Environments the tool is active in.
95
- config.enabled_environments = %w[development staging]
96
-
97
- # Extra per-request gate. Return false to hide the tool. Receives the request.
98
- config.enabled = ->(request) { true }
99
-
100
- # Who may open the triage dashboard. Independent of the gates above; defaults
101
- # to development only. Wire it to your own admin check to open it elsewhere.
102
- config.authorize_admin = ->(request) { Rails.env.development? }
103
-
104
- # Attribute a suggestion to a user (optional). Return an object responding to
105
- # #id, or nil. Receives the request.
106
- config.current_user = ->(request) { nil }
107
-
108
- # Label shown for the author in the "already suggested" list.
109
- config.author_label = ->(user) { user.try(:email) }
110
-
111
- # Inject the widget automatically. Set false to place it yourself.
112
- config.auto_inject = true
113
-
114
- # Show the floating "Suggest edits" pill. Set false to drive suggest mode from
115
- # your own link instead (see below).
116
- config.show_pill = true
106
+ # Only signed-in staff
107
+ config.enabled = ->(request) { request.env["warden"]&.user&.staff? }
117
108
 
118
- # Query parameter that toggles suggest mode.
119
- config.toggle_param = "i18n_proofreading"
109
+ # Behind a feature flag
110
+ config.enabled = ->(request) { Flipper.enabled?(:i18n_proofreading) }
120
111
 
121
- # Keep in sync with the `mount` in config/routes.rb.
122
- config.mount_path = "/i18n_proofreading"
112
+ # Devise / Warden
113
+ config.current_user = ->(request) { request.env["warden"]&.user }
114
+ config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
123
115
 
124
- # Per-IP throttle on the submission endpoint, passed to Rails' built-in rate
125
- # limiter (Rails 7.2+; ignored on 7.1). Set nil to disable.
126
- config.rate_limit = { to: 30, within: 60 }
116
+ # Rails 8 built-in auth (bin/rails generate authentication)
117
+ config.current_user = lambda do |request|
118
+ token = request.cookies["session_token"]
119
+ Session.find_signed(token)&.user if token
127
120
  end
128
- ```
129
121
 
130
- ### Toggling suggest mode from your own link
122
+ # Ping me when a suggestion lands
123
+ config.on_submit = ->(s) { SuggestionMailer.with(suggestion: s).created.deliver_later }
124
+ ```
131
125
 
132
- Prefer a menu item over the floating pill? You can drive suggest mode from any
133
- link in your own UI — a nav item, a sidebar entry, a footer — and optionally hide
134
- the pill (the two can also coexist):
126
+ <details>
127
+ <summary><b>Toggling suggest mode from your own link</b></summary>
135
128
 
136
- ```ruby
137
- config.show_pill = false # optional
138
- ```
129
+ Prefer a menu item over the floating pill? Drive suggest mode from any link —
130
+ and optionally hide the pill with `config.show_pill = false` (the two can also
131
+ coexist).
139
132
 
140
133
  A one-way "turn it on" link is just the toggle parameter:
141
134
 
@@ -143,10 +136,9 @@ A one-way "turn it on" link is just the toggle parameter:
143
136
  <%= link_to t("i18n_proofreading.start"), "?i18n_proofreading=true" %>
144
137
  ```
145
138
 
146
- For a single control that flips both ways, read the current state from the
147
- `i18n_proofreading` cookie and point at the opposite state. The gem ships `start`
148
- and `stop` labels under the `i18n_proofreading.*` scope in every bundled language,
149
- so a localized toggle needs no keys of your own:
139
+ For a single control that flips both ways, read the cookie and point at the
140
+ opposite state. The gem ships `start` and `stop` labels in every bundled
141
+ language:
150
142
 
151
143
  ```erb
152
144
  <% if I18nProofreading.available?(request) %>
@@ -156,72 +148,90 @@ so a localized toggle needs no keys of your own:
156
148
  <% end %>
157
149
  ```
158
150
 
159
- Good to know:
151
+ Three things worth knowing:
160
152
 
161
- - `?i18n_proofreading=true` turns suggest mode on, `false` turns it off. The state is
162
- stored in the `i18n_proofreading` cookie, and the middleware then redirects to the
163
- same URL without the parameter so it never sticks in the address bar and the
164
- cookie stays the single source of truth. `Esc` (or the pill) also exits.
153
+ - `?i18n_proofreading=true` turns it on, `false` off. State lives in the
154
+ `i18n_proofreading` cookie; the middleware then redirects to the same URL
155
+ without the parameter, so it never sticks in the address bar.
165
156
  - These links keep working **while suggest mode is active**. The widget freezes
166
- ordinary navigation during proofreading (so a stray click can't leave the page
167
- mid-edit), but any link carrying the toggle parameter is exempt — so a "Disable"
168
- item in your nav always gets you out.
169
- - The `i18n_proofreading.*` keys are **safe to run through `I18n.t`** that scope is
170
- exempt from key-marking, so the tool never flags its own controls as editable.
171
- Use the bundled `i18n_proofreading.start` / `i18n_proofreading.stop` labels (or your own
172
- keys); either way, no plain-literal workaround is needed.
157
+ ordinary navigation during proofreading so a stray click can't leave the page
158
+ mid-edit but any link carrying the toggle parameter is exempt.
159
+ - The `i18n_proofreading.*` scope is **exempt from key-marking**, so the tool
160
+ never flags its own controls as editable. No plain-literal workaround needed.
173
161
 
174
- ### Gating examples
162
+ </details>
175
163
 
176
- ```ruby
177
- # Only signed-in staff (however your app resolves that):
178
- config.enabled = ->(request) { request.env["warden"]&.user&.staff? }
164
+ <details>
165
+ <summary><b>Placing the widget yourself</b></summary>
179
166
 
180
- # Behind a feature flag:
181
- config.enabled = ->(request) { Flipper.enabled?(:i18n_proofreading) }
167
+ Set `config.auto_inject = false` and drop the helper at the end of your layout:
168
+
169
+ ```erb
170
+ <%= i18n_proofreading_tag %>
182
171
  ```
183
172
 
184
- ### Resolving the current user
173
+ It renders nothing unless the tool is available for the request.
185
174
 
186
- `current_user` (optional — it attributes suggestions) and the gates all
187
- receive the raw request, so they work with whatever auth you have:
175
+ </details>
188
176
 
189
- ```ruby
190
- # Devise / Warden:
191
- config.current_user = ->(request) { request.env["warden"]&.user }
177
+ ## Reviewing suggestions
192
178
 
193
- # Rails 8 built-in auth (bin/rails generate authentication):
194
- config.current_user = lambda do |request|
195
- token = request.cookies["session_token"]
196
- Session.find_signed(token)&.user if token
179
+ The engine root (default `/i18n_proofreading`) is a **read-only** board:
180
+ pending / applied / rejected tabs with counts, a per-locale filter, and each
181
+ suggestion shown as current-vs-proposed with its comment and author.
182
+
183
+ Read-only is deliberate. The gem never writes to your locale files, so it
184
+ doesn't pretend to — you review here, then edit your own
185
+ `config/locales/*.yml`.
186
+
187
+ Its gate, `authorize_admin`, is independent of `enabled` /
188
+ `enabled_environments`: the widget can be dev/staging-only while a maintainer
189
+ triages from production.
190
+
191
+ Suggestions are also ordinary records:
192
+
193
+ ```ruby
194
+ I18nProofreading::Suggestion.where(status: "pending").newest_first.each do |s|
195
+ puts "#{s.locale} #{s.translation_key}: #{s.old_value.inspect} -> #{s.proposed_value.inspect}"
197
196
  end
198
197
  ```
199
198
 
200
- ### Placing the widget yourself
199
+ Each row stores `translation_key`, `locale`, `old_value`, `proposed_value`,
200
+ `comment`, `page_url`, `status`, and optional `author_id` / `author_label`.
201
201
 
202
- Set `config.auto_inject = false` and drop the helper at the end of your layout:
202
+ <details>
203
+ <summary><b>Statuses</b></summary>
203
204
 
204
- ```erb
205
- <%= i18n_proofreading_tag %>
205
+ Every suggestion is `pending`, `applied`, or `rejected`
206
+ (`I18nProofreading::Suggestion::STATUSES`), backed by an Active Record enum.
207
+ New suggestions start `pending`; once you apply a wording or decide against it,
208
+ set the status so the popover stops offering it as pending context:
209
+
210
+ ```ruby
211
+ suggestion.status_applied! # bang setter
212
+ suggestion.status_applied? # => true
213
+ I18nProofreading::Suggestion.status_pending.newest_first # scope per status
206
214
  ```
207
215
 
208
- It renders nothing unless the tool is available for the request.
216
+ </details>
217
+
218
+ ## Localization
209
219
 
210
- ### Localizing the widget UI
220
+ The pill and popover speak the app's language — every string resolves through
221
+ Rails I18n under `i18n_proofreading.*` and follows the language the page was
222
+ rendered in (`<html lang>`, falling back to `I18n.locale`). 26 languages ship,
223
+ missing keys fall back to English, and RTL locales render the popover
224
+ right-to-left.
211
225
 
212
- The pill and the suggestion popover speak the app's language: every string
213
- resolves through Rails I18n under the `i18n_proofreading.*` scope and follows the
214
- language the page was rendered in (its `<html lang>`, falling back to
215
- `I18n.locale`). Translations ship out of the box for English plus 25 more
216
- languages — Arabic, Bengali, Bulgarian, Chinese (Simplified), Croatian, Dutch,
226
+ <details>
227
+ <summary><b>Bundled languages, and overriding the copy</b></summary>
228
+
229
+ Arabic, Bengali, Bulgarian, Chinese (Simplified), Croatian, Dutch, English,
217
230
  French, German, Greek, Hindi, Indonesian, Italian, Japanese, Korean,
218
231
  Luxembourgish, Polish, Portuguese, Romanian, Russian, Spanish, Thai, Turkish,
219
- Ukrainian, Urdu and Vietnamese — so the tool is already localized for most apps.
220
- RTL locales (Arabic, Urdu, …) render the popover right-to-left automatically.
232
+ Ukrainian, Urdu, Vietnamese.
221
233
 
222
- Any key you haven't translated falls back to English, so a partially translated
223
- locale never leaves a control blank. To add a language, or reword the bundled
224
- copy, define the keys in your own locale files (yours win over the gem's):
234
+ Define the keys in your own locale files yours win over the gem's:
225
235
 
226
236
  ```yaml
227
237
  # config/locales/fr.yml
@@ -241,101 +251,44 @@ fr:
241
251
  error_save: "Impossible d'enregistrer la suggestion."
242
252
  ```
243
253
 
244
- `config.pill_label` still overrides the pill text with a fixed string if you set
245
- it; leave it `nil` (the default) to use the localized `i18n_proofreading.pill` key.
246
-
247
- ### Light / dark / system appearance
248
-
249
- The widget follows the reviewer's operating-system appearance via
250
- `prefers-color-scheme` — no configuration needed. The pill and popover render on a
251
- dark surface when the system is in dark mode and a light surface otherwise; the
252
- blue accent stays the same in both.
253
-
254
- ## Reviewing suggestions
255
-
256
- ### Review dashboard
257
-
258
- Mounted at your `mount_path` (default `/i18n_proofreading`), the engine root is a
259
- built-in **read-only** review board: pending / applied / rejected tabs with
260
- counts, a per-locale filter, and each suggestion shown as current-vs-proposed
261
- with its comment and author. It's plain server-rendered HTML with its own
262
- styling — no host assets or JS framework needed.
263
-
264
- It is deliberately read-only. The gem never writes to your locale files, so it
265
- doesn't pretend to: you review the suggestions here, then make the edits in your
266
- own `config/locales/*.yml`. (A suggestion's `status` still exists on the model
267
- for your own tracking — set it from the console — and is the groundwork for a
268
- future "apply to locale file" feature.)
269
-
270
- It has its own gate, `config.authorize_admin`, **defaulting to development
271
- only** — so a fresh install never exposes it in production. Point it at your own
272
- admin check to open it elsewhere:
273
-
274
- ```ruby
275
- config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
276
- ```
277
-
278
- The gate is independent of `enabled` / `enabled_environments`: the widget can be
279
- dev/staging-only while a maintainer still triages from production.
254
+ `config.pill_label` overrides the pill text with a fixed string; leave it `nil`
255
+ to use the localized `i18n_proofreading.pill` key.
280
256
 
281
- ### From the console
282
-
283
- Suggestions are also ordinary records:
284
-
285
- ```ruby
286
- I18nProofreading::Suggestion.where(status: "pending").newest_first.each do |s|
287
- puts "#{s.locale} #{s.translation_key}: #{s.old_value.inspect} -> #{s.proposed_value.inspect}"
288
- end
289
- ```
290
-
291
- Each row stores `translation_key`, `locale`, `old_value`, `proposed_value`,
292
- `comment`, `page_url`, `status`, and optional `author_id` / `author_label`.
293
-
294
- Every suggestion has a `status` — one of `pending`, `applied`, or `rejected`
295
- (`I18nProofreading::Suggestion::STATUSES`), backed by an Active Record enum. New
296
- suggestions start `pending`; once you apply a wording to your locale files or
297
- decide against it, set the status accordingly so the popover stops offering it
298
- as pending context:
299
-
300
- ```ruby
301
- suggestion.status_applied! # bang setter
302
- suggestion.status_applied? # => true
303
- I18nProofreading::Suggestion.status_pending.newest_first # scope per status
304
- ```
305
-
306
- ### Getting notified
307
-
308
- To be pinged when a suggestion comes in, set `on_submit`. It's called with the
309
- saved `Suggestion` right after it's stored — notify Slack, send an email, open a
310
- ticket. It runs inline in the request, so keep it fast or hand off to a job:
311
-
312
- ```ruby
313
- config.on_submit = ->(suggestion) { SuggestionMailer.with(suggestion:).created.deliver_later }
314
- ```
257
+ </details>
315
258
 
316
259
  ## Security
317
260
 
318
- - The tool is gated **on the server** for every marker, endpoint, and injection.
319
- Setting the cookie by hand does nothing outside an enabled environment where
320
- `config.enabled` returns true.
321
- - Format and lookup namespaces (`number.*`, `date.*`, `*_html` formats, etc.) are
322
- never marked, so currency and date formatting are unaffected.
323
- - The injected widget code carries the request's Content-Security-Policy nonce
324
- (the same one `ActionDispatch` emits), so it runs under a nonce-based
325
- `script-src` policy including `strict-dynamic` with no configuration. It is
326
- a no-op when the app sets no CSP nonce. The runtime config is shipped as a
327
- `<script type="application/json">` block (data, not code), so it needs no nonce
328
- and stays correct across Turbo visits.
261
+ - **Gated on the server** for every marker, endpoint, and injection. Setting
262
+ the cookie by hand does nothing outside an enabled environment where
263
+ `enabled` returns true.
264
+ - **Format and lookup namespaces are never marked** (`number.*`, `date.*`,
265
+ `*_html` formats), so currency and date formatting are unaffected.
266
+ - **CSP nonce carried** from `ActionDispatch`, so it runs under a nonce-based
267
+ `script-src` including `strict-dynamic`. Runtime config ships as
268
+ `<script type="application/json">` (data, not code), so it needs no nonce and
269
+ survives Turbo visits.
270
+ - **Rate-limited per IP** on the submission endpoint (30/min by default,
271
+ Rails 7.2+).
329
272
 
330
273
  ## Development
331
274
 
332
275
  ```bash
333
- bin/setup # or: bundle install
334
- bundle exec rspec
276
+ bin/setup
277
+ bundle exec rake test # unit + integration
278
+ bundle exec rake test:system # browser tests (headless Chrome)
279
+ bundle exec rubocop
335
280
  ```
336
281
 
337
- Tests run against a dummy Rails app under `spec/dummy`.
282
+ Tests run against a dummy Rails app in `test/dummy`.
283
+
284
+ ## Also by the same author
285
+
286
+ - [testimonials](https://github.com/yshmarov/testimonials) — testimonials,
287
+ reviews and NPS for Rails.
288
+ - [ideasbugs](https://github.com/yshmarov/ideasbugs) — in-app bug reports and
289
+ feature requests.
290
+ - [SupeRails](https://superails.com) — Rails screencasts.
338
291
 
339
292
  ## License
340
293
 
341
- Released under the [MIT License](MIT-LICENSE).
294
+ MIT.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module I18nProofreading
4
- VERSION = '0.9.7'
4
+ VERSION = '0.10.0'
5
5
  end
@@ -36,6 +36,8 @@
36
36
  var priorNode = null;
37
37
  var openerNode = null;
38
38
  var savedOverflow = null; // pre-lock inline overflow of <html>; null = not locked
39
+ var panelNode = null; // the open dialog's .i18np-panel (for keyboard pinning)
40
+ var viewportHandler = null; // visualViewport listener while open; null = detached
39
41
 
40
42
  function ready(fn) {
41
43
  if (document.readyState === "loading") {
@@ -92,6 +94,7 @@
92
94
  // survives the swap, so release the scroll lock or the new page is frozen.
93
95
  if (overlay && !document.body.contains(overlay)) {
94
96
  overlay = null;
97
+ unwatchViewport();
95
98
  unlockScroll();
96
99
  }
97
100
  injectStyles();
@@ -231,6 +234,7 @@
231
234
  });
232
235
 
233
236
  var panel = el("div", "i18np-panel");
237
+ panelNode = panel;
234
238
  panel.setAttribute("role", "dialog");
235
239
  panel.setAttribute("aria-modal", "true");
236
240
  panel.setAttribute("aria-labelledby", "i18np-title");
@@ -260,12 +264,14 @@
260
264
  overlay.appendChild(panel);
261
265
  document.body.appendChild(overlay);
262
266
  lockScroll();
267
+ watchViewport();
263
268
  proposedInput.focus();
264
269
  loadPrior(key);
265
270
  }
266
271
 
267
272
  function close() {
268
273
  if (overlay) {
274
+ unwatchViewport();
269
275
  overlay.remove();
270
276
  overlay = null;
271
277
  unlockScroll();
@@ -274,6 +280,51 @@
274
280
  }
275
281
  }
276
282
 
283
+ // --- keep the mobile full-screen dialog above the on-screen keyboard ------
284
+ //
285
+ // On phones the panel is a 100dvh fixed box (see the max-width:480px CSS).
286
+ // iOS Safari opens the keyboard *over* that fixed box without resizing it, so
287
+ // the Save/Cancel row ends up hidden behind the keyboard. visualViewport
288
+ // reports the actually-visible area, so while the dialog is open we pin the
289
+ // panel to it — its height and top follow the viewport as the keyboard slides
290
+ // in, keeping the action row just above it. Desktop is untouched: the media
291
+ // query gates every write, and we clear the inline styles whenever it doesn't
292
+ // match (e.g. a phone rotated to a wide viewport). Browsers without
293
+ // visualViewport keep the plain CSS behaviour (no-op).
294
+
295
+ function pinPanelToViewport() {
296
+ if (!panelNode) return;
297
+ var vv = window.visualViewport;
298
+ if (window.matchMedia("(max-width: 480px)").matches) {
299
+ panelNode.style.height = vv.height + "px";
300
+ panelNode.style.top = vv.offsetTop + "px";
301
+ } else {
302
+ panelNode.style.height = "";
303
+ panelNode.style.top = "";
304
+ }
305
+ }
306
+
307
+ function watchViewport() {
308
+ if (!window.visualViewport) return; // older browsers: leave the CSS as-is
309
+ pinPanelToViewport(); // pin once on open, before the keyboard even appears
310
+ viewportHandler = pinPanelToViewport;
311
+ window.visualViewport.addEventListener("resize", viewportHandler);
312
+ window.visualViewport.addEventListener("scroll", viewportHandler);
313
+ }
314
+
315
+ function unwatchViewport() {
316
+ if (viewportHandler && window.visualViewport) {
317
+ window.visualViewport.removeEventListener("resize", viewportHandler);
318
+ window.visualViewport.removeEventListener("scroll", viewportHandler);
319
+ }
320
+ viewportHandler = null;
321
+ if (panelNode) {
322
+ panelNode.style.height = "";
323
+ panelNode.style.top = "";
324
+ }
325
+ panelNode = null;
326
+ }
327
+
277
328
  // Scroll-lock the page behind the modal, remembering the inline value so
278
329
  // close() can put it back exactly as it was.
279
330
  function lockScroll() {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: i18n_proofreading
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.7
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov