senren-ui 0.2.0 → 0.4.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.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +238 -0
  3. data/CONTRIBUTING.md +11 -0
  4. data/README.md +64 -0
  5. data/docs/i18n.md +194 -0
  6. data/docs/visual_style.md +80 -0
  7. data/lib/generators/senren/install/install_generator.rb +39 -0
  8. data/lib/generators/senren/install/templates/base_component.rb.tt +136 -7
  9. data/lib/generators/senren/install/templates/conventions.md.tt +57 -0
  10. data/lib/generators/senren/install/templates/senren_themes.css.tt +339 -0
  11. data/lib/senren/rails/agent_rules_writer.rb +7 -0
  12. data/lib/senren/rails/base_component_patch.rb +24 -0
  13. data/lib/senren/rails/component_copier.rb +21 -0
  14. data/lib/senren/rails/host_paths.rb +5 -0
  15. data/lib/senren/rails/locale_installer.rb +90 -0
  16. data/lib/senren/rails/version.rb +1 -1
  17. data/lib/senren/rails.rb +1 -0
  18. data/lib/tasks/senren.rake +9 -0
  19. data/templates/components/accordion/accordion_component.html.erb +24 -6
  20. data/templates/components/accordion/accordion_component.rb +2 -1
  21. data/templates/components/alert_dialog/alert_dialog_component.html.erb +7 -4
  22. data/templates/components/alert_dialog/alert_dialog_component.rb +4 -0
  23. data/templates/components/api_key_field/api_key_field_component.html.erb +2 -2
  24. data/templates/components/api_key_field/api_key_field_component.rb +8 -1
  25. data/templates/components/app_shell/app_shell_component.rb +2 -1
  26. data/templates/components/breadcrumb/breadcrumb_component.html.erb +8 -6
  27. data/templates/components/breadcrumb/breadcrumb_component.rb +2 -1
  28. data/templates/components/bulk_action_bar/bulk_action_bar_component.html.erb +1 -1
  29. data/templates/components/bulk_action_bar/bulk_action_bar_component.rb +4 -2
  30. data/templates/components/button/button_component.html.erb +1 -1
  31. data/templates/components/button/button_component.rb +27 -1
  32. data/templates/components/calendar/calendar_component.html.erb +2 -2
  33. data/templates/components/calendar/calendar_component.rb +37 -2
  34. data/templates/components/card/card_component.html.erb +4 -6
  35. data/templates/components/carousel/carousel_component.html.erb +6 -6
  36. data/templates/components/carousel/carousel_component.rb +12 -1
  37. data/templates/components/cart/cart_component.html.erb +13 -7
  38. data/templates/components/cart/cart_component.rb +11 -2
  39. data/templates/components/clipboard/clipboard_component.html.erb +3 -3
  40. data/templates/components/clipboard/clipboard_component.rb +6 -1
  41. data/templates/components/collapsible/collapsible_component.html.erb +3 -3
  42. data/templates/components/collapsible/collapsible_component.rb +1 -1
  43. data/templates/components/combobox/combobox_component.html.erb +3 -3
  44. data/templates/components/combobox/combobox_component.rb +2 -1
  45. data/templates/components/command/command_component.html.erb +1 -1
  46. data/templates/components/command/command_component.rb +3 -1
  47. data/templates/components/context_menu/context_menu_component.html.erb +6 -3
  48. data/templates/components/data_table/data_table_component.html.erb +3 -3
  49. data/templates/components/data_table/data_table_component.rb +16 -1
  50. data/templates/components/date_picker/date_picker_component.html.erb +5 -5
  51. data/templates/components/date_picker/date_picker_component.rb +2 -1
  52. data/templates/components/dialog/dialog_component.html.erb +9 -5
  53. data/templates/components/dialog/dialog_component.rb +11 -0
  54. data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +6 -3
  55. data/templates/components/dropdown_menu/dropdown_menu_component.rb +57 -5
  56. data/templates/components/filter_bar/filter_bar_component.rb +1 -1
  57. data/templates/components/form/form_component.html.erb +1 -1
  58. data/templates/components/form/form_component.rb +21 -0
  59. data/templates/components/hover_card/hover_card_component.html.erb +6 -3
  60. data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +11 -9
  61. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +11 -3
  62. data/templates/components/link/link_component.html.erb +1 -1
  63. data/templates/components/native_select/native_select_component.html.erb +2 -2
  64. data/templates/components/native_select/native_select_component.rb +12 -3
  65. data/templates/components/pagination/pagination_component.html.erb +4 -4
  66. data/templates/components/pagination/pagination_component.rb +2 -1
  67. data/templates/components/popover/popover_component.html.erb +6 -3
  68. data/templates/components/product_card/product_card_component.html.erb +3 -3
  69. data/templates/components/product_card/product_card_component.rb +2 -2
  70. data/templates/components/progress/progress_component.html.erb +2 -2
  71. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +2 -16
  72. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +32 -1
  73. data/templates/components/search_input/search_input_component.html.erb +2 -2
  74. data/templates/components/search_input/search_input_component.rb +2 -1
  75. data/templates/components/sheet/sheet_component.html.erb +15 -6
  76. data/templates/components/sheet/sheet_component.rb +11 -0
  77. data/templates/components/shortcut_key/shortcut_key_component.html.erb +2 -2
  78. data/templates/components/sidebar/sidebar_component.html.erb +4 -4
  79. data/templates/components/sidebar/sidebar_component.rb +4 -1
  80. data/templates/components/switch/switch_component.html.erb +1 -1
  81. data/templates/components/table/table_component.html.erb +2 -2
  82. data/templates/components/tabs/tabs_component.html.erb +3 -3
  83. data/templates/components/tabs/tabs_component.rb +4 -2
  84. data/templates/components/theme_toggle/theme_toggle_component.html.erb +17 -3
  85. data/templates/components/theme_toggle/theme_toggle_component.rb +6 -1
  86. data/templates/components/tooltip/tooltip_component.html.erb +5 -3
  87. data/templates/components/top_nav/top_nav_component.html.erb +3 -3
  88. data/templates/components/top_nav/top_nav_component.rb +3 -2
  89. data/templates/controllers/accordion_controller.js +68 -3
  90. data/templates/controllers/api_key_field_controller.js +3 -2
  91. data/templates/controllers/carousel_controller.js +6 -1
  92. data/templates/controllers/cart_controller.js +3 -1
  93. data/templates/controllers/clipboard_controller.js +3 -3
  94. data/templates/controllers/rich_text_editor_lite_controller.js +2 -2
  95. data/templates/controllers/theme_toggle_controller.js +13 -3
  96. data/templates/locales/senren.en.yml +151 -0
  97. data/templates/locales/senren.vi.yml +151 -0
  98. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d99681351df1dad873d945bf82b3070b3f8393c9d14b3ca65917262e6258ef6c
