i18n_feedback
In-context translation proofreading for Rails.
i18n_feedback renders every translated string alongside its i18n key in the
environments you choose, lets a reviewer click any string in the running app and
suggest a better wording, and stores those suggestions for a developer to apply.
It is meant for development and staging, never production.

- Zero UI dependencies. The widget is plain JavaScript and styles itself. No Tailwind, no daisyUI, no Stimulus, no importmap, no build step.
- Zero layout changes. The widget is injected into HTML responses automatically (opt out and place it yourself if you prefer).
- Trigger it your way. Use the built-in floating pill, or hide it and switch suggest mode on from your own link (a nav item, a menu, anywhere).
- Pluggable gating and attribution. You decide which environments and which users see the tool, and how a suggestion is attributed.
How it works
- In an enabled environment, the I18n backend appends a hidden
⟦some.key⟧marker to each translated string. Markers are only emitted while a reviewer has the tool switched on (a cookie), so pages are clean by default. - The browser widget strips every marker out of the DOM on load and remembers which key produced each piece of text.
- Clicking a string opens a popover showing the current text, any pending suggestions, and a field to propose a new wording.
- Suggestions are
POSTed to the mounted engine and stored in thei18n_feedback_suggestionstable for you to review and apply.
Turbo
Works with Turbo Drive out of the box. Turbo replaces <body> on every visit,
which would take the pill and the active-mode highlighting with it, so the
widget registers its document-level listeners once and re-renders on
turbo:load. The pill survives navigation without a full reload.
Requirements
- Ruby >= 3.2
- Rails >= 7.1
Installation
Add the gem:
# Gemfile
gem "i18n_feedback"
bundle install
bin/rails generate i18n_feedback:install
bin/rails db:migrate
The generator:
-
writes
config/initializers/i18n_feedback.rb, -
creates the
i18n_feedback_suggestionsmigration, -
mounts the engine in
config/routes.rb:mount I18nFeedback::Engine => "/i18n_feedback"
Boot the app in development and look for the “Suggest edits” pill in the
bottom-left corner. Click it to turn on suggest mode, then click any text to
propose a fix. Press Esc (or the pill) to exit.
The widget reads the CSRF token from
<meta name="csrf-token">, whichcsrf_meta_tagsin your layout already provides in a standard Rails app.
Configuration
Everything is optional; the defaults work out of the box in development.
# config/initializers/i18n_feedback.rb
I18nFeedback.configure do |config|
# Environments the tool is active in.
config.enabled_environments = %w[development staging]
# Extra per-request gate. Return false to hide the tool. Receives the request.
config.enabled = ->(request) { true }
# Attribute a suggestion to a user (optional). Return an object responding to
# #id, or nil. Receives the request.
config.current_user = ->(request) { nil }
# Label shown for the author in the "already suggested" list.
config. = ->(user) { user.try(:email) }
# Inject the widget automatically. Set false to place it yourself.
config.auto_inject = true
# Show the floating "Suggest edits" pill. Set false to drive suggest mode from
# your own link instead (see below).
config.show_pill = true
# Query parameter that toggles suggest mode.
config.toggle_param = "i18n_feedback"
# Keep in sync with the `mount` in config/routes.rb.
config.mount_path = "/i18n_feedback"
end
Toggling suggest mode from your own link
Prefer a menu item over the floating pill? You can drive suggest mode from any link in your own UI — a nav item, a sidebar entry, a footer — and optionally hide the pill (the two can also coexist):
config.show_pill = false # optional
A one-way "turn it on" link is just the toggle parameter:
<%= link_to "Proofread translations", "?i18n_feedback=true" %>
For a single control that flips both ways, read the current state from the
i18n_feedback cookie and point at the opposite state:
<% on = cookies[:i18n_feedback].present? %>
<%= link_to (on ? "Disable translations editing" : "Enable translations editing"),
"?i18n_feedback=#{!on}" %>
Good to know:
?i18n_feedback=trueturns suggest mode on,falseturns it off. The state is stored in thei18n_feedbackcookie, and the middleware then redirects to the same URL without the parameter — so it never sticks in the address bar and the cookie stays the single source of truth.Esc(or the pill) also exits.- These links keep working while suggest mode is active. The widget freezes ordinary navigation during proofreading (so a stray click can't leave the page mid-edit), but any link carrying the toggle parameter is exempt — so a "Disable" item in your nav always gets you out.
- Don't run the label through
I18n.t: the tool would then mark its own control as an editable string. Keep the label a plain literal.
Gating examples
# Only signed-in staff (however your app resolves that):
config.enabled = ->(request) { request.env["warden"]&.user&.staff? }
# Behind a feature flag:
config.enabled = ->(request) { Flipper.enabled?(:i18n_feedback) }
Placing the widget yourself
Set config.auto_inject = false and drop the helper at the end of your layout:
<%= i18n_feedback_tag %>
It renders nothing unless the tool is available for the request.
Reviewing suggestions
Suggestions are ordinary records:
I18nFeedback::Suggestion.order(created_at: :desc).each do |s|
puts "#{s.locale} #{s.translation_key}: #{s.old_value.inspect} -> #{s.proposed_value.inspect}"
end
Each row stores translation_key, locale, old_value, proposed_value,
comment, page_url, and optional author_id / author_label.
Security
- The tool is gated on the server for every marker, endpoint, and injection.
Setting the cookie by hand does nothing outside an enabled environment where
config.enabledreturns true. - Format and lookup namespaces (
number.*,date.*,*_htmlformats, etc.) are never marked, so currency and date formatting are unaffected. - The injected widget code carries the request's Content-Security-Policy nonce
(the same one
ActionDispatchemits), so it runs under a nonce-basedscript-srcpolicy — includingstrict-dynamic— with no configuration. It is a no-op when the app sets no CSP nonce. The runtime config is shipped as a<script type="application/json">block (data, not code), so it needs no nonce and stays correct across Turbo visits.
Development
bin/setup # or: bundle install
bundle exec rspec
Tests run against a dummy Rails app under spec/dummy.
License
Released under the MIT License.