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
@@ -11,6 +11,24 @@ module Senren
11
11
  class_option :force, type: :boolean, default: false,
12
12
  desc: 'Overwrite existing Senren-managed files.'
13
13
 
14
+ # Nothing by default: English renders with no file. Every locale, English
15
+ # included, is asked for by name. A shipped translation adds its locale to
16
+ # I18n.available_locales, which a language switcher built from that list
17
+ # will then offer; a copied English file overrides the app's own en.yml.
18
+ #
19
+ # `enum:` makes Thor refuse an unknown locale while it parses the options,
20
+ # before any step has run. Refusing in the middle would leave an app with the
21
+ # components copied and no instruction files.
22
+ class_option :locales, type: :array, default: [],
23
+ enum: Senren::Rails::LocaleInstaller.available,
24
+ desc: 'Translations to copy into config/locales.'
25
+
26
+ # --force refreshes the files Senren owns. A locale file stops being one
27
+ # the moment it is copied: it is the app's translation. So replacing it is
28
+ # its own flag, and a routine --force re-run cannot undo someone's wording.
29
+ class_option :force_locales, type: :boolean, default: false,
30
+ desc: 'Replace locale files that already exist with the shipped ones.'
31
+
14
32
  def create_senren_dir
15
33
  empty_directory '.senren'
16
34
  end
@@ -30,6 +48,9 @@ module Senren
30
48
  def copy_base_files
31
49
  template 'base_component.rb.tt', 'app/components/senren/base_component.rb'
32
50
  template 'senren.css.tt', 'app/assets/stylesheets/senren.css'
51
+ # Optional: link it only if you want the palette presets. senren.css
52
+ # alone gives you the default, so this file costs nothing until used.
53
+ template 'senren_themes.css.tt', 'app/assets/stylesheets/senren_themes.css'
33
54
  template 'conventions.md.tt', '.senren/conventions.md'
34
55
  template 'installed_components.yml.tt', '.senren/installed_components.yml'
35
56
  end
@@ -38,6 +59,23 @@ module Senren
38
59
  copy_file Senren::Rails.registry_path, '.senren/registry.yml'
39
60
  end
40
61
 
62
+ # Components work without these: the English is in the code. Nothing is
63
+ # copied unless asked for, English included -- a copied senren.en.yml would
64
+ # override the app's own en.yml and pin today's wording (see
65
+ # LocaleInstaller). An existing file is a translation someone wrote, so it
66
+ # is kept unless --force-locales is given.
67
+ def copy_locale_files
68
+ locales = options[:locales]
69
+ if locales.empty?
70
+ say_status :senren, 'no locale files copied: English needs none (bin/rails senren:locales vi to add one)'
71
+ return
72
+ end
73
+
74
+ say_status :senren, "copying locale files (#{locales.join(', ')}) to config/locales"
75
+ installer = Senren::Rails::LocaleInstaller.new(paths: host_paths)
76
+ installer.install(locales: locales, force: options[:force_locales])
77
+ end
78
+
41
79
  # Switches Stimulus to on-demand loading instead of documenting it.
42
80
  #
43
81
  # Rails' default is `eagerLoadControllersFrom("controllers", application)`,
@@ -72,6 +110,7 @@ module Senren
72
110
  def print_next_steps
73
111
  say "\nSenren installed."
74
112
  say 'Next: bin/rails senren:add button card badge alert dialog'
113
+ say 'Translate the components: bin/rails senren:locales vi (see docs/i18n.md)'
75
114
  say 'Then: bin/rails senren:agents:sync'
76
115
  end
77
116
 
@@ -1,4 +1,5 @@
1
1
  require 'uri'
2
+ require 'active_support/i18n'
2
3
  require 'view_component'
3
4
 
4
5
  module Senren
@@ -34,25 +35,51 @@ module Senren
34
35
  # Splatting html_attrs after a computed `data:` used to overwrite it
35
36
  # wholesale, so any component given a `data:` hash silently lost its
36
37
  # data-senren-component marker. MaskedInput hit this on every render.
37
- data = (html_attrs[:data] || {})
38
- .merge(extra.delete(:data) || {})
39
- .merge(senren_component: senren_component_name)
38
+ data = merge_data(caller_data, extra.delete(:data), { senren_component: senren_component_name })
40
39
 
41
40
  # `class` had exactly the same defect and it was never fixed alongside
42
41
  # `data`. Every Rails tag helper accepts `class:`, so it is the first