4
- data.tar.gz: b487d9ac589b1d9ba2c304a5f53e4d93064b321d76e97a43ccfa1b9909a44b58
3
+ metadata.gz: 99b7430e710e44023cbc886c862b4d237d06bb489a4a00f5e30685b59768f6dc
4
+ data.tar.gz: 6ead04eac8e4e518cbf7129c65dfb63ad683db3dfb44b13a794366eee8be37dd
5
5
  SHA512:
6
- metadata.gz: f383e88dea3c90da1f1f93831bdf26c4bc5e00e1c63ffeb8ae53031d7c7e38c8afbaa30c445839cdbf8ad346acf247dcd04ae597c01b23704837daeb4eb4c2bf
7
- data.tar.gz: 54e1f4ad10351c05906cdf1f864160cd3a0555e1698c0fa4944a728752cd97560300a9bae40a337a214a39d19eae87a3620fd4690e412d689b9430d022df0662
6
+ metadata.gz: 7cf42a8f5992b7de9bc4135c7d6bea7c44987ca3844de06b575876957877efd773711cdfcb9cd54d2ddc1ca878f38842d4df082b0fb7451b5b63613c9bb5f056
7
+ data.tar.gz: e3c329bc7eb8ef0c7b90af18971c92f24dfdea4822e17a9e43faff3b1e94a6815ed4b6c18cff30a2e65932cd8bf96d88b2e64153671d72e2a52c8ffe2d759c76
data/CHANGELOG.md CHANGED
@@ -7,6 +7,244 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  v0.x is a pre-stable line: minor bumps may break things; patch bumps are
8
8
  bug fixes only.
9
9
 
