senren-ui 0.1.6 → 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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +215 -0
  3. data/CONTRIBUTING.md +41 -3
  4. data/README.md +138 -33
  5. data/Rakefile +14 -1
  6. data/docs/components.md +10 -10
  7. data/docs/hot_reload.md +103 -0
  8. data/docs/performance_testing.md +7 -3
  9. data/docs/visual_style.md +80 -0
  10. data/lib/generators/senren/component/templates/controller.js.tt +7 -4
  11. data/lib/generators/senren/install/install_generator.rb +77 -0
  12. data/lib/generators/senren/install/templates/base_component.rb.tt +142 -4
  13. data/lib/generators/senren/install/templates/conventions.md.tt +57 -1
  14. data/lib/generators/senren/install/templates/senren_themes.css.tt +339 -0
  15. data/lib/senren/rails/agent_rules_writer.rb +65 -19
  16. data/lib/senren/rails/asset_path_guard.rb +128 -0
  17. data/lib/senren/rails/base_component_patch.rb +64 -0
  18. data/lib/senren/rails/component_copier.rb +101 -50
  19. data/lib/senren/rails/component_installer.rb +26 -0
  20. data/lib/senren/rails/doctor.rb +7 -4
  21. data/lib/senren/rails/engine.rb +23 -0
  22. data/lib/senren/rails/host_paths.rb +11 -2
  23. data/lib/senren/rails/marker_block.rb +81 -0
  24. data/lib/senren/rails/registry.rb +10 -4
  25. data/lib/senren/rails/safe_write.rb +169 -0
  26. data/lib/senren/rails/skill_writer.rb +47 -11
  27. data/lib/senren/rails/version.rb +1 -1
  28. data/lib/senren/rails.rb +1 -1
  29. data/lib/senren-ui.rb +15 -0
  30. data/lib/tasks/senren.rake +43 -17
  31. data/registry/components.yml +45 -0
  32. data/registry/recipes.yml +12 -0
  33. data/templates/components/accordion/accordion_component.html.erb +3 -3
  34. data/templates/components/alert_dialog/alert_dialog_component.html.erb +6 -3
  35. data/templates/components/alert_dialog/alert_dialog_component.rb +5 -1
  36. data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
  37. data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
  38. data/templates/components/avatar/avatar_component.rb +8 -1
  39. data/templates/components/breadcrumb/breadcrumb_component.html.erb +8 -6
  40. data/templates/components/button/button_component.html.erb +1 -1
  41. data/templates/components/button/button_component.rb +27 -1
  42. data/templates/components/calendar/calendar_component.html.erb +2 -2
  43. data/templates/components/card/card_component.html.erb +4 -6
  44. data/templates/components/carousel/carousel_component.html.erb +1 -1
  45. data/templates/components/cart/cart_component.html.erb +67 -0
  46. data/templates/components/cart/cart_component.rb +71 -0
  47. data/templates/components/checkbox/checkbox_component.rb +1 -1
  48. data/templates/components/clipboard/clipboard_component.html.erb +3 -3
  49. data/templates/components/collapsible/collapsible_component.html.erb +3 -3
  50. data/templates/components/combobox/combobox_component.html.erb +3 -3
  51. data/templates/components/command/command_component.html.erb +1 -1
  52. data/templates/components/command/command_component.rb +1 -1
  53. data/templates/components/context_menu/context_menu_component.html.erb +6 -3
  54. data/templates/components/date_picker/date_picker_component.html.erb +3 -3
  55. data/templates/components/dialog/dialog_component.html.erb +7 -3
  56. data/templates/components/dialog/dialog_component.rb +12 -1
  57. data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +6 -3
  58. data/templates/components/dropdown_menu/dropdown_menu_component.rb +57 -5
  59. data/templates/components/form/form_component.rb +9 -1
  60. data/templates/components/hover_card/hover_card_component.html.erb +6 -3
  61. data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +3 -2
  62. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
  63. data/templates/components/link/link_component.html.erb +1 -1
  64. data/templates/components/native_select/native_select_component.html.erb +2 -2
  65. data/templates/components/pagination/pagination_component.html.erb +2 -2
  66. data/templates/components/popover/popover_component.html.erb +6 -3
  67. data/templates/components/product_card/product_card_component.html.erb +38 -0
  68. data/templates/components/product_card/product_card_component.rb +49 -0
  69. data/templates/components/progress/progress_component.html.erb +2 -2
  70. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
  71. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
  72. data/templates/components/search_input/search_input_component.html.erb +2 -2
  73. data/templates/components/separator/separator_component.rb +7 -0
  74. data/templates/components/sheet/sheet_component.html.erb +13 -4
  75. data/templates/components/sheet/sheet_component.rb +12 -1
  76. data/templates/components/shortcut_key/shortcut_key_component.html.erb +2 -2
  77. data/templates/components/sidebar/sidebar_component.html.erb +2 -2
  78. data/templates/components/switch/switch_component.html.erb +1 -1
  79. data/templates/components/table/table_component.html.erb +2 -2
  80. data/templates/components/tabs/tabs_component.html.erb +3 -3
  81. data/templates/components/tooltip/tooltip_component.html.erb +5 -3
  82. data/templates/components/tooltip/tooltip_component.rb +2 -2
  83. data/templates/components/top_nav/top_nav_component.html.erb +2 -2
  84. data/templates/components/typography/typography_component.rb +7 -0
  85. data/templates/controllers/accordion_controller.js +1 -1
  86. data/templates/controllers/alert_dialog_controller.js +31 -7
  87. data/templates/controllers/cart_controller.js +85 -0
  88. data/templates/controllers/clipboard_controller.js +12 -1
  89. data/templates/controllers/command_controller.js +3 -4
  90. data/templates/controllers/context_menu_controller.js +38 -11
  91. data/templates/controllers/data_table_controller.js +8 -3
  92. data/templates/controllers/dialog_controller.js +47 -21
  93. data/templates/controllers/dropdown_menu_controller.js +40 -27
  94. data/templates/controllers/hover_card_controller.js +8 -0
  95. data/templates/controllers/invite_member_dialog_controller.js +6 -0
  96. data/templates/controllers/masked_input_controller.js +8 -1
  97. data/templates/controllers/popover_controller.js +25 -10
  98. data/templates/controllers/rich_text_editor_lite_controller.js +165 -28
  99. data/templates/controllers/sheet_controller.js +41 -11
  100. metadata +14 -17
  101. data/lib/senren/rails/installer.rb +0 -85