43
42
  # thing anyone types — and splatting html_attrs replaced the computed
44
43
  # value outright, erasing the component's variant and size styling with
45
- # no warning. It is merged like any other caller-supplied class instead,
46
- # last so it still wins where the two genuinely conflict.
44
+ # no warning. It is merged like any other caller-supplied class instead.
45
+ #
46
+ # Merged, not resolved, and the distinction matters. See merge_classes.
47
47
  tag_class = merge_classes(
48
48
  classes,
49
49
  self.class::VARIANTS[@variant],
50
50
  self.class::SIZES[@size],
51
51
  @class_name,
52
52
  extra.delete(:class),
53
- html_attrs[:class]
53
+ caller_class
54
54
  )
55
- { class: tag_class, data: data, **html_attrs.except(:data, :class), **extra }
55
+ { class: tag_class, data: data, **html_attrs_without_class_and_data, **extra }
56
+ end
57
+
58
+ # For components whose root is a wrapper and whose styling lives on an inner
59
+ # element -- every overlay: the panel is what gets positioned, the root just
60
+ # hosts the controller.
61
+ #
62
+ # Same merging as root_attrs, minus VARIANTS and SIZES. Passing those to the
63
+ # root of a sheet applied `translate-x-full` there, and a transformed
64
+ # ancestor becomes the containing block for its `position: fixed`
65
+ # descendants -- so the panel was positioned against the shifted root and
66
+ # rendered off-screen. It opened; you just could not see it.
67
+ def wrapper_attrs(*classes, **extra)
68
+ data = merge_data(caller_data, extra.delete(:data), { senren_component: senren_component_name })
69
+ tag_class = merge_classes(classes, extra.delete(:class))
70
+
71
+ { class: tag_class.presence, data: data, **html_attrs_without_class_and_data, **extra }.compact
72
+ end
73
+
74
+ # The class list for the element a wrapper component actually styles.
75
+ #
76
+ # For an overlay the caller means the panel: `class_name: "max-w-2xl"` on a
77
+ # dialog is a request to widen the dialog, not to widen an invisible
78
+ # wrapper. Putting it on the root left it in the DOM doing nothing, which is
79
+ # harder to debug than dropping it -- the class is right there and the panel
80
+ # is still max-w-lg.
81
+ def panel_class(*classes)
82
+ merge_classes(classes, @class_name, caller_class)
56
83
  end
57
84
 
58
85
  def senren_component_name
@@ -79,8 +106,83 @@ module Senren
79
106
  "#{base}-#{seed.parameterize}"
80
107
  end
81
108
 
109
+ # Translates a string the component shows or announces: a visible label, an
110
+ # aria-label, sr-only text, a default placeholder, an announcement a Stimulus
111
+ # controller will read out.
112
+ #
113
+ # senren_t('pagination.next', default: 'Next')
114
+ # senren_t('cart.remove_item', default: 'Remove %{name}', name: item[:name])
115
+ #
116
+ # `default:` is the English text and it is required. It is what renders when
117
+ # the host app has no `senren.<key>` translation, which is why installing
118
+ # Senren needs no locale file, why a key nobody translated never raises (not
119
+ # even under `config.i18n.raise_on_missing_translations`, which only fires
120
+ # for a key with no default), and why the output is the same as it was before
121
+ # any string was translatable.
122
+ #
123
+ # This is I18n.t on purpose, not ViewComponent's `t`. `t` needs a view
124
+ # context, so it cannot run in a unit test that renders a template without
125
+ # Rails; it resolves a leading-dot key against the component's own sidecar
126
+ # scope, which is the lookup this namespace must not have; and it marks keys
127
+ # ending in `_html` as safe, which no Senren string is.
128
+ #
129
+ # Keys are `senren.<component>.<key>`; `senren.shared.<key>` is for a word
130
+ # that is the same word in more than one component. Interpolate with `%{}`.
131
+ # With no values passed the raw template comes back untouched, which is how a
132
+ # Stimulus controller is handed a string it fills in itself.
133
+ #
134
+ # A translation that names a variable the call does not pass (`%{nmae}`
135
+ # for `%{name}`) would raise I18n::MissingInterpolationArgument and turn a
136
+ # typo in a locale file into a 500. It falls back to the English instead,
137
+ # interpolated with the values the call did pass.
138
+ #
139
+ # Public, because the unit tests that render a template without a view
140
+ # delegate every public component method to it.
141
+ def senren_t(key, default:, **options)
142
+ I18n.t("senren.#{key}", default: default, **options)
143
+ rescue I18n::MissingInterpolationArgument
144
+ I18n.interpolate(default, options)
145
+ end
146
+
82
147
  private
