senren-ui 0.2.0 → 0.3.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -0
  3. data/CONTRIBUTING.md +1 -0
  4. data/README.md +28 -0
  5. data/docs/visual_style.md +80 -0
  6. data/lib/generators/senren/install/install_generator.rb +3 -0
  7. data/lib/generators/senren/install/templates/base_component.rb.tt +97 -7
  8. data/lib/generators/senren/install/templates/conventions.md.tt +41 -0
  9. data/lib/generators/senren/install/templates/senren_themes.css.tt +339 -0
  10. data/lib/senren/rails/agent_rules_writer.rb +3 -0
  11. data/lib/senren/rails/version.rb +1 -1
  12. data/templates/components/accordion/accordion_component.html.erb +3 -3
  13. data/templates/components/alert_dialog/alert_dialog_component.html.erb +6 -3
  14. data/templates/components/alert_dialog/alert_dialog_component.rb +4 -0
  15. data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
  16. data/templates/components/breadcrumb/breadcrumb_component.html.erb +8 -6
  17. data/templates/components/button/button_component.html.erb +1 -1
  18. data/templates/components/button/button_component.rb +27 -1
  19. data/templates/components/calendar/calendar_component.html.erb +2 -2
  20. data/templates/components/card/card_component.html.erb +4 -6
  21. data/templates/components/carousel/carousel_component.html.erb +1 -1
  22. data/templates/components/cart/cart_component.html.erb +7 -1
  23. data/templates/components/clipboard/clipboard_component.html.erb +3 -3
  24. data/templates/components/collapsible/collapsible_component.html.erb +3 -3
  25. data/templates/components/combobox/combobox_component.html.erb +3 -3
  26. data/templates/components/command/command_component.html.erb +1 -1
  27. data/templates/components/context_menu/context_menu_component.html.erb +6 -3
  28. data/templates/components/date_picker/date_picker_component.html.erb +3 -3
  29. data/templates/components/dialog/dialog_component.html.erb +7 -3
  30. data/templates/components/dialog/dialog_component.rb +11 -0
  31. data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +6 -3
  32. data/templates/components/dropdown_menu/dropdown_menu_component.rb +57 -5
  33. data/templates/components/hover_card/hover_card_component.html.erb +6 -3
  34. data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +3 -2
  35. data/templates/components/link/link_component.html.erb +1 -1
  36. data/templates/components/native_select/native_select_component.html.erb +2 -2
  37. data/templates/components/pagination/pagination_component.html.erb +2 -2
  38. data/templates/components/popover/popover_component.html.erb +6 -3
  39. data/templates/components/product_card/product_card_component.html.erb +2 -2
  40. data/templates/components/progress/progress_component.html.erb +2 -2
  41. data/templates/components/search_input/search_input_component.html.erb +2 -2
  42. data/templates/components/sheet/sheet_component.html.erb +13 -4
  43. data/templates/components/sheet/sheet_component.rb +11 -0
  44. data/templates/components/shortcut_key/shortcut_key_component.html.erb +2 -2
  45. data/templates/components/sidebar/sidebar_component.html.erb +2 -2
  46. data/templates/components/switch/switch_component.html.erb +1 -1
  47. data/templates/components/table/table_component.html.erb +2 -2
  48. data/templates/components/tabs/tabs_component.html.erb +3 -3
  49. data/templates/components/tooltip/tooltip_component.html.erb +5 -3
  50. data/templates/components/top_nav/top_nav_component.html.erb +2 -2
  51. data/templates/controllers/cart_controller.js +3 -1
  52. metadata +2 -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: c822ae4c548e611bba93a0ffd7654a881b4626173e3a85898e8221318aca6f2e
4
+ data.tar.gz: 48b9d43cf61a38d652f5e993e5baff8d5bff3514b381ee4eeafce95dad7f1113
5
5
  SHA512:
6
- metadata.gz: f383e88dea3c90da1f1f93831bdf26c4bc5e00e1c63ffeb8ae53031d7c7e38c8afbaa30c445839cdbf8ad346acf247dcd04ae597c01b23704837daeb4eb4c2bf
7
- data.tar.gz: 54e1f4ad10351c05906cdf1f864160cd3a0555e1698c0fa4944a728752cd97560300a9bae40a337a214a39d19eae87a3620fd4690e412d689b9430d022df0662
6
+ metadata.gz: cb02d352ef3be737cc00be01c8178874d6b9409c7e54da22917d2d6c4dbe1be0e3baa948bfabce77e5f18241284097bd254b520845ab71b5d1d4d01f573d4e64
7
+ data.tar.gz: edcb1633812058eba68a7014f7239cddf4df698dccc8371c3d968c73e6cbd0913453d7fb9fee00e791e12e4c1080a9b31f1c1db76b3ac42b069606975d07d684
data/CHANGELOG.md CHANGED
@@ -7,6 +7,117 @@ 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.3.0] — 2026-09-01
11
+
12
+ ### Breaking
13
+
14
+ - **`ButtonComponent` no longer emits `type="button"` by default.** The
15
+ attribute is omitted, so a button inside a form submits it, which is what
16
+ plain HTML does and what everyone expects. The old default silently swallowed
17
+ submits: a form's own submit button did nothing, with no error anywhere to
18
+ explain it. The new default fails the other way, and loudly — a trigger that
19
+ should not submit now has to say so.
20
+
21
+ **Migrate:** pass `type: :button` to any button that must not submit — overlay
22
+ triggers, menu triggers, a dialog's Cancel. Everything inside a form that is
23
+ meant to submit needs no change and starts working. Buttons outside a form
24
+ are unaffected either way.
25
+
26
+ ```erb
27
+ <%= render(Senren::ButtonComponent.new(type: :button)) { "Open dialog" } %>
28
+ ```
29
+
30
+ ### Fixed
31
+
32
+ - Every component now merges a caller's `class:` and `data:` instead of
33
+ dropping them. Splatting `**html_attrs` after the computed values replaced
34
+ them outright, so `class:` erased the component's own variant and size
35
+ styling and `data:` erased its `data-senren-component` marker.
36
+ - For the eight wrapper components — dialog, alert dialog, sheet, popover,
37
+ dropdown menu, context menu, hover card, tooltip — a caller's class now lands
38
+ on the panel, which is the element they style, not on the empty root, where it
39
+ sat in the DOM doing nothing.
40
+
41
+ This makes the extension point reachable; it does not make it resolve
42
+ conflicts. `class_name: "max-w-sm"` on a dialog emits
43
+ `class="max-w-lg max-w-sm"` and both declarations stay live, so which one
44
+ applies is decided by the order Tailwind emits them, not by the order of the
45
+ attribute. Measured against a real build: `max-w-sm` wins (narrowing works),
46
+ `max-w-2xl` loses (widening is a silent no-op). Use Tailwind's important
47
+ modifier — `class_name: "max-w-2xl!"` — for an override that does not depend
48
+ on emit order.
49
+ - `data-controller` and `data-action` are appended rather than substituted, so
50
+ attaching your own Stimulus controller to a Senren component no longer unbinds
51
+ the component's own.
52
+ - A caller's `data-controller` or `data-action` that repeated one of the
53
+ component's own tokens was appended rather than deduplicated:
54
+ `data: { controller: "a senren--popover" }` produced
55
+ `"a senren--popover senren--popover"`. Stimulus reads that list literally, so
56
+ the controller connected twice and every action fired twice — a toggle opened
57
+ and immediately closed. Tokens are deduplicated individually now.
58
+ - `data:` written with String keys (`data: { "action" => ... }`) is merged the
59
+ same as Symbol keys. The Symbol-only read meant a dropdown item passing a
60
+ String key lost close-on-click and arrow-key handling.
61
+ - A dropdown item's `class:` is merged rather than substituted. Losing the hover
62
+ style was cosmetic; losing `focus:bg-` removed the only indication a keyboard
63
+ user has of where they are in the menu.
64
+ - Sheet's scrolling body no longer clips focus rings.
65
+ - Dialog, sheet, and the invite-member dialog close on an overlay click.
66
+ - `method:` on a dropdown item reaches Turbo. It was passed to `link_to` as a
67
+ rails-ujs option, which Rails 7 dropped, so it had rendered an inert `method`
68
+ attribute since the library began targeting Rails 7.1.
69
+ - Card footer spacing, and pagination now wraps.
70
+ - `CartComponent` accepted `remove_url:` per item, normalised it, and wrote it
71
+ nowhere. Removal is client-side and dispatches `senren--cart:removed` for the
72
+ application to act on, so the URL was the one thing the listener needed and
73
+ the only thing it could not get. It is emitted as `data-remove-url` on the
74
+ line and carried in the event detail, omitted entirely when not supplied, and
75
+ passed through `safe_url` like every other URL the library renders.
76
+ - `senren_themes.css` shipped with its ERB examples unrendered, so the header
77
+ comment showed `<%%=` where it means `<%=`. The assertion guarding against that
78
+ named a single file, so the property held exactly where someone had thought to
79
+ look; it now runs over everything the generator writes.
80
+
81
+ ### Documentation
82
+
83
+ - The palette presets shipped in 0.2.0 with no mention in the README, the docs,
84
+ or the generated conventions file. All three now cover them, including the
85
+ load-order constraint: `senren_themes.css` must be linked after `senren.css`
86
+ or the theme silently does nothing.
87
+
88
+ ### Internal
89
+
90
+ - ERB linting names every linter it runs. Thirteen were running against nine
91
+ named in the config; eight formatting linters had been on by default and
92
+ unrecorded. A test now fails if an upgrade adds a fourteenth.
93
+ - `bin/ci` and the GitHub workflow call `erb_lint` rather than the deprecated
94
+ `erblint` shim.
95
+ - json bumped to 2.21.2 for CVE-2026-71847.
96
+ - Herb now lints the markup, and the 32 offenses it found in shipped templates
97
+ are fixed. It was adopted after a developer reported warnings in their editor
98
+ that none of this project's gates produced — RuboCop covered the Ruby, Biome
99
+ the Stimulus controllers, erb_lint the ERB structure, and the HTML itself had
100
+ no linter at all. Pinned to 0.10.3 in `.herb.yml`, and wired into `bin/ci`,
101
+ `bin/lint-fix` and the GitHub workflow.
102
+
103
+ Most of what it found is invisible in the rendered page but visible in the
104
+ editor of anyone who installs these components, since `senren:add` copies them
105
+ into their repository. Fifteen component roots moved from
106
+ `<div <%= tag.attributes(**root_attrs(...)) %>>` to `tag.div(...) do`, which
107
+ is what the overlays already used; seven conditional boolean attributes moved
108
+ from `<%= "hidden" unless expanded %>` to `<% unless expanded %>hidden<% end %>`;
109
+ three raw `<img>` tags became `image_tag`; breadcrumb stopped writing its whole
110
+ `<nav>` once per branch; and an empty `aria-activedescendant` is gone.
111
+
112
+ Nothing changes visually, but the markup string is not identical: rendering
113
+ all 64 components before and after produces 35 differing lines — 26 of them
114
+ whitespace and indentation, 6 the attribute order and self-closing slash that
115
+ `image_tag` emits, 2 the `href` moving to the front of an `<a>`, and 1 the
116
+ removed empty attribute. If you assert on exact markup anywhere, those
117
+ snapshots need regenerating. If you have already installed these components,
118
+ nothing in your application changes at all — the new markup arrives only if
119
+ you re-run `senren:add --force`.
120
+
10
121
  ## [0.2.0] — 2026-08-02
