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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +215 -0
- data/CONTRIBUTING.md +41 -3
- data/README.md +138 -33
- data/Rakefile +14 -1
- data/docs/components.md +10 -10
- data/docs/hot_reload.md +103 -0
- data/docs/performance_testing.md +7 -3
- data/docs/visual_style.md +80 -0
- data/lib/generators/senren/component/templates/controller.js.tt +7 -4
- data/lib/generators/senren/install/install_generator.rb +77 -0
- data/lib/generators/senren/install/templates/base_component.rb.tt +142 -4
- data/lib/generators/senren/install/templates/conventions.md.tt +57 -1
- data/lib/generators/senren/install/templates/senren_themes.css.tt +339 -0
- data/lib/senren/rails/agent_rules_writer.rb +65 -19
- data/lib/senren/rails/asset_path_guard.rb +128 -0
- data/lib/senren/rails/base_component_patch.rb +64 -0
- data/lib/senren/rails/component_copier.rb +101 -50
- data/lib/senren/rails/component_installer.rb +26 -0
- data/lib/senren/rails/doctor.rb +7 -4
- data/lib/senren/rails/engine.rb +23 -0
- data/lib/senren/rails/host_paths.rb +11 -2
- data/lib/senren/rails/marker_block.rb +81 -0
- data/lib/senren/rails/registry.rb +10 -4
- data/lib/senren/rails/safe_write.rb +169 -0
- data/lib/senren/rails/skill_writer.rb +47 -11
- data/lib/senren/rails/version.rb +1 -1
- data/lib/senren/rails.rb +1 -1
- data/lib/senren-ui.rb +15 -0
- data/lib/tasks/senren.rake +43 -17
- data/registry/components.yml +45 -0
- data/registry/recipes.yml +12 -0
- data/templates/components/accordion/accordion_component.html.erb +3 -3
- data/templates/components/alert_dialog/alert_dialog_component.html.erb +6 -3
- data/templates/components/alert_dialog/alert_dialog_component.rb +5 -1
- data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
- data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
- data/templates/components/avatar/avatar_component.rb +8 -1
- data/templates/components/breadcrumb/breadcrumb_component.html.erb +8 -6
- data/templates/components/button/button_component.html.erb +1 -1
- data/templates/components/button/button_component.rb +27 -1
- data/templates/components/calendar/calendar_component.html.erb +2 -2
- data/templates/components/card/card_component.html.erb +4 -6
- data/templates/components/carousel/carousel_component.html.erb +1 -1
- data/templates/components/cart/cart_component.html.erb +67 -0
- data/templates/components/cart/cart_component.rb +71 -0
- data/templates/components/checkbox/checkbox_component.rb +1 -1
- data/templates/components/clipboard/clipboard_component.html.erb +3 -3
- data/templates/components/collapsible/collapsible_component.html.erb +3 -3
- data/templates/components/combobox/combobox_component.html.erb +3 -3
- data/templates/components/command/command_component.html.erb +1 -1
- data/templates/components/command/command_component.rb +1 -1
- data/templates/components/context_menu/context_menu_component.html.erb +6 -3
- data/templates/components/date_picker/date_picker_component.html.erb +3 -3
- data/templates/components/dialog/dialog_component.html.erb +7 -3
- data/templates/components/dialog/dialog_component.rb +12 -1
- data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +6 -3
- data/templates/components/dropdown_menu/dropdown_menu_component.rb +57 -5
- data/templates/components/form/form_component.rb +9 -1
- data/templates/components/hover_card/hover_card_component.html.erb +6 -3
- data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +3 -2
- data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
- data/templates/components/link/link_component.html.erb +1 -1
- data/templates/components/native_select/native_select_component.html.erb +2 -2
- data/templates/components/pagination/pagination_component.html.erb +2 -2
- data/templates/components/popover/popover_component.html.erb +6 -3
- data/templates/components/product_card/product_card_component.html.erb +38 -0
- data/templates/components/product_card/product_card_component.rb +49 -0
- data/templates/components/progress/progress_component.html.erb +2 -2
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
- data/templates/components/search_input/search_input_component.html.erb +2 -2
- data/templates/components/separator/separator_component.rb +7 -0
- data/templates/components/sheet/sheet_component.html.erb +13 -4
- data/templates/components/sheet/sheet_component.rb +12 -1
- data/templates/components/shortcut_key/shortcut_key_component.html.erb +2 -2
- data/templates/components/sidebar/sidebar_component.html.erb +2 -2
- data/templates/components/switch/switch_component.html.erb +1 -1
- data/templates/components/table/table_component.html.erb +2 -2
- data/templates/components/tabs/tabs_component.html.erb +3 -3
- data/templates/components/tooltip/tooltip_component.html.erb +5 -3
- data/templates/components/tooltip/tooltip_component.rb +2 -2
- data/templates/components/top_nav/top_nav_component.html.erb +2 -2
- data/templates/components/typography/typography_component.rb +7 -0
- data/templates/controllers/accordion_controller.js +1 -1
- data/templates/controllers/alert_dialog_controller.js +31 -7
- data/templates/controllers/cart_controller.js +85 -0
- data/templates/controllers/clipboard_controller.js +12 -1
- data/templates/controllers/command_controller.js +3 -4
- data/templates/controllers/context_menu_controller.js +38 -11
- data/templates/controllers/data_table_controller.js +8 -3
- data/templates/controllers/dialog_controller.js +47 -21
- data/templates/controllers/dropdown_menu_controller.js +40 -27
- data/templates/controllers/hover_card_controller.js +8 -0
- data/templates/controllers/invite_member_dialog_controller.js +6 -0
- data/templates/controllers/masked_input_controller.js +8 -1
- data/templates/controllers/popover_controller.js +25 -10
- data/templates/controllers/rich_text_editor_lite_controller.js +165 -28
- data/templates/controllers/sheet_controller.js +41 -11
- metadata +14 -17
- data/lib/senren/rails/installer.rb +0 -85
data/docs/hot_reload.md
ADDED
|
@@ -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.
|
data/docs/performance_testing.md
CHANGED
|
@@ -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`
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
//
|
|
6
|
+
// Scope — local UI only:
|
|
7
7
|
// - Manage open/close, focus, or keyboard behavior for this component.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// -
|
|
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
|
|
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, **
|
|
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
|
-
|
|
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.
|
|
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 |
|