83
148
 
149
+ # Callers write `data: { controller: "x" }` and `data: { "controller" => "x" }`
150
+ # interchangeably, and Rails renders both the same, so the merge has to see
151
+ # both the same. Reading with dig(:symbol) missed the String form entirely:
152
+ # the append never ran and the tag builder kept the caller's value alone,
153
+ # which for data-action removed the component's own close-on-click and
154
+ # arrow-key handling.
155
+ #
156
+ # `controller` and `action` are space-separated lists in Stimulus, so a
157
+ # caller's value is appended rather than substituted -- attaching your own
158
+ # controller to a Senren component must not unbind the component's.
159
+ APPENDABLE_DATA = %i[controller action].freeze
160
+
161
+ def merge_data(*sources)
162
+ sources.compact.map { |h| h.transform_keys(&:to_sym) }.reduce({}) do |acc, incoming|
163
+ acc.merge(incoming) do |key, old, new|
164
+ # Split before uniq. Deduplicating the two whole strings only catches an
165
+ # exact repeat: `data: { controller: "a senren--popover" }` on a popover
166
+ # produced "a senren--popover senren--popover", and Stimulus reads that
167
+ # list literally -- same controller connected twice on one element, every
168
+ # action firing twice, so a toggle opens and immediately closes.
169
+ APPENDABLE_DATA.include?(key) ? [old, new].map(&:to_s).join(' ').split.uniq.join(' ') : new
170
+ end
171
+ end
172
+ end
173
+
174
+ def caller_class
175
+ html_attrs[:class] || html_attrs['class']
176
+ end
177
+
178
+ def caller_data
179
+ html_attrs[:data] || html_attrs['data']
180
+ end
181
+
182
+ def html_attrs_without_class_and_data
183
+ html_attrs.reject { |k, _| %w[class data].include?(k.to_s) }
184
+ end
185
+
84
186
  def safe_url(value, fallback: '#', protocols: SAFE_URL_PROTOCOLS)
85
187
  url = value.to_s.strip
86
188
  return fallback if url.empty?
@@ -119,6 +221,33 @@ module Senren
119
221
  raise ArgumentError, "Unknown #{label}: #{value.inspect}. Allowed: #{table.keys.join(', ')}"
120
222
  end
121
223
 
224
+ # Concatenation, not conflict resolution — and the caller's class going last
225
+ # in the attribute does not make it win.
226
+ #
227
+ # `class="max-w-lg max-w-2xl"` leaves both declarations live. Which applies
228
+ # is decided by the order the two rules appear in the stylesheet, because
229
+ # their specificity is identical; the order of the attribute is not an input
230
+ # to that at all. Tailwind emits the named scale alphabetically, so measured
231
+ # against a real build:
232
+ #
233
+ # dialog base max-w-lg + class_name: "max-w-sm" -> sm wins (works)
234
+ # dialog base max-w-lg + class_name: "max-w-2xl" -> lg wins (silent no-op)
235
+ #
236
+ # Narrowing happens to work and widening happens not to, and nothing about
237
+ # either class says which you are getting. Any pair drawn from one utility
238
+ # family has this property.
239
+ #
240
+ # Resolving it properly means knowing which Tailwind utilities conflict,
241
+ # which is irregular enough (`text-sm` and `text-red-500` share a prefix and
242
+ # are different families) that a partial implementation would silently drop
243
+ # a component's own styling — a worse failure than the one it replaces. That
244
+ # is what tailwind_merge is for, and taking the dependency is a decision
245
+ # this library has not made.
246
+ #
247
+ # Until it does, the deterministic override is Tailwind's own important
248
+ # modifier, which beats source order rather than depending on it:
249
+ #
250
+ # render Senren::DialogComponent.new(class_name: "max-w-2xl!")
122
251
  def merge_classes(*sources)
123
252
  sources.flatten.map { |s| s.to_s.strip }.reject(&:empty?).join(' ')
124
253
  end
@@ -42,6 +42,63 @@ and obey it strictly.
42
42
  # NOT app/components
