weft 0.1.0 → 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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +134 -28
  3. data/README.md +46 -23
  4. data/docs/app-patterns.md +8 -7
  5. data/docs/arbre.md +49 -18
  6. data/docs/configuration.md +42 -35
  7. data/docs/dsl.md +356 -105
  8. data/docs/error-handling.md +64 -24
  9. data/docs/examples/active-search.md +10 -10
  10. data/docs/examples/browser-dialogs.md +10 -10
  11. data/docs/examples/bulk-update.md +11 -11
  12. data/docs/examples/click-to-edit.md +14 -14
  13. data/docs/examples/click-to-load.md +8 -8
  14. data/docs/examples/delete-row.md +17 -19
  15. data/docs/examples/edit-row.md +17 -14
  16. data/docs/examples/file-upload.md +5 -5
  17. data/docs/examples/infinite-scroll.md +8 -8
  18. data/docs/examples/inline-expansion.md +8 -8
  19. data/docs/examples/inline-validation.md +17 -17
  20. data/docs/examples/lazy-loading.md +8 -8
  21. data/docs/examples/live-ticker.md +1 -1
  22. data/docs/examples/modal-dialog.md +3 -3
  23. data/docs/examples/progress-bar.md +1 -1
  24. data/docs/examples/reset-user-input.md +7 -7
  25. data/docs/examples/tabs.md +4 -4
  26. data/docs/examples/tooltip.md +8 -8
  27. data/docs/examples/updating-other-content.md +9 -9
  28. data/docs/examples/value-select.md +11 -11
  29. data/docs/params.md +112 -0
  30. data/docs/routing.md +13 -13
  31. data/docs/tutorial.md +46 -48
  32. data/lib/weft/action.rb +4 -2
  33. data/lib/weft/autoloading.rb +69 -0
  34. data/lib/weft/component.rb +97 -31
  35. data/lib/weft/configuration.rb +37 -5
  36. data/lib/weft/context/expansion.rb +184 -0
  37. data/lib/weft/context/interception.rb +22 -2
  38. data/lib/weft/context/modifiers.rb +78 -0
  39. data/lib/weft/context/traversal.rb +80 -0
  40. data/lib/weft/context/wiring.rb +85 -0
  41. data/lib/weft/context.rb +70 -164
  42. data/lib/weft/defaults/error_component.rb +57 -21
  43. data/lib/weft/defaults/error_page.rb +12 -10
  44. data/lib/weft/defaults/not_found_component.rb +14 -12
  45. data/lib/weft/defaults/not_found_page.rb +9 -8
  46. data/lib/weft/dsl/actions.rb +9 -9
  47. data/lib/weft/dsl/inclusions.rb +48 -11
  48. data/lib/weft/dsl/params.rb +265 -0
  49. data/lib/weft/dsl/recoveries.rb +36 -6
  50. data/lib/weft/dsl/sandbox.rb +26 -0
  51. data/lib/weft/dsl/triggers.rb +28 -8
  52. data/lib/weft/dsl/updates.rb +31 -8
  53. data/lib/weft/error.rb +12 -1
  54. data/lib/weft/page/assets.rb +222 -0
  55. data/lib/weft/page/head.rb +87 -0
  56. data/lib/weft/page.rb +55 -239
  57. data/lib/weft/params/assembly.rb +170 -0
  58. data/lib/weft/params.rb +138 -0
  59. data/lib/weft/presets.rb +96 -0
  60. data/lib/weft/redirect.rb +7 -7
  61. data/lib/weft/registry/eligibility.rb +5 -19
  62. data/lib/weft/registry.rb +58 -18
  63. data/lib/weft/resolver.rb +48 -20
  64. data/lib/weft/router/actions.rb +106 -22
  65. data/lib/weft/router/errors.rb +223 -83
  66. data/lib/weft/router/oob_includes.rb +202 -17
  67. data/lib/weft/router/streaming.rb +86 -20
  68. data/lib/weft/router.rb +33 -25
  69. data/lib/weft/version.rb +1 -1
  70. data/lib/weft.rb +37 -24
  71. metadata +32 -8
  72. data/lib/weft/attributes.rb +0 -65
  73. data/lib/weft/dsl/attributes.rb +0 -43
  74. data/lib/weft/shorthands.rb +0 -57