11
122
 
12
123
  A hardening release. Most of it came out of an adversarial review of the whole
data/CONTRIBUTING.md CHANGED
@@ -35,6 +35,7 @@ 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
38
39
  ```
39
40
 
40
41
  `bin/system` needs no setup: it uses a system Chrome and
data/README.md CHANGED
@@ -53,6 +53,33 @@ 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
+
56
83
  ## Daily commands
57
84
 
58
85
  ```bash
@@ -235,6 +262,7 @@ bin/performance # local payload/performance budgets
235
262
  bun run controllers:check # lint + syntax check for templates/controllers/*.js
236
263
  bun run controllers:lint:fix # auto-fix lint issues for controllers
237
264
  bundle exec rake test:system # Stimulus/system tests
265
+ bin/lint-fix # auto-fix RuboCop, ERB lint, and controller JS
238
266
  ```
239
267
 
240
268
  ## Contributing
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.
@@ -30,6 +30,9 @@ module Senren
30
30
  def copy_base_files
31
31
  template 'base_component.rb.tt', 'app/components/senren/base_component.rb'
32
32
  template 'senren.css.tt', 'app/assets/stylesheets/senren.css'
33
+ # Optional: link it only if you want the palette presets. senren.css
34
+ # alone gives you the default, so this file costs nothing until used.
35
+ template 'senren_themes.css.tt', 'app/assets/stylesheets/senren_themes.css'
33
36
  template 'conventions.md.tt', '.senren/conventions.md'
34
37
  template 'installed_components.yml.tt', '.senren/installed_components.yml'
35
38
  end
@@ -34,25 +34,51 @@ module Senren
34
34
  # Splatting html_attrs after a computed `data:` used to overwrite it
35
35
  # wholesale, so any component given a `data:` hash silently lost its
36
36
  # 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)
37
+ data = merge_data(caller_data, extra.delete(:data), { senren_component: senren_component_name })
40
38
 
41
39
  # `class` had exactly the same defect and it was never fixed alongside
42
40
  # `data`. Every Rails tag helper accepts `class:`, so it is the first
43
41
  # thing anyone types — and splatting html_attrs replaced the computed
44
42
  # 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.
43
+ # no warning. It is merged like any other caller-supplied class instead.
44
+ #
45
+ # Merged, not resolved, and the distinction matters. See merge_classes.
47
46
  tag_class = merge_classes(
48
47
  classes,
49
48
  self.class::VARIANTS[@variant],
50
49
  self.class::SIZES[@size],
51
50
  @class_name,
52
51
  extra.delete(:class),
53
- html_attrs[:class]
52
+ caller_class
54
53
  )
55
- { class: tag_class, data: data, **html_attrs.except(:data, :class), **extra }
54
+ { class: tag_class, data: data, **html_attrs_without_class_and_data, **extra }
55
+ end
56
+
57
+ # For components whose root is a wrapper and whose styling lives on an inner
58
+ # element -- every overlay: the panel is what gets positioned, the root just
59
+ # hosts the controller.
60
+ #
61
+ # Same merging as root_attrs, minus VARIANTS and SIZES. Passing those to the
62
+ # root of a sheet applied `translate-x-full` there, and a transformed
63
+ # ancestor becomes the containing block for its `position: fixed`
64
+ # descendants -- so the panel was positioned against the shifted root and
65
+ # rendered off-screen. It opened; you just could not see it.
66
+ def wrapper_attrs(*classes, **extra)
67
+ data = merge_data(caller_data, extra.delete(:data), { senren_component: senren_component_name })
68
+ tag_class = merge_classes(classes, extra.delete(:class))
69
+
70
+ { class: tag_class.presence, data: data, **html_attrs_without_class_and_data, **extra }.compact
71
+ end
72
+
73
+ # The class list for the element a wrapper component actually styles.
74
+ #
75
+ # For an overlay the caller means the panel: `class_name: "max-w-2xl"` on a
76
+ # dialog is a request to widen the dialog, not to widen an invisible
77
+ # wrapper. Putting it on the root left it in the DOM doing nothing, which is
78
+ # harder to debug than dropping it -- the class is right there and the panel
79
+ # is still max-w-lg.
80
+ def panel_class(*classes)
81
+ merge_classes(classes, @class_name, caller_class)
56
82
  end
57
83
 
58
84
  def senren_component_name
@@ -81,6 +107,43 @@ module Senren
81
107
 
82
108
  private
83
109
 
110
+ # Callers write `data: { controller: "x" }` and `data: { "controller" => "x" }`
111
+ # interchangeably, and Rails renders both the same, so the merge has to see
112
+ # both the same. Reading with dig(:symbol) missed the String form entirely:
113
+ # the append never ran and the tag builder kept the caller's value alone,
114
+ # which for data-action removed the component's own close-on-click and
115
+ # arrow-key handling.
116
+ #
117
+ # `controller` and `action` are space-separated lists in Stimulus, so a
118
+ # caller's value is appended rather than substituted -- attaching your own
119
+ # controller to a Senren component must not unbind the component's.
120
+ APPENDABLE_DATA = %i[controller action].freeze
121
+
122
+ def merge_data(*sources)
123
+ sources.compact.map { |h| h.transform_keys(&:to_sym) }.reduce({}) do |acc, incoming|
124
+ acc.merge(incoming) do |key, old, new|
125
+ # Split before uniq. Deduplicating the two whole strings only catches an
126
+ # exact repeat: `data: { controller: "a senren--popover" }` on a popover
127
+ # produced "a senren--popover senren--popover", and Stimulus reads that
128
+ # list literally -- same controller connected twice on one element, every
129
+ # action firing twice, so a toggle opens and immediately closes.
130
+ APPENDABLE_DATA.include?(key) ? [old, new].map(&:to_s).join(' ').split.uniq.join(' ') : new
131
+ end
132
+ end
133
+ end
134
+
135
+ def caller_class
136
+ html_attrs[:class] || html_attrs['class']
137
+ end
138
+
139
+ def caller_data
140
+ html_attrs[:data] || html_attrs['data']
141
+ end
142
+
143
+ def html_attrs_without_class_and_data
144
+ html_attrs.reject { |k, _| %w[class data].include?(k.to_s) }
145
+ end
146
+
84
147
  def safe_url(value, fallback: '#', protocols: SAFE_URL_PROTOCOLS)
85
148
  url = value.to_s.strip
86
149
  return fallback if url.empty?
@@ -119,6 +182,33 @@ module Senren
119
182
  raise ArgumentError, "Unknown #{label}: #{value.inspect}. Allowed: #{table.keys.join(', ')}"
120
183
  end
121
184
 
185
+ # Concatenation, not conflict resolution — and the caller's class going last
186
+ # in the attribute does not make it win.
187
+ #
188
+ # `class="max-w-lg max-w-2xl"` leaves both declarations live. Which applies
189
+ # is decided by the order the two rules appear in the stylesheet, because
190
+ # their specificity is identical; the order of the attribute is not an input
191
+ # to that at all. Tailwind emits the named scale alphabetically, so measured
192
+ # against a real build:
193
+ #
194
+ # dialog base max-w-lg + class_name: "max-w-sm" -> sm wins (works)
195
+ # dialog base max-w-lg + class_name: "max-w-2xl" -> lg wins (silent no-op)
196
+ #
197
+ # Narrowing happens to work and widening happens not to, and nothing about
198
+ # either class says which you are getting. Any pair drawn from one utility
199
+ # family has this property.
200
+ #
201
+ # Resolving it properly means knowing which Tailwind utilities conflict,
202
+ # which is irregular enough (`text-sm` and `text-red-500` share a prefix and
203
+ # are different families) that a partial implementation would silently drop
204
+ # a component's own styling — a worse failure than the one it replaces. That
205
+ # is what tailwind_merge is for, and taking the dependency is a decision
206
+ # this library has not made.
207
+ #
208
+ # Until it does, the deterministic override is Tailwind's own important
209
+ # modifier, which beats source order rather than depending on it:
210
+ #
211
+ # render Senren::DialogComponent.new(class_name: "max-w-2xl!")
122
212
  def merge_classes(*sources)
123
213
  sources.flatten.map { |s| s.to_s.strip }.reject(&:empty?).join(' ')
124
214
  end
@@ -42,6 +42,47 @@ 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
+
45
86
  ## File ownership
46
87
 
47
88
  | Path | Owned by |