senren-ui 0.1.5 → 0.2.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 +124 -2
- data/CONTRIBUTING.md +79 -9
- data/README.md +149 -14
- data/Rakefile +14 -1
- data/docs/components.md +222 -0
- data/docs/hot_reload.md +103 -0
- data/docs/performance_testing.md +38 -0
- data/lib/commands/senren/add/add_command.rb +35 -0
- data/lib/generators/senren/component/templates/controller.js.tt +7 -4
- data/lib/generators/senren/install/install_generator.rb +74 -0
- data/lib/generators/senren/install/templates/base_component.rb.tt +89 -8
- data/lib/generators/senren/install/templates/conventions.md.tt +26 -7
- data/lib/senren/rails/agent_rules_writer.rb +62 -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 +138 -18
- data/lib/senren/rails/component_installer.rb +73 -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 +73 -35
- data/lib/senren/rails/safe_write.rb +169 -0
- data/lib/senren/rails/skill_writer.rb +48 -12
- data/lib/senren/rails/version.rb +1 -1
- data/lib/senren/rails.rb +2 -1
- data/lib/senren-ui.rb +15 -0
- data/lib/tasks/senren.rake +53 -34
- data/registry/components.yml +45 -0
- data/registry/recipes.yml +12 -0
- data/templates/components/alert_dialog/alert_dialog_component.rb +1 -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/billing_plan_card/billing_plan_card_component.html.erb +1 -1
- data/templates/components/breadcrumb/breadcrumb_component.rb +2 -2
- data/templates/components/button/button_component.html.erb +1 -1
- data/templates/components/carousel/carousel_component.rb +1 -1
- data/templates/components/cart/cart_component.html.erb +61 -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 +1 -1
- data/templates/components/command/command_component.rb +2 -2
- data/templates/components/date_picker/date_picker_component.html.erb +1 -1
- data/templates/components/dialog/dialog_component.rb +1 -1
- data/templates/components/dropdown_menu/dropdown_menu_component.rb +10 -7
- data/templates/components/form/form_component.html.erb +8 -1
- data/templates/components/form/form_component.rb +12 -2
- data/templates/components/input/input_component.html.erb +1 -1
- data/templates/components/input/input_component.rb +19 -0
- data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
- data/templates/components/label/label_component.html.erb +1 -2
- data/templates/components/label/label_component.rb +12 -2
- data/templates/components/link/link_component.html.erb +1 -1
- data/templates/components/native_select/native_select_component.html.erb +19 -5
- data/templates/components/native_select/native_select_component.rb +17 -5
- data/templates/components/pagination/pagination_component.rb +2 -1
- 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/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/separator/separator_component.rb +7 -0
- data/templates/components/sheet/sheet_component.rb +1 -1
- data/templates/components/sidebar/sidebar_component.rb +2 -2
- data/templates/components/switch/switch_component.html.erb +2 -2
- data/templates/components/tooltip/tooltip_component.rb +2 -2
- data/templates/components/top_nav/top_nav_component.rb +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 +83 -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 +175 -28
- data/templates/controllers/sheet_controller.js +41 -11
- metadata +20 -6
- data/lib/senren/rails/installer.rb +0 -84
data/docs/components.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# Senren Components Guide
|
|
2
|
+
|
|
3
|
+
This guide covers the form-related components in detail, with runnable examples
|
|
4
|
+
and explicit guidance on common pitfalls.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## FormComponent
|
|
9
|
+
|
|
10
|
+
Wraps `form_with` with Senren semantic tokens and consistent spacing.
|
|
11
|
+
|
|
12
|
+
### Key behavior
|
|
13
|
+
|
|
14
|
+
- **Yields a Rails form builder** (`|f|`) — use `f.text_field`, `f.select`, etc.
|
|
15
|
+
inside the block just like a normal Rails form.
|
|
16
|
+
- **`method:` defaults to `nil`** — Rails will infer `:post` for new records and
|
|
17
|
+
`:patch` for persisted records. Only pass `method:` explicitly when you need to
|
|
18
|
+
override this (e.g., `method: :delete`).
|
|
19
|
+
- **`model: nil` is safe** — model-less forms (login, search, password reset)
|
|
20
|
+
work without passing a model.
|
|
21
|
+
|
|
22
|
+
### Examples
|
|
23
|
+
|
|
24
|
+
#### Basic model form (create)
|
|
25
|
+
|
|
26
|
+
```erb
|
|
27
|
+
<%= render(Senren::FormComponent.new(model: @post, url: posts_path)) do |f| %>
|
|
28
|
+
<%= render(Senren::LabelComponent.new(for_field: "title", variant: :required)) { "Title" } %>
|
|
29
|
+
<%= f.text_field :title, class: "senren-input" %>
|
|
30
|
+
<%= render(Senren::ButtonComponent.new(variant: :primary, type: :submit)) { "Create" } %>
|
|
31
|
+
<% end %>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
#### Edit form (Rails infers PATCH automatically)
|
|
35
|
+
|
|
36
|
+
```erb
|
|
37
|
+
<%= render(Senren::FormComponent.new(model: @post, url: post_path(@post))) do |f| %>
|
|
38
|
+
<%= render(Senren::LabelComponent.new(for_field: "title")) { "Title" } %>
|
|
39
|
+
<%= f.text_field :title, class: "senren-input" %>
|
|
40
|
+
<%= render(Senren::ButtonComponent.new(variant: :primary, type: :submit)) { "Update" } %>
|
|
41
|
+
<% end %>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
#### Model-less form (login)
|
|
45
|
+
|
|
46
|
+
```erb
|
|
47
|
+
<%= render(Senren::FormComponent.new(url: session_path)) do |f| %>
|
|
48
|
+
<%= render(Senren::InputComponent.new(name: "email", type: "email", placeholder: "you@example.com")) %>
|
|
49
|
+
<%= render(Senren::InputComponent.new(name: "password", type: "password")) %>
|
|
50
|
+
<%= render(Senren::ButtonComponent.new(variant: :primary, type: :submit)) { "Sign in" } %>
|
|
51
|
+
<% end %>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Leave `method:` unset on edit forms for persisted models. Rails needs it to be
|
|
55
|
+
`nil` to infer `PATCH`; passing `method: :post` produces
|
|
56
|
+
`No route matches [POST] "/resource/:id"`.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## InputComponent
|
|
61
|
+
|
|
62
|
+
### InputComponent vs `form.text_field`
|
|
63
|
+
|
|
64
|
+
**`InputComponent` renders its own `<input>` tag.** It is a **replacement** for
|
|
65
|
+
`form.text_field`, not an add-on. Do not combine them.
|
|
66
|
+
|
|
67
|
+
#### Use one or the other
|
|
68
|
+
|
|
69
|
+
```erb
|
|
70
|
+
<%# Option A: Use InputComponent standalone %>
|
|
71
|
+
<%= render(Senren::InputComponent.new(name: "email", type: "email")) %>
|
|
72
|
+
|
|
73
|
+
<%# Option B: Use Rails form builder with plain classes %>
|
|
74
|
+
<%= f.text_field :email, class: "your-input-classes" %>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
#### Not both together
|
|
78
|
+
|
|
79
|
+
```erb
|
|
80
|
+
<%# This renders two inputs %>
|
|
81
|
+
<%= render(Senren::InputComponent.new(name: "email")) do %>
|
|
82
|
+
<%= f.text_field :email %>
|
|
83
|
+
<% end %>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Required params
|
|
87
|
+
|
|
88
|
+
| Param | Type | Required | Default | Description |
|
|
89
|
+
|-------|------|----------|---------|-------------|
|
|
90
|
+
| `name` | String/Symbol | **Yes** | — | Input `name` attribute |
|
|
91
|
+
| `type` | String | No | `"text"` | HTML input type |
|
|
92
|
+
| `value` | String | No | `nil` | Pre-filled value |
|
|
93
|
+
| `placeholder` | String | No | `nil` | Placeholder text |
|
|
94
|
+
| `id` | String | No | auto | Defaults to parameterized `name` |
|
|
95
|
+
| `variant` | Symbol | No | `:default` | `:default` or `:error` |
|
|
96
|
+
| `size` | Symbol | No | `:md` | `:sm`, `:md`, or `:lg` |
|
|
97
|
+
|
|
98
|
+
### File inputs
|
|
99
|
+
|
|
100
|
+
File inputs automatically get styled button treatment:
|
|
101
|
+
|
|
102
|
+
```erb
|
|
103
|
+
<%= render(Senren::InputComponent.new(name: "avatar", type: "file")) %>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The file selector button uses a segmented style with a divider, semantic surface
|
|
107
|
+
color, and pointer cursor — no additional classes needed.
|
|
108
|
+
|
|
109
|
+
### Date/time inputs
|
|
110
|
+
|
|
111
|
+
Native date and datetime-local inputs work correctly. The component intentionally
|
|
112
|
+
omits `display: flex` from base styles because it breaks browser-native
|
|
113
|
+
date/time picker UI on some engines.
|
|
114
|
+
|
|
115
|
+
```erb
|
|
116
|
+
<%= render(Senren::InputComponent.new(name: "starts_at", type: "datetime-local")) %>
|
|
117
|
+
<%= render(Senren::InputComponent.new(name: "due_date", type: "date")) %>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## LabelComponent
|
|
123
|
+
|
|
124
|
+
### Required params
|
|
125
|
+
|
|
126
|
+
| Param | Type | Required | Default | Description |
|
|
127
|
+
|-------|------|----------|---------|-------------|
|
|
128
|
+
| `for_field` | String | **Yes** | — | ID of the associated form control |
|
|
129
|
+
| `text` | String | No | `nil` | Fallback label text (if block is empty) |
|
|
130
|
+
| `variant` | Symbol | No | `:default` | `:default` or `:required` (adds `*`) |
|
|
131
|
+
|
|
132
|
+
### Examples
|
|
133
|
+
|
|
134
|
+
Both patterns below are fully supported and produce identical output:
|
|
135
|
+
|
|
136
|
+
```erb
|
|
137
|
+
<%# Block syntax %>
|
|
138
|
+
<%= render(Senren::LabelComponent.new(for_field: "name", variant: :required)) { "Student name" } %>
|
|
139
|
+
|
|
140
|
+
<%# Text param syntax (useful when block content might be empty) %>
|
|
141
|
+
<%= render(Senren::LabelComponent.new(for_field: "name", text: "Student name", variant: :required)) %>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
> **Note**: The `text:` param acts as a fallback. If both a block and `text:` are
|
|
145
|
+
> provided, the block content wins.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## NativeSelectComponent
|
|
150
|
+
|
|
151
|
+
Renders a native `<select>` element with Senren styling.
|
|
152
|
+
|
|
153
|
+
### Key behavior
|
|
154
|
+
|
|
155
|
+
- **Defaults to native browser arrow** (`native_arrow: true`). This preserves
|
|
156
|
+
the platform's familiar select appearance (iOS wheel, Android dropdown, etc.).
|
|
157
|
+
- Pass `native_arrow: false` to use a custom SVG chevron overlay instead.
|
|
158
|
+
|
|
159
|
+
### Required params
|
|
160
|
+
|
|
161
|
+
| Param | Type | Required | Default | Description |
|
|
162
|
+
|-------|------|----------|---------|-------------|
|
|
163
|
+
| `name` | String/Symbol | **Yes** | — | Select `name` attribute |
|
|
164
|
+
| `options` | Array | **Yes** | — | `["a","b"]` or `[["val","Label"],...]` |
|
|
165
|
+
| `selected` | String | No | `nil` | Pre-selected value |
|
|
166
|
+
| `prompt` | String | No | `nil` | Blank option text (e.g., "Choose…") |
|
|
167
|
+
| `native_arrow` | Boolean | No | `true` | Use native browser arrow |
|
|
168
|
+
| `variant` | Symbol | No | `:default` | `:default` or `:error` |
|
|
169
|
+
|
|
170
|
+
### Examples
|
|
171
|
+
|
|
172
|
+
```erb
|
|
173
|
+
<%# Native arrow (default — recommended) %>
|
|
174
|
+
<%= render(Senren::NativeSelectComponent.new(
|
|
175
|
+
name: "role",
|
|
176
|
+
options: [["admin", "Admin"], ["member", "Member"]],
|
|
177
|
+
prompt: "Choose role…"
|
|
178
|
+
)) %>
|
|
179
|
+
|
|
180
|
+
<%# Custom SVG arrow %>
|
|
181
|
+
<%= render(Senren::NativeSelectComponent.new(
|
|
182
|
+
name: "role",
|
|
183
|
+
options: [["admin", "Admin"], ["member", "Member"]],
|
|
184
|
+
native_arrow: false
|
|
185
|
+
)) %>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## FormFieldComponent (pattern — not yet a shipped component)
|
|
191
|
+
|
|
192
|
+
A common pattern when building forms is wrapping label + control + error. Until a
|
|
193
|
+
dedicated `FormFieldComponent` ships, use this pattern:
|
|
194
|
+
|
|
195
|
+
```erb
|
|
196
|
+
<div class="space-y-1.5">
|
|
197
|
+
<%= render(Senren::LabelComponent.new(for_field: "email", variant: :required)) { "Email" } %>
|
|
198
|
+
<%= render(Senren::InputComponent.new(name: "email", type: "email", variant: @errors[:email] ? :error : :default)) %>
|
|
199
|
+
<% if @errors[:email] %>
|
|
200
|
+
<p class="text-sm text-[hsl(var(--senren-destructive))]"><%= @errors[:email] %></p>
|
|
201
|
+
<% end %>
|
|
202
|
+
</div>
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Render syntax reminder
|
|
208
|
+
|
|
209
|
+
Always use **parentheses** around `render` when passing an inline content block:
|
|
210
|
+
|
|
211
|
+
```erb
|
|
212
|
+
<%# Correct: parens around render %>
|
|
213
|
+
<%= render(Senren::ButtonComponent.new(variant: :primary)) { "Save" } %>
|
|
214
|
+
|
|
215
|
+
<%# Correct: do/end block %>
|
|
216
|
+
<%= render Senren::ButtonComponent.new(variant: :primary) do %>
|
|
217
|
+
Save
|
|
218
|
+
<% end %>
|
|
219
|
+
|
|
220
|
+
<%# Wrong: the block attaches to .new, not to render %>
|
|
221
|
+
<%= render Senren::ButtonComponent.new(variant: :primary) { "Save" } %>
|
|
222
|
+
```
|
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.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Performance Testing
|
|
2
|
+
|
|
3
|
+
Senren uses CI-safe performance gates by default. The goal is to catch
|
|
4
|
+
regressions that this source-copy UI gem can control: template payload
|
|
5
|
+
growth, Stimulus controller payload growth, eager controller loading, and
|
|
6
|
+
runtime-heavy client behavior.
|
|
7
|
+
|
|
8
|
+
## Commands
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
bin/performance
|
|
12
|
+
bin/system
|
|
13
|
+
bin/ci
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`bin/performance` reads `config/performance_budgets.yml`.
|
|
17
|
+
`bin/system` runs headless browser tests against `test/dummy`.
|
|
18
|
+
|
|
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.
|
|
26
|
+
|
|
27
|
+
## Benchmark Model
|
|
28
|
+
|
|
29
|
+
- Static budgets are deterministic and run on every PR.
|
|
30
|
+
- System tests verify browser behavior and coarse budgets.
|
|
31
|
+
- Lighthouse CI is deferred until a stable preview/docs URL exists.
|
|
32
|
+
- Competitive framework benchmarking is out of scope for normal CI.
|
|
33
|
+
|
|
34
|
+
## Browser Budgets
|
|
35
|
+
|
|
36
|
+
System tests assert gross DOM size, controller loading, external resource,
|
|
37
|
+
and interaction-duration budgets. They intentionally avoid tight timing
|
|
38
|
+
thresholds because shared CI hardware is noisy.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/command'
|
|
4
|
+
require 'senren/rails'
|
|
5
|
+
|
|
6
|
+
module Senren
|
|
7
|
+
module Command
|
|
8
|
+
class AddCommand < ::Rails::Command::Base
|
|
9
|
+
class_option :client, type: :boolean, default: nil,
|
|
10
|
+
desc: 'Override registry client behavior for installed components.'
|
|
11
|
+
class_option :force, type: :boolean, default: false,
|
|
12
|
+
desc: 'Overwrite existing component files.'
|
|
13
|
+
|
|
14
|
+
desc 'add COMPONENT [COMPONENT...]',
|
|
15
|
+
'Install one or more Senren components into the current Rails app.'
|
|
16
|
+
def perform(*names)
|
|
17
|
+
require_application!
|
|
18
|
+
|
|
19
|
+
component_installer.install(
|
|
20
|
+
names: names,
|
|
21
|
+
client_override: options[:client],
|
|
22
|
+
force: options[:force]
|
|
23
|
+
)
|
|
24
|
+
rescue ArgumentError => e
|
|
25
|
+
raise ::Rails::Command::Base::Error, e.message
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def component_installer
|
|
31
|
+
Senren::Rails::ComponentInstaller.new
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -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 = {}
|
|
@@ -38,6 +38,27 @@ module Senren
|
|
|
38
38
|
copy_file Senren::Rails.registry_path, '.senren/registry.yml'
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# Switches Stimulus to on-demand loading instead of documenting it.
|
|
42
|
+
#
|
|
43
|
+
# Rails' default is `eagerLoadControllersFrom("controllers", application)`,
|
|
44
|
+
# which imports every controller in the importmap on every page. Because
|
|
45
|
+
# `pin_all_from "app/javascript/controllers"` is recursive it also covers
|
|
46
|
+
# app/javascript/controllers/senren, so a static page paid for every
|
|
47
|
+
# interactive component the app had installed.
|
|
48
|
+
#
|
|
49
|
+
# This was a README instruction the developer had to follow by hand, and a
|
|
50
|
+
# "PASS" in bin/performance that only grepped that README. An instruction
|
|
51
|
+
# nobody runs is not a feature.
|
|
52
|
+
#
|
|
53
|
+
# It uses the official stimulus-loading helper rather than a Senren-specific
|
|
54
|
+
# loader, and therefore changes loading for the app's own controllers too.
|
|
55
|
+
# That is the trade the README already asked for; the generator only acts
|
|
56
|
+
# when the file still carries the untouched Rails default, and says so.
|
|
57
|
+
def configure_stimulus_loading
|
|
58
|
+
enable_lazy_controller_loading
|
|
59
|
+
disable_controller_preloading
|
|
60
|
+
end
|
|
61
|
+
|
|
41
62
|
def write_skill_file
|
|
42
63
|
say_status :senren, 'writing .senren/skill.md'
|
|
43
64
|
Senren::Rails::SkillWriter.new(paths: host_paths).sync!
|
|
@@ -56,6 +77,59 @@ module Senren
|
|
|
56
77
|
|
|
57
78
|
private
|
|
58
79
|
|
|
80
|
+
def enable_lazy_controller_loading
|
|
81
|
+
index = 'app/javascript/controllers/index.js'
|
|
82
|
+
unless host_file?(index)
|
|
83
|
+
return say_status(:skip,
|
|
84
|
+
"#{index} not found; switch to lazyLoadControllersFrom by hand")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
source = File.read(File.join(destination_root, index))
|
|
88
|
+
if source.include?('lazyLoadControllersFrom')
|
|
89
|
+
return say_status(:senren,
|
|
90
|
+
'controllers already load on demand')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
unless source.include?('eagerLoadControllersFrom')
|
|
94
|
+
return say_status(:skip, "#{index} has a custom loader; left alone")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
gsub_file index, 'eagerLoadControllersFrom', 'lazyLoadControllersFrom'
|
|
98
|
+
say_status :senren, 'Stimulus controllers now load when their data-controller appears'
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Only strips the modulepreload tags. On its own it does not stop eager
|
|
102
|
+
# importing, which is why it is paired with the change above.
|
|
103
|
+
def disable_controller_preloading
|
|
104
|
+
importmap = 'config/importmap.rb'
|
|
105
|
+
unless host_file?(importmap)
|
|
106
|
+
return say_status(:skip,
|
|
107
|
+
"#{importmap} not found; add preload: false by hand")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
source = File.read(File.join(destination_root, importmap))
|
|
111
|
+
if source.match?(/under:\s*["']controllers["'].*preload:\s*false/)
|
|
112
|
+
return say_status(:senren,
|
|
113
|
+
'controller preloading already disabled')
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if source.match?(%r{pin_all_from\s+["']app/javascript/controllers["']})
|
|
117
|
+
# A backreference rather than a block: Thor forwards the block to
|
|
118
|
+
# String#gsub across several frames, where $~ is no longer the match.
|
|
119
|
+
# The lookahead makes a second run a no-op.
|
|
120
|
+
gsub_file importmap,
|
|
121
|
+
%r{(pin_all_from\s+["']app/javascript/controllers["'](?![^\n]*preload:)[^\n]*)},
|
|
122
|
+
'\1, preload: false'
|
|
123
|
+
else
|
|
124
|
+
append_to_file importmap,
|
|
125
|
+
%(\npin_all_from "app/javascript/controllers", under: "controllers", preload: false\n)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def host_file?(relative)
|
|
130
|
+
File.exist?(File.join(destination_root, relative))
|
|
131
|
+
end
|
|
132
|
+
|
|
59
133
|
def host_paths
|
|
60
134
|
@host_paths ||= Senren::Rails::HostPaths.new(destination_root)
|
|
61
135
|
end
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
require 'uri'
|
|
2
|
+
require 'view_component'
|
|
3
|
+
|
|
1
4
|
module Senren
|
|
2
5
|
# Base class for every Senren ViewComponent.
|
|
3
6
|
#
|
|
@@ -6,13 +9,16 @@ module Senren
|
|
|
6
9
|
# - variant and size resolution against class-level constants
|
|
7
10
|
# - small class merger (no external `tailwind_merge` dependency in v0.1)
|
|
8
11
|
# - a default root-attribute helper that emits `data-senren-component="<name>"`
|
|
9
|
-
class BaseComponent < ViewComponent::Base
|
|
10
|
-
VARIANTS = { default:
|
|
11
|
-
SIZES = { md:
|
|
12
|
+
class BaseComponent < ::ViewComponent::Base
|
|
13
|
+
VARIANTS = { default: '' }.freeze
|
|
14
|
+
SIZES = { md: '' }.freeze
|
|
15
|
+
SAFE_URL_PROTOCOLS = %w[http https mailto tel].freeze
|
|
16
|
+
SAFE_MEDIA_URL_PROTOCOLS = %w[http https].freeze
|
|
12
17
|
|
|
13
18
|
attr_reader :class_name, :variant, :size, :html_attrs
|
|
14
19
|
|
|
15
20
|
def initialize(variant: :default, size: :md, class_name: nil, **html_attrs)
|
|
21
|
+
super()
|
|
16
22
|
@variant = resolve!(variant, self.class::VARIANTS, :variant)
|
|
17
23
|
@size = resolve!(size, self.class::SIZES, :size)
|
|
18
24
|
@class_name = class_name
|
|
@@ -21,25 +27,100 @@ module Senren
|
|
|
21
27
|
|
|
22
28
|
# Compose final root attributes; subclasses pass their base classes.
|
|
23
29
|
def root_attrs(*classes, **extra)
|
|
24
|
-
data
|
|
25
|
-
|
|
26
|
-
|
|
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 = (html_attrs[:data] || {})
|
|
38
|
+
.merge(extra.delete(:data) || {})
|
|
39
|
+
.merge(senren_component: senren_component_name)
|
|
40
|
+
|
|
41
|
+
# `class` had exactly the same defect and it was never fixed alongside
|
|
42
|
+
# `data`. Every Rails tag helper accepts `class:`, so it is the first
|
|
43
|
+
# thing anyone types — and splatting html_attrs replaced the computed
|
|
44
|
+
# 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.
|
|
47
|
+
tag_class = merge_classes(
|
|
48
|
+
classes,
|
|
49
|
+
self.class::VARIANTS[@variant],
|
|
50
|
+
self.class::SIZES[@size],
|
|
51
|
+
@class_name,
|
|
52
|
+
extra.delete(:class),
|
|
53
|
+
html_attrs[:class]
|
|
54
|
+
)
|
|
55
|
+
{ class: tag_class, data: data, **html_attrs.except(:data, :class), **extra }
|
|
27
56
|
end
|
|
28
57
|
|
|
29
58
|
def senren_component_name
|
|
30
|
-
self.class.name.to_s.sub(/^Senren::/,
|
|
59
|
+
self.class.name.to_s.sub(/^Senren::/, '').sub(/Component$/, '').gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Builds a DOM id from the arguments that identify this component, so the
|
|
63
|
+
# same inputs always produce the same id.
|
|
64
|
+
#
|
|
65
|
+
# Components used to append `SecureRandom.hex`, which made every render emit
|
|
66
|
+
# different HTML. That single fact disabled Turbo morph (it pairs nodes by
|
|
67
|
+
# id), HTTP ETags (the body never matched), fragment caching (cached and
|
|
68
|
+
# fresh fragments referenced different ids), and snapshot testing.
|
|
69
|
+
#
|
|
70
|
+
# Two components with identical identifying inputs on one page now produce
|
|
71
|
+
# the same id and the accessibility test fails. That is deliberate: the
|
|
72
|
+
# caller should pass an explicit `id:`. A random suffix hid the collision
|
|
73
|
+
# instead of resolving it.
|
|
74
|
+
def senren_dom_id(*parts)
|
|
75
|
+
seed = parts.flatten.map(&:to_s).reject(&:empty?).join('-')
|
|
76
|
+
base = "senren-#{senren_component_name.tr('_', '-')}"
|
|
77
|
+
return base if seed.empty?
|
|
78
|
+
|
|
79
|
+
"#{base}-#{seed.parameterize}"
|
|
31
80
|
end
|
|
32
81
|
|
|
33
82
|
private
|
|
34
83
|
|
|
84
|
+
def safe_url(value, fallback: '#', protocols: SAFE_URL_PROTOCOLS)
|
|
85
|
+
url = value.to_s.strip
|
|
86
|
+
return fallback if url.empty?
|
|
87
|
+
# Browsers treat "\" as "/" for special schemes and strip TAB/CR/LF
|
|
88
|
+
# before parsing, so "/\evil.example" and "/<TAB>/evil.example" would
|
|
89
|
+
# both slip past a plain "//" check and resolve off-origin.
|
|
90
|
+
return fallback if url.include?('\\')
|
|
91
|
+
return fallback if url.match?(/[[:cntrl:]]/)
|
|
92
|
+
return url if url.start_with?('#')
|
|
93
|
+
# Any leading "//" is protocol-relative regardless of how many slashes
|
|
94
|
+
# follow. Rejecting here rather than relying on URI.parse matters:
|
|
95
|
+
# URI.parse("///evil.example") reports no scheme and no host, so the
|
|
96
|
+
# scheme-less fallback below would otherwise hand back a URL the browser
|
|
97
|
+
# resolves to https://evil.example/.
|
|
98
|
+
return fallback if url.start_with?('//')
|
|
99
|
+
return url if url.start_with?('/')
|
|
100
|
+
|
|
101
|
+
uri = URI.parse(url)
|
|
102
|
+
return url if uri.scheme && Array(protocols).map(&:to_s).include?(uri.scheme.downcase)
|
|
103
|
+
return fallback if uri.host
|
|
104
|
+
return url unless uri.scheme
|
|
105
|
+
|
|
106
|
+
fallback
|
|
107
|
+
rescue URI::InvalidURIError
|
|
108
|
+
fallback
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def safe_media_url(value, fallback: nil)
|
|
112
|
+
safe_url(value, fallback: fallback, protocols: SAFE_MEDIA_URL_PROTOCOLS)
|
|
113
|
+
end
|
|
114
|
+
|
|
35
115
|
def resolve!(value, table, label)
|
|
36
116
|
key = value.to_sym
|
|
37
117
|
return key if table.key?(key)
|
|
118
|
+
|
|
38
119
|
raise ArgumentError, "Unknown #{label}: #{value.inspect}. Allowed: #{table.keys.join(', ')}"
|
|
39
120
|
end
|
|
40
121
|
|
|
41
122
|
def merge_classes(*sources)
|
|
42
|
-
sources.flatten.map { |s| s.to_s.strip }.reject(&:empty?).join(
|
|
123
|
+
sources.flatten.map { |s| s.to_s.strip }.reject(&:empty?).join(' ')
|
|
43
124
|
end
|
|
44
125
|
end
|
|
45
126
|
end
|