43
43
  ```
44
44
 
45
+ 9. **Do not pass `type:` to a button inside a form.** `ButtonComponent`
46
+ omits the attribute, so the browser's own rule applies and the button
47
+ submits, exactly as plain HTML does. Pass `type: :button` only for a
48
+ control that must *not* submit — a dialog, sheet, or menu trigger.
49
+ ```erb
50
+ <%%= form_with url: sessions_path do %>
51
+ <%%= render(Senren::ButtonComponent.new(variant: :primary)) { "Sign in" } %>
52
+ <%% end %>
53
+
54
+ <%%= render(Senren::ButtonComponent.new(type: :button)) { "Open dialog" } %>
55
+ ```
56
+
57
+ 10. **Load `senren_themes.css` after `senren.css`.** The install writes both.
58
+ `senren.css` declares the token set in `:root`; `senren_themes.css`
59
+ redeclares it under `[data-senren-theme="..."]` for the `rose`, `slate`,
60
+ `indigo`, `emerald`, and `amber` presets. The two selectors have equal
61
+ specificity, so source order decides which wins. In the wrong order every
62
+ file still loads, the attribute is still on `<html>`, nothing errors — and
63
+ the page renders in the default palette.
64
+ ```erb
65
+ <%%= stylesheet_link_tag "senren" %>
66
+ <%%= stylesheet_link_tag "senren_themes" %>
67
+
68
+ <html data-senren-theme="rose"> <%%# omit for the default palette %>
69
+ ```
70
+ The attribute belongs on `<html>` so it composes with `.dark` rather than
71
+ replacing it. Add a palette by copying a block and changing values; no
72
+ component reads the theme, so none needs editing.
73
+
74
+ 11. **`class:` is merged, not resolved.** Passing `class_name: "max-w-sm"` to a
75
+ dialog produces `class="… max-w-lg max-w-sm …"` — the component's own class
76
+ is still there. Both have the same specificity, so the winner is decided by
77
+ which one Tailwind emits later in the stylesheet, **not** by the order in
78
+ the attribute. Narrowing a dialog works; widening it silently does not.
79
+ When overriding a utility the component already uses, add Tailwind's
80
+ important modifier:
81
+ ```erb
82
+ <%%= render Senren::DialogComponent.new(class_name: "max-w-2xl!") %>
83
+ ```
84
+ Classes from families the component does not use need no modifier.
85
+
86
+ 12. **Every word a component shows or announces goes through `senren_t`.**
87
+ Visible text, `aria-label`, `sr-only` text, a default `label:` or
88
+ `placeholder:`, and any sentence a Stimulus controller will say. The English
89
+ is the `default:`, the key is `<component>.<key>`, and both are literals:
90
+ ```ruby
91
+ senren_t('pagination.next', default: 'Next')
92
+ senren_t('cart.remove_item', default: 'Remove %{name}', name: item[:name])
93
+ def initialize(label: senren_t('tabs.label', default: 'Tabs'), **html)
94
+ ```
95
+ Never hard-code a sentence in a template, and never write one in a Stimulus
96
+ controller: hand the controller a translated string as a value. An app with
97
+ no locale file renders the English unchanged; to change a word, set its
98
+ `senren.*` key in a locale file, and to add a language, start from
99
+ `bin/rails senren:locales en` (`bin/rails senren:locales vi` installs a
100
+ shipped translation).
101
+
45
102
  ## File ownership
46
103
 
47
104
  | Path | Owned by |
@@ -0,0 +1,339 @@
1
+ /*
2
+ * Senren UI - optional palette presets.
3
+ *
4
+ * Link this after senren.css and set data-senren-theme on <html> to switch:
5
+ *
6
+ * <%%= stylesheet_link_tag "senren" %>
7
+ * <%%= stylesheet_link_tag "senren_themes" %>
8
+ *
9
+ * <html data-senren-theme="slate">
10
+ *
11
+ * Leave the attribute off for the default Spring Garden palette, which
12
+ * senren.css declares in :root.
13
+ *
14
+ * Every theme below is nothing but a re-declaration of the same token set that
15
+ * senren.css defines. No component is aware that themes exist: components read
16
+ * hsl(var(--senren-*)) and inherit whatever is in scope. Adding your own theme
17
+ * means copying one of these blocks and changing the values — there is no
18
+ * component to touch and no build step to run.
19
+ *
20
+ * Scoping: [data-senren-theme] on <html>, so it composes with the existing
21
+ * .dark class rather than replacing it. Each theme therefore ships a light and
22
+ * a dark block, and light/dark stays orthogonal to the palette choice.
23
+ *
24
+ * `senren` is the default and needs no block: it is what senren.css already
25
+ * declares in :root and .dark.
26
+ *
27
+ * test/themes_test.rb asserts every theme here declares the complete token set,
28
+ * so a palette cannot ship with a variable quietly missing.
29
+ */
30
+
31
+ /* ── slate ────────────────────────────────────────────────────────────────
32
+ Cool neutrals, near-black primary, tight radius. The restrained look most
33
+ developer tools converge on. */
34
+ [data-senren-theme="slate"] {
35
+ --senren-background: 0 0% 100%;
36
+ --senren-foreground: 222 47% 11%;
37
+ --senren-muted: 210 40% 96%;
38
+ --senren-muted-foreground: 215 16% 47%;
39
+ --senren-card: 0 0% 100%;
40
+ --senren-card-foreground: 222 47% 11%;
41
+ --senren-popover: 0 0% 100%;
42
+ --senren-popover-foreground: 222 47% 11%;
43
+ --senren-border: 214 32% 91%;
44
+ --senren-input: 214 32% 91%;
45
+ --senren-ring: 222 47% 11%;
46
+ --senren-primary: 222 47% 11%;
47
+ --senren-primary-foreground: 210 40% 98%;
48
+ --senren-secondary: 210 40% 96%;
49
+ --senren-secondary-foreground: 222 47% 11%;
50
+ --senren-accent: 210 40% 94%;
51
+ --senren-accent-foreground: 222 47% 11%;
52
+ --senren-destructive: 0 72% 51%;
53
+ --senren-destructive-foreground: 210 40% 98%;
54
+ --senren-success: 142 71% 36%;
55
+ --senren-success-foreground: 210 40% 98%;
56
+ --senren-warning: 38 92% 50%;
57
+ --senren-warning-foreground: 222 47% 11%;
58
+ --senren-radius: 0.375rem;
59
+ --senren-palette-sky: 217 91% 60%;
60
+ --senren-palette-sakura: 330 81% 60%;
61
+ --senren-palette-pond: 199 89% 48%;
62
+ --senren-palette-leaf: 142 71% 45%;
63
+ --senren-palette-iris: 258 90% 66%;
64
+ --senren-palette-paper: 210 40% 98%;
65
+ }
66
+
67
+ .dark[data-senren-theme="slate"] {
68
+ --senren-background: 222 47% 7%;
69
+ --senren-foreground: 210 40% 98%;
70
+ --senren-muted: 217 33% 17%;
71
+ --senren-muted-foreground: 215 20% 65%;
72
+ --senren-card: 222 47% 9%;
73
+ --senren-card-foreground: 210 40% 98%;
74
+ --senren-popover: 222 47% 9%;
75
+ --senren-popover-foreground: 210 40% 98%;
76
+ --senren-border: 217 33% 20%;
77
+ --senren-input: 217 33% 20%;
78
+ --senren-ring: 213 27% 84%;
79
+ --senren-primary: 210 40% 98%;
80
+ --senren-primary-foreground: 222 47% 11%;
81
+ --senren-secondary: 217 33% 17%;
82
+ --senren-secondary-foreground: 210 40% 98%;
83
+ --senren-accent: 217 33% 20%;
84
+ --senren-accent-foreground: 210 40% 98%;
85
+ --senren-destructive: 0 63% 46%;
86
+ --senren-destructive-foreground: 210 40% 98%;
87
+ --senren-success: 142 60% 40%;
88
+ --senren-success-foreground: 210 40% 98%;
89
+ --senren-warning: 38 85% 52%;
90
+ --senren-warning-foreground: 222 47% 11%;
91
+ }
92
+
93
+ /* ── indigo ───────────────────────────────────────────────────────────────
94
+ The default of a great many B2B products: white surfaces, indigo action
95
+ colour, cool grey text. */
96
+ [data-senren-theme="indigo"] {
97
+ --senren-background: 0 0% 100%;
98
+ --senren-foreground: 224 71% 4%;
99
+ --senren-muted: 220 14% 96%;
100
+ --senren-muted-foreground: 220 9% 46%;
101
+ --senren-card: 0 0% 100%;
102
+ --senren-card-foreground: 224 71% 4%;
103
+ --senren-popover: 0 0% 100%;
104
+ --senren-popover-foreground: 224 71% 4%;
105
+ --senren-border: 220 13% 91%;
106
+ --senren-input: 220 13% 91%;
107
+ --senren-ring: 239 84% 67%;
108
+ --senren-primary: 243 75% 59%;
109
+ --senren-primary-foreground: 0 0% 100%;
110
+ --senren-secondary: 226 100% 97%;
111
+ --senren-secondary-foreground: 243 75% 40%;
112
+ --senren-accent: 226 100% 94%;
113
+ --senren-accent-foreground: 243 75% 40%;
114
+ --senren-destructive: 0 84% 60%;
115
+ --senren-destructive-foreground: 0 0% 100%;
116
+ --senren-success: 160 84% 32%;
117
+ --senren-success-foreground: 0 0% 100%;
118
+ --senren-warning: 32 95% 52%;
119
+ --senren-warning-foreground: 224 71% 4%;
120
+ --senren-radius: 0.5rem;
121
+ --senren-palette-sky: 217 91% 60%;
122
+ --senren-palette-sakura: 330 81% 70%;
123
+ --senren-palette-pond: 199 89% 60%;
124
+ --senren-palette-leaf: 160 84% 39%;
125
+ --senren-palette-iris: 243 75% 59%;
126
+ --senren-palette-paper: 220 14% 96%;
127
+ }
128
+
129
+ .dark[data-senren-theme="indigo"] {
130
+ --senren-background: 229 42% 8%;
131
+ --senren-foreground: 220 14% 96%;
132
+ --senren-muted: 229 30% 16%;
133
+ --senren-muted-foreground: 218 11% 65%;
134
+ --senren-card: 229 40% 11%;
135
+ --senren-card-foreground: 220 14% 96%;
136
+ --senren-popover: 229 40% 11%;
137
+ --senren-popover-foreground: 220 14% 96%;
138
+ --senren-border: 229 25% 22%;
139
+ --senren-input: 229 25% 22%;
140
+ --senren-ring: 239 84% 72%;
141
+ --senren-primary: 239 84% 70%;
142
+ --senren-primary-foreground: 229 42% 8%;
143
+ --senren-secondary: 229 30% 18%;
144
+ --senren-secondary-foreground: 226 100% 88%;
145
+ --senren-accent: 229 30% 22%;
146
+ --senren-accent-foreground: 226 100% 92%;
147
+ --senren-destructive: 0 72% 55%;
148
+ --senren-destructive-foreground: 0 0% 100%;
149
+ --senren-success: 160 70% 42%;
150
+ --senren-success-foreground: 229 42% 8%;
151
+ --senren-warning: 32 90% 58%;
152
+ --senren-warning-foreground: 229 42% 8%;
153
+ }
154
+
155
+ /* ── emerald ──────────────────────────────────────────────────────────────
156
+ Green action colour on soft neutrals, with a generous radius. Common in
157
+ finance, health, and sustainability products. */
158
+ [data-senren-theme="emerald"] {
159
+ --senren-background: 150 20% 99%;
160
+ --senren-foreground: 160 40% 10%;
161
+ --senren-muted: 150 24% 95%;
162
+ --senren-muted-foreground: 160 12% 42%;
163
+ --senren-card: 0 0% 100%;
164
+ --senren-card-foreground: 160 40% 10%;
165
+ --senren-popover: 0 0% 100%;
166
+ --senren-popover-foreground: 160 40% 10%;
167
+ --senren-border: 152 22% 88%;
168
+ --senren-input: 152 22% 88%;
169
+ --senren-ring: 160 84% 39%;
170
+ --senren-primary: 160 84% 30%;
171
+ --senren-primary-foreground: 150 30% 98%;
172
+ --senren-secondary: 152 45% 92%;
173
+ --senren-secondary-foreground: 160 60% 20%;
174
+ --senren-accent: 168 60% 90%;
175
+ --senren-accent-foreground: 168 70% 18%;
176
+ --senren-destructive: 0 74% 50%;
177
+ --senren-destructive-foreground: 150 30% 98%;
178
+ --senren-success: 160 84% 32%;
179
+ --senren-success-foreground: 150 30% 98%;
180
+ --senren-warning: 40 95% 50%;
181
+ --senren-warning-foreground: 160 40% 10%;
182
+ --senren-radius: 0.75rem;
183
+ --senren-palette-sky: 190 80% 45%;
184
+ --senren-palette-sakura: 340 70% 82%;
185
+ --senren-palette-pond: 172 66% 78%;
186
+ --senren-palette-leaf: 145 63% 49%;
187
+ --senren-palette-iris: 250 60% 78%;
188
+ --senren-palette-paper: 150 24% 95%;
189
+ }
190
+
191
+ .dark[data-senren-theme="emerald"] {
192
+ --senren-background: 165 40% 6%;
193
+ --senren-foreground: 150 24% 95%;
194
+ --senren-muted: 165 28% 14%;
195
+ --senren-muted-foreground: 152 14% 62%;
196
+ --senren-card: 165 38% 9%;
197
+ --senren-card-foreground: 150 24% 95%;
198
+ --senren-popover: 165 38% 9%;
199
+ --senren-popover-foreground: 150 24% 95%;
200
+ --senren-border: 165 22% 20%;
201
+ --senren-input: 165 22% 20%;
202
+ --senren-ring: 160 84% 45%;
203
+ --senren-primary: 158 74% 44%;
204
+ --senren-primary-foreground: 165 40% 6%;
205
+ --senren-secondary: 165 28% 16%;
206
+ --senren-secondary-foreground: 152 45% 88%;
207
+ --senren-accent: 168 40% 20%;
208
+ --senren-accent-foreground: 168 60% 88%;
209
+ --senren-destructive: 0 66% 52%;
210
+ --senren-destructive-foreground: 150 30% 98%;
211
+ --senren-success: 158 70% 42%;
212
+ --senren-success-foreground: 165 40% 6%;
213
+ --senren-warning: 40 88% 56%;
214
+ --senren-warning-foreground: 165 40% 6%;
215
+ }
216
+
217
+ /* ── rose ─────────────────────────────────────────────────────────────────
218
+ Warm neutrals and a soft pink action colour, with the roundest corners of
219
+ the set. The consumer-facing end of SaaS. */
220
+ [data-senren-theme="rose"] {
221
+ --senren-background: 20 40% 99%;
222
+ --senren-foreground: 340 30% 12%;
223
+ --senren-muted: 20 30% 96%;
224
+ --senren-muted-foreground: 340 8% 46%;
225
+ --senren-card: 0 0% 100%;
226
+ --senren-card-foreground: 340 30% 12%;
227
+ --senren-popover: 0 0% 100%;
228
+ --senren-popover-foreground: 340 30% 12%;
229
+ --senren-border: 20 25% 90%;
230
+ --senren-input: 20 25% 90%;
231
+ --senren-ring: 347 77% 60%;
232
+ --senren-primary: 347 77% 50%;
233
+ --senren-primary-foreground: 20 40% 99%;
234
+ --senren-secondary: 350 80% 95%;
235
+ --senren-secondary-foreground: 347 70% 32%;
236
+ --senren-accent: 350 80% 92%;
237
+ --senren-accent-foreground: 347 70% 30%;
238
+ --senren-destructive: 0 78% 52%;
239
+ --senren-destructive-foreground: 20 40% 99%;
240
+ --senren-success: 150 62% 36%;
241
+ --senren-success-foreground: 20 40% 99%;
242
+ --senren-warning: 35 95% 55%;
243
+ --senren-warning-foreground: 340 30% 12%;
244
+ --senren-radius: 1rem;
245
+ --senren-palette-sky: 205 85% 58%;
246
+ --senren-palette-sakura: 350 85% 84%;
247
+ --senren-palette-pond: 190 70% 82%;
248
+ --senren-palette-leaf: 140 55% 55%;
249
+ --senren-palette-iris: 280 70% 78%;
250
+ --senren-palette-paper: 20 30% 96%;
251
+ }
252
+
253
+ .dark[data-senren-theme="rose"] {
254
+ --senren-background: 340 22% 8%;
255
+ --senren-foreground: 20 30% 96%;
256
+ --senren-muted: 340 16% 16%;
257
+ --senren-muted-foreground: 340 8% 64%;
258
+ --senren-card: 340 20% 11%;
259
+ --senren-card-foreground: 20 30% 96%;
260
+ --senren-popover: 340 20% 11%;
261
+ --senren-popover-foreground: 20 30% 96%;
262
+ --senren-border: 340 14% 22%;
263
+ --senren-input: 340 14% 22%;
264
+ --senren-ring: 347 77% 66%;
265
+ --senren-primary: 347 82% 62%;
266
+ --senren-primary-foreground: 340 22% 8%;
267
+ --senren-secondary: 340 16% 18%;
268
+ --senren-secondary-foreground: 350 80% 90%;
269
+ --senren-accent: 340 20% 24%;
270
+ --senren-accent-foreground: 350 80% 90%;
271
+ --senren-destructive: 0 70% 55%;
272
+ --senren-destructive-foreground: 20 40% 99%;
273
+ --senren-success: 150 55% 44%;
274
+ --senren-success-foreground: 340 22% 8%;
275
+ --senren-warning: 35 90% 60%;
276
+ --senren-warning-foreground: 340 22% 8%;
277
+ }
278
+
279
+ /* ── amber ────────────────────────────────────────────────────────────────
280
+ Sand-toned neutrals, amber action colour, near-square corners. The
281
+ document-and-workspace look. */
282
+ [data-senren-theme="amber"] {
283
+ --senren-background: 40 30% 98%;
284
+ --senren-foreground: 30 25% 12%;
285
+ --senren-muted: 40 24% 94%;
286
+ --senren-muted-foreground: 32 10% 44%;
287
+ --senren-card: 40 40% 99%;
288
+ --senren-card-foreground: 30 25% 12%;
289
+ --senren-popover: 40 40% 99%;
290
+ --senren-popover-foreground: 30 25% 12%;
291
+ --senren-border: 36 20% 87%;
292
+ --senren-input: 36 20% 87%;
293
+ --senren-ring: 32 95% 44%;
294
+ --senren-primary: 30 30% 16%;
295
+ --senren-primary-foreground: 40 30% 98%;
296
+ --senren-secondary: 40 60% 90%;
297
+ --senren-secondary-foreground: 30 40% 20%;
298
+ --senren-accent: 38 80% 88%;
299
+ --senren-accent-foreground: 30 45% 20%;
300
+ --senren-destructive: 4 74% 48%;
301
+ --senren-destructive-foreground: 40 30% 98%;
302
+ --senren-success: 140 55% 34%;
303
+ --senren-success-foreground: 40 30% 98%;
304
+ --senren-warning: 32 95% 46%;
305
+ --senren-warning-foreground: 40 30% 98%;
306
+ --senren-radius: 0.25rem;
307
+ --senren-palette-sky: 200 75% 50%;
308
+ --senren-palette-sakura: 20 80% 82%;
309
+ --senren-palette-pond: 190 60% 80%;
310
+ --senren-palette-leaf: 100 45% 50%;
311
+ --senren-palette-iris: 265 55% 74%;
312
+ --senren-palette-paper: 40 24% 94%;
313
+ }
314
+
315
+ .dark[data-senren-theme="amber"] {
316
+ --senren-background: 30 18% 8%;
317
+ --senren-foreground: 40 24% 94%;
318
+ --senren-muted: 30 14% 15%;
319
+ --senren-muted-foreground: 36 10% 62%;
320
+ --senren-card: 30 16% 11%;
321
+ --senren-card-foreground: 40 24% 94%;
322
+ --senren-popover: 30 16% 11%;
323
+ --senren-popover-foreground: 40 24% 94%;
324
+ --senren-border: 30 12% 21%;
325
+ --senren-input: 30 12% 21%;
326
+ --senren-ring: 32 95% 55%;
327
+ --senren-primary: 36 90% 58%;
328
+ --senren-primary-foreground: 30 25% 10%;
329
+ --senren-secondary: 30 14% 17%;
330
+ --senren-secondary-foreground: 40 60% 86%;
331
+ --senren-accent: 32 30% 22%;
332
+ --senren-accent-foreground: 38 80% 86%;
333
+ --senren-destructive: 4 68% 52%;
334
+ --senren-destructive-foreground: 40 30% 98%;
335
+ --senren-success: 140 50% 42%;
336
+ --senren-success-foreground: 30 18% 8%;
337
+ --senren-warning: 32 90% 56%;
338
+ --senren-warning-foreground: 30 18% 8%;
339
+ }
@@ -106,6 +106,13 @@ module Senren
106
106
  publishes every file under an asset path, so component `.rb` and
107
107
  `.html.erb` source is precompiled into `public/assets` and served
108
108
  over HTTP. Put sidecar assets in `app/components/assets` instead.
109
+ - A button inside a form needs no `type:`. ButtonComponent omits the
110
+ attribute so the browser submits, as plain HTML does. Pass
111
+ `type: :button` only for a trigger that must not submit.
112
+ - Text a component shows or announces goes through
113
+ `senren_t('<component>.<key>', default: 'English')`, with a literal key
114
+ and default. Never hard-code a sentence in a template or in a Stimulus
115
+ controller; hand the controller a translated string as a value.
109
116
 
110
117
  ## Important Files
111
118