i18n_proofreading 0.11.2 → 0.12.1
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 +4 -4
- data/AGENTS.md +1 -0
- data/CHANGELOG.md +45 -0
- data/README.md +3 -11
- data/app/views/i18n_proofreading/suggestions/_suggestion_panel.html.erb +1 -1
- data/app/views/i18n_proofreading/suggestions/index.html.erb +16 -15
- data/config/locales/i18n_proofreading.en.yml +0 -1
- data/lib/i18n_proofreading/dashboard.css +274 -117
- data/lib/i18n_proofreading/seeds.rb +10 -7
- data/lib/i18n_proofreading/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 22fd6bf49d89c237346f9d8a2a5bc10314e0eab0fd8401dcca45dd96278ca62b
|
|
4
|
+
data.tar.gz: ba87b38138192aa1d850203839268109f1fc916c3e12160aa439f703f943336a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9d22947ee3ec8bb759537acbdee15e0d1520427f948499cd1cdc9af43eb4b0cf82ca30f35c562d02c4e0bada7813f8215a6ca071ecdb393563b7fca22581177e
|
|
7
|
+
data.tar.gz: d71f01a6a53c301f5f15453bdcdc8015ffefedcbd442c08ae0638301591496545776f2c98b424d68ad1b7777f5ea7f682b01c5de83bcba5e60e7a7fab0112d74
|
data/AGENTS.md
CHANGED
|
@@ -151,5 +151,6 @@ Conventions this codebase holds to — follow them rather than the first thing t
|
|
|
151
151
|
- **The tool never writes to the host's locale files.** That is why there is deliberately no update or destroy route, and why the dashboard is read-only. Do not add an "apply" action that edits YAML.
|
|
152
152
|
- **The widget is injected by middleware and served same-origin**, which is what keeps it working under a nonce-based CSP with `strict-dynamic` across Turbo body swaps. Do not inline it.
|
|
153
153
|
- **Key marking must degrade to plain strings.** A host that reads translations outside a request, or in an environment where the tool is off, has to get ordinary values back.
|
|
154
|
+
- **`lib/i18n_proofreading/dashboard.css` is half shared.** Everything above the `GEM-SPECIFIC` banner is the design system all five gems in the family ship — the same tokens, the same `.page-head`/`.tabs`/`.filters`/`.card`/`.badge`/`button`/`.status-switch`, the same `.dashboard-shell` + `.record-row` + `.detail-panel` two-pane dashboard — identical in every repo apart from the `ip` prefix. Diff it against a sibling before changing it, and carry the change to the other four. Anything only this gem has goes below the banner. New dashboard markup reuses the shared class names rather than inventing a domain-specific one.
|
|
154
155
|
- Every user-facing change bumps `lib/i18n_proofreading/version.rb` and adds a `CHANGELOG.md` entry (Keep a Changelog format) that says what it costs, not only what it adds.
|
|
155
156
|
- Commit messages are prose that explains the tradeoff — read `git log` before writing one.
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.1
|
|
4
|
+
|
|
5
|
+
- **The demo suggestions now teach the review contract.** Pending explains the
|
|
6
|
+
inbox decision, applied makes clear that a human edited and committed the
|
|
7
|
+
locale file, and rejected demonstrates a proposal that changes meaning. The
|
|
8
|
+
seed is now a compact tutorial for the gem's deliberately read-only role.
|
|
9
|
+
- `i18n_proofreading:seed_demo` updates its existing demo suggestions in place,
|
|
10
|
+
so rerunning it overwrites edits to those gem-owned rows. Host locale files
|
|
11
|
+
and non-demo suggestions remain untouched.
|
|
12
|
+
|
|
13
|
+
## 0.12.0
|
|
14
|
+
|
|
15
|
+
- **One design system across the family.** The stylesheet now opens with a
|
|
16
|
+
shared core — the colour tokens, `.page-head`, `.tabs`, `.filters`, `.card`,
|
|
17
|
+
`.badge`, buttons and form controls, and the `.dashboard-shell` +
|
|
18
|
+
`.record-row` + `.detail-panel` two-pane dashboard — identical in all five
|
|
19
|
+
gems of the family, apart from the `--ip-` prefix. The five had drifted:
|
|
20
|
+
sidebars between 380px and 430px, reading columns between 860px and 1020px,
|
|
21
|
+
two different tab styles and three different button styles. The sidebar is
|
|
22
|
+
now 330–430px everywhere, a reading page 1020px, a dashboard 1280px.
|
|
23
|
+
Everything below the `GEM-SPECIFIC` banner is what only this gem has.
|
|
24
|
+
- **The dashboard markup uses the shared class names.** `.review-shell`,
|
|
25
|
+
`.review-sidebar` and `.review-detail` are `.dashboard-shell`,
|
|
26
|
+
`.dashboard-sidebar` and `.dashboard-detail`; `.suggestion-list`,
|
|
27
|
+
`.suggestion-row`, `.suggestion-main`, `.suggestion-topline`,
|
|
28
|
+
`.suggestion-copy`, `.suggestion-meta` and `.suggestion-time` are
|
|
29
|
+
`.record-*`; `.suggestion-panel` is `.detail-panel`; the page heading is a
|
|
30
|
+
`.page-head` rather than a `header.page`, and the status badge in a row sits
|
|
31
|
+
in `.record-side`. A card is unpadded until you add `.pad`, as in the other
|
|
32
|
+
four gems, and the empty state lost its dashed border. If you styled or
|
|
33
|
+
scripted any of those names in a host app, that is the breaking change — no
|
|
34
|
+
configuration, route or database change is involved.
|
|
35
|
+
- **The narrow-screen rules work again.** The `max-width: 760px` block sat above
|
|
36
|
+
the component rules it means to override, and CSS nesting adds no specificity,
|
|
37
|
+
so the desktop grid won every tie: showing one pane at a time on a phone had
|
|
38
|
+
quietly stopped working. The media query moved to the end of the file.
|
|
39
|
+
- Smaller fixes that came with the shared core: a submit input is styled as a
|
|
40
|
+
button rather than a full-width field, the filter row keeps its search box and
|
|
41
|
+
button on one line, and a `code.key` truncates inside a list row instead of
|
|
42
|
+
wrapping over three lines.
|
|
43
|
+
- **The dashboard subtitle is gone,** and with it the
|
|
44
|
+
`i18n_proofreading.dashboard.subtitle` translation key. The page said what the
|
|
45
|
+
three tabs already say. If you overrode that key in your own locale file, the
|
|
46
|
+
override is now dead weight — nothing reads it.
|
|
47
|
+
|
|
3
48
|
## 0.11.0
|
|
4
49
|
|
|
5
50
|
- **`config.admin_layout` now works on its own.** The dashboard's stylesheet was
|
data/README.md
CHANGED
|
@@ -298,14 +298,6 @@ bundle exec rubocop
|
|
|
298
298
|
|
|
299
299
|
Tests run against a dummy Rails app in `test/dummy`.
|
|
300
300
|
|
|
301
|
-
## Also by the same author
|
|
302
|
-
|
|
303
|
-
- [testimonials](https://github.com/yshmarov/testimonials) — testimonials,
|
|
304
|
-
reviews and NPS for Rails.
|
|
305
|
-
- [ideasbugs](https://github.com/yshmarov/ideasbugs) — in-app bug reports and
|
|
306
|
-
feature requests.
|
|
307
|
-
- [SupeRails](https://superails.com) — Rails screencasts.
|
|
308
|
-
|
|
309
301
|
## One family
|
|
310
302
|
|
|
311
303
|
Five Rails engines built on the same backbone, so adopting a second one is
|
|
@@ -321,9 +313,9 @@ mostly muscle memory:
|
|
|
321
313
|
|
|
322
314
|
They share the install shape (`generate <gem>:install`, mount, one initializer),
|
|
323
315
|
the same host hooks (`base_controller_class` to inherit your admin's controller,
|
|
324
|
-
`admin_layout` for just the shell),
|
|
325
|
-
|
|
326
|
-
`primary_key_type`.
|
|
316
|
+
`admin_layout` for just the shell), one dashboard design system — the same
|
|
317
|
+
two-pane layout, colour tokens and components in all five, scoped so it cannot
|
|
318
|
+
touch your own CSS — and migrations that follow your app's `primary_key_type`.
|
|
327
319
|
|
|
328
320
|
## License
|
|
329
321
|
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
<% content_for :body_class, 'ip-index' %>
|
|
2
2
|
|
|
3
3
|
<%= render layout: 'i18n_proofreading/shared/dashboard' do %>
|
|
4
|
-
<
|
|
4
|
+
<div class="page-head">
|
|
5
5
|
<h1><%= t('i18n_proofreading.dashboard.title', default: 'I18nProofreading') %></h1>
|
|
6
|
-
|
|
7
|
-
</header>
|
|
6
|
+
</div>
|
|
8
7
|
|
|
9
|
-
<div class="
|
|
10
|
-
<aside class="
|
|
8
|
+
<div class="dashboard-shell <%= 'has-selected' if @selected_suggestion %>">
|
|
9
|
+
<aside class="dashboard-sidebar" aria-label="<%= t('i18n_proofreading.dashboard.suggestions', default: 'Suggestions') %>">
|
|
11
10
|
<nav class="tabs">
|
|
12
11
|
<% I18nProofreading::Suggestion::STATUSES.each do |status| %>
|
|
13
12
|
<%= link_to suggestions_path(status: status, locale: @locale),
|
|
@@ -34,30 +33,32 @@
|
|
|
34
33
|
</form>
|
|
35
34
|
<% end %>
|
|
36
35
|
|
|
37
|
-
<div class="
|
|
36
|
+
<div class="record-list">
|
|
38
37
|
<% if @suggestions.any? %>
|
|
39
38
|
<% @suggestions.each do |suggestion| %>
|
|
40
39
|
<%= link_to suggestions_path(status: @status, locale: @locale, page: @page,
|
|
41
40
|
suggestion_id: suggestion.id),
|
|
42
|
-
class: ['
|
|
43
|
-
<span class="
|
|
44
|
-
<span class="
|
|
41
|
+
class: ['record-row', ('active' if @selected_suggestion&.id == suggestion.id)].compact do %>
|
|
42
|
+
<span class="record-main">
|
|
43
|
+
<span class="record-topline">
|
|
45
44
|
<code class="key"><%= suggestion.translation_key %></code>
|
|
46
45
|
<span class="badge locale"><%= suggestion.locale %></span>
|
|
47
|
-
<span class="muted
|
|
46
|
+
<span class="muted record-time" title="<%= suggestion.created_at %>">
|
|
48
47
|
<%= time_ago_in_words(suggestion.created_at) %> <%= t('i18n_proofreading.dashboard.ago', default: 'ago') %>
|
|
49
48
|
</span>
|
|
50
49
|
</span>
|
|
51
|
-
<span class="
|
|
52
|
-
<span class="muted
|
|
50
|
+
<span class="record-copy" dir="auto"><%= truncate(suggestion.proposed_value, length: 110) %></span>
|
|
51
|
+
<span class="muted record-meta">
|
|
53
52
|
<span>#<%= suggestion.id %></span>
|
|
54
53
|
<% if suggestion.author_label.present? %>
|
|
55
54
|
<span><%= suggestion.author_label %></span>
|
|
56
55
|
<% end %>
|
|
57
56
|
</span>
|
|
58
57
|
</span>
|
|
59
|
-
<span class="
|
|
60
|
-
|
|
58
|
+
<span class="record-side">
|
|
59
|
+
<span class="badge status-<%= suggestion.status %>">
|
|
60
|
+
<%= t("i18n_proofreading.statuses.#{suggestion.status}", default: suggestion.status.humanize) %>
|
|
61
|
+
</span>
|
|
61
62
|
</span>
|
|
62
63
|
<% end %>
|
|
63
64
|
<% end %>
|
|
@@ -82,7 +83,7 @@
|
|
|
82
83
|
</nav>
|
|
83
84
|
</aside>
|
|
84
85
|
|
|
85
|
-
<main class="
|
|
86
|
+
<main class="dashboard-detail" aria-label="<%= t('i18n_proofreading.dashboard.current_suggestion', default: 'Current suggestion') %>">
|
|
86
87
|
<% if @selected_suggestion %>
|
|
87
88
|
<p class="mobile-back">
|
|
88
89
|
<%= link_to t('i18n_proofreading.dashboard.back', default: '← All suggestions'),
|
|
@@ -4,61 +4,56 @@
|
|
|
4
4
|
*
|
|
5
5
|
* :root custom properties, all `--ip-` prefixed so they can
|
|
6
6
|
* neither overwrite a host's nor be overwritten by them.
|
|
7
|
-
* .ip-page
|
|
7
|
+
* .ip-page the page frame (was bare html/body). Only the gem's own
|
|
8
8
|
* layout puts this class on <body>, so inside a host admin
|
|
9
9
|
* these rules simply do not apply.
|
|
10
|
-
* .ip-dashboard
|
|
10
|
+
* .ip-dashboard everything else, nested. Names like .container, .card
|
|
11
11
|
* and .tabs are confined to the dashboard's own markup, so
|
|
12
12
|
* loading this file inside a host cannot restyle its chrome.
|
|
13
13
|
*
|
|
14
14
|
* Nothing here styles a bare `body`, `a`, `table` or `*` at the top level.
|
|
15
|
+
*
|
|
16
|
+
* Two sections follow: the design system shared by the whole gem family,
|
|
17
|
+
* then what only this gem has. A rule that belongs in the shared section
|
|
18
|
+
* belongs in all five copies of it.
|
|
19
|
+
*
|
|
20
|
+
* Order inside a section matters — components, then the page frame, then the
|
|
21
|
+
* narrow-screen overrides. Nesting adds no specificity, so the last rule
|
|
22
|
+
* wins the ties, and an override placed before the rule it means to beat
|
|
23
|
+
* silently does nothing.
|
|
15
24
|
*/
|
|
16
25
|
|
|
26
|
+
/* ---------------------------------------------------------------------------
|
|
27
|
+
* SHARED CORE — identical in testimonials, ideasbugs, livechat, product_tours
|
|
28
|
+
* and i18n_proofreading apart from the prefix. Diff it against a sibling gem
|
|
29
|
+
* before you change it, and carry the change to all five.
|
|
30
|
+
* ------------------------------------------------------------------------- */
|
|
17
31
|
|
|
18
32
|
:root {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
33
|
+
color-scheme: light dark;
|
|
34
|
+
--ip-bg: #f6f7f9;
|
|
35
|
+
--ip-surface: #fff;
|
|
36
|
+
--ip-text: #1c2024;
|
|
37
|
+
--ip-muted: #6b7280;
|
|
38
|
+
--ip-border: #e5e7eb;
|
|
39
|
+
--ip-accent: #2563eb;
|
|
40
|
+
--ip-accent-text: #fff;
|
|
41
|
+
--ip-code: #f0f1f3;
|
|
42
|
+
/* The four semantic states every gem in the family badges something with. */
|
|
43
|
+
--ip-success: #16a34a;
|
|
44
|
+
--ip-warning: #d97706;
|
|
45
|
+
--ip-danger: #dc2626;
|
|
46
|
+
--ip-neutral: #6b7280;
|
|
23
47
|
}
|
|
24
48
|
@media (prefers-color-scheme: dark) {
|
|
25
49
|
:root {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
.ip-index { overflow-y: auto; overflow-x: hidden; }
|
|
34
|
-
.ip-index, .ip-index .container { min-height: 100vh; height: auto; }
|
|
35
|
-
.ip-index .container { padding-bottom: 10px; }
|
|
36
|
-
.ip-dashboard {
|
|
37
|
-
& .container { padding: 14px 10px 24px; }
|
|
38
|
-
& .review-detail,
|
|
39
|
-
& .suggestion-panel,
|
|
40
|
-
& .detail-scroll,
|
|
41
|
-
& .card,
|
|
42
|
-
& dl,
|
|
43
|
-
& dd { min-width: 0; max-width: 100%; }
|
|
44
|
-
& .suggestion-panel .card { width: calc(100vw - 20px); }
|
|
45
|
-
& .lede,
|
|
46
|
-
& dd,
|
|
47
|
-
& .comment { max-width: calc(100vw - 54px); white-space: normal; overflow-wrap: anywhere; }
|
|
48
|
-
& .review-shell { display: block; min-height: calc(100vh - 76px); }
|
|
49
|
-
& .review-sidebar,
|
|
50
|
-
& .review-detail { min-height: calc(100vh - 76px); }
|
|
51
|
-
& .review-shell.has-selected .review-sidebar { display: none; }
|
|
52
|
-
& .review-shell:not(.has-selected) .review-detail { display: none; }
|
|
53
|
-
& .suggestion-list { overflow: visible; }
|
|
54
|
-
& .suggestion-row { padding: 12px; }
|
|
55
|
-
& .suggestion-meta { display: none; }
|
|
56
|
-
& .review-detail .suggestion-panel { min-height: calc(100vh - 64px); }
|
|
57
|
-
& .mobile-back { display: block; flex: 0 0 auto; margin: 10px 10px 0; font-size: 13px; }
|
|
58
|
-
& .panel-head h1 { font-size: 18px; }
|
|
59
|
-
& .panel-head .key { min-width: 0; white-space: normal; overflow-wrap: anywhere; }
|
|
60
|
-
& dl.diff,
|
|
61
|
-
& .meta-list { grid-template-columns: 1fr; gap: 3px; }
|
|
50
|
+
--ip-bg: #111418;
|
|
51
|
+
--ip-surface: #1a1f26;
|
|
52
|
+
--ip-text: #e6e8ea;
|
|
53
|
+
--ip-muted: #9aa2ab;
|
|
54
|
+
--ip-border: #2a313a;
|
|
55
|
+
--ip-accent: #3b82f6;
|
|
56
|
+
--ip-code: #232a33;
|
|
62
57
|
}
|
|
63
58
|
}
|
|
64
59
|
|
|
@@ -71,91 +66,253 @@
|
|
|
71
66
|
& * { box-sizing: border-box; }
|
|
72
67
|
& a { color: var(--ip-accent); text-decoration: none; }
|
|
73
68
|
& a:hover { text-decoration: underline; }
|
|
74
|
-
& .container { max-width: 960px; margin: 0 auto; padding: 24px 16px 64px; }
|
|
75
|
-
& h1 { font-size: 22px; margin: 0; }
|
|
76
|
-
& .lede { color: var(--ip-muted); margin: 4px 0 0; }
|
|
77
|
-
& header.page { margin-bottom: 20px; }
|
|
78
|
-
& .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--ip-border); margin-bottom: 16px; flex-wrap: wrap; }
|
|
79
|
-
& .tabs a { padding: 8px 14px; border-radius: 8px 8px 0 0; color: var(--ip-muted); }
|
|
80
|
-
& .tabs a.active { color: var(--ip-text); font-weight: 600; border: 1px solid var(--ip-border); border-bottom: 2px solid var(--ip-surface); background: var(--ip-surface); margin-bottom: -1px; }
|
|
81
|
-
& .tabs a:hover { text-decoration: none; color: var(--ip-text); }
|
|
82
|
-
& .count { display: inline-block; min-width: 20px; padding: 0 6px; margin-left: 4px; border-radius: 999px; background: var(--ip-border); font-size: 12px; text-align: center; font-variant-numeric: tabular-nums; }
|
|
83
|
-
& .filters { margin-bottom: 16px; display: flex; gap: 8px; align-items: center; }
|
|
84
|
-
& .filters label { color: var(--ip-muted); font-size: 13px; }
|
|
85
|
-
& .filters select { padding: 6px 8px; border: 1px solid var(--ip-border); border-radius: 8px; background: var(--ip-surface); color: var(--ip-text); font: inherit; }
|
|
86
|
-
& .card { background: var(--ip-surface); border: 1px solid var(--ip-border); border-radius: 12px; padding: 14px 16px; margin-bottom: 12px; }
|
|
87
|
-
& .card.pad { margin: 0; }
|
|
88
|
-
& .row-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
89
|
-
& code.key { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; background: var(--ip-code); padding: 2px 7px; border-radius: 6px; overflow-wrap: anywhere; }
|
|
90
|
-
& .badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; color: #fff; white-space: nowrap; }
|
|
91
|
-
& .badge.status-pending { background: var(--ip-pending); }
|
|
92
|
-
& .badge.status-applied { background: var(--ip-applied); }
|
|
93
|
-
& .badge.status-rejected { background: var(--ip-rejected); }
|
|
94
|
-
& .badge.locale { background: transparent; color: var(--ip-muted); border: 1px solid var(--ip-border); }
|
|
95
|
-
& .spacer { flex: 1; }
|
|
96
|
-
& .meta { color: var(--ip-muted); font-size: 13px; }
|
|
97
|
-
& dl.diff { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; }
|
|
98
|
-
& dl.diff dt { color: var(--ip-muted); font-size: 13px; }
|
|
99
|
-
& dl.diff dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
100
|
-
& dd .old { color: var(--ip-muted); }
|
|
101
|
-
& dd .new { font-weight: 600; }
|
|
102
|
-
& .comment { margin-top: 10px; padding: 8px 10px; background: var(--ip-code); border-radius: 8px; font-size: 14px; overflow-wrap: anywhere; }
|
|
103
|
-
& button { padding: 7px 13px; border: 1px solid var(--ip-border); border-radius: 8px; cursor: pointer; background: var(--ip-surface); color: var(--ip-text); font: inherit; font-size: 14px; font-weight: 600; }
|
|
104
|
-
& button:hover { border-color: var(--ip-muted); }
|
|
105
69
|
& :focus-visible { outline: 2px solid var(--ip-accent); outline-offset: 2px; }
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
70
|
+
|
|
71
|
+
/* Page furniture. `.container` is the reading width; an index page widens it
|
|
72
|
+
to 1280px in the page frame below. */
|
|
73
|
+
& .container { max-width: 1020px; margin: 0 auto; padding: 24px 16px 64px; }
|
|
74
|
+
& h1 { margin: 0 0 16px; font-size: 22px; }
|
|
75
|
+
& h2 { margin: 0 0 12px; font-size: 16px; }
|
|
76
|
+
& .page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
|
|
77
|
+
& .page-head h1 { margin: 0; }
|
|
78
|
+
& .lede { margin: 4px 0 0; color: var(--ip-muted); }
|
|
109
79
|
& .breadcrumb { margin: 0 0 12px; font-size: 13px; }
|
|
80
|
+
& .muted { color: var(--ip-muted); font-size: 13px; }
|
|
110
81
|
& .case-id { color: var(--ip-muted); font-weight: 400; font-size: 15px; }
|
|
111
|
-
& .
|
|
112
|
-
& .
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
& .
|
|
117
|
-
& .
|
|
118
|
-
& .
|
|
119
|
-
& .
|
|
120
|
-
& .
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
& .
|
|
125
|
-
& .
|
|
126
|
-
& .
|
|
127
|
-
& .
|
|
128
|
-
& .
|
|
129
|
-
& .
|
|
130
|
-
& .
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
& .
|
|
135
|
-
& .
|
|
136
|
-
& .
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
& .
|
|
140
|
-
& .
|
|
141
|
-
& .
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
&
|
|
145
|
-
&
|
|
82
|
+
& .spacer { flex: 1; }
|
|
83
|
+
& .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
|
84
|
+
|
|
85
|
+
/* Section switcher: the pills above the dashboard when a gem has more than
|
|
86
|
+
one section. Lives on the page, so a host `admin_layout` can drop it. */
|
|
87
|
+
& .nav { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
|
|
88
|
+
& .nav a { padding: 6px 16px; border: 1px solid var(--ip-border); border-radius: 999px; background: var(--ip-surface); color: var(--ip-muted); font-weight: 600; }
|
|
89
|
+
& .nav a:hover { text-decoration: none; color: var(--ip-text); }
|
|
90
|
+
& .nav a.active { border-color: var(--ip-accent); background: var(--ip-accent); color: var(--ip-accent-text); }
|
|
91
|
+
& .nav a.active:hover { color: var(--ip-accent-text); }
|
|
92
|
+
|
|
93
|
+
/* Tabs: one system for the whole family — an underlined row of equal-width
|
|
94
|
+
tabs, sized to sit at the top of the sidebar. */
|
|
95
|
+
& .tabs { display: flex; gap: 6px; }
|
|
96
|
+
& .tabs a { position: relative; display: flex; flex: 1 1 0; align-items: center; justify-content: center; gap: 6px; min-height: 34px; padding: 5px 10px 8px; border-radius: 8px; color: var(--ip-muted); font-weight: 600; }
|
|
97
|
+
& .tabs a:hover { background: var(--ip-bg); color: var(--ip-text); text-decoration: none; }
|
|
98
|
+
& .tabs a.active { color: var(--ip-text); }
|
|
99
|
+
& .tabs a.active::after { content: ""; position: absolute; right: 20px; bottom: 0; left: 20px; height: 2px; border-radius: 999px; background: var(--ip-accent); }
|
|
100
|
+
& .count { display: inline-block; min-width: 20px; padding: 0 6px; border-radius: 999px; background: color-mix(in srgb, var(--ip-muted) 14%, transparent); font-size: 12px; text-align: center; font-variant-numeric: tabular-nums; }
|
|
101
|
+
& .tabs a.active .count { background: var(--ip-border); }
|
|
102
|
+
|
|
103
|
+
/* Filter row under the tabs: a full-width select over a search + submit row,
|
|
104
|
+
however many of the three a gem actually has. */
|
|
105
|
+
& .filters { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 12px; border-bottom: 1px solid var(--ip-border); }
|
|
106
|
+
& .filters label { flex: 1 1 100%; color: var(--ip-muted); font-size: 13px; }
|
|
107
|
+
& .filters select { flex: 1 1 100%; }
|
|
108
|
+
/* Basis 0, not auto: the controls below are `width: 100%`, which would
|
|
109
|
+
otherwise push the submit button onto its own row. */
|
|
110
|
+
& .filters input[type="search"] { flex: 1 1 0; min-width: 0; }
|
|
111
|
+
& .filters button,
|
|
112
|
+
& .filters input[type="submit"] { flex: 0 0 auto; }
|
|
113
|
+
|
|
114
|
+
/* Form controls. 16px so iOS does not zoom the page on focus. */
|
|
115
|
+
& input,
|
|
116
|
+
& select,
|
|
117
|
+
& textarea { width: 100%; min-height: 38px; padding: 7px 9px; border: 1px solid var(--ip-border); border-radius: 8px; background: var(--ip-surface); color: var(--ip-text); font: inherit; font-size: 16px; letter-spacing: 0; }
|
|
118
|
+
& textarea { resize: vertical; }
|
|
119
|
+
& input[type="checkbox"],
|
|
120
|
+
& input[type="radio"] { width: auto; min-width: 0; min-height: 0; }
|
|
121
|
+
& input:focus,
|
|
122
|
+
& select:focus,
|
|
123
|
+
& textarea:focus { border-color: var(--ip-accent); outline: 2px solid color-mix(in srgb, var(--ip-accent) 22%, transparent); outline-offset: 0; }
|
|
124
|
+
& .field { margin-bottom: 16px; }
|
|
125
|
+
& .field label { display: block; margin-bottom: 5px; font-weight: 600; }
|
|
126
|
+
& .field small { display: block; margin-top: 5px; color: var(--ip-muted); }
|
|
127
|
+
|
|
128
|
+
/* A submit input is a button, whatever the markup says. */
|
|
129
|
+
& button,
|
|
130
|
+
& .button,
|
|
131
|
+
& input[type="submit"] { display: inline-flex; width: auto; align-items: center; justify-content: center; min-height: 38px; padding: 8px 14px; border: 1px solid var(--ip-border); border-radius: 8px; background: var(--ip-surface); color: var(--ip-text); font: inherit; cursor: pointer; }
|
|
132
|
+
& button:hover,
|
|
133
|
+
& .button:hover,
|
|
134
|
+
& input[type="submit"]:hover { border-color: var(--ip-muted); text-decoration: none; }
|
|
135
|
+
& button.primary,
|
|
136
|
+
& .button.primary,
|
|
137
|
+
& input[type="submit"].primary { border-color: var(--ip-accent); background: var(--ip-accent); color: var(--ip-accent-text); }
|
|
138
|
+
& button.danger,
|
|
139
|
+
& .button.danger { color: var(--ip-danger); }
|
|
140
|
+
& button.small,
|
|
141
|
+
& .button.small { min-height: 32px; padding: 4px 10px; font-size: 13px; }
|
|
142
|
+
& .actions,
|
|
143
|
+
& .form-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
|
144
|
+
& .actions form { display: inline; }
|
|
145
|
+
& .form-actions { padding-top: 12px; }
|
|
146
|
+
|
|
147
|
+
/* Status switch: one pill holding every state a record can be in, the state it
|
|
148
|
+
is in now lit. Each other segment is a submit button, so moving a record
|
|
149
|
+
takes one click and no JavaScript; the current segment is a `type="button"`
|
|
150
|
+
so clicking it cannot re-file the record it is already filed under. Two
|
|
151
|
+
states or five, the pill sizes itself. A gem tints the lit segment with its
|
|
152
|
+
own status colour — see the `GEM-SPECIFIC` section. */
|
|
153
|
+
& .status-switch { display: inline-flex; flex: 0 0 auto; margin: 0; padding: 3px; border: 1px solid var(--ip-border); border-radius: 999px; background: var(--ip-bg); }
|
|
154
|
+
& .status-switch button { min-height: 30px; padding: 0 12px; border: 0; border-radius: 999px; background: none; color: var(--ip-muted); font-size: 13px; font-weight: 700; white-space: nowrap; transition: background .16s ease, color .16s ease; }
|
|
155
|
+
& .status-switch button:hover { border: 0; background: color-mix(in srgb, var(--ip-muted) 12%, transparent); color: var(--ip-text); }
|
|
156
|
+
& .status-switch button.current { background: var(--ip-surface); color: var(--ip-text); box-shadow: 0 1px 2px rgba(15, 23, 42, .12); cursor: default; }
|
|
157
|
+
& .status-switch button.current:hover { background: var(--ip-surface); }
|
|
158
|
+
& .panel-head .status-switch { margin-left: auto; }
|
|
159
|
+
|
|
160
|
+
& .card { overflow: hidden; border: 1px solid var(--ip-border); border-radius: 12px; background: var(--ip-surface); }
|
|
161
|
+
& .card.pad { padding: 16px; overflow: visible; }
|
|
162
|
+
& table { width: 100%; border-collapse: collapse; }
|
|
163
|
+
& th,
|
|
164
|
+
& td { padding: 10px 14px; text-align: left; vertical-align: top; }
|
|
165
|
+
& th { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--ip-muted); border-bottom: 1px solid var(--ip-border); }
|
|
166
|
+
& tr + tr td { border-top: 1px solid var(--ip-border); }
|
|
167
|
+
& td.row-actions { white-space: nowrap; text-align: right; }
|
|
168
|
+
& td.row-actions form { display: inline-block; }
|
|
169
|
+
& td.row-actions form + form { margin-left: 6px; }
|
|
170
|
+
|
|
171
|
+
& .badge { display: inline-flex; padding: 2px 10px; border-radius: 999px; color: #fff; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
|
172
|
+
& .badge.locale { border: 1px solid var(--ip-border); background: transparent; color: var(--ip-muted); }
|
|
173
|
+
& code.key { max-width: 100%; padding: 2px 7px; border-radius: 6px; background: var(--ip-code); font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
|
|
174
|
+
|
|
175
|
+
& .flash,
|
|
176
|
+
& .errors { margin-bottom: 16px; padding: 10px 12px; border: 1px solid var(--ip-border); border-radius: 8px; background: var(--ip-surface); }
|
|
177
|
+
& .flash.notice { border-color: var(--ip-success); }
|
|
178
|
+
& .flash.alert,
|
|
179
|
+
& .errors { border-color: var(--ip-danger); }
|
|
180
|
+
& .errors ul { margin-bottom: 0; }
|
|
181
|
+
|
|
182
|
+
& dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; margin: 0; }
|
|
183
|
+
& dt { color: var(--ip-muted); }
|
|
184
|
+
& dd { margin: 0; overflow-wrap: anywhere; }
|
|
185
|
+
& .message { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
186
|
+
|
|
187
|
+
& .empty { padding: 48px 16px; color: var(--ip-muted); text-align: center; }
|
|
188
|
+
& .empty-state { max-width: 320px; margin: auto; padding: 24px; text-align: center; }
|
|
146
189
|
& .empty-state h2 { margin: 0 0 6px; font-size: 18px; }
|
|
147
190
|
& .empty-state p { margin: 0; }
|
|
191
|
+
|
|
192
|
+
/* The two-pane dashboard: the queue on the left, the selected record on the
|
|
193
|
+
right, each pane owning its own scroll. */
|
|
194
|
+
& .dashboard-shell { display: grid; flex: 1 1 auto; grid-template-columns: minmax(330px, 430px) 1fr; gap: 16px; min-height: 0; }
|
|
195
|
+
& .dashboard-sidebar { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--ip-border); border-radius: 8px; background: var(--ip-surface); }
|
|
196
|
+
& .dashboard-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; }
|
|
197
|
+
& .dashboard-sidebar .filters { flex: 0 0 auto; }
|
|
198
|
+
|
|
199
|
+
/* One row shape for every gem: a topline of badges and a timestamp, the copy
|
|
200
|
+
that identifies the record, then a muted line of ids and authors. */
|
|
201
|
+
& .record-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
202
|
+
& .record-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px; border-bottom: 1px solid var(--ip-border); color: var(--ip-text); }
|
|
203
|
+
& .record-row:hover { background: var(--ip-bg); text-decoration: none; }
|
|
204
|
+
& .record-row.active { background: color-mix(in srgb, var(--ip-accent) 9%, var(--ip-surface)); box-shadow: inset 3px 0 0 var(--ip-accent); }
|
|
205
|
+
& .record-main { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
|
|
206
|
+
& .record-topline,
|
|
207
|
+
& .record-meta { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
208
|
+
& .record-name,
|
|
209
|
+
& .record-copy,
|
|
210
|
+
& .record-meta span,
|
|
211
|
+
/* A key is `overflow-wrap: anywhere` in prose; in a row it truncates. */
|
|
212
|
+
& .record-topline code.key,
|
|
213
|
+
& .record-meta code.key { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
214
|
+
& .record-name { font-weight: 600; }
|
|
215
|
+
& .record-copy { display: block; font-weight: 600; }
|
|
216
|
+
& .record-time { margin-left: auto; white-space: nowrap; }
|
|
217
|
+
& .record-side { display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; gap: 8px; padding-top: 1px; }
|
|
218
|
+
& .pager { display: flex; flex: 0 0 auto; justify-content: space-between; gap: 16px; min-height: 42px; margin: 0; padding: 10px 12px; border-top: 1px solid var(--ip-border); }
|
|
219
|
+
|
|
220
|
+
/* The selected record. `.detail-scroll` is the only part that scrolls, so the
|
|
221
|
+
panel heading and its actions stay put. */
|
|
222
|
+
& .dashboard-detail { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; }
|
|
223
|
+
& .dashboard-detail .detail-panel { flex: 1 1 auto; min-height: 0; }
|
|
224
|
+
& .dashboard-detail .detail-scroll { flex: 1 1 auto; overflow-y: auto; padding-right: 2px; }
|
|
225
|
+
& .dashboard-detail .actions { margin-bottom: 0; }
|
|
226
|
+
& .detail-panel { display: flex; min-width: 0; flex-direction: column; }
|
|
227
|
+
/* The panel's own heading row: the title on the left, whatever acts on the
|
|
228
|
+
whole record on the right. */
|
|
229
|
+
& .panel-head { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
230
|
+
& .panel-head h1 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0; }
|
|
231
|
+
& .detail-scroll { display: flex; min-height: 0; flex-direction: column; gap: 12px; }
|
|
148
232
|
& .mobile-back { display: none; }
|
|
149
233
|
}
|
|
150
234
|
|
|
151
|
-
/* Page frame — the gem's own layout only.
|
|
152
|
-
specificity ties against
|
|
153
|
-
.ip-page { margin: 0; background: var(--ip-bg); color: var(--ip-text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
154
|
-
/*
|
|
235
|
+
/* Page frame — the gem's own layout only. After the components, so it wins the
|
|
236
|
+
specificity ties against them. */
|
|
237
|
+
.ip-page { margin: 0; min-height: 100vh; background: var(--ip-bg); color: var(--ip-text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; letter-spacing: 0; }
|
|
238
|
+
/* An index page is the dashboard: it fills the viewport and never scrolls
|
|
239
|
+
itself, because the queue and the detail pane each scroll on their own. */
|
|
155
240
|
.ip-index, .ip-index .container { height: 100vh; height: 100dvh; }
|
|
156
241
|
.ip-index { overflow: hidden; }
|
|
157
|
-
.ip-index .container { max-width: 1280px;
|
|
158
|
-
.ip-index
|
|
242
|
+
.ip-index .container { display: flex; max-width: 1280px; flex-direction: column; padding-bottom: 16px; }
|
|
243
|
+
.ip-index .nav,
|
|
244
|
+
.ip-index .page-head { flex: 0 0 auto; }
|
|
159
245
|
/* Standalone show pages keep normal page scrolling. */
|
|
160
246
|
.ip-show { min-height: 100vh; overflow: auto; }
|
|
247
|
+
.ip-show .detail-panel { width: 100%; }
|
|
161
248
|
.ip-show .detail-scroll { overflow: visible; }
|
|
249
|
+
|
|
250
|
+
/* Narrow screens: one pane at a time, the queue until a record is picked.
|
|
251
|
+
Last in the file, so these overrides actually reach the rules above. */
|
|
252
|
+
@media (max-width: 760px) {
|
|
253
|
+
.ip-page { font-size: 14px; }
|
|
254
|
+
.ip-index, .ip-show { overflow-x: hidden; }
|
|
255
|
+
.ip-index { overflow-y: auto; }
|
|
256
|
+
.ip-index, .ip-index .container { min-height: 100vh; height: auto; }
|
|
257
|
+
.ip-index .container { padding-bottom: 10px; }
|
|
258
|
+
.ip-dashboard {
|
|
259
|
+
& .container { padding: 14px 10px 24px; }
|
|
260
|
+
& .page-head { margin-bottom: 12px; }
|
|
261
|
+
& .detail-panel,
|
|
262
|
+
& .detail-scroll,
|
|
263
|
+
& .card,
|
|
264
|
+
& dl,
|
|
265
|
+
& dd { min-width: 0; max-width: 100%; }
|
|
266
|
+
& .dashboard-detail .card { width: 100%; }
|
|
267
|
+
& .message,
|
|
268
|
+
& .lede,
|
|
269
|
+
& dd,
|
|
270
|
+
& .muted { overflow-wrap: anywhere; }
|
|
271
|
+
& .dashboard-shell { display: block; min-height: calc(100vh - 62px); }
|
|
272
|
+
& .dashboard-sidebar,
|
|
273
|
+
& .dashboard-detail { min-height: calc(100vh - 62px); }
|
|
274
|
+
& .dashboard-shell.has-selected .dashboard-sidebar { display: none; }
|
|
275
|
+
& .dashboard-shell:not(.has-selected) .dashboard-detail { display: none; }
|
|
276
|
+
& .record-list { overflow: visible; }
|
|
277
|
+
& .record-row { padding: 12px; }
|
|
278
|
+
& .record-meta { display: none; }
|
|
279
|
+
& .dashboard-detail .detail-panel { min-height: calc(100vh - 64px); }
|
|
280
|
+
& .mobile-back { display: block; flex: 0 0 auto; margin: 4px 0 10px; font-size: 13px; }
|
|
281
|
+
& .panel-head h1 { font-size: 18px; }
|
|
282
|
+
& .status-switch button { padding: 0 9px; font-size: 12px; }
|
|
283
|
+
& dl { grid-template-columns: 1fr; gap: 3px; }
|
|
284
|
+
& dd { margin-bottom: 8px; }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/* ---------------------------------------------------------------------------
|
|
289
|
+
* GEM-SPECIFIC — i18n_proofreading only: the before/after diff of a proposed
|
|
290
|
+
* translation and the reviewer's comment.
|
|
291
|
+
* ------------------------------------------------------------------------- */
|
|
292
|
+
|
|
293
|
+
:root {
|
|
294
|
+
--ip-pending: var(--ip-warning);
|
|
295
|
+
--ip-applied: var(--ip-success);
|
|
296
|
+
--ip-rejected: var(--ip-neutral);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.ip-dashboard {
|
|
300
|
+
& .badge.status-pending { background: var(--ip-pending); }
|
|
301
|
+
& .badge.status-applied { background: var(--ip-applied); }
|
|
302
|
+
& .badge.status-rejected { background: var(--ip-rejected); }
|
|
303
|
+
|
|
304
|
+
/* What the translation says now, against what the reviewer proposes. */
|
|
305
|
+
& dl.diff dt { font-size: 13px; }
|
|
306
|
+
& dl.diff dd { white-space: pre-wrap; }
|
|
307
|
+
& dd .old { color: var(--ip-muted); }
|
|
308
|
+
& dd .new { font-weight: 600; }
|
|
309
|
+
& .meta-list dt { font-size: 13px; }
|
|
310
|
+
& .comment { margin-top: 10px; padding: 8px 10px; border-radius: 8px; background: var(--ip-code); font-size: 14px; overflow-wrap: anywhere; }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
@media (max-width: 760px) {
|
|
314
|
+
.ip-dashboard {
|
|
315
|
+
& .comment { max-width: 100%; white-space: normal; overflow-wrap: anywhere; }
|
|
316
|
+
& .panel-head code.key { min-width: 0; white-space: normal; overflow-wrap: anywhere; }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -7,34 +7,37 @@ module I18nProofreading
|
|
|
7
7
|
translation_key: 'dashboard.title',
|
|
8
8
|
locale: 'en',
|
|
9
9
|
old_value: 'Dashboard',
|
|
10
|
-
proposed_value: '
|
|
11
|
-
comment: '
|
|
10
|
+
proposed_value: 'Product overview',
|
|
11
|
+
comment: 'Start here: pending suggestions are the review inbox. Compare the rendered wording and ' \
|
|
12
|
+
'page context, then decide whether the proposed copy belongs in your locale file.',
|
|
12
13
|
page_url: '/dashboard',
|
|
13
14
|
status: 'pending',
|
|
14
15
|
author_id: 'i18n-proofreading-demo:reviewer',
|
|
15
|
-
author_label: 'Demo
|
|
16
|
+
author_label: 'Demo reviewer · needs a decision'
|
|
16
17
|
},
|
|
17
18
|
{
|
|
18
19
|
translation_key: 'billing.cta',
|
|
19
20
|
locale: 'en',
|
|
20
21
|
old_value: 'Go',
|
|
21
22
|
proposed_value: 'Update billing details',
|
|
22
|
-
comment: '
|
|
23
|
+
comment: 'Applied is bookkeeping, not automation: a maintainer copied this clearer call to action ' \
|
|
24
|
+
'into config/locales and committed the change. The gem never edits YAML for you.',
|
|
23
25
|
page_url: '/billing',
|
|
24
26
|
status: 'applied',
|
|
25
27
|
author_id: 'i18n-proofreading-demo:copywriter',
|
|
26
|
-
author_label: 'Demo
|
|
28
|
+
author_label: 'Demo copywriter · applied by a human'
|
|
27
29
|
},
|
|
28
30
|
{
|
|
29
31
|
translation_key: 'settings.cancel',
|
|
30
32
|
locale: 'fr',
|
|
31
33
|
old_value: 'Annuler',
|
|
32
34
|
proposed_value: 'Supprimer le compte',
|
|
33
|
-
comment: 'Rejected
|
|
35
|
+
comment: 'Rejected on purpose: the proposal means “Delete the account,” not “Cancel.” The original ' \
|
|
36
|
+
'translation stays the source of truth, and the rejected row preserves the review decision.',
|
|
34
37
|
page_url: '/settings',
|
|
35
38
|
status: 'rejected',
|
|
36
39
|
author_id: 'i18n-proofreading-demo:reviewer',
|
|
37
|
-
author_label: 'Demo
|
|
40
|
+
author_label: 'Demo reviewer · meaning changed'
|
|
38
41
|
}
|
|
39
42
|
].freeze
|
|
40
43
|
|
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.
|
|
4
|
+
version: 0.12.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Shmarov
|
|
@@ -116,7 +116,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
116
116
|
- !ruby/object:Gem::Version
|
|
117
117
|
version: '0'
|
|
118
118
|
requirements: []
|
|
119
|
-
rubygems_version:
|
|
119
|
+
rubygems_version: 4.0.10
|
|
120
120
|
specification_version: 4
|
|
121
121
|
summary: 'In-context i18n proofreading for Rails: click any translated string and
|
|
122
122
|
suggest a fix.'
|