10
+ ## [0.4.0] — 2026-10-08
11
+
12
+ ### Added
13
+
14
+ - **Every word a component shows or announces can be translated from the host
15
+ app, without editing a component.** Visible text, `aria-label` and `sr-only`
16
+ text, the defaults of `label:`, `placeholder:` and `empty_text:`, the
17
+ calendar's month and weekday names, and the sentences a Stimulus controller
18
+ says after load now go through one helper, `senren_t`, with the English as its
19
+ default: `senren_t('pagination.next', default: 'Next')` is
20
+ `I18n.t("senren.pagination.next", default: "Next")`. 111 keys across 27
21
+ components, named `senren.<component>.<key>`, with `senren.shared.*` for the
22
+ two words that are the same word in several components.
23
+
24
+ An app with no locale file renders exactly what it did before, and a missing
25
+ translation never raises, including under
26
+ `config.i18n.raise_on_missing_translations`. Text you pass in yourself is used
27
+ as given and never looked up; `label: nil` still means "none", and leaving the
28
+ argument out is what gets the translated default.
29
+
30
+ The gem ships `senren.en.yml` and `senren.vi.yml`, and copies either only
31
+ when asked for: `bin/rails generate senren:install --locales vi`, or later
32
+ `bin/rails senren:locales vi`. Not even English is copied by default: a copied
33
+ `senren.en.yml` loads after the app's `en.yml`, overriding any `senren.*` key
34
+ set there, and pins today's wording against later releases. An existing file
35
+ is never overwritten unless that is asked for by name
36
+ (`senren:locales vi --force`, or `senren:install --force-locales`); the
37
+ `--force` that refreshes Senren's own files leaves translations alone. To add
38
+ another language, `bin/rails senren:locales en`, copy the file, change `en:`,
39
+ and translate the values. A translation with a misspelt `%{variable}` falls
40
+ back to the English instead of raising. See [docs/i18n.md](docs/i18n.md).
41
+
42
+ `bin/i18n-sync` regenerates `templates/locales/senren.en.yml` from the code,
43
+ and a test fails if it, `senren.vi.yml`, and the components disagree, or if a
44
+ template shows a word that did not go through `senren_t`.
45
+
46
+ ### Changed
47
+
48
+ - **Five Stimulus controllers no longer carry an English sentence.** `clipboard`
49
+ ("Copied", "Copied to clipboard"), `api_key_field` ("Copy complete"),
50
+ `carousel` ("Slide 2 of 3"), `theme_toggle` ("Light theme", "Dark theme") and
51
+ `rich_text_editor_lite` (the link prompt) read the sentence from a Stimulus
52
+ value the component renders, so they speak the host's language. The markup
53
+ gains six `data-senren--<controller>-…-value` attributes and is otherwise
54
+ byte-for-byte what it was.
55
+
56
+ **Migrate:** none for an unmodified install. The controller and its component
57
+ go together: if you copied a component and edited its controller, or the other
58
+ way round, copy both again (`bin/rails senren:add <name> --force`) so the value
59
+ the controller reads is the one the component writes.
60
+ - `bin/rails senren:add` appends `senren_t` to an existing
61
+ `app/components/senren/base_component.rb` that lacks it, so a component added
62
+ after upgrading the gem does not raise `NoMethodError`. Components you copied
63
+ earlier keep the English written into them until you copy them again.
64
+ - The performance budgets for component source (140000 -> 150000 bytes) and for
65
+ the Stimulus payload (56000 -> 56500) are raised, with the reasons recorded in
66
+ `config/performance_budgets.yml`. The per-file caps and the gzip total are
67
+ unchanged.
68
+ - **`NativeSelectComponent` defaults to its own chevron instead of the browser's
69
+ arrow** (`native_arrow: false`). A select was the one control in the library
70
+ with no visible state: the OS arrow cannot be styled or animated, so nothing
71
+ distinguished an open select from a closed one. The custom chevron already
72
+ existed, rotated on focus, and had to be asked for by name — which meant
73
+ nobody got it. Pass `native_arrow: true` to opt back in; worth doing on
74
+ mobile, where the OS renders its own picker anyway.
75
+
76
+ The component marker moves with it. In custom-arrow mode
77
+ `data-senren-component="native_select"` sits on the wrapper that positions the
78
+ chevron, and the `<select>` underneath still carries the name, the controller
79
+ and the selection. Anything selecting on `select[data-senren-component]` needs
80
+ updating.
81
+ - `InviteMemberDialogComponent` renders its role field through
82
+ `NativeSelectComponent` rather than a hand-written `<select>` whose styling
83
+ had been copied out of it — which is why it silently missed everything that
84
+ component gained afterwards, the chevron included.
85
+
86
+ ### Fixed
87
+
88
+ - `FormComponent` yields the Rails form builder, as `docs/components.md` always
89
+ said it did. The block received the component instead, and because a
90
+ component is an `ActionView::Base` the documented `f.text_field :title,
91
+ class: "x"` did not raise: it reached the template-level helper and rendered
92
+ `name="title[{class: "x"}]"`, so the field posted under a name no controller
93
+ reads. Blocks that never touch `f`, and `with_content`, render as before.
94
+
95
+ Code that treated `|f|` as the component breaks: `f.tag`, `f.link_to`,
96
+ `f.render`, `f.model` and `f.url` now raise `NoMethodError` on the builder.
97
+ Call those on the view instead. A `FormComponent` rendered with neither a
98
+ block nor content now renders an empty `<form>` rather than raising.
99
+ - `TopNavComponent`'s default variant had no background. Its class,
100
+ `bg-[hsl(var(--senren-background))/0.88]`, put the alpha outside `hsl()`,
101
+ inside the brackets, so Tailwind emitted `hsl(...)/0.88` verbatim and the
102
+ browser dropped it as an invalid colour: the sticky header was transparent
103
+ over scrolled content. It is now `bg-[hsl(var(--senren-background)/0.88)]`,
104
+ the form every other translucent class in the library uses, and a test bans
105
+ the broken shape across `templates/` and `lib/`.
106
+ - `TableComponent` and `DataTableComponent` are now `relative`, so their
107
+ `sr-only` caption is positioned inside the component. `sr-only` is
108
+ `position: absolute`, and with nothing positioned the caption escaped the
109
+ components' own overflow clipping and was placed against the page. In a
110
+ fixed-height shell whose `<main>` scrolls, a captioned table below the fold
111
+ made the document taller, and the window scrolled into blank space under a
112
+ shell that should never scroll.
113
+ - `DataTableComponent` no longer breaks a `<td>` whose cell holds markup. The
114
+ cell value was written straight into `data-sort-value="..."`, and a badge,
115
+ link or anything else built with `tag` or `render` is a SafeBuffer, which ERB
116
+ does not escape: the markup's first `"` closed the attribute, the rest became
117
+ junk attributes, and every row sorted on the same `<span class=` fragment.
118
+ The sort value is now the cell's decoded text, escaped once, so a cell
119
+ showing `R&D` sorts as `R&D`. Plain-string and numeric cells are unchanged.
120
+ - `ThemeToggleComponent` rendered the letters `O`, `D` and `L` where an icon
121
+ belongs. It now ships sun and moon SVGs and swaps which is hidden, so the
122
+ correct icon is in the HTML the server sends rather than written in by
123
+ JavaScript after first paint. The icon names the theme the click switches to,
124
+ matching the label.
125
+ - `AccordionComponent` showed a static `+` and snapped open. It now has a
126
+ chevron that turns over, driven by `aria-expanded` so the icon cannot drift
127
+ out of step with the state assistive technology is told, and the panel
128
+ animates via `grid-template-rows: 0fr → 1fr` — which reaches the content's own
129
+ height without measuring it in JavaScript.
130
+
131
+ The `hidden` attribute is kept through the animation. Content that is visually
132
+ collapsed but present is still focusable and still read aloud, so the
133
+ controller drops `hidden` before opening and restores it once the closing
134
+ transition ends, with a timer for the cases where `transitionend` never
135
+ arrives: reduced motion, an off-screen element, an already-collapsed panel.
136
+
137
+ ## [0.3.0] — 2026-09-01
138
+
139
+ ### Breaking
140
+
141
+ - **`ButtonComponent` no longer emits `type="button"` by default.** The
142
+ attribute is omitted, so a button inside a form submits it, which is what
143
+ plain HTML does and what everyone expects. The old default silently swallowed
144
+ submits: a form's own submit button did nothing, with no error anywhere to
145
+ explain it. The new default fails the other way, and loudly — a trigger that
146
+ should not submit now has to say so.
147
+
148
+ **Migrate:** pass `type: :button` to any button that must not submit — overlay
149
+ triggers, menu triggers, a dialog's Cancel. Everything inside a form that is
150
+ meant to submit needs no change and starts working. Buttons outside a form
151
+ are unaffected either way.
152
+
153
+ ```erb
154
+ <%= render(Senren::ButtonComponent.new(type: :button)) { "Open dialog" } %>
155
+ ```
156
+
157
+ ### Fixed
158
+
159
+ - Every component now merges a caller's `class:` and `data:` instead of
160
+ dropping them. Splatting `**html_attrs` after the computed values replaced
161
+ them outright, so `class:` erased the component's own variant and size
162
+ styling and `data:` erased its `data-senren-component` marker.
163
+ - For the eight wrapper components — dialog, alert dialog, sheet, popover,
164
+ dropdown menu, context menu, hover card, tooltip — a caller's class now lands
165
+ on the panel, which is the element they style, not on the empty root, where it
166
+ sat in the DOM doing nothing.
167
+
168
+ This makes the extension point reachable; it does not make it resolve
169
+ conflicts. `class_name: "max-w-sm"` on a dialog emits
170
+ `class="max-w-lg max-w-sm"` and both declarations stay live, so which one
171
+ applies is decided by the order Tailwind emits them, not by the order of the
172
+ attribute. Measured against a real build: `max-w-sm` wins (narrowing works),
173
+ `max-w-2xl` loses (widening is a silent no-op). Use Tailwind's important
174
+ modifier — `class_name: "max-w-2xl!"` — for an override that does not depend
175
+ on emit order.
176
+ - `data-controller` and `data-action` are appended rather than substituted, so
177
+ attaching your own Stimulus controller to a Senren component no longer unbinds
178
+ the component's own.
179
+ - A caller's `data-controller` or `data-action` that repeated one of the
180
+ component's own tokens was appended rather than deduplicated:
181
+ `data: { controller: "a senren--popover" }` produced
182
+ `"a senren--popover senren--popover"`. Stimulus reads that list literally, so
183
+ the controller connected twice and every action fired twice — a toggle opened
184
+ and immediately closed. Tokens are deduplicated individually now.
185
+ - `data:` written with String keys (`data: { "action" => ... }`) is merged the
186
+ same as Symbol keys. The Symbol-only read meant a dropdown item passing a
187
+ String key lost close-on-click and arrow-key handling.
188
+ - A dropdown item's `class:` is merged rather than substituted. Losing the hover
189
+ style was cosmetic; losing `focus:bg-` removed the only indication a keyboard
190
+ user has of where they are in the menu.
191
+ - Sheet's scrolling body no longer clips focus rings.
192
+ - Dialog, sheet, and the invite-member dialog close on an overlay click.
193
+ - `method:` on a dropdown item reaches Turbo. It was passed to `link_to` as a
194
+ rails-ujs option, which Rails 7 dropped, so it had rendered an inert `method`
195
+ attribute since the library began targeting Rails 7.1.
196
+ - Card footer spacing, and pagination now wraps.
197
+ - `CartComponent` accepted `remove_url:` per item, normalised it, and wrote it
198
+ nowhere. Removal is client-side and dispatches `senren--cart:removed` for the
199
+ application to act on, so the URL was the one thing the listener needed and
200
+ the only thing it could not get. It is emitted as `data-remove-url` on the
201
+ line and carried in the event detail, omitted entirely when not supplied, and
202
+ passed through `safe_url` like every other URL the library renders.
203
+ - `senren_themes.css` shipped with its ERB examples unrendered, so the header
204
+ comment showed `<%%=` where it means `<%=`. The assertion guarding against that
205
+ named a single file, so the property held exactly where someone had thought to
206
+ look; it now runs over everything the generator writes.
207
+
208
+ ### Documentation
209
+
210
+ - The palette presets shipped in 0.2.0 with no mention in the README, the docs,
211
+ or the generated conventions file. All three now cover them, including the
212
+ load-order constraint: `senren_themes.css` must be linked after `senren.css`
213
+ or the theme silently does nothing.
214
+
215
+ ### Internal
216
+
217
+ - ERB linting names every linter it runs. Thirteen were running against nine
218
+ named in the config; eight formatting linters had been on by default and
219
+ unrecorded. A test now fails if an upgrade adds a fourteenth.
220
+ - `bin/ci` and the GitHub workflow call `erb_lint` rather than the deprecated
221
+ `erblint` shim.
222
+ - json bumped to 2.21.2 for CVE-2026-71847.
223
+ - Herb now lints the markup, and the 32 offenses it found in shipped templates
224
+ are fixed. It was adopted after a developer reported warnings in their editor
225
+ that none of this project's gates produced — RuboCop covered the Ruby, Biome
226
+ the Stimulus controllers, erb_lint the ERB structure, and the HTML itself had
227
+ no linter at all. Pinned to 0.10.3 in `.herb.yml`, and wired into `bin/ci`,
228
+ `bin/lint-fix` and the GitHub workflow.
229
+
230
+ Most of what it found is invisible in the rendered page but visible in the
231
+ editor of anyone who installs these components, since `senren:add` copies them
232
+ into their repository. Fifteen component roots moved from
233
+ `<div <%= tag.attributes(**root_attrs(...)) %>>` to `tag.div(...) do`, which
234
+ is what the overlays already used; seven conditional boolean attributes moved
235
+ from `<%= "hidden" unless expanded %>` to `<% unless expanded %>hidden<% end %>`;
236
+ three raw `<img>` tags became `image_tag`; breadcrumb stopped writing its whole
237
+ `<nav>` once per branch; and an empty `aria-activedescendant` is gone.
238
+
239
+ Nothing changes visually, but the markup string is not identical: rendering
240
+ all 64 components before and after produces 35 differing lines — 26 of them
241
+ whitespace and indentation, 6 the attribute order and self-closing slash that
242
+ `image_tag` emits, 2 the `href` moving to the front of an `<a>`, and 1 the
243
+ removed empty attribute. If you assert on exact markup anywhere, those
244
+ snapshots need regenerating. If you have already installed these components,
245
+ nothing in your application changes at all — the new markup arrives only if
246
+ you re-run `senren:add --force`.
247
+
10
248
  ## [0.2.0] — 2026-08-02