@@ -0,0 +1,103 @@
1
+ # Hot reload
2
+
3
+ What reloads automatically, what does not, and why — for both people working
4
+ on the gem and people using it in an app.
5
+
6
+ ## Working on the gem
7
+
8
+ `templates/` is the source of truth, and the preview app under `.local/preview`
9
+ holds *copies* made by `bin/seed_preview`. Editing a template therefore has no
10
+ effect on the running preview until the copy is refreshed. `bin/watch` closes
11
+ that gap.
12
+
13
+ ```bash
14
+ bin/seed_preview # once
15
+ cd .local/preview && bin/rails server # terminal 1
16
+ bin/watch # terminal 2, from the gem root
17
+ ```
18
+
19
+ With both running, editing `templates/components/button/button_component.html.erb`
20
+ copies that one file into the preview app and the browser reloads within about
21
+ a second.
22
+
23
+ What each kind of edit does:
24
+
25
+ | You edit | `bin/watch` does | Result |
26
+ |---|---|---|
27
+ | `templates/components/<name>/*` | copies the one file | page reloads |
28
+ | `templates/controllers/*.js` | copies the one controller | page reloads |
29
+ | `registry/*.yml` | full reinstall of every component | page reloads |
30
+ | `lib/generators/senren/install/templates/*.tt` | copies to its host path | page reloads |
31
+ | `lib/**/*.rb` | nothing | restart `bin/watch` and the server |
32
+
33
+ `lib/` is deliberately not watched: changing gem internals requires restarting
34
+ the Ruby process, which a file copy cannot do.
35
+
36
+ `installed_components.yml.tt` is never synced. That file is a *template* for a
37
+ ledger, and the ledger in the preview app is state — copying over it would
38
+ erase the install history.
39
+
40
+ Options:
41
+
42
+ - `SENREN_PREVIEW_ROOT` — watch a preview app somewhere else. Matches
43
+ `bin/seed_preview`.
44
+ - `SENREN_WATCH_INTERVAL` — poll interval in seconds, default `0.5`.
45
+
46
+ ### How the browser reload works
47
+
48
+ `bin/watch` writes a timestamp to `tmp/senren-reload.txt` after every sync. The
49
+ preview layout polls `/senren/reload_token` twice a second and calls
50
+ `location.reload()` when the value changes.
51
+
52
+ This exists because Rails only reloads *Ruby and ERB*. A Stimulus controller is
53
+ JavaScript already delivered to the browser: syncing the file changes nothing
54
+ until the page is reloaded and the module is fetched again. The token is what
55
+ makes controller edits feel hot.
56
+
57
+ There is no websocket, no ActionCable, and no extra gem — deliberately, since
58
+ the whole plumbing exists only in the gitignored `.local/preview` app and must
59
+ never reach a published gem or a host app.
60
+
61
+ ### Manual check
62
+
63
+ Automated tests cover the path-mapping logic (`test/template_sync_test.rb`).
64
+ The polling loop itself is verified by hand:
65
+
66
+ 1. Start the server and `bin/watch`.
67
+ 2. Change a colour class in a component template — the page reloads with the
68
+ new style.
69
+ 3. Add a `console.log` to a controller — the page reloads and the log appears.
70
+ 4. Add a component to `registry/components.yml` — the watcher reports a full
71
+ reinstall and the new component renders.
72
+
73
+ ## Using the gem in your app
74
+
75
+ After `bin/rails senren:add`, the components are yours. Rails handles most of
76
+ the reloading, so there is little for Senren to add:
77
+
78
+ | You edit | Reloads? |
79
+ |---|---|
80
+ | `app/components/senren/*.rb` | Yes — Rails reloads on the next request |
81
+ | `app/components/senren/*.html.erb` | Yes — same |
82
+ | `app/assets/stylesheets/senren.css` | Yes, once Tailwind rebuilds |
83
+ | `app/javascript/controllers/senren/*.js` | **No** — needs a page reload |
84
+
85
+ The Stimulus row is the one to know about. With importmap the controller is a
86
+ module the browser has already fetched, so editing it does nothing until you
87
+ reload the page. In development Propshaft serves the new file immediately, so
88
+ an ordinary refresh is enough — you do not need to restart the server or clear
89
+ a cache.
90
+
91
+ If you want that refresh automated, the usual choice is the `hotwire-livereload`
92
+ gem in your own `Gemfile`. Senren does not ship one, because doing so would put
93
+ a development dependency and a websocket into every app that installs a
94
+ component library.
95
+
96
+ ### Turbo morphing
97
+
98
+ If your app uses Turbo 8 page morphing, a morph can move or replace the element
99
+ a controller is attached to, which triggers `disconnect()` and `connect()`.
100
+ Senren controllers clean up their document-level listeners in `disconnect()`,
101
+ so they survive repeated morphs. If you write your own controller, do the same:
102
+ anything you add to `document` or `window` in `connect()` or on open must be
103
+ removed in `disconnect()`, or it will accumulate on every navigation.
@@ -16,9 +16,13 @@ bin/ci
16
16
  `bin/performance` reads `config/performance_budgets.yml`.
