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
@@ -8,8 +8,9 @@ Two layers cooperate to make this work. On the server, the Router catches errors
8
8
 
9
9
  - [The error classes](#the-error-classes)
10
10
  - [The `recovers` chain](#the-recovers-chain) — matching, targets, blocks, and [the built-in edges](#the-built-in-edges)
11
- - [What happens when something raises](#what-happens-when-something-raises)
12
- - [Auto-injected recovery attributes](#auto-injected-recovery-attributes)
11
+ - [What happens when something raises](#what-happens-when-something-raises) — including [where failure-prone work belongs](#where-the-work-that-can-fail-belongs) and [when a companion fails](#when-a-companion-fails)
12
+ - [Error handling on live streams](#error-handling-on-live-streams)
13
+ - [Auto-injected recovery params](#auto-injected-recovery-params)
13
14
  - [Presentation settings](#presentation-settings)
14
15
 
15
16
  ## The error classes
@@ -26,7 +27,7 @@ Weft ships a small semantic hierarchy rooted at `Weft::Error`:
26
27
  | `Weft::Unprocessable` | 422 | The request was understood but can't be acted on — validation failures, mostly. |
27
28
  | `Weft::InternalError` | 500 | An explicit "we broke" signal. |
28
29
 
29
- Raise these from your `build` methods and action callables to communicate outcomes with the right status semantics: `raise Weft::NotFound` when a record lookup comes up empty, `raise Weft::Unprocessable` when validation fails. Errors that aren't `Weft::HTTPError`san unrescued `ActiveRecord::RecordNotFound`, a `NoMethodError` are treated as status 500.
30
+ Raise these from your `build` methods and action callables to communicate outcomes with the right status semantics: `raise Weft::NotFound` when a record lookup comes up empty, `raise Weft::Unprocessable` when validation fails. They're a convenience, not a requirement your code can keep raising its own vocabulary (`ActiveRecord::RecordNotFound`, a domain error) and let a `recovers` edge [declare what it means](#the-recovers-chain) with `status:`. An error that's neither a `Weft::HTTPError` nor mapped by such an edge is treated as status 500.
30
31
 
31
32
  A separate branch of the family reports *your* mistakes to you, raised at definition or configuration time rather than during request handling: `Weft::InvalidConfiguration` (a bad value inside `Weft.configure`), `Weft::InvalidDefinition` (a bad class-body declaration, including route collisions), and `Weft::InvalidUsage` (a bad call at render time). These are meant to fail loudly during development, not to be recovery targets.
32
33
 
@@ -36,10 +37,11 @@ Components and pages declare how they handle errors with `recovers`:
36
37
 
37
38
  ```ruby
38
39
  class OrderEditor < Weft::Component
39
- recovers from: Weft::Unprocessable do |attrs, error|
40
+ recovers from: Weft::Unprocessable do |params, error|
40
41
  { error_message: error.message }
41
42
  end
42
43
  recovers from: Weft::Unauthorized, with: LoginPage
44
+ recovers from: ActiveRecord::RecordNotFound, with: NotFoundCard, status: 404
43
45
  end
44
46
  ```
45
47
 
@@ -53,9 +55,13 @@ Each declaration is an edge: *when this kind of error escapes me, render that in
53
55
  - a **Range** — matches statuses in the range (`from: 500..599`);
54
56
  - an **Array** of any of the above — matches if any element does.
55
57
 
56
- **`with:`** names the recovery target — what renders in place of the failure. It accepts a component or page class, or a symbol naming a [configuration knob](configuration.md#the-four-fallback-targets) (`with: :error_component`), resolved at error-handling time so reconfiguration propagates. Omitted, it defaults to the declaring class itself — "on this error, re-render me" — which pairs naturally with a block that adjusts attrs.
58
+ **`with:`** names the recovery target — what renders in place of the failure. It accepts a component or page class, or a symbol naming a [configuration knob](configuration.md#the-four-fallback-targets) (`with: :error_component`), resolved at error-handling time so reconfiguration propagates. Omitted, it defaults to the declaring class itself — "on this error, re-render me" — which pairs naturally with a block that adjusts params.
57
59
 
58
- **The block**, if given, receives `(attrs, error)` the same resolved attrs an action callable sees, plus the exception — and returns a hash merged into the attrs the recovery target renders with (returned keys win). It's for *carrying information onto the error rendering*, like the validation messages above; it never returns HTML.
60
+ **`status:`** declares what a matched error *means* on the wire. Weft's own error classes carry their status with them, but your app's errors don't need translating into Weft's recover from them directly and let the edge supply the semantics, as the `ActiveRecord::RecordNotFound` edge above does: the response status and the auto-injected `:status_code` param both follow it, so the branded rendering is a genuine 404. Without it, a recovered non-`Weft::HTTPError` reports as 500. Only error statuses (400–599) are assignable; an invalid value raises `Weft::InvalidUsage` at declaration time.
61
+
62
+ **The block**, if given, receives `(params, error)` — plus the exception — and returns a hash merged into the params the recovery target renders with (returned keys win). It's for *carrying information onto the error rendering*, like the validation messages above; it never returns HTML.
63
+
64
+ The `params` it gets are **the state the request had reached when it broke**, with every door open: wire values, derivations, defines. If the failure was in an action callable, that's what the callable was reading; if the callable succeeded and the *render* failed, it's that plus whatever the callable returned; and a companion's recovery block sees what its own `build` saw, its inclusion block's delta included. A derivation the failed code had already forced is still forced, so reading it in the recovery block costs nothing — and one it hadn't will run now, which is worth remembering if it's the kind of lookup that can fail twice.
59
65
 
60
66
  Edges are consulted in a defined order: a class's own declarations first (in declaration order), then its ancestors' — so subclass declarations beat inherited ones, and within a class, first match wins. Put more-specific edges before catch-alls.
61
67
 
@@ -77,27 +83,61 @@ The symbols resolve through `Weft.configuration`, so [reassigning those knobs](c
77
83
 
78
84
  ## What happens when something raises
79
85
 
80
- **In component context** — a fragment render, an action, an SSE frame — the Router walks the failing component's chain and renders the matched target as a fragment, with the response status taken from the exception (`Weft::HTTPError#status`, else 500). On the client, the fragment swaps in where the component's response would have gone, so the error appears exactly where the problem is. If the matched target is a *page* class, the recovery becomes a redirect to that page instead (`HX-Redirect` for htmx requests, 302 otherwise) — the `with: LoginPage` pattern above.
86
+ **In component context** — a fragment render or an action — the Router walks the failing component's chain and renders the matched target as a fragment, with the response status taken from the exception (`Weft::HTTPError#status`, else 500). On the client, the fragment swaps in where the component's response would have gone, so the error appears exactly where the problem is. If the matched target is a *page* class, the recovery becomes a redirect to that page instead (`HX-Redirect` for htmx requests, 302 otherwise) — the `with: LoginPage` pattern above.
81
87
 
82
- One wrinkle worth knowing: for actions with a destructive swap (`dismisses`, or any `performs` with `swap: :delete`), a successful response removes the element — which would make an error invisible. Weft overrides the swap on error responses (via `HX-Reswap`) so the error rendering replaces the component instead of vanishing with it.
88
+ One wrinkle worth knowing: for actions with a destructive swap (`dismisses`, or any `performs` with `swap: :delete`), a successful response removes the element — which would make an error invisible. Weft overrides the swap on error responses (via `HX-Reswap`) so the error rendering replaces the component instead of vanishing with it. The replacement also arrives correctly shaped: recovery fragments adopt the failing component's wrapper tag (via `:component_tag`, below), so a failed delete on a table row produces an error *row* the table can legally contain.
83
89
 
84
90
  **In page context** — a full-document render, or a request no route matched — the Router walks the page's chain (for routing misses, the base `Weft::Page` chain, which lands on the not-found page). A traditional request gets the recovery page as a complete document; an htmx request gets just the page's body content, since the document shell is already on the client.
85
91
 
86
92
  **If the recovery itself raises** — a bug in your error component, say — Weft stops walking and emits a minimal hardcoded error rendering, logging the recovery failure and surfacing the *original* error. There is always a floor; error handling never recurses into itself.
87
93
 
88
- Errors during SSE pushes don't kill the stream: the frame is skipped, the error logged, and pushing resumes on the next interval.
94
+ Whatever renders, it wears the **failing component's DOM id**. A recovery fragment stands in the failed component's place, and an out-of-band swap addressed anywhere else would land on the wrong element — or on none. You don't opt into this and can't forget it; Weft stamps the id on every recovery fragment it produces.
95
+
96
+ ### Where the work that can fail belongs
97
+
98
+ Two Weft surfaces can raise, and they are not equivalent. An action callable — `performs`, `transfers`, `dismisses` — is where your app *does* something: writes, external calls, state changes. A `build` method is where it *describes* something.
99
+
100
+ **Put the fallible work in the callable and keep `build` free of side effects.** The reason will be familiar from any MVC framework: by the time rendering begins, whatever the action committed is already committed. A failure inside the callable can still be reported as a failure of the whole operation; a failure during rendering can only be reported after the fact — which is why Rails rolls back a controller error but not a view error.
101
+
102
+ Weft leans into that split rather than papering over it. An action that raises produces an error *response*; a fragment that raises produces an error *fragment*, and everything around it stands. The more your `build` methods are pure descriptions of state, the more that second case is a display problem rather than a correctness one.
103
+
104
+ ### When a companion fails
105
+
106
+ A component can bring [companions](dsl.md#includes--companions-in-the-same-response) along with a response — other fragments that went stale and ride back on the same request. **A companion is a courtesy, not a contract:** if one raises, the response still belongs to the component the request was about.
107
+
108
+ So the primary render, the status, and the `HX-*` headers are all untouched. The failing companion walks *its own* `recovers` chain, and the result is delivered as a companion in that companion's own DOM slot — the error appears exactly where that fragment would have been and nowhere else, while the rest of the response arrives as though nothing happened. This is what makes an action with side effects honest: a companion that breaks *after* your callable has written to the database can no longer turn a committed change into a reported failure.
89
109
 
90
- ## Auto-injected recovery attributes
110
+ Two wrinkles worth knowing:
91
111
 
92
- A recovery target usually wants context: what failed, where, with what status. The Router offers five values, injected **schema-gated**: each is passed only if the target *declares an attribute of that name*. Declaring the attribute is the opt-in; anything not declared is never injected, so nothing leaks into renders (or URLs) uninvited.
112
+ - **Recovery edges that point at a page class are skipped here**, and the walk continues to the next match. A fragment riding inside a successful response has no business navigating away from it.
113
+ - **If the chain yields nothing, or the recovery render itself raises**, that companion is dropped and the failure is logged with the class and the `includes` declaration site that brought it along. The rest of the response is unaffected either way.
93
114
 
94
- | Attribute | Value |
115
+ Companion failures on a [live stream](#error-handling-on-live-streams) behave the same, with one addition: they don't count against the stream's attempts budget. The budget measures the *stream's* health, and a companion's trouble says nothing about it.
116
+
117
+ ## Error handling on live streams
118
+
119
+ A failing SSE push walks the same `recovers` chain as any other component-context failure, with three stream-shaped differences:
120
+
121
+ - **Component targets only.** A stream can't redirect, so an entry whose target is a page class is skipped and the walk continues to the next match. The gem-default `StandardError` edge sits at the bottom of every chain, so a failing push always finds an error component to render (unless you've deliberately reconfigured that default away).
122
+ - **Frames mirror normal pushes.** The recovery component's *content* is pushed under the failing component's event name and swaps into the persistent wrapper's interior, exactly like a healthy frame. The recovery component's own wrapper never ships — so on this path, put the error box and its styling on inner elements (the gem defaults do).
123
+ - **Failure is budgeted.** Consecutive failed pushes count against an attempts budget — the [`push_attempts`](configuration.md#push_attempts) setting (default 3), or per component with `pushes every: ..., attempts: ...`. A successful push resets the count. When the budget runs out, Weft pushes the final recovery frame, then a close event that the wrapper's `sse-close` attribute tells htmx to honor: the browser closes the EventSource and does not reconnect. The stream ends server-side too; the rest of the page is untouched.
124
+
125
+ The countdown is visible to your error components through the `:attempts_remaining` auto-injected param (below): it reaches 0 on the final frame — the moment to offer a resume affordance. The [`reopen_stream:` preset](dsl.md#presets) makes that a one-liner: `button "Resume live updates", reopen_stream: @params.retry_url` re-fetches the component whole, and the fresh render carries a fresh `sse-connect`, so the stream reopens with a full budget.
126
+
127
+ If the recovery render itself raises, that frame is skipped (and the failure still counts) — streams share the no-recursion floor with every other error path.
128
+
129
+ ## Auto-injected recovery params
130
+
131
+ A recovery target usually wants context: what failed, where, with what status. The Router offers six values, delivered as **request overlays**: a component reads each by *declaring a param of that name*. The declaration is the opt-in — anything not declared is never read — and because overlays reach the whole recovery render, a component *nested inside* your branded error page can declare and read them too (a shared error-detail partial reading `:exception` itself, say). Recovery redirect URLs stay schema-gated and carry only the redirect-safe values the destination declares, so nothing rides a URL uninvited.
132
+
133
+ | Param | Value |
95
134
  | --- | --- |
96
135
  | `:exception` | The exception object itself. |
97
136
  | `:request_path` | The path of the failing request. |
98
137
  | `:status_code` | The resolved HTTP status (the exception's, or 500). |
99
- | `:component_id` | The failing component's DOM id. |
100
- | `:retry_url` | A GET URL that re-renders the failing component with its current attrs. |
138
+ | `:component_tag` | The failing component's wrapper tag name. |
139
+ | `:retry_url` | A GET URL that re-renders the failing component with its current params. |
140
+ | `:attempts_remaining` | On a live stream: failed pushes left before the stream closes. Absent elsewhere. |
101
141
 
102
142
  So a custom error component opts in by declaration:
103
143
 
@@ -105,26 +145,28 @@ So a custom error component opts in by declaration:
105
145
  class MyApp::ErrorComponent < Weft::Component
106
146
  abstract!
107
147
 
108
- attribute :exception
109
- attribute :retry_url
148
+ param :exception
149
+ param :retry_url
110
150
 
111
151
  def build(attributes = {})
112
152
  super
113
153
  add_class "weft-error"
114
154
  div { text_node "Something went wrong." }
115
- div @attrs.exception.message if Weft.configuration.verbose_error_pages
116
- button "Retry", retry: @attrs.retry_url if @attrs.retry_url
155
+ div @params.exception.message if Weft.configuration.verbose_error_pages
156
+ button "Retry", retry: @params.retry_url if @params.retry_url
117
157
  end
118
158
  end
119
159
  ```
120
160
 
121
161
  Notes on the individual values:
122
162
 
123
- - **These five names are reserved** on any class used as a recovery target. Declaring an attribute with one of these names *means* "inject the recovery value here" — so don't reuse them for your own data on error components, or on any component/page reachable through a `recovers` edge.
124
- - **`:component_id`** preserves DOM identity: render your error wrapper with it as the element id (the gem's defaults do) and the error lands under the failing component's original id so multiple simultaneous failures each swap into their own slot rather than colliding.
125
- - **`:retry_url`** feeds the [`retry:` shorthand](dsl.md#shorthands): one button attribute, and the user can re-request the failed component in place. For a failed *action*, the URL renders the underlying component's view a fresh look, not a replay of the failed action.
163
+ - **These six names are reserved** on any class used as a recovery target. Declaring a param with one of these names *means* "inject the recovery value here" — so don't reuse them for your own data on error components, or on any component/page reachable through a `recovers` edge.
164
+ - **DOM identity is not among them**, because it isn't optional. Weft stamps the failing component's id onto every recovery fragment, so simultaneous failures each swap into their own slot rather than colliding, and a recovery target that has never heard of any of this still lands correctly.
165
+ - **`:component_tag`** keeps swaps *valid*: return it from your error component's `tag_name` (the gem's defaults do) and a failure inside a `<tr>` or `<li>` component produces a fragment its surroundings can legally contain, instead of a `<div>` forced somewhere divs can't go. Weft reads the tag without re-running the failed construction; when a component computes its tag from instance state, the value falls back to absent and the target renders with its own default tag.
166
+ - **`:retry_url`** feeds the [`retry:` preset](dsl.md#presets): one button attribute, and the user can re-request the failed component in place. For a failed *action*, the URL renders the underlying component's view — a fresh look, not a replay of the failed action. On a stream's final frame it feeds [`reopen_stream:`](dsl.md#presets) the same way.
167
+ - **`:attempts_remaining`** is also the push-path context signal: non-nil only when rendering a recovery frame for a live stream. Its presence lets one error component serve both paths — the gem default renders its request shape when it's nil and its stream shape otherwise.
126
168
  - When a recovery resolves to a **redirect** (page target from component context), only `:request_path` and `:status_code` travel — the others have no meaning in a URL.
127
- - Keep the `weft-error` CSS class on custom error components: it's the DOM marker the `retry:` shorthand targets, and a useful styling hook besides.
169
+ - Keep the `weft-error` CSS class on custom error components: it's the DOM marker the `retry:` preset targets, and a useful styling hook besides.
128
170
 
129
171
  ## Presentation settings
130
172
 
@@ -132,5 +174,3 @@ Two configuration settings shape how the built-in fallbacks present; both are co
132
174
 
133
175
  - [`verbose_error_pages`](configuration.md#verbose_error_pages) — whether the gem defaults show exception class/message and the failing path (turn off in production).
134
176
  - [`htmx_errors`](configuration.md#htmx_errors) — whether htmx-request errors falling through to the gem defaults render in place (`:fragment`) or navigate to the error page (`:redirect`). Your own `recovers` edges are never affected, and 404s always render in place.
135
-
136
- > **v0.1 limitation:** custom `recovers from: Weft::NotFound` declarations are not yet reliably honored — the gem-default not-found rendering can take over the response. To customize not-found presentation in v0.1, assign the [`not_found_page` / `not_found_component` knobs](configuration.md#the-four-fallback-targets), which are fully supported. First-class custom `NotFound` recoveries land in v0.2.
@@ -2,7 +2,7 @@
2
2
 
3
3
  A search box that filters a result list as the user types. Each (debounced) keystroke asks the server for matches, and the results region is refilled with the answer — search-as-you-type with all the logic living server-side.
4
4
 
5
- This is Weft's take on [htmx's active-search example](https://htmx.org/examples/active-search/), searching the same kind of small contact directory by name or email. One preset detail differs and is worth stating precisely: Weft's `live_search:` shorthand fires on `input changed delay:300ms` — the `input` event, debounced by 300 milliseconds — where htmx's published example uses `keyup changed delay:500ms`. If you want htmx's exact timing (or any other), the `trigger:` kwarg overrides the preset, as shown below.
5
+ This is Weft's take on [htmx's active-search example](https://htmx.org/examples/active-search/), searching the same kind of small contact directory by name or email. One preset detail differs and is worth stating precisely: Weft's `live_search:` preset fires on `input changed delay:300ms` — the `input` event, debounced by 300 milliseconds — where htmx's published example uses `keyup changed delay:500ms`. If you want htmx's exact timing (or any other), the `trigger:` kwarg overrides the preset, as shown below.
6
6
 
7
7
  ## The components
8
8
 
@@ -21,13 +21,13 @@ PEOPLE = [
21
21
  class ContactResults < Weft::Component
22
22
  builder_method :contact_results
23
23
 
24
- attribute :q, default: ""
24
+ param :q, default: ""
25
25
 
26
26
  def build(attributes = {})
27
27
  super
28
- matches = search(attrs.q)
28
+ matches = search(params.q)
29
29
  if matches.empty?
30
- para "No one matches “#{attrs.q}”."
30
+ para "No one matches “#{params.q}”."
31
31
  else
32
32
  table do
33
33
  thead { tr { th "Name"; th "Email" } }
@@ -55,7 +55,7 @@ class ContactSearch < Weft::Component
55
55
  input type: "search", name: "q", placeholder: "Begin typing to search...",
56
56
  live_search: ContactResults, target: "#search-results"
57
57
  div id: "search-results" do
58
- contact_results(q: "")
58
+ contact_results
59
59
  end
60
60
  end
61
61
  end
@@ -65,7 +65,7 @@ end
65
65
 
66
66
  ## How it works
67
67
 
68
- **The input's `name` is the search parameter.** [`live_search:`](../dsl.md#shorthands) presets trigger `:input` and swap `:fill`; the call site supplies the target. Notice that the generated URL below carries no query string — htmx includes the triggering input's own `name`/value with the request, which is how the typed text travels. The two halves must agree: the input says `name: "q"`, and `ContactResults` declares `attribute :q` to receive it. An empty default keeps the blank-box case (matching everyone) working.
68
+ **The input's `name` is the search parameter.** [`live_search:`](../dsl.md#presets) presets trigger `:input` and swap `:fill`; the call site supplies the target. Notice that the generated URL below carries no query string — htmx includes the triggering input's own `name`/value with the request, which is how the typed text travels. The two halves must agree: the input says `name: "q"`, and `ContactResults` declares `param :q` to receive it. An empty default keeps the blank-box case (matching everyone) working.
69
69
 
70
70
  **Debounced by the preset, adjustable at the call site.** The `:input` semantic trigger expands to `input changed delay:300ms`: fire on input events, only when the value actually changed, at most once per 300ms lull. To reproduce htmx's example exactly, override it in place — the preset's request, swap, and target are all kept:
71
71
 
@@ -75,7 +75,7 @@ input type: "search", name: "q",
75
75
  trigger: "keyup changed delay:500ms"
76
76
  ```
77
77
 
78
- **Results refill a stable container.** The `:fill` swap replaces the *contents* of `#search-results`, so the container div persists across searches while a fresh `ContactResults` lands inside it each time. Pre-rendering `contact_results(q: "")` in the container means the page starts with the full directory rather than an empty pane — the initial state and every subsequent state are the same component.
78
+ **Results refill a stable container.** The `:fill` swap replaces the *contents* of `#search-results`, so the container div persists across searches while a fresh `ContactResults` lands inside it each time. Pre-rendering a bare `contact_results` in the container means the page starts with the full directory rather than an empty pane — its `q` param defaults to the empty string, which matches everyone — and the initial state and every subsequent state are the same component.
79
79
 
80
80
  **The whole result set re-renders per search.** No row diffing, no client-side state: each request returns the complete table (or the "no matches" paragraph) for that query. At search-box scale this is the simple, correct trade.
81
81
 
@@ -94,7 +94,7 @@ The initial render — the wired input, and the container holding the unfiltered
94
94
  </div>
95
95
  ```
96
96
 
97
- (That `id="contact-results-"` is the component's DOM id — dasherized class name plus first attribute value, which here is the empty string.)
97
+ (That `id="contact-results-"` is the component's DOM id — dasherized class name plus first param value, which here is the empty string.)
98
98
 
99
99
  Typing "grimes" settles into `GET /_components/contact_results?q=grimes`:
100
100
 
@@ -121,5 +121,5 @@ And a query with no matches (`GET /_components/contact_results?q=zz`) returns th
121
121
  ## Related
122
122
 
123
123
  - [Tabs](tabs.md) — the same fill-a-stable-container shape, driven by clicks instead of keystrokes.
124
- - The [shorthands table](../dsl.md#shorthands) and [trigger values](../dsl.md#trigger-values) in the DSL reference.
125
- - The [tutorial](../tutorial.md) covers the pairing of form field names with declared attributes in depth.
124
+ - The [presets table](../dsl.md#presets) and [trigger values](../dsl.md#trigger-values) in the DSL reference.
125
+ - The [tutorial](../tutorial.md) covers the pairing of form field names with declared params in depth.
@@ -2,7 +2,7 @@
2
2
 
3
3
  A destructive button guarded by the browser's native `confirm()` dialog — the user gets a chance to back out before the request is ever made, with no dialog component to build and no CSS to write.
4
4
 
5
- This is Weft's take on [htmx's dialogs example](https://htmx.org/examples/dialogs/). The honest point of this page is that Weft has no kwarg for `hx-confirm` and doesn't need one: raw htmx attributes pass through to the element untouched, side by side with whatever the Weft kwargs expand to.
5
+ This is Weft's take on [htmx's dialogs example](https://htmx.org/examples/dialogs/). The confirm half is one kwarg [`confirm:`](../dsl.md#confirm) rides the same button the action already wired. The prompt half is deliberately *not* a kwarg (below), and shows the escape hatch that remains for htmx vocabulary Weft doesn't cover.
6
6
 
7
7
  ## The components
8
8
 
@@ -22,7 +22,7 @@ class AccountPanel < Weft::Component
22
22
  para "Your account is #{ACCOUNT[:status]}."
23
23
  if ACCOUNT[:status] == "active"
24
24
  button "Deactivate my account", action: :deactivate,
25
- "hx-confirm" => "Deactivate your account? You can sign back in to reactivate."
25
+ confirm: "Deactivate your account? You can sign back in to reactivate."
26
26
  end
27
27
  end
28
28
  end
@@ -32,25 +32,25 @@ end
32
32
 
33
33
  ## How it works
34
34
 
35
- **Raw htmx attributes ride along.** Weft intercepts only its own kwargs`action:`, `trigger:`, and friends. Everything else on an element, string-keyed htmx attributes included, renders as a plain HTML attribute. So `"hx-confirm" => "..."` lands verbatim next to the wiring that [`action:`](../dsl.md#action) expanded, and htmx picks it up like any hand-written page. This is the general escape hatch: whenever htmx has a feature Weft has no vocabulary for, write the attribute yourself.
35
+ **The guard is one modifier kwarg.** [`confirm:`](../dsl.md#confirm) works alongside any interaction kwargthis action, a [`navigate:`](../dsl.md#navigate), a preset or standalone on a container, where htmx inheritance applies it to every request fired from inside. Weft consumes the kwarg and emits `hx-confirm` beside the wiring [`action:`](../dsl.md#action) expanded.
36
36
 
37
- **The guard lives in the browser, not on the server.** `hx-confirm` gates the *request*: htmx shows the native dialog and only issues the POST if the user accepts. The endpoint itself is unchanged — a request made outside htmx skips the question entirely. Treat it as protection against misclicks, never as access control; anything truly destructive still needs authorization server-side.
37
+ **The guard lives in the browser, not on the server.** The confirm gates the *request*: htmx shows the native dialog and only issues the POST if the user accepts. The endpoint itself is unchanged — a request made outside htmx skips the question entirely. Treat it as protection against misclicks, never as access control; anything truly destructive still needs authorization server-side.
38
38
 
39
39
  **The action is ordinary Weft.** `performs :deactivate` runs the write and re-renders the component, which now shows the deactivated state — the standard action contract, unaware that a dialog ever happened.
40
40
 
41
- **`hx-prompt` doesn't carry over.** htmx's companion attribute asks for a line of text and sends the answer as an `HX-Prompt` *request header* — but a Weft action callable receives only the component's resolved attributes, which come from request parameters, so the prompted value never reaches your code. When an action needs user input, give the component a real input: a form field paired with a declared attribute, as in [Click to Edit](click-to-edit.md).
41
+ **There is no `prompt:` kwarg on purpose.** htmx's companion attribute asks for a line of text and sends the answer as an `HX-Prompt` *request header* — but a Weft action callable receives only the component's resolved params, which come from request parameters, so the prompted value would never reach your code. When an action needs user input, give the component a real input: a form field paired with a declared param, as in [Click to Edit](click-to-edit.md). And if you want the browser's prompt anyway, the escape hatch is always open: kwargs Weft doesn't recognize pass through untouched, so `"hx-prompt" => "..."` renders verbatim — the general pattern whenever htmx has a feature Weft has no vocabulary for.
42
42
 
43
43
  ## On the wire
44
44
 
45
- The initial render — the confirm attribute sits verbatim beside the expanded action wiring:
45
+ The initial render — the confirm lands beside the expanded action wiring:
46
46
 
47
47
  ```html
48
48
  <div id="account-panel">
49
49
  <p>Your account is active.</p>
50
- <button hx-confirm="Deactivate your account? You can sign back in to reactivate."
51
- hx-post="/_components/account_panel/deactivate"
50
+ <button hx-post="/_components/account_panel/deactivate"
52
51
  hx-target="#account-panel" hx-swap="outerHTML"
53
- hx-vals="{}">Deactivate my account</button>
52
+ hx-vals="{}"
53
+ hx-confirm="Deactivate your account? You can sign back in to reactivate.">Deactivate my account</button>
54
54
  </div>
55
55
  ```
56
56
 
@@ -67,5 +67,5 @@ The same POST sent from outside the browser — no htmx, no dialog — is accept
67
67
  ## Related
68
68
 
69
69
  - [Modal Dialog](modal-dialog.md) — when you want a dialog you own instead of the browser's.
70
- - [Click to Edit](click-to-edit.md) — form fields paired with attributes: the Weft answer to "prompt the user for a value."
70
+ - [Click to Edit](click-to-edit.md) — form fields paired with params: the Weft answer to "prompt the user for a value."
71
71
  - [`action:`](../dsl.md#action) and [`performs`](../dsl.md#performs--user-initiated-actions) in the DSL reference.
@@ -17,11 +17,11 @@ TEAM_MEMBERS = {
17
17
  class MemberRoster < Weft::Component
18
18
  builder_method :member_roster
19
19
 
20
- attribute :active_ids, default: []
21
- attribute :status
20
+ param :active_ids, default: []
21
+ param :status
22
22
 
23
- performs :update, target: "#member-roster" do |attrs|
24
- checked = attrs.active_ids
23
+ performs :update, target: "#member-roster" do |params|
24
+ checked = params.active_ids
25
25
  activated = deactivated = 0
26
26
  TEAM_MEMBERS.each do |id, member|
27
27
  active = checked.include?(id)
@@ -56,7 +56,7 @@ class MemberRoster < Weft::Component
56
56
  end
57
57
  input type: "submit", value: "Bulk Update"
58
58
  end
59
- para attrs.status if attrs.status
59
+ para params.status if params.status
60
60
  end
61
61
  end
62
62
  ```
@@ -65,15 +65,15 @@ end
65
65
 
66
66
  ## How it works
67
67
 
68
- **Bracket naming turns the checkboxes into one array.** Every checkbox shares the name `active_ids[]`, and Rack's parameter parsing folds those into a single array under the bracket-less key — so the component's declared `active_ids` attribute receives `["1", "2", "4"]`, and the callable reads it as `attrs.active_ids`. The values arrive as strings, which is why the data stub's keys are strings too. Checkboxes the user leaves unchecked simply aren't in the submission; that's the whole trick of the pattern.
68
+ **Bracket naming turns the checkboxes into one array.** Every checkbox shares the name `active_ids[]`, and Rack's parameter parsing folds those into a single array under the bracket-less key — so the component's declared `active_ids` param receives `["1", "2", "4"]`, and the callable reads it as `params.active_ids`. The values arrive as strings, which is why the data stub's keys are strings too. Checkboxes the user leaves unchecked simply aren't in the submission; that's the whole trick of the pattern.
69
69
 
70
- **The `default: []` is load-bearing.** When *no* boxes are checked, the browser sends no `active_ids` parameter at all, and the attribute falls back to its default. An empty array makes that case mean "deactivate everyone" — and guarantees the callable always has a real array to call `include?` on, rather than `nil`.
70
+ **The `default: []` is load-bearing.** When *no* boxes are checked, the browser sends no `active_ids` parameter at all, and the param falls back to its default. An empty array makes that case mean "deactivate everyone" — and guarantees the callable always has a real array to call `include?` on, rather than `nil`.
71
71
 
72
- **The callable diffs, then reports through its return value.** It compares each member's stored state against the submitted array, counts the flips, and writes the new state. Returning a hash merges it into the attrs for the re-render (see [the callable contract](../dsl.md#the-callable-contract)), so `{ status: "Activated 1 and deactivated 1 members." }` is how the count reaches the status line — `attrs.status` is `nil` on a fresh render and the paragraph only appears after an update.
72
+ **The callable diffs, then reports through its return value.** It compares each member's stored state against the submitted array, counts the flips, and writes the new state. Returning a hash merges it into the params for the re-render (see [the callable contract](../dsl.md#the-callable-contract)), so `{ status: "Activated 1 and deactivated 1 members." }` is how the count reaches the status line — `params.status` is `nil` on a fresh render and the paragraph only appears after an update.
73
73
 
74
- **An array can't anchor a DOM id.** Weft derives a component's DOM id from its first declared attribute — perfect when that's a record id, unusable when it's an array (`id="member-roster-[]"` is not a selector htmx can target). So this component pins its own identity: `set_attribute :id, "member-roster"` fixes the wrapper's id inside `build`, and `performs :update, target: "#member-roster"` points the action's swap at that same anchor. Both live server-side, so every re-rendered fragment carries the same stable wiring.
74
+ **An array can't anchor a DOM id.** Weft derives a component's DOM id from its first declared param — perfect when that's a record id, unusable when it's an array (`id="member-roster-[]"` is not a selector htmx can target). So this component pins its own identity: `set_attribute :id, "member-roster"` fixes the wrapper's id inside `build`, and `performs :update, target: "#member-roster"` points the action's swap at that same anchor. Both live server-side, so every re-rendered fragment carries the same stable wiring.
75
75
 
76
- **The checkboxes tell the truth after the write.** `build` renders each checkbox from the data store, not from the submitted attrs — the response reflects what was actually saved. And since `form(action: :update)` also emits plain `action`/`method` attributes, the whole thing degrades to a normal POST without JavaScript.
76
+ **The checkboxes tell the truth after the write.** `build` renders each checkbox from the data store, not from the submitted params — the response reflects what was actually saved. And since `form(action: :update)` also emits plain `action`/`method` attributes, the whole thing degrades to a normal POST without JavaScript.
77
77
 
78
78
  ## On the wire
79
79
 
@@ -117,6 +117,6 @@ Submitting with every box unchecked sends an empty body; the default kicks in an
117
117
 
118
118
  ## Related
119
119
 
120
- - [Click to Edit](click-to-edit.md) — the basics of pairing form fields with declared attributes.
120
+ - [Click to Edit](click-to-edit.md) — the basics of pairing form fields with declared params.
121
121
  - [Delete Row](delete-row.md) and [Edit Row](edit-row.md) — acting on table rows one at a time instead of all at once.
122
122
  - [`performs`](../dsl.md#performs--user-initiated-actions) and [the callable contract](../dsl.md#the-callable-contract) in the DSL reference.
@@ -14,16 +14,16 @@ CONTACTS = {
14
14
  class ContactCard < Weft::Component
15
15
  builder_method :contact_card
16
16
 
17
- attribute :contact_id
17
+ param :contact_id
18
18
 
19
19
  def build(attributes = {})
20
20
  super
21
- contact = CONTACTS.fetch(attrs.contact_id)
21
+ contact = CONTACTS.fetch(params.contact_id)
22
22
  div { strong "First Name: "; text_node contact[:first_name] }
23
23
  div { strong "Last Name: "; text_node contact[:last_name] }
24
24
  div { strong "Email: "; text_node contact[:email] }
25
25
  button "Click To Edit",
26
- loads: ContactEditor, with: { contact_id: attrs.contact_id },
26
+ loads: ContactEditor, with: { contact_id: params.contact_id },
27
27
  swap: :replace, target: self
28
28
  end
29
29
  end
@@ -31,23 +31,23 @@ end
31
31
  class ContactEditor < Weft::Component
32
32
  builder_method :contact_editor
33
33
 
34
- attribute :contact_id
35
- attribute :first_name
36
- attribute :last_name
37
- attribute :email
34
+ param :contact_id
35
+ param :first_name
36
+ param :last_name
37
+ param :email
38
38
 
39
- transfers :save, to: ContactCard do |attrs|
40
- CONTACTS.fetch(attrs.contact_id).merge!(
41
- first_name: attrs.first_name, last_name: attrs.last_name, email: attrs.email
39
+ transfers :save, to: ContactCard do |params|
40
+ CONTACTS.fetch(params.contact_id).merge!(
41
+ first_name: params.first_name, last_name: params.last_name, email: params.email
42
42
  )
43
43
  nil
44
44
  end
45
45
 
46
46
  def build(attributes = {})
47
47
  super
48
- contact = CONTACTS.fetch(attrs.contact_id)
48
+ contact = CONTACTS.fetch(params.contact_id)
49
49
  form(action: :save) do
50
- input(type: "hidden", name: "contact_id", value: attrs.contact_id)
50
+ input(type: "hidden", name: "contact_id", value: params.contact_id)
51
51
  div do
52
52
  label("First Name ", for: "first_name")
53
53
  input(type: "text", name: "first_name", id: "first_name", value: contact[:first_name])
@@ -62,7 +62,7 @@ class ContactEditor < Weft::Component
62
62
  end
63
63
  input(type: "submit", value: "Submit")
64
64
  button "Cancel", type: "button",
65
- loads: ContactCard, with: { contact_id: attrs.contact_id },
65
+ loads: ContactCard, with: { contact_id: params.contact_id },
66
66
  swap: :replace, target: self
67
67
  end
68
68
  end
@@ -79,7 +79,7 @@ end
79
79
 
80
80
  **The two components reference each other — without a cycle.** `transfers :save, to: ContactCard` runs in the class body, so `ContactCard` must already be defined; but `loads: ContactEditor` isn't evaluated until render. Defining the display component first therefore breaks the loop with no forward-declaration tricks. This ordering trick generalizes to any two-state component pair.
81
81
 
82
- **Form fields pair with declared attributes.** The editor declares `first_name`, `last_name`, and `email` so its fields reach the save callable as `attrs.first_name` and friends — and `contact_id` rides along as a hidden input, because it's part of the component's identity rather than something the user edits. (This pairing is covered in depth in [the tutorial](../tutorial.md#7-taking-rsvps).)
82
+ **Form fields pair with declared params.** The editor declares `first_name`, `last_name`, and `email` so its fields reach the save callable as `params.first_name` and friends — and `contact_id` rides along as a hidden input, because it's part of the component's identity rather than something the user edits. (This pairing is covered in depth in [the tutorial](../tutorial.md#7-taking-rsvps).)
83
83
 
84
84
  **It still works without JavaScript.** `form(action: :save)` emits plain `action`/`method` attributes alongside the htmx wiring, so the save degrades to a normal POST. Note `type: "button"` on Cancel — inside a form, a bare `<button>` is a submit button.
85
85
 
@@ -14,16 +14,16 @@ class AgentRows < Weft::Component
14
14
 
15
15
  PER_PAGE = 6
16
16
 
17
- attribute :page, default: 1
17
+ param :page, default: 1, type: :integer
18
18
 
19
19
  def build(attributes = {})
20
20
  super
21
- batch = AGENTS[(attrs.page - 1) * PER_PAGE, PER_PAGE]
21
+ batch = AGENTS[(params.page - 1) * PER_PAGE, PER_PAGE]
22
22
  batch.each do |agent|
23
23
  div { strong agent[:name]; text_node " — #{agent[:email]} (##{agent[:number]})" }
24
24
  end
25
- if attrs.page * PER_PAGE < AGENTS.size
26
- button "Load More Agents...", load_more: AgentRows, with: { page: attrs.page + 1 }
25
+ if params.page * PER_PAGE < AGENTS.size
26
+ button "Load More Agents...", load_more: AgentRows, with: { page: params.page + 1 }
27
27
  end
28
28
  end
29
29
  end
@@ -31,15 +31,15 @@ end
31
31
 
32
32
  (The `AGENTS` array stands in for your data layer — swap in ActiveRecord or whatever your app uses.)
33
33
 
34
- Render `agent_rows(page: 1)` wherever the list lives, and the pattern takes care of itself from there.
34
+ Render a bare `agent_rows` wherever the list lives — its `page` param defaults to 1 — and the pattern takes care of itself from there.
35
35
 
36
36
  ## How it works
37
37
 
38
- **One shorthand, no ceremony.** [`load_more:`](../dsl.md#shorthands) is a preset over the [`loads:`](../dsl.md#loads) machinery: trigger `:click`, swap `:replace`, target `:self`. In plain terms — when this button is clicked, fetch the named component and put it where the button was. The call site supplies only what varies: which component to load (`AgentRows`, the component's own class) and its wire attrs (`with: { page: attrs.page + 1 }`).
38
+ **One preset, no ceremony.** [`load_more:`](../dsl.md#presets) is a preset over the [`loads:`](../dsl.md#loads) machinery: trigger `:click`, swap `:replace`, target `:self`. In plain terms — when this button is clicked, fetch the named component and put it where the button was. The call site supplies only what varies: which component to load (`AgentRows`, the component's own class) and its wire params (`with: { page: params.page + 1 }`).
39
39
 
40
40
  **The component is a chunk, not the whole list.** Each `AgentRows` instance renders one page of agents and, when more remain, the button that fetches the next chunk *in its own place*. Clicking never touches the agents already on screen; the button alone is replaced, and the new chunk arrives with its own button. The recursion bottoms out naturally — the `if` guard means the final chunk simply renders no button.
41
41
 
42
- **Attributes make the chunk addressable.** Declaring `attribute :page, default: 1` gives the component a route ([Routing](../routing.md)) and coerces the wire value: `page=2` arrives as the string `"2"` and reaches `attrs.page` as the Integer `2`, because the default is an Integer.
42
+ **Params make the chunk addressable.** Declaring `param :page, default: 1, type: :integer` gives the component a route ([Routing](../routing.md)) and coerces the wire value: `page=2` arrives as the string `"2"` and reaches `params.page` as the Integer `2`.
43
43
 
44
44
  **A component can load itself.** `load_more: AgentRows` inside `AgentRows`'s own `build` is unremarkable — the class reference is evaluated at render time, so self-reference needs no tricks.
45
45
 
@@ -73,5 +73,5 @@ The last page (`GET /_components/agent_rows?page=4`) renders its agents and no b
73
73
 
74
74
  - [Infinite Scroll](infinite-scroll.md) — the same next-page mechanic, triggered by scrolling instead of a click, in a real table.
75
75
  - [Lazy Loading](lazy-loading.md) — deferring one expensive section rather than paginating many.
76
- - The [shorthands table](../dsl.md#shorthands) in the DSL reference, including how to register your own preset.
76
+ - The [presets table](../dsl.md#presets) in the DSL reference, including how to register your own preset.
77
77
  - For pagination that *replaces* the current page instead of accumulating, [`navigate:`](../dsl.md#navigate) is the better verb.
@@ -16,10 +16,11 @@ CONTACT_BOOK = {
16
16
  class ContactRow < Weft::Component
17
17
  builder_method :contact_row
18
18
 
19
- attribute :contact_id
19
+ param :contact_id
20
+ receives :contact_id
20
21
 
21
- dismisses :destroy do |attrs|
22
- CONTACT_BOOK.delete(attrs.contact_id)
22
+ dismisses :destroy do |params|
23
+ CONTACT_BOOK.delete(params.contact_id)
23
24
  nil
24
25
  end
25
26
 
@@ -29,14 +30,12 @@ class ContactRow < Weft::Component
29
30
 
30
31
  def build(attributes = {})
31
32
  super
32
- contact = CONTACT_BOOK[attrs.contact_id]
33
- return unless contact
34
-
33
+ contact = CONTACT_BOOK[params.contact_id]
35
34
  td contact[:name]
36
35
  td contact[:email]
37
36
  td contact[:status]
38
37
  td do
39
- button "Delete", action: :destroy, "hx-confirm" => "Are you sure?"
38
+ button "Delete", action: :destroy, confirm: "Are you sure?"
40
39
  end
41
40
  end
42
41
  end
@@ -60,13 +59,15 @@ end
60
59
 
61
60
  ## How it works
62
61
 
63
- **The row is the component.** Overriding `tag_name` makes the wrapper a `<tr>`, so each contact renders as a real table row with its own DOM id. The identifying attribute is declared first because that's where the id comes from — `contact_id` of `"1"` yields `id="contact-row-1"`, which is exactly what the delete needs to target.
62
+ **The row is the component.** Overriding `tag_name` makes the wrapper a `<tr>`, so each contact renders as a real table row with its own DOM id. The identifying param is declared first because that's where the id comes from — `contact_id` of `"1"` yields `id="contact-row-1"`, which is exactly what the delete needs to target.
63
+
64
+ **Each row is handed its id, and declares it as a param too.** The table gives every row a *different* `contact_id` (`contact_row(contact_id: id)`), which is a [`receives`](../dsl.md#receives--caller-hand-offs) hand-off — sibling rows each need a distinct value, and the shared params bag that flows down the render tree can't supply per-row differences. Declaring [`param :contact_id`](../dsl.md#how-the-doors-combine) alongside it means the same id also serializes into the row's own route and the Delete button's payload, so `GET /_components/contact_row?contact_id=1` reconstructs the row on its own and the delete targets the right record.
64
65
 
65
- **`dismisses` is the delete-shaped verb.** It's sugar for a `performs` with `method: :delete, swap: :delete` (see [`dismisses`](../dsl.md#dismisses--remove-from-the-dom)): the button wired with `action: :destroy` issues a `DELETE` to the action's route, the callable removes the record, and on success htmx deletes the target element — the row — from the DOM. The row's identity travels automatically: an action button carries the component's attrs as `hx-vals`. Note there's no non-JavaScript fallback here — plain HTML has no DELETE — which is the nature of the pattern rather than a Weft limitation.
66
+ **`dismisses` is the delete-shaped verb.** It's sugar for a `performs` with `method: :delete, swap: :delete` (see [`dismisses`](../dsl.md#dismisses--remove-from-the-dom)): the button wired with `action: :destroy` issues a `DELETE` to the action's route, the callable removes the record, and on success htmx deletes the target element — the row — from the DOM. The row's identity travels automatically: an action button carries the component's params as `hx-vals`. Note there's no non-JavaScript fallback here — plain HTML has no DELETE — which is the nature of the pattern rather than a Weft limitation.
66
67
 
67
- **The confirmation is one raw attribute.** Kwargs Weft doesn't recognize pass straight through to the element, so `"hx-confirm" => "Are you sure?"` lands on the button as-is and htmx shows the browser's native confirm dialog before sending anything. No request fires on Cancel. For the fuller confirm-and-prompt story, see [Browser Dialogs](browser-dialogs.md).
68
+ **The confirmation is one kwarg.** [`confirm:`](../dsl.md#confirm) rides along on the wired button: htmx shows the browser's native confirm dialog before sending anything, and no request fires on Cancel. For the fuller confirm-and-prompt story, see [Browser Dialogs](browser-dialogs.md).
68
69
 
69
- **The dismissal response is rendered, then thrown away.** htmx ignores the response body on a delete swap, but Weft still renders the component once after the callable runs so `build` must survive the record being gone, hence the `return unless contact` guard (an empty `<tr>` nobody will see). The trailing `nil` in the callable matters for the same reason: `Hash#delete` returns the deleted record, and a hash returned from a callable merges into the attrs for that final render. If the callable *raises*, Weft overrides the destructive swap (via `HX-Reswap`) so the error rendering appears where the row was, instead of the row silently vanishing.
70
+ **A successful dismissal responds with nothing.** htmx removes the row on its own, so the response carries no body Weft doesn't render the component it just helped delete, which is why `build` can assume its record exists without guarding. (Out-of-band [`includes`](../dsl.md#includes--companions-in-the-same-response) companions still ride the response when declared a delete can update the count beside the table it shrinks.) The trailing `nil` in the callable keeps `Hash#delete`'s return value — the deleted record from merging into the params those companions read. If the callable *raises*, Weft overrides the destructive swap (via `HX-Reswap`) so the error rendering appears where the row was, instead of the row silently vanishing — and the error fragment arrives as a `<tr>` itself, because recovery fragments [adopt the failing component's tag](../error-handling.md#auto-injected-recovery-params), landing as valid table content.
70
71
 
71
72
  ## On the wire
72
73
 
@@ -78,20 +79,17 @@ Each row arrives fully wired — `GET /_components/contact_row?contact_id=1` ret
78
79
  <td>angie@macdowell.org</td>
79
80
  <td>Active</td>
80
81
  <td>
81
- <button hx-confirm="Are you sure?" hx-delete="/_components/contact_row/destroy"
82
+ <button hx-delete="/_components/contact_row/destroy"
82
83
  hx-target="#contact-row-1" hx-swap="delete"
83
- hx-vals="{&quot;contact_id&quot;:&quot;1&quot;}">Delete</button>
84
+ hx-vals="{&quot;contact_id&quot;:&quot;1&quot;}"
85
+ hx-confirm="Are you sure?">Delete</button>
84
86
  </td>
85
87
  </tr>
86
88
  ```
87
89
 
88
- Confirming the dialog sends `DELETE /_components/contact_row/destroy?contact_id=1` (htmx 2 puts DELETE parameters in the query string). The server deletes the record and responds `200` with the guarded, now-empty renderwhich htmx discards while removing the row:
89
-
90
- ```html
91
- <tr id="contact-row-1"></tr>
92
- ```
90
+ Confirming the dialog sends `DELETE /_components/contact_row/destroy?contact_id=1` (htmx 2 puts DELETE parameters in the query string). The server deletes the record and responds `200` with an empty bodythere is nothing left worth rendering — and htmx removes the row.
93
91
 
94
- Fetching the table again shows two rows; fetching the deleted row's own URL returns the same empty `<tr>`, confirming the record is gone.
92
+ Fetching the table again shows two rows. Fetching the deleted row's own URL now fails like any other missing-record lookup and lands in the error chain — if stale fetches are a real path in your app, brand them with [`recovers from:`](../error-handling.md).
95
93
 
96
94
  ## Related
97
95