@@ -16,7 +16,8 @@ PEOPLE = {
16
16
  class PersonRow < Weft::Component
17
17
  builder_method :person_row
18
18
 
19
- attribute :person_id
19
+ param :person_id
20
+ receives :person_id
20
21
 
21
22
  def tag_name
22
23
  "tr"
@@ -24,11 +25,11 @@ class PersonRow < Weft::Component
24
25
 
25
26
  def build(attributes = {})
26
27
  super
27
- person = PEOPLE.fetch(attrs.person_id)
28
+ person = PEOPLE.fetch(params.person_id)
28
29
  td person[:name]
29
30
  td person[:email]
30
31
  td do
31
- button "Edit", loads: PersonRowEditor, with: { person_id: attrs.person_id },
32
+ button "Edit", loads: PersonRowEditor, with: { person_id: params.person_id },
32
33
  swap: :replace, target: self
33
34
  end
34
35
  end
@@ -37,12 +38,12 @@ end
37
38
  class PersonRowEditor < Weft::Component
38
39
  builder_method :person_row_editor
39
40
 
40
- attribute :person_id
41
- attribute :name
42
- attribute :email
41
+ param :person_id
42
+ param :name
43
+ param :email
43
44
 
44
- transfers :save, to: PersonRow do |attrs|
45
- PEOPLE.fetch(attrs.person_id).merge!(name: attrs.name, email: attrs.email)
45
+ transfers :save, to: PersonRow do |params|
46
+ PEOPLE.fetch(params.person_id).merge!(name: params.name, email: params.email)
46
47
  nil
47
48
  end
48
49
 
@@ -52,16 +53,16 @@ class PersonRowEditor < Weft::Component
52
53
 
53
54
  def build(attributes = {})
54
55
  super
55
- person = PEOPLE.fetch(attrs.person_id)
56
- save_form = "save-person-#{attrs.person_id}"
56
+ person = PEOPLE.fetch(params.person_id)
57
+ save_form = "save-person-#{params.person_id}"
57
58
  td { input type: "text", name: "name", value: person[:name], form: save_form }
58
59
  td { input type: "text", name: "email", value: person[:email], form: save_form }
59
60
  td do
60
61
  form(action: :save, id: save_form) do
61
- input type: "hidden", name: "person_id", value: attrs.person_id
62
+ input type: "hidden", name: "person_id", value: params.person_id
62
63
  input type: "submit", value: "Save"
63
64
  button "Cancel", type: "button",
64
- loads: PersonRow, with: { person_id: attrs.person_id },
65
+ loads: PersonRow, with: { person_id: params.person_id },
65
66
  swap: :replace, target: self
66
67
  end
67
68
  end
@@ -87,7 +88,9 @@ end
87
88
 
88
89
  ## How it works
89
90
 
90
- **It's click-to-edit, once per row.** Both components render as `<tr>` (the `tag_name` override), with the identifying attribute declared first so each carries a usable DOM id. Entering edit mode changes nothing on the server, so Edit is a [`loads:`](../dsl.md#loads) — a GET that fetches the editor row and replaces the display row (`swap: :replace, target: self`). Saving is a [`transfers`](../dsl.md#transfers--actions-that-render-something-else): the write runs, then the *display* row renders in the editor's place. Cancel is the Edit button's mirror image, pointed back at `PersonRow`. As in click-to-edit, defining the display component first lets `transfers :save, to: PersonRow` resolve in the editor's class body, while `loads: PersonRowEditor` waits until render.
91
+ **It's click-to-edit, once per row.** Both components render as `<tr>` (the `tag_name` override), with the identifying param declared first so each carries a usable DOM id. Entering edit mode changes nothing on the server, so Edit is a [`loads:`](../dsl.md#loads) — a GET that fetches the editor row and replaces the display row (`swap: :replace, target: self`). Saving is a [`transfers`](../dsl.md#transfers--actions-that-render-something-else): the write runs, then the *display* row renders in the editor's place. Cancel is the Edit button's mirror image, pointed back at `PersonRow`. As in click-to-edit, defining the display component first lets `transfers :save, to: PersonRow` resolve in the editor's class body, while `loads: PersonRowEditor` waits until render.
92
+
93
+ **Where `person_id` comes from differs by component.** The table hands each display row its `person_id` (`person_row(person_id: id)`) — a [`receives`](../dsl.md#receives--caller-hand-offs) hand-off, since per-row values can't inherit down the render tree — and `PersonRow` declares it as a `param` too, so the same id serializes into the row's own route and DOM id. The editor is different: it's always *fetched* by URL (Edit and Cancel both `loads:` it with `with: { person_id: … }`), so it reads `person_id` straight from the wire — `param` alone, no hand-off.
91
94
 
92
95
  **A form can't wrap table cells — so the cells point at the form.** HTML won't allow a `<form>` to span `<td>`s inside a row, which is the structural puzzle of this pattern. htmx's original solves it with `hx-include="closest tr"`; here plain HTML does the same job: the form lives in the last cell, and the name and email inputs associate with it from their own cells via the standard [`form` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#form). Form-associated elements are part of the form's submission set, so both htmx's payload *and* the no-JavaScript fallback submission include all three fields — nothing about the association needs scripting.
93
96
 
@@ -137,7 +140,7 @@ Submitting `POST /_components/person_row_editor/save` with the edited fields ret
137
140
  </tr>
138
141
  ```
139
142
 
140
- Only the attributes `PersonRow` itself declares travel into that render — the editor's `name` and `email` were consumed by the save and play no part in the row's element.
143
+ Only the params `PersonRow` itself declares travel into that render — the editor's `name` and `email` were consumed by the save and play no part in the row's element.
141
144
 
142
145
  ## Related
143
146
 
@@ -12,10 +12,10 @@ UPLOADED_REPORTS = []
12
12
  class ReportUploader < Weft::Component
13
13
  builder_method :report_uploader
14
14
 
15
- attribute :document
15
+ param :document
16
16
 
17
- performs :upload do |attrs|
18
- file = attrs.document
17
+ performs :upload do |params|
18
+ file = params.document
19
19
  if file.is_a?(Hash) && file[:tempfile]
20
20
  UPLOADED_REPORTS << { name: file[:filename], size: file[:tempfile].size }
21
21
  end
@@ -44,9 +44,9 @@ end
44
44
 
45
45
  **One HTML attribute makes it multipart.** `enctype: "multipart/form-data"` isn't Weft vocabulary — it passes through to the `<form>` untouched, and it does double duty there: htmx honors a form's native enctype when it builds the request, and the no-JS fallback submit needs the same attribute anyway. Leave it off and the request still fires, but as an ordinary urlencoded POST whose file field has collapsed to the string `"[object File]"` — nothing a server can use. (htmx also has an `hx-encoding` attribute; it's only needed to force multipart from something other than a form.)
46
46
 
47
- **The file arrives through a declared attribute.** The `document` attribute receives whatever the server's multipart parsing produces — under Sinatra, a hash carrying `:filename`, `:type`, and a `:tempfile` ready to read. The callable checks for that shape before storing, which quietly covers the other case too: submitting with no file chosen sends `document=` (an empty string), the `is_a?(Hash)` guard skips it, and the re-render is a no-op.
47
+ **The file arrives through a declared param.** The `document` param receives whatever the server's multipart parsing produces — under Sinatra, a hash carrying `:filename`, `:type`, and a `:tempfile` ready to read. The callable checks for that shape before storing, which quietly covers the other case too: submitting with no file chosen sends `document=` (an empty string), the `is_a?(Hash)` guard skips it, and the re-render is a no-op.
48
48
 
49
- **Returning `{ document: nil }` is load-bearing.** A callable's returned hash merges into the attrs for the re-render ([the callable contract](../dsl.md#the-callable-contract)) — and this one uses that to *clear* the file param rather than add anything. Weft derives a component's DOM id from its first declared attribute, and a tempfile-toting multipart hash in that slot would smear itself across the wrapper's id and every piece of htmx wiring derived from it. Cleared, the component comes back as plain `#report-uploader`: same id, same wiring, fresh empty file input.
49
+ **Returning `{ document: nil }` is load-bearing.** A callable's returned hash merges into the params for the re-render ([the callable contract](../dsl.md#the-callable-contract)) — and this one uses that to *clear* the file param rather than add anything. Weft derives a component's DOM id from its first declared param, and a tempfile-toting multipart hash in that slot would smear itself across the wrapper's id and every piece of htmx wiring derived from it. Cleared, the component comes back as plain `#report-uploader`: same id, same wiring, fresh empty file input.
50
50
 
51
51
  ## On the wire
52
52
 
@@ -14,7 +14,7 @@ class ContactRows < Weft::Component
14
14
 
15
15
  PER_PAGE = 10
16
16
 
17
- attribute :page, default: 1
17
+ param :page, default: 1, type: :integer
18
18
 
19
19
  def tag_name
20
20
  "tbody"
@@ -22,10 +22,10 @@ class ContactRows < Weft::Component
22
22
 
23
23
  def build(attributes = {})
24
24
  super
25
- rows = DIRECTORY[(attrs.page - 1) * PER_PAGE, PER_PAGE]
25
+ rows = DIRECTORY[(params.page - 1) * PER_PAGE, PER_PAGE]
26
26
  rows.each_with_index do |contact, index|
27
27
  if index == rows.size - 1 && more_pages?
28
- tr(infinite_scroll: ContactRows, with: { page: attrs.page + 1 }, target: "closest tbody") do
28
+ tr(infinite_scroll: ContactRows, with: { page: params.page + 1 }, target: "closest tbody") do
29
29
  td contact[:name]; td contact[:email]
30
30
  end
31
31
  else
@@ -37,7 +37,7 @@ class ContactRows < Weft::Component
37
37
  private
38
38
 
39
39
  def more_pages?
40
- attrs.page * PER_PAGE < DIRECTORY.size
40
+ params.page * PER_PAGE < DIRECTORY.size
41
41
  end
42
42
  end
43
43
 
@@ -49,7 +49,7 @@ class ContactDirectory < Weft::Component
49
49
  h3 "Contact directory"
50
50
  table do
51
51
  thead { tr { th "Name"; th "Email" } }
52
- contact_rows(page: 1)
52
+ contact_rows
53
53
  end
54
54
  end
55
55
  end
@@ -59,13 +59,13 @@ end
59
59
 
60
60
  ## How it works
61
61
 
62
- **The last row is the sentinel.** [`infinite_scroll:`](../dsl.md#shorthands) presets trigger `:visible` and swap `:after`; the call site adds the target. Placed on the final `tr` of a batch, it means: when this row scrolls into view, fetch the next batch and insert it after the closest `tbody`. The trigger expands to htmx's `revealed`, which fires once per element — each sentinel row does its job exactly one time.
62
+ **The last row is the sentinel.** [`infinite_scroll:`](../dsl.md#presets) presets trigger `:visible` and swap `:after`; the call site adds the target. Placed on the final `tr` of a batch, it means: when this row scrolls into view, fetch the next batch and insert it after the closest `tbody`. The trigger expands to htmx's `revealed`, which fires once per element — each sentinel row does its job exactly one time.
63
63
 
64
64
  **`target: "closest tbody"` keeps the table valid.** The fetched component is a whole `<tbody>` (that's the `tag_name` override), and swapping it `afterend` of the *row* would nest table sections illegally. Aiming the swap at the closest `tbody` instead makes each batch a sibling section — the shape HTML already sanctions for row grouping. This is why the preset leaves the target to you: only the call site knows what the insertion point should be.
65
65
 
66
66
  **The chain stops itself.** The sentinel wiring is only rendered while `more_pages?` holds. The final batch is just rows — nothing left to trigger, nothing fetched past the end of the data.
67
67
 
68
- **`page` travels as wire state.** `attribute :page, default: 1` makes each batch independently addressable (`/_components/contact_rows?page=3`) and coerces the URL string to an Integer, since the default is one. The `ContactDirectory` wrapper, by contrast, declares nothing — it's plain composition and renders only as part of a page.
68
+ **`page` travels as wire state.** `param :page, default: 1, type: :integer` makes each batch independently addressable (`/_components/contact_rows?page=3`), coercing the URL string to an Integer on the way in. The `ContactDirectory` wrapper, by contrast, declares nothing — it's plain composition and renders only as part of a page.
69
69
 
70
70
  ## On the wire
71
71
 
@@ -95,4 +95,4 @@ Scrolling that row into view issues `GET /_components/contact_rows?page=2`, whos
95
95
 
96
96
  - [Click to Load](click-to-load.md) — the same batch-by-batch growth, but on the reader's explicit request.
97
97
  - [Lazy Loading](lazy-loading.md) — the `:visible` trigger deferring a single section instead of paginating many.
98
- - The [shorthands table](../dsl.md#shorthands) and [targets](../dsl.md#targets) in the DSL reference.
98
+ - The [presets table](../dsl.md#presets) and [targets](../dsl.md#targets) in the DSL reference.
@@ -2,7 +2,7 @@
2
2
 
3
3
  A compact row with a disclosure control. Clicking it fetches the row's detail from the server and inserts it directly beneath — the list stays a list, and depth appears exactly where the reader asked for it.
4
4
 
5
- There's no counterpart for this in the htmx examples catalog; `inline_expand:` is Weft-native sugar over the same [`loads:`](../dsl.md#loads) machinery as the other shorthands. It's the pattern for master-detail tables where the detail is cheap to want and expensive to preload for every row.
5
+ There's no counterpart for this in the htmx examples catalog; `inline_expand:` is Weft-native sugar over the same [`loads:`](../dsl.md#loads) machinery as the other presets. It's the pattern for master-detail tables where the detail is cheap to want and expensive to preload for every row.
6
6
 
7
7
  ## The components
8
8
 
@@ -16,7 +16,7 @@ ORDERS = {
16
16
  class OrderItemsRow < Weft::Component
17
17
  builder_method :order_items_row
18
18
 
19
- attribute :order_id
19
+ param :order_id
20
20
 
21
21
  def tag_name
22
22
  "tr"
@@ -24,7 +24,7 @@ class OrderItemsRow < Weft::Component
24
24
 
25
25
  def build(attributes = {})
26
26
  super
27
- order = ORDERS.fetch(attrs.order_id)
27
+ order = ORDERS.fetch(params.order_id)
28
28
  td colspan: 4 do
29
29
  strong "Items: "
30
30
  text_node order[:items].join(", ")
@@ -44,7 +44,7 @@ class OrdersTable < Weft::Component
44
44
  tr do
45
45
  td do
46
46
  button "▸", inline_expand: OrderItemsRow, with: { order_id: id },
47
- target: "closest tr", trigger: "click once"
47
+ target: "closest tr"
48
48
  end
49
49
  td id
50
50
  td order[:customer]
@@ -61,9 +61,9 @@ end
61
61
 
62
62
  ## How it works
63
63
 
64
- **The detail arrives *after* the trigger's row.** [`inline_expand:`](../dsl.md#shorthands) presets trigger `:click` and swap `:after` (htmx's `afterend`); the call site supplies the target. `target: "closest tr"` walks up from the button to its row, so the fetched component is inserted as the next sibling row — which is why `OrderItemsRow` overrides `tag_name` to render as a `<tr>`. A `colspan` spanning the table's columns lets the detail breathe across the full width.
64
+ **The detail arrives *after* the trigger's row.** [`inline_expand:`](../dsl.md#presets) presets trigger `:click_once` and swap `:after` (htmx's `afterend`); the call site supplies the target. `target: "closest tr"` walks up from the button to its row, so the fetched component is inserted as the next sibling row — which is why `OrderItemsRow` overrides `tag_name` to render as a `<tr>`. A `colspan` spanning the table's columns lets the detail breathe across the full width.
65
65
 
66
- **`trigger: "click once"` guards against double insertion.** The preset's `:click` fires on *every* click — left alone, a second click would insert a second copy of the detail row. The `trigger:` kwarg overrides the preset with htmx's raw trigger grammar, and `once` caps the interaction at a single firing. (There's currently no semantic symbol for "click once" the way `:hover` bakes in `mouseenter once`, so the raw string is the honest spelling.)
66
+ **One click, one insertion.** The preset's `:click_once` bakes in htmx's `once` modifier, capping the interaction at a single firing because the detail lands *after* the button's row rather than replacing anything, a repeat click would otherwise insert a second copy. If your detail row removes itself on close and the button should work again, override with `trigger: :click`.
67
67
 
68
68
  **Expansion, not a toggle.** Once expanded, the row stays expanded — this preset opens, it doesn't close. When you need collapse, give the detail component the behavior: a [`dismisses`](../dsl.md#dismisses--remove-from-the-dom) verb and a "Hide" button inside `OrderItemsRow` remove it from the DOM server-consistently. (The one-shot button does remain spent after that; a fully re-armable open/close control is a two-state component pair, as in [Click to Edit](click-to-edit.md).)
69
69
 
@@ -93,6 +93,6 @@ Clicking issues `GET /_components/order_items_row?order_id=1001`, and the respon
93
93
 
94
94
  ## Related
95
95
 
96
- - [Tooltip](tooltip.md) — the other Weft-native shorthand: hover-driven detail loaded into a bubble instead of a row.
96
+ - [Tooltip](tooltip.md) — the other Weft-native preset: hover-driven detail loaded into a bubble instead of a row.
97
97
  - [Click to Edit](click-to-edit.md) — two-state rows that swap in place rather than expanding.
98
- - The [shorthands table](../dsl.md#shorthands) and [trigger values](../dsl.md#trigger-values) in the DSL reference.
98
+ - The [presets table](../dsl.md#presets) and [trigger values](../dsl.md#trigger-values) in the DSL reference.
@@ -12,11 +12,11 @@ TAKEN_EMAILS = ["taken@example.com"].freeze
12
12
  class SignupEmailField < Weft::Component
13
13
  builder_method :signup_email_field
14
14
 
15
- attribute :email
16
- attribute :error_message
15
+ param :email
16
+ param :error_message
17
17
 
18
- performs :validate, target: "#signup-email-field" do |attrs|
19
- email = attrs.email.to_s.strip
18
+ performs :validate, target: "#signup-email-field" do |params|
19
+ email = params.email.to_s.strip
20
20
  unless email.match?(URI::MailTo::EMAIL_REGEXP)
21
21
  raise Weft::Unprocessable, "That doesn't look like an email address."
22
22
  end
@@ -25,21 +25,21 @@ class SignupEmailField < Weft::Component
25
25
  nil
26
26
  end
27
27
 
28
- recovers from: Weft::Unprocessable do |_attrs, error|
28
+ recovers from: Weft::Unprocessable do |_params, error|
29
29
  { error_message: error.message }
30
30
  end
31
31
 
32
32
  def build(attributes = {})
33
33
  super
34
34
  set_attribute :id, "signup-email-field"
35
- form(action: :validate, trigger: "change") do
35
+ form(action: :validate, trigger: :change) do
36
36
  label "Email Address ", for: "email"
37
- input type: "email", name: "email", id: "email", value: attrs.email
37
+ input type: "email", name: "email", id: "email", value: params.email
38
38
  end
39
- if attrs.error_message
40
- para attrs.error_message, style: "color:#b91c1c"
41
- elsif attrs.email
42
- para "#{attrs.email} looks good.", style: "color:#15803d"
39
+ if params.error_message
40
+ para params.error_message, style: "color:#b91c1c"
41
+ elsif params.email
42
+ para "#{params.email} looks good.", style: "color:#15803d"
43
43
  end
44
44
  end
45
45
  end
@@ -49,15 +49,15 @@ end
49
49
 
50
50
  ## How it works
51
51
 
52
- **The field is a component, and the form belongs to the field.** `form(action: :validate, trigger: "change")` wires the POST like any action form, but `trigger:` swaps the form's natural submit trigger for `change` events — which bubble up from the input, so the request fires the moment the user leaves the field. A form's fields are its payload: the typed email reaches the callable as `attrs.email`, exactly as it would on a full submit.
52
+ **The field is a component, and the form belongs to the field.** `form(action: :validate, trigger: :change)` wires the POST like any action form, but `trigger:` swaps the form's natural submit trigger for `change` events — which bubble up from the input, so the request fires the moment the user leaves the field. A form's fields are its payload: the typed email reaches the callable as `params.email`, exactly as it would on a full submit.
53
53
 
54
- **Put the action on the form, not the input.** It's tempting to skip the form and hang `action: :validate, trigger: "change"` on the input itself. Don't: on a non-form element, `action:` carries the component's declared attributes along as `hx-vals`, and htmx gives those precedence over the triggering element's own value — so the request goes out with the *component's* stale idea of the email, never the fresh keystrokes. A one-field form is the honest wiring: what's in the field is what gets sent.
54
+ **Put the action on the form, not the input.** It's tempting to skip the form and hang `action: :validate, trigger: :change` on the input itself. Don't: on a non-form element, `action:` carries the component's declared params along as `hx-vals`, and htmx gives those precedence over the triggering element's own value — so the request goes out with the *component's* stale idea of the email, never the fresh keystrokes. A one-field form is the honest wiring: what's in the field is what gets sent.
55
55
 
56
- **Validation failures are still renders.** Bad input raises `Weft::Unprocessable`; the `recovers from:` block catches it and returns `{ error_message: error.message }`, which merges into the attrs for the re-render. The response goes out as a semantic `422 Unprocessable Content` whose body is this same component wearing its error paragraph. Valid input sails through to the `nil` return and renders the success line at a plain `200`. (The machinery is [the `recovers` chain](../error-handling.md#the-recovers-chain); the merge is [the callable contract](../dsl.md#the-callable-contract).)
56
+ **Validation failures are still renders.** Bad input raises `Weft::Unprocessable`; the `recovers from:` block catches it and returns `{ error_message: error.message }`, which merges into the params for the re-render. The response goes out as a semantic `422 Unprocessable Content` whose body is this same component wearing its error paragraph. Valid input sails through to the `nil` return and renders the success line at a plain `200`. (The machinery is [the `recovers` chain](../error-handling.md#the-recovers-chain); the merge is [the callable contract](../dsl.md#the-callable-contract).)
57
57
 
58
- **A value that changes can't anchor the DOM id.** Weft derives a component's DOM id from its first declared attribute — here that's the email itself, which would give the wrapper a different id on every render. So the component pins its own slot: `set_attribute :id, "signup-email-field"` fixes the wrapper's id, and `performs :validate, target: "#signup-email-field"` points the swap at that same anchor. (The same stable-slot idiom as [Bulk Update](bulk-update.md), for the same reason.)
58
+ **A value that changes can't anchor the DOM id.** Weft derives a component's DOM id from its first declared param — here that's the email itself, which would give the wrapper a different id on every render. So the component pins its own slot: `set_attribute :id, "signup-email-field"` fixes the wrapper's id, and `performs :validate, target: "#signup-email-field"` points the swap at that same anchor. (The same stable-slot idiom as [Bulk Update](bulk-update.md), for the same reason.)
59
59
 
60
- **The field echoes what the user typed.** The swap replaces the whole component, input included, so `value: attrs.email` writes the submitted text back into the fresh input — without it, every complaint would also blank the field.
60
+ **The field echoes what the user typed.** The swap replaces the whole component, input included, so `value: params.email` writes the submitted text back into the fresh input — without it, every complaint would also blank the field.
61
61
 
62
62
  ## On the wire
63
63
 
@@ -97,5 +97,5 @@ And the part that makes it *inline*: a browser's `change` event on the input bub
97
97
  ## Related
98
98
 
99
99
  - [Bulk Update](bulk-update.md) — the stable-slot idiom this page borrows, and reporting back through the returned hash.
100
- - [Click to Edit](click-to-edit.md) — the basics of pairing form fields with declared attributes.
100
+ - [Click to Edit](click-to-edit.md) — the basics of pairing form fields with declared params.
101
101
  - [`performs`](../dsl.md#performs--user-initiated-actions), [`recovers`](../dsl.md#recovers--declare-error-behavior), and [`trigger:`](../dsl.md#trigger) in the DSL reference; [Error handling](../error-handling.md#the-recovers-chain) for the full recovery story.
@@ -14,14 +14,14 @@ QUARTERLY_REVENUE = {
14
14
  class RevenueTable < Weft::Component
15
15
  builder_method :revenue_table
16
16
 
17
- attribute :year, default: 2025
17
+ param :year, default: 2025, type: :integer
18
18
 
19
19
  def build(attributes = {})
20
20
  super
21
21
  table do
22
22
  thead { tr { th "Quarter"; th "Revenue" } }
23
23
  tbody do
24
- QUARTERLY_REVENUE.fetch(attrs.year).each do |quarter, amount|
24
+ QUARTERLY_REVENUE.fetch(params.year).each do |quarter, amount|
25
25
  tr { td quarter; td amount }
26
26
  end
27
27
  end
@@ -32,15 +32,15 @@ end
32
32
  class AnnualReport < Weft::Component
33
33
  builder_method :annual_report
34
34
 
35
- attribute :year, default: 2025
35
+ param :year, default: 2025, type: :integer
36
36
 
37
37
  def build(attributes = {})
38
38
  super
39
- h2 "#{attrs.year} annual report"
39
+ h2 "#{params.year} annual report"
40
40
  para "Commentary, highlights, and everything else the reader scrolls through " \
41
41
  "before the numbers. The revenue table below is expensive to produce, " \
42
42
  "so it loads only when it comes into view."
43
- div lazy: RevenueTable, with: { year: attrs.year } do
43
+ div lazy: RevenueTable, with: { year: params.year } do
44
44
  para "Loading revenue…"
45
45
  end
46
46
  end
@@ -51,13 +51,13 @@ end
51
51
 
52
52
  ## How it works
53
53
 
54
- **The placeholder is an ordinary element with one extra kwarg.** [`lazy:`](../dsl.md#shorthands) is a preset over the [`loads:`](../dsl.md#loads) machinery: trigger `:visible`, swap `:fill`, target `:self`. When the div scrolls into view, fetch `RevenueTable` and swap it into the div's interior. Because trigger, swap, *and* target all have an obvious right answer here, the call site needs nothing beyond the component class and its attrs.
54
+ **The placeholder is an ordinary element with one extra kwarg.** [`lazy:`](../dsl.md#presets) is a preset over the [`loads:`](../dsl.md#loads) machinery: trigger `:visible`, swap `:fill`, target `:self`. When the div scrolls into view, fetch `RevenueTable` and swap it into the div's interior. Because trigger, swap, *and* target all have an obvious right answer here, the call site needs nothing beyond the component class and its params.
55
55
 
56
56
  **The div's children are the loading state.** Because the swap is `:fill` (`innerHTML`), the placeholder element itself survives; only its contents — the "Loading revenue…" paragraph — are replaced by the fetched component. Whatever you put in the block is what users see until the content arrives.
57
57
 
58
58
  **`:visible` means once.** The semantic trigger expands to htmx's `revealed`, which fires a single time when the element first enters the viewport. The wrapper keeps its wiring after the swap, but no re-fetch loop follows.
59
59
 
60
- **`with:` passes the wire state along.** The placeholder hands its own `year` down to the loaded component (`with: { year: attrs.year }`), and the value travels in the URL — visible below. If you omit `with:`, the enclosing component's attrs are passed by default.
60
+ **`with:` passes the wire state along.** The placeholder hands its own `year` down to the loaded component (`with: { year: params.year }`), and the value travels in the URL — visible below. If you omit `with:`, the enclosing component's params are passed by default.
61
61
 
62
62
  ## On the wire
63
63
 
@@ -93,4 +93,4 @@ Scrolling it into view issues `GET /_components/revenue_table?year=2025`, and th
93
93
 
94
94
  - [Infinite Scroll](infinite-scroll.md) — the same `:visible` trigger, used repeatedly to grow a table page by page.
95
95
  - [Click to Load](click-to-load.md) — deferred loading where the user asks for more, instead of scrolling to it.
96
- - The [shorthands table](../dsl.md#shorthands) and the [trigger values](../dsl.md#trigger-values) in the DSL reference.
96
+ - The [presets table](../dsl.md#presets) and the [trigger values](../dsl.md#trigger-values) in the DSL reference.
@@ -88,4 +88,4 @@ Each multi-line rendering becomes consecutive `data:` lines in one event, per th
88
88
 
89
89
  - [Progress Bar](progress-bar.md) — the polling counterpart: the client re-fetches on a timer instead of holding a connection.
90
90
  - [`pushes`](../dsl.md#pushes--the-server-sends-updates) in the DSL reference; [`stream_suffix`](../configuration.md#stream_suffix) and [`include_sse_ext`](../configuration.md#include_sse_ext) in configuration.
91
- - A component with attributes streams too — its wire state rides the `sse-connect` URL as query parameters, so each subscriber gets frames for *its* instance.
91
+ - A component with params streams too — its wire state rides the `sse-connect` URL as query parameters, so each subscriber gets frames for *its* instance.
@@ -42,9 +42,9 @@ end
42
42
 
43
43
  ## How it works
44
44
 
45
- **Opening is a load into a stable slot.** [`modal:`](../dsl.md#shorthands) presets trigger `:click` and swap `:fill`; the call site supplies the target. Clicking the button fetches `TourDialog` and fills the empty `#modal-slot` div with it. The slot is permanent page structure — it outlives any dialog placed in it, so the modal can be opened again after closing.
45
+ **Opening is a load into a stable slot.** [`modal:`](../dsl.md#presets) presets trigger `:click` and swap `:fill`; the call site supplies the target. Clicking the button fetches `TourDialog` and fills the empty `#modal-slot` div with it. The slot is permanent page structure — it outlives any dialog placed in it, so the modal can be opened again after closing.
46
46
 
47
- **Closing is a dismissal.** `dismisses :close` declares a DELETE action whose swap removes the component from the page entirely — and for a modal, removed from the DOM *is* closed. The dialog vanishes, the slot div stays, and the page underneath was never touched. Give the dismissal a block (`dismisses :close do |attrs| ... end`) if closing should also do something server-side, like recording that the tour offer was seen.
47
+ **Closing is a dismissal.** `dismisses :close` declares a DELETE action whose swap removes the component from the page entirely — and for a modal, removed from the DOM *is* closed. The dialog vanishes, the slot div stays, and the page underneath was never touched. Give the dismissal a block (`dismisses :close do |params| ... end`) if closing should also do something server-side, like recording that the tour offer was seen.
48
48
 
49
49
  **The underlay closes too — same action, different element.** [`action:`](../dsl.md#action) isn't just for buttons: on the underlay div it expands to exactly the same wiring as the Close button, and htmx's default trigger for a div is a click. Because the underlay and the content panel are *siblings* — the underlay covers the viewport, the panel floats above it — a click on the panel never bubbles to the underlay, so only clicks on the dimmed backdrop dismiss.
50
50
 
@@ -85,4 +85,4 @@ Clicking either one issues `DELETE /_components/tour_dialog/close`. The response
85
85
 
86
86
  - [Browser Dialogs](browser-dialogs.md) — when a native `confirm()` box is dialog enough.
87
87
  - [Keyboard Shortcuts](keyboard-shortcuts.md) — the [`trigger:`](../dsl.md#trigger) grammar that could add Escape-to-close to this dialog.
88
- - [`dismisses`](../dsl.md#dismisses--remove-from-the-dom) and the [shorthands table](../dsl.md#shorthands) in the DSL reference.
88
+ - [`dismisses`](../dsl.md#dismisses--remove-from-the-dom) and the [presets table](../dsl.md#presets) in the DSL reference.
@@ -42,7 +42,7 @@ class JobMonitor < Weft::Component
42
42
 
43
43
  refreshes every: 1
44
44
 
45
- performs :start do |_attrs|
45
+ performs :start do |_params|
46
46
  FakeJob.start!
47
47
  nil
48
48
  end
@@ -14,12 +14,12 @@ GUEST_COMMENTS = [
14
14
  class CommentSection < Weft::Component
15
15
  builder_method :comment_section
16
16
 
17
- attribute :author
18
- attribute :body
17
+ param :author
18
+ param :body
19
19
 
20
- performs :post do |attrs|
21
- author = attrs.author.to_s.strip
22
- body = attrs.body.to_s.strip
20
+ performs :post do |params|
21
+ author = params.author.to_s.strip
22
+ body = params.body.to_s.strip
23
23
  GUEST_COMMENTS << { author: author, body: body } unless author.empty? || body.empty?
24
24
  { author: nil, body: nil }
25
25
  end
@@ -52,7 +52,7 @@ end
52
52
 
53
53
  **The response replaces the form, typed text and all.** `build` renders the list from the store and the inputs with no `value:` at all — so every render of this component has empty fields. The action's `outerHTML` swap replaces the old elements wholesale: the inputs holding the typed text leave the DOM, and fresh, empty ones arrive in the same response that shows the new comment in the list. What htmx solves with an after-request reset handler, the render model here dissolves.
54
54
 
55
- **Clearing the params in the return keeps the identity stable.** The callable ends with `{ author: nil, body: nil }` — a returned hash merges into the attrs for the re-render ([the callable contract](../dsl.md#the-callable-contract)), and Weft derives a component's DOM id from its first declared attribute. Cleared, the wrapper comes back as the same `#comment-section` the htmx wiring points at; left populated, the submitted name would leak into the re-rendered component's identity.
55
+ **Clearing the params in the return keeps the identity stable.** The callable ends with `{ author: nil, body: nil }` — a returned hash merges into the params for the re-render ([the callable contract](../dsl.md#the-callable-contract)), and Weft derives a component's DOM id from its first declared param. Cleared, the wrapper comes back as the same `#comment-section` the htmx wiring points at; left populated, the submitted name would leak into the re-rendered component's identity.
56
56
 
57
57
  **Blank submits are the callable's problem, and it handles them.** The browser happily posts `author=&body=`; the strip-and-check guard appends nothing, and the response is simply the current state re-rendered. Client-side `required` attributes would be a fine courtesy on top, but the server-side guard is the one that holds.
58
58
 
@@ -97,5 +97,5 @@ In a real browser the captured round trip reads the same: the POST body carries
97
97
  ## Related
98
98
 
99
99
  - [File Upload](file-upload.md) — clearing a param in the return for a different reason.
100
- - [Click to Edit](click-to-edit.md) — when you *do* want fields pre-filled, pair them with declared attributes.
100
+ - [Click to Edit](click-to-edit.md) — when you *do* want fields pre-filled, pair them with declared params.
101
101
  - [`performs`](../dsl.md#performs--user-initiated-actions) and [the callable contract](../dsl.md#the-callable-contract) in the DSL reference.
@@ -59,11 +59,11 @@ end
59
59
 
60
60
  ## How it works
61
61
 
62
- **Every button aims at the same panel.** [`tabs:`](../dsl.md#shorthands) presets trigger `:click` and swap `:fill`; the call site supplies the shared target. Clicking a tab fetches its component and replaces the panel's contents. The default tab is simply pre-rendered into the panel — the initial state is the same component a click would fetch.
62
+ **Every button aims at the same panel.** [`tabs:`](../dsl.md#presets) presets trigger `:click` and swap `:fill`; the call site supplies the shared target. Clicking a tab fetches its component and replaces the panel's contents. The default tab is simply pre-rendered into the panel — the initial state is the same component a click would fetch.
63
63
 
64
- **`routable!` makes contentful-but-stateless tabs fetchable.** A component normally earns its route by declaring attributes or verbs ([Routing](../routing.md)); these tabs declare neither, so without help they would render fine *and* be unreachable over the wire — the buttons would point at URLs that 404. `routable!` opts them in explicitly. (Tabs that take wire state — an `order_id`, say — get their routes the ordinary way and don't need it.)
64
+ **`routable!` makes contentful-but-stateless tabs fetchable.** A component normally earns its route by declaring params or verbs ([Routing](../routing.md)); these tabs declare neither, so without help they would render fine *and* be unreachable over the wire — the buttons would point at URLs that 404. `routable!` opts them in explicitly. (Tabs that take wire state — an `order_id`, say — get their routes the ordinary way and don't need it.)
65
65
 
66
- **The selected tab isn't highlighted — yet.** Only the panel is swapped; the buttons themselves never re-render, so nothing moves a "selected" class around. htmx's example re-renders the whole tab bar with each click for exactly this reason. The Weft equivalent of that move is to give the wrapping component the state and re-render it whole — `attribute :tab` plus [`navigate: { tab: "shipping" }`](../dsl.md#navigate) on each button replaces the entire `ProductTabs` component, selected styling included, at the cost of re-sending the bar with every switch. Start with the version above; reach for `navigate:` when the highlight matters.
66
+ **The selected tab isn't highlighted — yet.** Only the panel is swapped; the buttons themselves never re-render, so nothing moves a "selected" class around. htmx's example re-renders the whole tab bar with each click for exactly this reason. The Weft equivalent of that move is to give the wrapping component the state and re-render it whole — `param :tab` plus [`navigate: { tab: "shipping" }`](../dsl.md#navigate) on each button replaces the entire `ProductTabs` component, selected styling included, at the cost of re-sending the bar with every switch. Start with the version above; reach for `navigate:` when the highlight matters.
67
67
 
68
68
  ## On the wire
69
69
 
@@ -99,4 +99,4 @@ Clicking Shipping issues `GET /_components/shipping_tab`, and the response fills
99
99
 
100
100
  - [Active Search](active-search.md) — the same fill-a-stable-container shape, driven by typing instead of clicks.
101
101
  - [`abstract!` and `routable!`](../routing.md#abstract-and-routable) in the routing reference.
102
- - The [shorthands table](../dsl.md#shorthands) in the DSL reference.
102
+ - The [presets table](../dsl.md#presets) in the DSL reference.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Hovering over an element fetches a small piece of server-rendered detail — a profile card, a definition, a status readout — and places it in a bubble beside the trigger. The detail is rendered fresh from the server on first hover, then stays put.
4
4
 
5
- There's no counterpart for this in the htmx examples catalog; `tooltip:` is Weft-native sugar over the same [`loads:`](../dsl.md#loads) machinery as the other shorthands. It earns its keep when the tooltip content is worth a server round-trip — live data, per-record queries — rather than static text a `title=` attribute could carry.
5
+ There's no counterpart for this in the htmx examples catalog; `tooltip:` is Weft-native sugar over the same [`loads:`](../dsl.md#loads) machinery as the other presets. It earns its keep when the tooltip content is worth a server round-trip — live data, per-record queries — rather than static text a `title=` attribute could carry.
6
6
 
7
7
  ## The components
8
8
 
@@ -16,12 +16,12 @@ TEAM = {
16
16
  class MemberPeek < Weft::Component
17
17
  builder_method :member_peek
18
18
 
19
- attribute :handle
19
+ param :handle
20
20
 
21
21
  def build(attributes = {})
22
22
  super
23
- member = TEAM.fetch(attrs.handle)
24
- strong attrs.handle.capitalize
23
+ member = TEAM.fetch(params.handle)
24
+ strong params.handle.capitalize
25
25
  para "#{member[:role]} — #{member[:timezone]}"
26
26
  para "Focus: #{member[:focus]}"
27
27
  end
@@ -51,11 +51,11 @@ end
51
51
 
52
52
  ## How it works
53
53
 
54
- **Hover fetches; the bubble receives.** [`tooltip:`](../dsl.md#shorthands) presets trigger `:hover` and swap `:fill`; the call site says where the content lands. Each name targets its own empty bubble div (`target: "#peek-#{handle}"`), so every row has an independent tooltip slot. The bubbles start empty and cost nothing until hovered.
54
+ **Hover fetches; the bubble receives.** [`tooltip:`](../dsl.md#presets) presets trigger `:hover` and swap `:fill`; the call site says where the content lands. Each name targets its own empty bubble div (`target: "#peek-#{handle}"`), so every row has an independent tooltip slot. The bubbles start empty and cost nothing until hovered.
55
55
 
56
56
  **`:hover` means *first* hover.** The semantic trigger expands to htmx's `mouseenter once` — the fetch happens the first time the pointer enters, and never again. Be clear about what that buys and what it doesn't: Weft delivers the *content*, once, lazily. It does not show and hide the bubble as the pointer comes and goes — that's presentation, and it belongs to CSS (a `.peek-bubble` hidden until `li:hover`, for instance). After the first hover, showing the tooltip again is free, because the content is already in the page.
57
57
 
58
- **Per-row wire attrs, one component.** Every trigger loads the same `MemberPeek` class with a different `with: { handle: ... }` — the component is written once and addressed per record, which is the same shape as every list-plus-detail pattern in this catalog.
58
+ **Per-row wire params, one component.** Every trigger loads the same `MemberPeek` class with a different `with: { handle: ... }` — the component is written once and addressed per record, which is the same shape as every list-plus-detail pattern in this catalog.
59
59
 
60
60
  ## On the wire
61
61
 
@@ -83,6 +83,6 @@ The first hover over Priya issues `GET /_components/member_peek?handle=priya`, a
83
83
 
84
84
  ## Related
85
85
 
86
- - [Inline Expansion](inline-expansion.md) — the other Weft-native shorthand: click-driven detail that lands *after* its trigger instead of in a bubble.
86
+ - [Inline Expansion](inline-expansion.md) — the other Weft-native preset: click-driven detail that lands *after* its trigger instead of in a bubble.
87
87
  - [Lazy Loading](lazy-loading.md) — the same load-once deferral, triggered by visibility instead of the pointer.
88
- - The [shorthands table](../dsl.md#shorthands) in the DSL reference.
88
+ - The [presets table](../dsl.md#presets) in the DSL reference.
@@ -28,13 +28,13 @@ end
28
28
  class NewContactForm < Weft::Component
29
29
  builder_method :new_contact_form
30
30
 
31
- attribute :name
32
- attribute :email
31
+ param :name
32
+ param :email
33
33
 
34
34
  includes ContactsTable
35
35
 
36
- performs :add do |attrs|
37
- ADDRESS_BOOK << { name: attrs.name, email: attrs.email }
36
+ performs :add do |params|
37
+ ADDRESS_BOOK << { name: params.name, email: params.email }
38
38
  { name: nil, email: nil }
39
39
  end
40
40
 
@@ -72,11 +72,11 @@ end
72
72
 
73
73
  **`includes` declares the relationship once, in the class body.** [`includes ContactsTable`](../dsl.md#includes--companions-in-the-same-response) means: whenever this form responds to an action, render the table too, marked out-of-band. htmx receives one response containing two fragments — the re-rendered form swaps into the form's place as usual, and the table fragment, carrying `hx-swap-oob="true"`, is routed to its own DOM slot by id (`#contacts-table`). One request, one response, two regions updated. This is htmx's out-of-band solution with the response construction, the OOB attribute, and the id bookkeeping all handled for you.
74
74
 
75
- **The included component needs no route.** In this variant `ContactsTable` declares no attributes and no verbs, so it isn't independently addressable — `GET /_components/contacts_table` answers 404 — and that's fine: it renders inside the page and travels inside the form's responses. Companions only need to *render* (see [routing](../routing.md#routable-vs-render-target)).
75
+ **The included component needs no route.** In this variant `ContactsTable` declares no params and no verbs, so it isn't independently addressable — `GET /_components/contacts_table` answers 404 — and that's fine: it renders inside the page and travels inside the form's responses. Companions only need to *render* (see [routing](../routing.md#routable-vs-render-target)).
76
76
 
77
- **The callable resets the form.** An action callable's return value directs the re-render ([the callable contract](../dsl.md#the-callable-contract)): returning a hash merges it into the attrs. Returning `{ name: nil, email: nil }` clears the just-submitted values, so the form comes back empty after each add — htmx's reset-the-form problem solved server-side, with no `hx-on` handler. (It also keeps the component's derived DOM id, which is built from the first attribute's value, stable across renders.)
77
+ **The callable resets the form.** An action callable's return value directs the re-render ([the callable contract](../dsl.md#the-callable-contract)): returning a hash merges it into the params. Returning `{ name: nil, email: nil }` clears the just-submitted values, so the form comes back empty after each add — htmx's reset-the-form problem solved server-side, with no `hx-on` handler. (It also keeps the component's derived DOM id, which is built from the first param's value, stable across renders.)
78
78
 
79
- **Form fields pair with declared attributes.** The form declares `name` and `email` so the submitted fields reach the callable as `attrs.name` and `attrs.email` — the same pairing as every Weft form (covered in depth in [the tutorial](../tutorial.md#7-taking-rsvps)). And since `form(action: :add)` also emits plain `action`/`method` attributes, the add still works without JavaScript; only the tableside update needs htmx.
79
+ **Form fields pair with declared params.** The form declares `name` and `email` so the submitted fields reach the callable as `params.name` and `params.email` — the same pairing as every Weft form (covered in depth in [the tutorial](../tutorial.md#7-taking-rsvps)). And since `form(action: :add)` also emits plain `action`/`method` attributes, the add still works without JavaScript; only the tableside update needs htmx.
80
80
 
81
81
  ## The decoupled variant
82
82
 
@@ -164,6 +164,6 @@ hears the event and issues `GET /_components/contacts_table`, which returns the
164
164
 
165
165
  ## Related
166
166
 
167
- - [Click to Edit](click-to-edit.md) — the form-fields-pair-with-attributes pattern this example builds on.
167
+ - [Click to Edit](click-to-edit.md) — the form-fields-pair-with-params pattern this example builds on.
168
168
  - [`includes`](../dsl.md#includes--companions-in-the-same-response), [`triggers`](../dsl.md#triggers--announce-to-the-rest-of-the-page), and [`refreshes`](../dsl.md#refreshes--the-client-re-fetches) in the DSL reference.
169
- - `includes` accepts `on: :action_name` to scope a companion to one action, and a block to map the primary component's attrs onto the companion's — see [the DSL reference](../dsl.md#includes--companions-in-the-same-response).
169
+ - `includes` accepts `on: :action_name` to scope a companion to one action, and a block to map the primary component's params onto the companion's — see [the DSL reference](../dsl.md#includes--companions-in-the-same-response).