11
249
 
12
250
  A hardening release. Most of it came out of an adversarial review of the whole
data/CONTRIBUTING.md CHANGED
@@ -35,6 +35,8 @@ bin/matrix # unit tests on every supported Rails
35
35
  bundle exec rubocop
36
36
  bundle exec bundle-audit check --update
37
37
  bun run controllers:check
38
+ bin/lint-fix # auto-fix RuboCop, ERB lint, controller JS
39
+ bin/i18n-sync --check # templates/locales/senren.en.yml matches the components
38
40
  ```
39
41
 
40
42
  `bin/system` needs no setup: it uses a system Chrome and
@@ -86,6 +88,13 @@ real documentation/reference app is maintained separately in
86
88
  - ViewComponent Ruby class under `Senren::` namespace.
87
89
  - ERB template with `data-senren-component="<name>"` on root.
88
90
  - Tailwind classes use semantic tokens only.
91
+ - Every word the component shows or announces (visible text, `aria-label`,
92
+ `sr-only`, a default `label:` or `placeholder:`, a sentence a controller will
93
+ say) goes through `senren_t('<component>.<key>', default: 'English')` with a
94
+ literal key and a literal default, and a controller receives it as a Stimulus
95
+ value, never as text in JavaScript. Run `bin/i18n-sync` to regenerate
96
+ `templates/locales/senren.en.yml` and add the same key to `senren.vi.yml`.
97
+ See `docs/i18n.md`.
89
98
  - Variants/sizes declared as class-level constants.
90
99
  - Stimulus controller iff client behavior is needed
91
100
  (`app/javascript/controllers/senren/<name>_controller.js`).
@@ -114,6 +123,8 @@ real documentation/reference app is maintained separately in
114
123
  templates, Stimulus controllers, or Importmap loading guidance.
115
124
  - Run `bun run controllers:check` before pushing if you touched
116
125
  `templates/controllers/*.js`.
126
+ - Run `bin/i18n-sync --check` before pushing if you touched text in a
127
+ component; `bin/i18n-sync` fixes what it reports.
117
128
 
118
129
  ## Pull request workflow
119
130
 
data/README.md CHANGED
@@ -53,6 +53,67 @@ bin/rails senren:add button card badge alert form input \
53
53
  bracketed Rake task form, `bin/rails 'senren:add[button,card]'`, remains
54
54
  supported for backward compatibility.
55
55
 
56
+ ## Palettes
57
+
58
+ The install writes two stylesheets. `senren.css` is the token set every
59
+ component renders against; `senren_themes.css` is optional and adds five
60
+ alternate palettes.
61
+
62
+ ```erb
63
+ <%= stylesheet_link_tag "senren" %>
64
+ <%= stylesheet_link_tag "senren_themes" %> <%# after senren.css %>
65
+ ```
66
+
67
+ ```erb
68
+ <html data-senren-theme="rose">
69
+ ```
70
+
71
+ `rose`, `slate`, `indigo`, `emerald`, `amber` — or omit the attribute for the
72
+ default, Spring Garden. Order matters: `:root` and `[data-senren-theme="rose"]`
73
+ have equal specificity, so the file that loads last wins. Reversed, nothing
74
+ errors and the page quietly renders in the default palette.
75
+
76
+ A theme is a re-declaration of the same tokens and nothing else. No component
77
+ reads the attribute, so a sixth palette is a copied CSS block — no component
78
+ change, no build step, no configuration. Switch at runtime with
79
+ `document.documentElement.dataset.senrenTheme = "slate"`.
80
+
81
+ Details and the token table: [docs/visual_style.md](docs/visual_style.md).
82
+
83
+ ## Translating components
84
+
85
+ Every word a component shows or announces (labels, `aria-label` and
86
+ screen-reader text, default placeholders, the calendar's month and weekday names,
87
+ and what a Stimulus controller says after load) goes through one helper with its
88
+ English as the default:
89
+
90
+ ```ruby
91
+ senren_t('pagination.next', default: 'Next') # I18n.t("senren.pagination.next", default: "Next")
92
+ ```
93
+
94
+ An app with no locale file renders exactly the English it always did, and the
95
+ installer copies none unless asked. To change a word, set its `senren.*` key in
96
+ your own locale file. To add a language, copy a shipped file and translate it:
97
+
98
+ ```bash
99
+ bin/rails generate senren:install --locales vi # writes config/locales/senren.vi.yml
100
+ bin/rails senren:locales en # every key and its English, to translate from
101
+ ```
102
+
103
+ ```yaml
104
+ # config/locales/senren.fr.yml - copy senren.en.yml, change `en:` to `fr:`, translate the values
105
+ fr:
106
+ senren:
107
+ pagination:
108
+ previous: "Précédent"
109
+ next: "Suivant"
110
+ ```
111
+
112
+ The gem ships `en` and `vi`. A key you leave out stays English, and a missing
113
+ key never raises. [docs/i18n.md](docs/i18n.md) has the key naming, the values a
114
+ controller reads, what is not translated, and how to move components you copied
115
+ before this existed.
116
+
56
117
  ## Daily commands
57
118
 
58
119
  ```bash
@@ -61,6 +122,7 @@ bin/rails generate senren:component picker --client # custom component with Sti
61
122
  bin/rails generate senren:component picker --no-client # without Stimulus
62
123
  bin/rails senren:add dialog --client # install interactive official component
63
124
  bin/rails senren:add button # install static official component
125
+ bin/rails senren:locales vi # copy a shipped translation to config/locales
64
126
  bundle exec rails senren:add form input # equivalent alternate entry point
65
127
  bin/rails senren:skill:sync # rebuild .senren/skill.md
66
128
  bin/rails senren:agents:sync # rebuild .senren/agent-rules + adapters
@@ -235,6 +297,8 @@ bin/performance # local payload/performance budgets
235
297
  bun run controllers:check # lint + syntax check for templates/controllers/*.js
236
298
  bun run controllers:lint:fix # auto-fix lint issues for controllers
237
299
  bundle exec rake test:system # Stimulus/system tests
300
+ bin/lint-fix # auto-fix RuboCop, ERB lint, and controller JS
301
+ bin/i18n-sync # rewrite templates/locales/senren.en.yml from the senren_t calls
238
302
  ```
239
303
 
240
304
  ## Contributing
data/docs/i18n.md ADDED
@@ -0,0 +1,194 @@
1
+ # Translating Senren components
2
+
3
+ Every word a component shows or announces can be translated from your app's
4
+ locale files, without editing a component. That covers the visible labels
5
+ ("Previous", "Next", "Close", "Today"), the `aria-label` and screen-reader text
6
+ ("Toggle sidebar", "Go to slide 2"), the defaults for arguments such as
7
+ `label:` and `placeholder:`, the calendar's month and weekday names, and the
8
+ sentences a Stimulus controller writes into the page after it loads ("Copied",
9
+ "Slide 2 of 3").
10
+
11
+ ## How it works
12
+
13
+ Each string goes through one helper, `senren_t`, with its English as the
14
+ default:
15
+
16
+ ```ruby
17
+ senren_t('pagination.next', default: 'Next')
18
+ ```
19
+
20
+ That is `I18n.t("senren.pagination.next", default: "Next")`. If your app defines
21
+ `senren.pagination.next` for the current locale it wins; if not, the English in
22
+ the component is used. Two things follow:
23
+
24
+ - **Nothing is required for English.** Install Senren, render a component, and it
25
+ says what it always said. There is no locale file to forget.
26
+ - **A missing translation never raises**, not even with
27
+ `config.i18n.raise_on_missing_translations = true`, because I18n only reports
28
+ a key that has no default.
29
+
30
+ Keys are `senren.<component>.<key>`. `senren.shared.*` holds the few words that
31
+ are the same word in more than one component (`open`, `close`).
32
+
33
+ ## Install the locale files
34
+
35
+ `senren:install` copies no locale file: English needs none. Every file is
36
+ copied only when you ask for it by name:
37
+
38
+ ```bash
39
+ bin/rails generate senren:install --locales vi # at install time
40
+ bin/rails senren:locales vi # any time later
41
+ bin/rails senren:locales en # every key and its English, to translate from
42
+ bin/rails senren:locales vi --force # replace your copy with the shipped one
43
+ ```
44
+
45
+ Today the gem ships `en` and `vi`. The files are yours once they are copied:
46
+ the installer never overwrites one that exists unless you ask for that file to
47
+ be replaced (`senren:locales <name> --force`, or `--force-locales` on
48
+ `senren:install`). The `--force` that refreshes Senren's own files leaves them
49
+ alone, so a re-run after a gem upgrade does not undo your wording.
50
+
51
+ A copied `senren.en.yml` has two side effects, which is why it is not copied by
52
+ default. Rails loads `config/locales` in name order, so it loads after your
53
+ `en.yml` and overrides any `senren.*` key you set there. And it pins today's
54
+ English: when a later Senren release improves a default, an app holding the copy
55
+ keeps the old wording. Copy it to translate from, and delete it once your own
56
+ language file exists.
57
+
58
+ A translation is only loaded when its file is in `config/locales`, and a locale
59
+ becomes known to I18n when a file for it is loaded. If your app builds a
60
+ language switcher from `I18n.available_locales`, copying `senren.vi.yml` is what
61
+ makes Vietnamese appear in it, which is why it is opt-in.
62
+
63
+ ## Change one word
64
+
65
+ Define just that key in any locale file you already have. You do not need to
66
+ copy the whole file:
67
+
68
+ ```yaml
69
+ # config/locales/en.yml
70
+ en:
71
+ senren:
72
+ pagination:
73
+ next: "Newer"
74
+ previous: "Older"
75
+ ```
76
+
77
+ Everything you do not mention keeps its English. If you copied
78
+ `config/locales/senren.en.yml`, edit the value there instead: that file loads
79
+ after `en.yml` and wins.
80
+
81
+ ## Add a language
82
+
83
+ 1. Run `bin/rails senren:locales en`, then copy `config/locales/senren.en.yml` to
84
+ `config/locales/senren.fr.yml`.
85
+ 2. Change the top-level key from `en:` to `fr:`.
86
+ 3. Translate the values.
87
+
88
+ ```yaml
89
+ fr:
90
+ senren:
91
+ pagination:
92
+ label: "Pagination"
93
+ previous: "Précédent"
94
+ next: "Suivant"
95
+ cart:
96
+ remove_item: "Retirer %{name}"
97
+ ```
98
+
99
+ Keep every `%{name}` spelled exactly as it is. They are filled in when the
100
+ component renders. A translation that names a variable the component does not
101
+ pass (a typo such as `%{nmae}`) cannot be filled in, so that string falls back to
102
+ its English rather than raising. You can
103
+ move a variable anywhere in the sentence, and leave one out if the sentence does
104
+ not need it. A key you delete falls back to English, so a partial translation is
105
+ a working translation.
106
+
107
+ Then select the locale the way your app already does, for example
108
+ `I18n.locale = :fr` in an `around_action`. A word you did not translate shows in
109
+ English, because the English is the default.
110
+
111
+ If you would like the language included in the gem, send the file in a pull
112
+ request: `templates/locales/senren.<locale>.yml`, with the same keys as
113
+ `senren.en.yml`. `test/i18n_catalog_test.rb` checks that the keys and the
114
+ `%{}` variables match.
115
+
116
+ ## Text you pass in yourself
117
+
118
+ An argument you pass is used exactly as given and is never looked up:
119
+
120
+ ```erb
121
+ <%= render Senren::PaginationComponent.new(label: "Pages", current_page: 2, total_pages: 5) %>
122
+ ```
123
+
124
+ Only the argument you leave out gets the translated default. Two details:
125
+
126
+ - `label: nil` still means "no label". For an attribute such as `aria-label` that
127
+ omits it, as it always has. Pass `""` for an empty string and leave the
128
+ argument out for the default.
129
+ - Words that arrive as *data* are yours to supply: a column header built from the
130
+ key `:created_at` ("Created at"), the items of a menu, the text in a slot. Give
131
+ `TableComponent` and `DataTableComponent` a `label:` per column to control it.
132
+
133
+ Values you interpolate into a translation are text. A name containing `<b>`
134
+ shows as the characters `<b>`, not as an element.
135
+
136
+ ## What a controller says
137
+
138
+ Five controllers write a sentence into the page after it loads. They no longer
139
+ carry the English in JavaScript: the component renders each one into a Stimulus
140
+ value, and the controller reads it.
141
+
142
+ | Controller | Value | Key |
143
+ | --- | --- | --- |
144
+ | `clipboard` | `copied-status` | `senren.clipboard.copied_status` |
145
+ | `api_key_field` | `copied-status` | `senren.api_key_field.copied_status` |
146
+ | `carousel` | `status-template` | `senren.carousel.status` |
147
+ | `theme_toggle` | `light-label`, `dark-label` | `senren.theme_toggle.light`, `.dark` |
148
+ | `rich_text_editor_lite` | `link-prompt` | `senren.rich_text_editor_lite.link_prompt` |
149
+
150
+ The carousel's value is a template. Its `%{current}` and `%{total}` are left in
151
+ for the controller to fill in on every slide change, so a translation has to keep
152
+ them.
153
+
154
+ ## Components you copied before this existed
155
+
156
+ `bin/rails senren:add <anything>` adds the `senren_t` helper to your
157
+ `app/components/senren/base_component.rb` if it is missing, so components you add
158
+ from now on work.
159
+
160
+ Components you copied earlier still have their English written into them. To
161
+ translate one, copy it again with `bin/rails senren:add pagination --force`
162
+ (which replaces any edits you made to that component), or change its strings by
163
+ hand. It is one call per string: swap `"Next"` for
164
+ `senren_t('pagination.next', default: 'Next')`. `senren.en.yml` lists the key for
165
+ every string.
166
+
167
+ ## Writing a component or an edit that has text
168
+
169
+ Anything a person reads or a screen reader announces goes through `senren_t`
170
+ with a literal key and a literal English default:
171
+
172
+ ```ruby
173
+ def initialize(label: senren_t('tabs.label', default: 'Tabs'), **html)
174
+ ```
175
+
176
+ ```erb
177
+ <button><%= senren_t('date_picker.today', default: 'Today') %></button>
178
+ <button aria-label="<%= senren_t('cart.remove_item', default: 'Remove %{name}', name: item[:name]) %>">
179
+ ```
180
+
181
+ - Use the component's own namespace. Use `shared` only for a word that more than
182
+ one component says.
183
+ - Put the English where the call is. Do not write it in a locale file first.
184
+ - Give an argument its default in the signature, as above. Ruby evaluates it
185
+ when the component is built, in the locale that is current then.
186
+ - A string a controller needs goes to it as a Stimulus value, never as text in the
187
+ JavaScript.
188
+ - Symbols that are not words (`×`, `+`, `*`) stay as they are.
189
+
190
+ If you are working on the gem itself, `bin/i18n-sync` regenerates
191
+ `templates/locales/senren.en.yml` from the calls, and `bin/i18n-sync --check`
192
+ reports whether it is current. `senren.vi.yml` is written by hand. The tests fail
193
+ if the two files disagree with the code or with each other, if a call is not
194
+ literal, and if a template shows a word that did not go through `senren_t`.
data/docs/visual_style.md CHANGED
@@ -37,6 +37,86 @@ tokens are available for documentation, examples, and branded surfaces.
37
37
  | `--senren-palette-iris` | secondary visual accent |
38
38
  | `--senren-palette-paper` | warm neutral surface |
39
39
 
40
+ ## Palette Presets
41
+
42
+ `senren.css` declares Spring Garden in `:root`. That is the default and needs no
43
+ attribute. `senren_themes.css` adds five alternates, each one a re-declaration
44
+ of the same token table:
45
+
46
+ | `data-senren-theme` | Reads as |
47
+ | --- | --- |
48
+ | *(omitted)* | Spring Garden — warm paper, pine ink, garden green |
49
+ | `rose` | warm pink, low contrast |
50
+ | `slate` | neutral grey, high contrast |
51
+ | `indigo` | cool blue-violet |
52
+ | `emerald` | cool green |
53
+ | `amber` | warm gold |
54
+
55
+ ```erb
56
+ <%= stylesheet_link_tag "senren" %>
57
+ <%= stylesheet_link_tag "senren_themes" %>
58
+ ```
59
+
60
+ ```erb
61
+ <html data-senren-theme="rose">
62
+ ```
63
+
64
+ **Load `senren_themes.css` after `senren.css`.** `:root` and
65
+ `[data-senren-theme="rose"]` are equal specificity, so the later rule wins and
66
+ source order is the whole mechanism. Reversed, both files load, the attribute is
67
+ on `<html>`, nothing errors, and the page renders in the default palette — the
68
+ one failure mode of this feature, and it looks like the theme file was never
69
+ installed.
70
+
71
+ The attribute goes on `<html>`, not on `<body>` or a wrapper, so it composes
72
+ with the existing `.dark` class instead of replacing it. Light/dark stays
73
+ orthogonal to palette: each preset ships both blocks.
74
+
75
+ No component knows themes exist. They read `hsl(var(--senren-*))` and inherit
76
+ whatever is in scope, which is why a sixth palette is a copied CSS block and
77
+ nothing else — no component to touch, no build step, no configuration.
78
+
79
+ Switching at runtime is one attribute write:
80
+
81
+ ```js
82
+ document.documentElement.dataset.senrenTheme = "slate"
83
+ ```
84
+
85
+ ## Overriding a Component's Classes
86
+
87
+ `class_name:` and `class:` are both merged into the element the component
88
+ styles. Merged, not resolved — the component's own class stays in the list:
89
+
90
+ ```erb
91
+ <%= render Senren::DialogComponent.new(class_name: "max-w-sm") %>
92
+ <!-- panel renders class="… max-w-lg max-w-sm …" -->
93
+ ```
94
+
95
+ Two `max-w-*` declarations with identical specificity, so the winner is whichever
96
+ Tailwind emits later in the stylesheet. The order of the HTML attribute is not an
97
+ input to that. Tailwind emits the named scale alphabetically, which measured
98
+ against a real build means:
99
+
100
+ | You pass | Against dialog's `max-w-lg` | Result |
101
+ | --- | --- | --- |
102
+ | `max-w-sm` | `sm` emitted after `lg` | applies |
103
+ | `max-w-2xl` | `2xl` emitted before `lg` | **silently ignored** |
104
+
105
+ Nothing about either class tells you which you are getting. For an override that
106
+ does not depend on emit order, use Tailwind's important modifier:
107
+
108
+ ```erb
109
+ <%= render Senren::DialogComponent.new(class_name: "max-w-2xl!") %>
110
+ ```
111
+
112
+ This applies to any pair from one utility family — `w-`, `h-`, `p-`, `z-`,
113
+ `text-`, `bg-`. It is not specific to widths. Senren does not ship a
114
+ class-conflict resolver; adding one means a `tailwind_merge` dependency, which
115
+ the library has so far chosen not to take.
116
+
117
+ Classes from families the component does not use need no modifier — there is
118
+ nothing to conflict with.
119
+
40
120
  ## Component Rules
41
121
 
42
122
  - Prefer semantic tokens over raw color utilities.