17
17
  `bin/system` runs headless browser tests against `test/dummy`.
18
18
 
19
- `bin/system` uses Selenium with local Chromium/ChromeDriver by default.
20
- Override paths with `SENREN_CHROME_BIN` and `SENREN_CHROMEDRIVER` if your
21
- machine installs them somewhere else.
19
+ `bin/system` runs Selenium against headless Chrome. It uses a system
20
+ Chrome/Chromium and `/usr/bin/chromedriver` when they are present (the Linux CI
21
+ image), and otherwise lets Selenium Manager resolve and download a matching
22
+ driver — so macOS and other local machines need no setup.
23
+
24
+ Set `SENREN_CHROME_BIN` or `SENREN_CHROMEDRIVER` only to force a specific
25
+ binary; leaving them unset is the supported path.
22
26
 
23
27
  ## Benchmark Model
24
28
 
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.
@@ -3,11 +3,14 @@ import { Controller } from "@hotwired/stimulus"
3
3
  // Stimulus controller for Senren <%= class_name %>.
4
4
  // Identifier: <%= stimulus_identifier %>
5
5
  //
6
- // Responsibilities (local UI only):
6
+ // Scope local UI only:
7
7
  // - Manage open/close, focus, or keyboard behavior for this component.
8
- // Forbidden:
9
- // - fetch / XHR (Turbo handles server state)
10
- // - importing React, Vue, Alpine, lit, or any other framework
8
+ //
9
+ // Out of scope:
10
+ // - fetch / XHR. Turbo owns server state; a controller that fetches ends up
11
+ // holding a second copy of it.
12
+ // - Importing a client-side rendering framework. This component's markup comes
13
+ // from the server, so a second renderer would be fighting it for the DOM.
11
14
  export default class extends Controller {
12
15
  static targets = []
13
16
  static values = {}
@@ -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
@@ -38,6 +41,27 @@ module Senren
38
41
  copy_file Senren::Rails.registry_path, '.senren/registry.yml'
39
42
  end
40
43
 
44
+ # Switches Stimulus to on-demand loading instead of documenting it.
45
+ #
46
+ # Rails' default is `eagerLoadControllersFrom("controllers", application)`,
47
+ # which imports every controller in the importmap on every page. Because
48
+ # `pin_all_from "app/javascript/controllers"` is recursive it also covers
49
+ # app/javascript/controllers/senren, so a static page paid for every
50
+ # interactive component the app had installed.
51
+ #
52
+ # This was a README instruction the developer had to follow by hand, and a
53
+ # "PASS" in bin/performance that only grepped that README. An instruction
54
+ # nobody runs is not a feature.
55
+ #
56
+ # It uses the official stimulus-loading helper rather than a Senren-specific
57
+ # loader, and therefore changes loading for the app's own controllers too.
58
+ # That is the trade the README already asked for; the generator only acts
59
+ # when the file still carries the untouched Rails default, and says so.
60
+ def configure_stimulus_loading
61
+ enable_lazy_controller_loading
62
+ disable_controller_preloading
63
+ end
64
+
41
65
  def write_skill_file
42
66
  say_status :senren, 'writing .senren/skill.md'
43
67
  Senren::Rails::SkillWriter.new(paths: host_paths).sync!
@@ -56,6 +80,59 @@ module Senren
56
80
 
57
81
  private
58
82
 
83
+ def enable_lazy_controller_loading
84
+ index = 'app/javascript/controllers/index.js'
85
+ unless host_file?(index)
86
+ return say_status(:skip,
87
+ "#{index} not found; switch to lazyLoadControllersFrom by hand")
88
+ end
89
+
90
+ source = File.read(File.join(destination_root, index))
91
+ if source.include?('lazyLoadControllersFrom')
92
+ return say_status(:senren,
93
+ 'controllers already load on demand')
94
+ end
95
+
96
+ unless source.include?('eagerLoadControllersFrom')
97
+ return say_status(:skip, "#{index} has a custom loader; left alone")
98
+ end
99
+
100
+ gsub_file index, 'eagerLoadControllersFrom', 'lazyLoadControllersFrom'
101
+ say_status :senren, 'Stimulus controllers now load when their data-controller appears'
102
+ end
103
+
104
+ # Only strips the modulepreload tags. On its own it does not stop eager
105
+ # importing, which is why it is paired with the change above.
106
+ def disable_controller_preloading
107
+ importmap = 'config/importmap.rb'
108
+ unless host_file?(importmap)
109
+ return say_status(:skip,
110
+ "#{importmap} not found; add preload: false by hand")
111
+ end
112
+
113
+ source = File.read(File.join(destination_root, importmap))
114
+ if source.match?(/under:\s*["']controllers["'].*preload:\s*false/)
115
+ return say_status(:senren,
116
+ 'controller preloading already disabled')
117
+ end
118
+
119
+ if source.match?(%r{pin_all_from\s+["']app/javascript/controllers["']})
120
+ # A backreference rather than a block: Thor forwards the block to
121
+ # String#gsub across several frames, where $~ is no longer the match.
122
+ # The lookahead makes a second run a no-op.
123
+ gsub_file importmap,
124
+ %r{(pin_all_from\s+["']app/javascript/controllers["'](?![^\n]*preload:)[^\n]*)},
125
+ '\1, preload: false'
126
+ else
127
+ append_to_file importmap,
128
+ %(\npin_all_from "app/javascript/controllers", under: "controllers", preload: false\n)
129
+ end
130
+ end
131
+
132
+ def host_file?(relative)
133
+ File.exist?(File.join(destination_root, relative))
134
+ end
135
+
59
136
  def host_paths
60
137
  @host_paths ||= Senren::Rails::HostPaths.new(destination_root)
61
138
  end
@@ -27,28 +27,139 @@ module Senren
27
27
 
28
28
  # Compose final root attributes; subclasses pass their base classes.
29
29
  def root_attrs(*classes, **extra)
30
- data = (extra.delete(:data) || {}).merge(senren_component: senren_component_name)
30
+ # `data` arrives from two places: the caller's own attributes (html_attrs)
31
+ # and whatever the template passes here. Both are merged, and the
32
+ # component marker is applied last so it always survives.
33
+ #
34
+ # Splatting html_attrs after a computed `data:` used to overwrite it
35
+ # wholesale, so any component given a `data:` hash silently lost its
36
+ # data-senren-component marker. MaskedInput hit this on every render.
37
+ data = merge_data(caller_data, extra.delete(:data), { senren_component: senren_component_name })
38
+
39
+ # `class` had exactly the same defect and it was never fixed alongside
40
+ # `data`. Every Rails tag helper accepts `class:`, so it is the first
41
+ # thing anyone types — and splatting html_attrs replaced the computed
42
+ # value outright, erasing the component's variant and size styling with
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.
31
46
  tag_class = merge_classes(
32
47
  classes,
33
48
  self.class::VARIANTS[@variant],
34
49
  self.class::SIZES[@size],
35
50
  @class_name,
36
- extra.delete(:class)
51
+ extra.delete(:class),
52
+ caller_class
37
53
  )
38
- { class: tag_class, data: data, **html_attrs, **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)
39
82
  end
40
83
 
41
84
  def senren_component_name
42
85
  self.class.name.to_s.sub(/^Senren::/, '').sub(/Component$/, '').gsub(/([a-z])([A-Z])/, '\1_\2').downcase
43
86
  end
44
87
 
88
+ # Builds a DOM id from the arguments that identify this component, so the
89
+ # same inputs always produce the same id.
90
+ #
91
+ # Components used to append `SecureRandom.hex`, which made every render emit
92
+ # different HTML. That single fact disabled Turbo morph (it pairs nodes by
93
+ # id), HTTP ETags (the body never matched), fragment caching (cached and
94
+ # fresh fragments referenced different ids), and snapshot testing.
95
+ #
96
+ # Two components with identical identifying inputs on one page now produce
97
+ # the same id and the accessibility test fails. That is deliberate: the
98
+ # caller should pass an explicit `id:`. A random suffix hid the collision
99
+ # instead of resolving it.
100
+ def senren_dom_id(*parts)
101
+ seed = parts.flatten.map(&:to_s).reject(&:empty?).join('-')
102
+ base = "senren-#{senren_component_name.tr('_', '-')}"
103
+ return base if seed.empty?
104
+
105
+ "#{base}-#{seed.parameterize}"
106
+ end
107
+
45
108
  private
46
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
+
47
147
  def safe_url(value, fallback: '#', protocols: SAFE_URL_PROTOCOLS)
48
148
  url = value.to_s.strip
49
149
  return fallback if url.empty?
150
+ # Browsers treat "\" as "/" for special schemes and strip TAB/CR/LF
151
+ # before parsing, so "/\evil.example" and "/<TAB>/evil.example" would
152
+ # both slip past a plain "//" check and resolve off-origin.
153
+ return fallback if url.include?('\\')
154
+ return fallback if url.match?(/[[:cntrl:]]/)
50
155
  return url if url.start_with?('#')
51
- return url if url.start_with?('/') && !url.start_with?('//')
156
+ # Any leading "//" is protocol-relative regardless of how many slashes
157
+ # follow. Rejecting here rather than relying on URI.parse matters:
158
+ # URI.parse("///evil.example") reports no scheme and no host, so the
159
+ # scheme-less fallback below would otherwise hand back a URL the browser
160
+ # resolves to https://evil.example/.
161
+ return fallback if url.start_with?('//')
162
+ return url if url.start_with?('/')
52
163
 
53
164
  uri = URI.parse(url)
54
165
  return url if uri.scheme && Array(protocols).map(&:to_s).include?(uri.scheme.downcase)
@@ -71,6 +182,33 @@ module Senren
71
182
  raise ArgumentError, "Unknown #{label}: #{value.inspect}. Allowed: #{table.keys.join(', ')}"
72
183
  end
73
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!")
74
212
  def merge_classes(*sources)
75
213
  sources.flatten.map { |s| s.to_s.strip }.reject(&:empty?).join(' ')
76
214
  end
@@ -8,7 +8,9 @@ and obey it strictly.
8
8
  1. Use Senren components before writing custom HTML for the same purpose.
9
9
  2. Server-render via ViewComponent. Stimulus only for **local** behavior.
10
10
  3. Turbo handles server state. Controllers do not fetch/XHR from JS.
11
- 4. No React, Vue, Alpine, lit, or external state framework. Ever.
11
+ 4. Interactivity stays in Stimulus. These components render on the server, so
12
+ introducing a client-side framework alongside them puts two systems in
13
+ charge of the same state.
12
14
  5. Tailwind classes must use **semantic tokens** (`bg-background`,
13
15
  `text-foreground`, `bg-primary`, `text-muted-foreground`,
14
16
  `border-border`, `bg-destructive`). Do not hard-code `gray-*`,
@@ -27,6 +29,60 @@ and obey it strictly.
27
29
  <%% end %>
28
30
  ```
29
31
 
32
+ 8. **Never put `app/components` on the asset load path.** With Propshaft this
33
+ publishes the directory: `assets:precompile` copies every component `.rb`
34
+ and `.html.erb` into `public/assets`, `.manifest.json` maps each logical
35
+ name to its digested filename, and the web server serves the source with
36
+ `HTTP 200` without Rails involved. Senren raises at boot in production if it
37
+ finds this, and warns elsewhere. Sidecar assets belong in their own
38
+ directory:
39
+ ```ruby
40
+ # config/initializers/assets.rb
41
+ Rails.application.config.assets.paths << Rails.root.join("app/components/assets")
42
+ # NOT app/components
43
+ ```
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
+
30
86
  ## File ownership
31
87
 
32
88
  | Path | Owned by |