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
data/docs/dsl.md CHANGED
@@ -1,20 +1,20 @@
1
1
  # The Weft DSL
2
2
 
3
- A Weft component describes its interactive behavior in two layers. **Class-body declarations** — the verbs — state what the component does: it refreshes on a timer, it performs an action, it recovers from an error. **Element kwargs**, used inside `build`, wire individual elements to those behaviors: this button performs the `:cancel` action, this div loads a tooltip on hover. Both layers compile down to auto-generated routes and htmx attributes; you write neither by hand. (The HTML itself — the `build` method and everything inside it — is [Arbre](arbre.md), documented separately.)
3
+ A Weft component describes its interactive behavior in two layers. **Class-body declarations** — the verbs — state what the component does: it refreshes on a timer, it performs an action, it recovers from an error. **Element kwargs**, used inside `build`, wire individual elements to those behaviors: this button performs the `:cancel` action, this div loads a tooltip on hover. Both layers compile down to auto-generated routes and htmx params; you write neither by hand. (The HTML itself — the `build` method and everything inside it — is [Arbre](arbre.md), documented separately.)
4
4
 
5
5
  ```ruby
6
6
  class DeliveryStatus < Weft::Component
7
- attribute :delivery_id # wire state
7
+ param :delivery_id # wire state
8
8
 
9
9
  refreshes every: 5.seconds # verb: live updates
10
10
 
11
- performs :cancel do |attrs| # verb: user-initiated action
12
- CancelDelivery.call(Delivery.find(attrs.delivery_id))
11
+ performs :cancel do |params| # verb: user-initiated action
12
+ CancelDelivery.call(Delivery.find(params.delivery_id))
13
13
  end
14
14
 
15
15
  def build(attributes = {})
16
16
  super
17
- delivery = Delivery.find(attrs.delivery_id)
17
+ delivery = Delivery.find(params.delivery_id)
18
18
  span "Arriving #{delivery.eta.humanize}"
19
19
  button "Cancel", action: :cancel # element kwarg: wires to the verb
20
20
  end
@@ -23,166 +23,349 @@ end
23
23
 
24
24
  **In this document:**
25
25
 
26
- - [Attributes](#attributes)
27
- - [Verbs](#verbs)
28
- - [`refreshes` — the client re-fetches](#refreshes--the-client-re-fetches)
29
- - [`pushes` — the server sends updates](#pushes--the-server-sends-updates)
30
- - [`performs` — user-initiated actions](#performs--user-initiated-actions) and [the callable contract](#the-callable-contract)
31
- - [`transfers` actions that render something else](#transfers--actions-that-render-something-else)
32
- - [`dismisses` — remove from the DOM](#dismisses--remove-from-the-dom)
33
- - [`triggers` announce to the rest of the page](#triggers--announce-to-the-rest-of-the-page)
34
- - [`includes` — companions in the same response](#includes--companions-in-the-same-response)
35
- - [`recovers` — declare error behavior](#recovers--declare-error-behavior)
36
- - [Other class-body declarations](#other-class-body-declarations)
37
- - [Element kwargs](#element-kwargs): [`action:`](#action), [`navigate:`](#navigate), [`loads:`](#loads), [`trigger:`](#trigger), [`push_url:`](#push_url) plus the [swap](#swap-values), [trigger](#trigger-values), and [target](#targets) value tables
38
- - [Shorthands](#shorthands)
26
+ - [Params](#params): the different ways data gets into your component on each request (analogous to Rails' `params`).
27
+ - [`param`](#param--wire-state) - path component params, query string params, form data, and POST content; all of which together is called "wire state"
28
+ - [`derives`](#derives--lazy-server-side-derivations) - enriched values that can be determined from the wire state, such as a database model loaded by an ID in the query string
29
+ - [`defines`](#defines--static-values) - a variant of `derives` for when a value is known at load time
30
+ - [`receives`](#receives--caller-hand-offs) - rich values handed straight over by whoever renders the component, such as a database model the caller already has in hand
31
+ - [How These Ways Combine](#how-the-doors-combine) - when and why to use them in combination
32
+ - [Inheritance](#inheritance-and-the-render-tree) - how wire state is made available at each layer when components are composed together
33
+ - [Behavioral Verbs](#verbs): the user-facing behaviors that your component exposes or provides.
34
+ - [`performs`](#performs--user-initiated-actions) - user-initiated actions which re-render the component afterwards, like submitting a form
35
+ - [`transfers`](#transfers--actions-that-render-something-else) - a variant of `performs` that replaces this component with another
36
+ - [`dismisses`](#dismisses--remove-from-the-dom) - a variant of `performs` that removes this component from the DOM
37
+ - [`refreshes`](#refreshes--the-client-re-fetches) - re-render this component periodically or on specific **client-side** events
38
+ - [`pushes`](#pushes--the-server-sends-updates) - re-render this component periodically or on specific **server-side** events
39
+ - [`recovers`](#recovers--declare-error-behavior) - what your component should do if it encounters an error
40
+ - [`triggers`](#triggers--announce-to-the-rest-of-the-page) - announce a **client-side** event to the other components on the page
41
+ - [`includes`](#includes--companions-in-the-same-response) - what other components need to redraw when this one does
42
+ - [The Callable Contract](#the-callable-contract) - how to attach logic to each of these behaviors
43
+ - [Other Class-Body Declarations](#other-class-body-declarations)
44
+ - [Element Kwargs](#element-kwargs): how the behaviors get attached to your component's UI elements.
45
+ - [`action:`](#action) - attach any of the actions defined with `performs`, `transfers`, or `dismisses` to any HTML element
46
+ - [`navigate:`](#navigate) - a variant of `action` that needs no declaration: re-fetch this same component with some of its wire state changed, which is the idiom for filtering, sorting, and pagination
47
+ - [`loads:`](#loads) - fetch a *different* component and place it somewhere on the page, such as a detail pane that fills in when a row is clicked
48
+ - [Interaction vs. Modifier Kwargs](#the-kwarg-rules)
49
+ - And the modifiers themselves:
50
+ - [`trigger:`](#trigger) - which browser event fires the request, when the default (usually a click) isn't the one you want
51
+ - [`push_url:`](#push_url) - update the browser's address bar when the request completes, so the new state is shareable and the back button still means something
52
+ - [`confirm:`](#confirm) - ask for confirmation in a browser dialog first, and make no request at all if the answer is no
53
+ - [`swap:`](#swap-values) - how the response lands in the DOM: replacing the target, filling it, or inserting around it
54
+ - [`target:`](#targets) - which element the response lands in, for the times it shouldn't be the component itself
55
+ - Kwarg [Presets](#presets) - named groups of settings which let you reuse a common or custom behavior across your whole app
56
+
57
+ ## Params
58
+
59
+ A component's inputs all reach it through `params`, and there are four ways to declare them — four *doors* into the same bag, each suited to a different kind of value:
60
+
61
+ - **[`param`](#param--wire-state)** — wire state: values small enough to travel in a URL (an id, a page number, a filter).
62
+ - **[`derives`](#derives--lazy-server-side-derivations)** — lazy server-side derivations: values the component works out for itself, on demand.
63
+ - **[`defines`](#defines--static-values)** — static values a subclass pins; sugar over `derives`.
64
+ - **[`receives`](#receives--caller-hand-offs)** — caller hand-offs: rich objects a call site already holds and passes straight in (a record, a computed collection).
65
+
66
+ Whichever door a value comes through, you read it the same way — `params.name`, or `params[:name]`. Every verb block sees the same doors `build` does, with one structural exception: `receives` values come from a *call site*, and an action arriving over the wire has no caller, so a callable can't see them. (Need one in an action? Give the key a second door — a `param` or a `defines` — and it stands on its own.) For the bigger picture — how params travel in from a request, down the render tree, and back out into the next refresh or action — see [How params flow](params.md).
67
+
68
+ ### `param` — wire state
39
69
 
40
- ## Attributes
70
+ ```ruby
71
+ param :status, default: "active"
72
+ param :page, default: 1, type: :integer
73
+ ```
74
+
75
+ Params are a component's *wire state* — the values that identify what this particular instance shows, small enough to travel in a URL. They come from the request: query, path, and body values. When the component renders inside a page, it reads the same wire params the page does (see [Inheritance and the render tree](#inheritance-and-the-render-tree)); when it renders over the wire — a refresh, an action, an SSE push — they come from that request's parameters.
76
+
77
+ Wire values arrive as strings, so a param that means something else declares its `type:`, and Weft coerces the wire value on the way in:
41
78
 
42
79
  ```ruby
43
- attribute :status, default: "active"
44
- attribute :page, default: 1
80
+ param :page, type: :integer # "2" → 2
81
+ param :rate, type: :float # "3.14" → 3.14
82
+ param :price, type: :decimal # "19.99" → BigDecimal("19.99") — full precision, right for money
83
+ param :active, type: :boolean # "true" and "1" → true; anything else → false
84
+ param :zip, type: :string # looks numeric, isn't — leading zeros survive
45
85
  ```
46
86
 
47
- Attributes are a component's *wire state*the values that identify what this particular instance shows, small enough to travel in a URL. When the component renders inside a page, attributes come from the rendering call (`orders_panel(status: "shipped")`); when it renders over the wire — a refresh, an action, an SSE push they come from request parameters. Either way, `build` and action callables see the same resolved values.
87
+ An untyped param accepts whatever arrives, uncoerced right for values that are already strings, and for rich shapes like the nested hash browsers submit for `items[widget]=2`. Declare `type: :boolean` on every flag param: without it, a wire `"false"` is just a truthy string. `default:` is independent of `type:` the default fills the key when no source supplies a value, is never itself coerced, and must already be an instance of the declared type. Weft checks declarations on the spot: an unknown type or a disagreeing default raises `Weft::InvalidDefinition` at class-load time, not mid-request.
88
+
89
+ Inside the component, `params` returns the resolved values with method-style access:
90
+
91
+ ```ruby
92
+ params.status # => "shipped"
93
+ params.page # => 2 (an Integer — coerced)
94
+ params[:status] # explicit hash-style access
95
+ params.to_h # the underlying hash
96
+ ```
97
+
98
+ Declared param names always win over hash methods — if you declare `param :count`, `params.count` is your value, not `Hash#count`. For anything not declared, the hash API is available directly on `params`.
99
+
100
+ Only a component's *own* declared params serialize — into its refresh and stream URLs and its action payloads. That's the refresh contract: a standalone request must be able to reconstruct the component from its URL, so only URL-safe wire state belongs there. The other three doors are server-side and never serialize.
101
+
102
+ The first `param` also anchors the component's DOM identity: the wrapper's element id is the dasherized class name suffixed with the first declared param's value — `StatCard` with `status: "shipped"` renders `id="stat-card-shipped"`, which is how sibling instances stay individually addressable. Declare the identifying param first. The suffix rides only when the value is a non-blank scalar (String, Symbol, number, or boolean): `nil`, `""`, and non-scalar values all derive the same bare class id, so a component's identity is stable across the different ways "no value" can arrive.
48
103
 
49
- Wire values arrive as strings, so Weft coerces them based on each attribute's default: an `Integer` default coerces with `to_i`, a `Float` with `to_f`, and a `true`/`false` default maps `"true"` and `"1"` to `true` (anything else to `false`). Attributes with other defaults (strings, `nil`) pass through untouched. A `type:` kwarg is accepted on `attribute` but reserved for future usetoday, the default *is* the type declaration.
104
+ That id decides more than where a fragment lands. Because an out-of-band swap is addressed by it, it also decides which [`includes`](#includes--companions-in-the-same-response) companions can coexist in one response: two that resolve to the same id collide, and only one rides. Changing which param you declare first or what that param holdscan therefore change *which* companions appear, not merely where they go.
50
105
 
51
- Inside the component, `attrs` returns the resolved values with method-style access:
106
+ Declaring a param has a routing consequence: a component with params (or any verb below) is considered independently addressable and gets its own route. See [Routing](routing.md).
107
+
108
+ ### `derives` — lazy server-side derivations
52
109
 
53
110
  ```ruby
54
- attrs.status # => "shipped"
55
- attrs.page # => 2 (an Integer — coerced)
56
- attrs[:status] # explicit hash-style access
57
- attrs.to_h # the underlying hash
111
+ derives(:order) { |params| Oms::Order.find(params.order_id) }
58
112
  ```
59
113
 
60
- Declared attribute names always win over hash methodsif you declare `attribute :count`, `attrs.count` is your value, not `Hash#count`. For anything not declared, the hash API is available directly on `attrs`.
114
+ A derivation is a value the component computes for itself the replacement for the find-by-id dance at the top of every `build`. Declaring one registers the block; it runs **at most once per render**, when `params.order` is first read, and **never runs if nothing reads it**. The result is memoized for the rest of that render.
61
115
 
62
- Declaring attributes has a routing consequence: a component with attributes (or any verb below) is considered independently addressable and gets its own route. See [Routing](routing.md).
116
+ Derivations chain lazily. A block that reads another derived key forces it on demand:
63
117
 
64
- ## Verbs
118
+ ```ruby
119
+ derives(:order) { |p| Oms::Order.find(p.order_id) }
120
+ derives(:shipments) { |p| Logistics::Shipment.for_order(p.order.id) }
121
+ ```
65
122
 
66
- ### `refreshes` — the client re-fetches
123
+ Reading `params.shipments` forces `shipments`, which reads `params.order` and forces that in turn so a render computes exactly what it touches and nothing more. Derived values are server-side (never serialized, not routable-making), but they do flow down the render tree like everything else in the bag, and a value an ancestor already computed is not recomputed by a child.
124
+
125
+ **The block is a `(params) -> value` pure function.** It runs against a sandboxed `self`: `params` and lexical constants are in reach, and `Kernel` stays available (`raise`, `format`, `Integer()`), but nothing component-specific is — a bare method call raises `NameError`, which keeps a derivation portable and side-effect-free. Each block runs in its own fresh, disposable context, so scratch instance variables are allowed but never outlive the one execution. If the derivation belongs to a service, call it explicitly: `derives(:report) { |p| ReportService.call(p.account_id) }`.
126
+
127
+ A failing derivation raises at *first read* — which lands inside the `recovers`-wrapped render, so `recovers from: ActiveRecord::RecordNotFound` and friends handle it the same way they handle a failure in `build`. A derivation nobody reads never raises. (Failures aren't memoized: like an RSpec `let`, a re-read runs the block again.)
128
+
129
+ `params.to_h` and any delegated Hash-API call materialize every remaining derivation first — the eager escape hatch when you genuinely want the whole bag.
130
+
131
+ ### `defines` — static values
67
132
 
68
133
  ```ruby
69
- refreshes every: 10.seconds # poll on a timer
70
- refreshes every: 0.6 # sub-second polling ("every 600ms")
71
- refreshes on: "order-updated" # re-fetch when an event fires
72
- refreshes every: 30, on: "saved" # both
134
+ defines label: "Drivers", accent: "available"
73
135
  ```
74
136
 
75
- The component's wrapper element gets the htmx wiring to GET its own route and replace itself with the response (`outerHTML` swap). With `every:`, that happens on a timer. With `on:`, it happens whenever the named event fires — typically emitted by some other component's `triggers` declaration, arriving as an `HX-Trigger` response header and listened for at the body level, so any component on the page can react to any other's events.
137
+ `defines` is sugar for statically-known derivations: each pair is exactly `derives(key) { value }`, with identical priority, overridability, and laziness. It shines in a subclass that pins constant faces of an inherited component while deriving the dynamic ones:
76
138
 
77
- Multiple `refreshes` calls accumulate into a single trigger list. Because the wiring is declared on the class, it's present both in the initial page render *and* in every refreshed fragment — the component keeps refreshing forever, with nothing duplicated by hand.
139
+ ```ruby
140
+ class AvailableDriversCard < StatCard
141
+ defines label: "Drivers", accent: "available" # fixed
142
+ derives(:value) { |_p| "#{Driver.available.count}/#{Driver.count}" } # per render
143
+ end
144
+ ```
78
145
 
79
- Intervals count in seconds an integer, a float, or an ActiveSupport duration. Whole seconds render as htmx's `every 5s`; fractional values render in millisecond syntax (`every 600ms`). One millisecond is the floor: anything smaller is rounded up to `1ms`, with a warning through `Weft.logger`.
146
+ The catch is in the name: the values are fixed **when the class body runs**, not per render. Anything computed a query, a count, a clock must stay in `derives`, because an interpolated value here would freeze at load time. If it isn't a literal constant, it's a `derives`.
80
147
 
81
- ### `pushes` — the server sends updates
148
+ ### `receives` — caller hand-offs
82
149
 
83
150
  ```ruby
84
- pushes every: 5.seconds
151
+ receives :order
152
+ receives :page_num, default: 1
85
153
  ```
86
154
 
87
- Where `refreshes` polls, `pushes` streams: the Router auto-generates an SSE endpoint for the component (at `<component path>/_stream` see [Routing](routing.md)), and the component renders with the htmx SSE attributes to connect to it. On the declared interval seconds, fractional or whole, with the same 1ms floor as `refreshes` the server re-renders the component and pushes the result down the open connection.
155
+ Some values can't ride a URL an `ActiveRecord` object, a pre-built collection, anything rich. `receives` declares that a call site hands the value over directly: `order_row(order: order)` fills `params.order`. The kwarg is consumed as the hand-off, so it never becomes an HTML attribute on the wrapper, and the value never serializes into a URL.
88
156
 
89
- A new subscriber receives an immediate snapshot frame, then the regular cadence. Pushed frames swap into the component's *interior* (`innerHTML`) the wrapper element holds the SSE connection, so it must persist across updates.
157
+ A hand-off is **required by default**: a call site that omits it raises `Weft::NotReceived`, with the backtrace pointing at the call site rather than deep inside the framework. Declaring a default makes it optional — `receives :page_num, default: 1`, and an explicit `default: nil` counts too (the presence of the keyword is what makes it optional, not the value).
90
158
 
91
- Pages include the htmx SSE extension script automatically when any component declares `pushes` (the [`include_sse_ext`](configuration.md#include_sse_ext) setting).
159
+ Hand-offs are server-side: declaring one doesn't make a component routable, since there's no way to reconstruct an `Order` from a URL. A component that lives only inside a parent — always handed its data, never served standalone — can say so with **`dependent!`** (an alias of [`abstract!`](routing.md)): "my parent passes this in every time; serving me on my own makes no sense."
160
+
161
+ If a component *is* routable and declares a required hand-off with no wire counterpart, Weft warns at route validation — such a component renders fine embedded but would raise `Weft::NotReceived` on every standalone refresh. Give it a wire dual (below) or mark it `dependent!`.
162
+
163
+ ### How the doors combine
164
+
165
+ A key can have more than one door, and Weft resolves the value from a fixed order of sources. Highest wins; `nil` at any level falls through to the next:
166
+
167
+ 1. a **received** hand-off (`receives`)
168
+ 2. a **request overlay** — a hash returned from a verb block earlier in this request (an action callable, a `transfers` or `includes` block, a `recovers` adjustment). An overlay entry speaks *as* the wire for its key: its value replaces the wire's — including rich objects, which pre-empt a matching `derives` so nothing refetches what the request already loaded — and an explicit `nil` *clears*, masking the wire so resolution falls below it
169
+ 3. the component's **own wire** value (`param`)
170
+ 4. an **inherited** value — from an ancestor in the render tree, or from whatever the request had already composed by the time this component rendered
171
+ 5. the component's **own derivation** (`derives` / `defines`)
172
+ 6. the component's **own declared default**
173
+
174
+ The first five are values the bag *holds*. The sixth is a fallback the bag *asks for* when a read finds nothing, and that difference shows at every boundary: a default belongs to the class that declared it and never travels, so a nested child — or the target of a `transfers` — falls back to its own, not to the one above it.
175
+
176
+ That order is what makes *duals* work — declaring a key through two doors so it resolves whether it's handed over or has to fetch itself:
177
+
178
+ - **`param` + `receives`** — handed the value when embedded (no query round-trip), wire-borne when rendered standalone, so a self-refreshing card embedded with `status_card(status: "hot")` keeps its status across refreshes.
179
+ - **`derives` + `receives`** — handed the value when embedded, self-fetching when standalone. A `derives` dual also satisfies the refresh-safety lint.
180
+ - **`param` + `derives`** — use the wire value if present, otherwise derive one.
181
+
182
+ A derivation that never runs is worth hearing about, so Weft logs a one-time warning for each of the two ways that happens: when an **inherited value** carries a *different* derivation for the same key (the value from above wins, and your block is dead), and when a **verb block in this request** returned the key (an overlay outranks derivations, so the block supplied what yours would have). Neither is an error — both are shapes you may well want — but a silently dead `derives` shouldn't have to be discovered.
183
+
184
+ Sharing one derivation between components silences the first: a common superclass, or the same block object mixed in, is agreement rather than divergence, and that's the fix when two components legitimately mean the same value.
185
+
186
+ ### Inheritance and the render tree
187
+
188
+ Within one render, each component starts from a copy of its nearest ancestor component's (or page's) resolved params: it sees everything *above* it in the tree, nothing *beside* it. A bare `shipments_card` embedded in a page that declares `param :order_id` reads `params.order_id` without declaring anything itself.
189
+
190
+ Two things do *not* travel down. Declared **defaults** stay with the class that declared them, so a child with `param :view, default: "open"` inside a parent with `param :view, default: "all"` shows `open` — a fallback is a private answer to "nobody told me," not an opinion to broadcast. And **hand-offs** (`receives`) are per-call-site by nature. Everything else rides, including values an ancestor's derivation already computed: a derivation forced above you is a value by the time you inherit it, so nothing refetches.
191
+
192
+ Two shapes of consumption both work, and both are idiomatic:
193
+
194
+ - **Declare-and-read.** The component declares the keys it consumes (through whichever door fits) and reads them. Self-documenting; the declaration is a contract. Most components want this.
195
+ - **Inherit-and-read.** A base component reads `params.order_id` that it never declares, trusting the render tree — or a subclass — to supply it. This keeps the base pipeline-agnostic: each subclass chooses its own door (`param`, `receives`, or `derives`) to fill the key, and the shared `build` stays the same. The cost is that the dependency is implicit — nothing in the base names what it needs.
196
+
197
+ Subclasses can also **redeclare** an inherited key. Redeclaring through the *same* door overrides the parent's declaration (the block or metadata is replaced, keeping the parent's declaration-order position). Redeclaring through a *different* door adds a dual — it doesn't replace the parent's door. There's no way to *un*-declare a key a parent declared; a subclass that needs different behavior overrides or duals, it doesn't remove.
198
+
199
+ ## Verbs
200
+
201
+ Verbs are **class-body declarations**: they state what a component *does* — once, at the class level, the way `param` and its siblings declare what it *consumes*. The other layer, [element kwargs](#element-kwargs), wires individual elements to these behaviors from inside `build`; a `performs` declared here is inert until some element carries `action:` naming it (forms and buttons usually).
92
202
 
93
203
  ### `performs` — user-initiated actions
94
204
 
95
205
  ```ruby
96
- performs :advance do |attrs|
97
- order = Oms::Order.find(attrs.order_id)
206
+ performs :advance do |params|
207
+ order = Oms::Order.find(params.order_id)
98
208
  Oms::AdvanceOrder.call(order)
99
209
  end
100
210
  ```
101
211
 
102
- Declares an action: the Router generates a route for it, and elements wire to it with the `action:` kwarg (below). When the request arrives, the callable runs, then the component re-renders and the response replaces it in the page.
212
+ Declares an action: the Router generates a route for it, and elements wire to it with the `action:` kwarg (below). When the request arrives, [the callable](#the-callable-contract) runs, then the component re-renders and the response replaces it in the page.
103
213
 
104
214
  The full signature:
105
215
 
106
216
  ```ruby
107
- performs :name, method: :post, swap: :outer_html, target: nil do |attrs| ... end
217
+ performs :name, method: :post, swap: :outer_html, target: nil do |params| ... end
108
218
  ```
109
219
 
110
220
  - **`method:`** — the HTTP method (default `:post`). A *named* action routes at `<component path>/<name>`; a *nameless* one (`performs method: :delete do ... end`) routes at the component's own path, distinguished by method. A nameless GET action is special: it intercepts the component's own render route, running the callable before every over-the-wire render.
111
221
  - **`swap:`** — how the response lands in the DOM (default `:outer_html`, replacing the component). See the [swap table](#swap-values).
112
222
  - **`target:`** — a CSS selector for where the response lands (default: the component itself, by DOM id).
113
223
 
114
- ### The callable contract
224
+ ### `transfers` actions that render something else
115
225
 
116
- Action callables receive one argument — the component's resolved `attrs` — and their return value directs what happens next:
226
+ ```ruby
227
+ transfers :edit, to: EditableOrderHeader do |params|
228
+ { mode: "full" }
229
+ end
230
+ ```
117
231
 
118
- - **`nil`** (or any ignored value): re-render with the original attrs. The common case the callable did its side effect; the fresh render reflects it.
119
- - **a `Hash`**: merged into the attrs (returned keys win), and the merged set drives the re-render. Use this to change state on the way through: `performs :filter do |attrs| { page: 1 } end`.
120
- - **a `Weft::Redirect`**: navigate away instead of re-rendering. Build one with `Weft.redirect`:
232
+ Identical to `performs` in signature and [contract](#the-callable-contract), except the response renders the `to:` component instead of the declaring one — for actions whose natural result is a different piece of UI (a read-only header becoming an edit form). The returned hash overlays the request for the target's render: override its wire values, or hand it rich objects that pre-empt its own `derives`.
233
+
234
+ The target inherits the state the request has composed, exactly as a nested child inherits its parent's — so a record the callable loaded is already there and needn't be handed over. Its own **defaults** stay sovereign (they don't travel), and it's the *target's* [`includes`](#includes--companions-in-the-same-response) companions that ride the response: after the swap, the target is the component in charge. To override something it inherited, return the key; an explicit `nil` clears it. The target only needs to *render*; it does not need its own route (see [routability vs. render targets](routing.md#routable-vs-render-target)).
235
+
236
+ ### `dismisses` — remove from the DOM
121
237
 
122
238
  ```ruby
123
- performs :create do |attrs|
124
- order = Oms::CreateOrder.call(attrs.to_h)
125
- Weft.redirect(OrderDetailPage, order_id: order.id)
239
+ dismisses :close # no side effects
240
+ dismisses :archive do |params| # with side effects
241
+ Item.find(params.item_id).archive!
126
242
  end
127
243
  ```
128
244
 
129
- `Weft.redirect` takes a `Weft::Page` subclass plus attrs (interpolated into the page's path pattern), or a plain URL string. The Router handles transport: htmx requests get an `HX-Redirect` header, traditional form submissions get a 302.
245
+ Sugar for `performs` with `method: :delete, swap: :delete`: on success, the component is removed from the page entirely. The callable, if given, runs for side effects, and the success response carries no body — htmx removes the element on its own, and Weft never re-renders a component whose record was just deleted, so `build` needs no guard against the vanished state. Out-of-band [`includes`](#includes--companions-in-the-same-response) companions still ride the response. Like `performs`, it accepts a `target:` for the occasional removal that should land elsewhere.
130
246
 
131
- If the callable raises, the error walks the component's recovery chainsee [Error handling](error-handling.md).
247
+ If the callable raises, Weft overrides the destructive swap (via `HX-Reswap`) so the error rendering appears where the component was, rather than the element silently vanishing and the error fragment [adopts the component's own tag](error-handling.md#auto-injected-recovery-params), so a failed row delete produces an error `<tr>`, not a `<div>` wedged into a table.
132
248
 
133
- ### `transfers` — actions that render something else
249
+ ### `refreshes` — the client re-fetches
134
250
 
135
251
  ```ruby
136
- transfers :edit, to: EditableOrderHeader do |attrs|
137
- { mode: "full" }
138
- end
252
+ refreshes every: 10.seconds # poll on a timer
253
+ refreshes every: 0.6 # sub-second polling ("every 600ms")
254
+ refreshes on: "order-updated" # re-fetch when an event fires
255
+ refreshes every: 30, on: "saved" # both
139
256
  ```
140
257
 
141
- Identical to `performs` in signature and contract, except the response renders the `to:` component instead of the declaring one for actions whose natural result is a different piece of UI (a read-only header becoming an edit form). The merged attrs feed the target component. The target only needs to *render*; it does not need its own route (see [routability vs. render targets](routing.md#routable-vs-render-target)).
258
+ The component's wrapper element gets the htmx wiring to GET its own route and replace itself with the response (`outerHTML` swap). With `every:`, that happens on a timer. With `on:`, it happens whenever the named event fires typically emitted by some other component's `triggers` declaration, arriving as an `HX-Trigger` response header and listened for at the body level, so any component on the page can react to any other's events.
142
259
 
143
- ### `dismisses` remove from the DOM
260
+ Multiple `refreshes` calls accumulate into a single trigger list. Because the wiring is declared on the class, it's present both in the initial page render *and* in every refreshed fragment — the component keeps refreshing forever, with nothing duplicated by hand.
261
+
262
+ Intervals count in seconds — an integer, a float, or an ActiveSupport duration. Whole seconds render as htmx's `every 5s`; fractional values render in millisecond syntax (`every 600ms`). One millisecond is the floor: anything smaller is rounded up to `1ms`, with a warning through `Weft.logger`.
263
+
264
+ ### `pushes` — the server sends updates
144
265
 
145
266
  ```ruby
146
- dismisses :close # no side effects
147
- dismisses :archive do |attrs| # with side effects
148
- Item.find(attrs.item_id).archive!
267
+ pushes every: 5.seconds
268
+ pushes every: 5.seconds, attempts: 5 # custom failure budget
269
+ pushes every: 5.seconds, immediate: false # wait one interval before the first frame
270
+ ```
271
+
272
+ Where `refreshes` polls, `pushes` streams: the Router auto-generates an SSE endpoint for the component (at `<component path>/_stream` — see [Routing](routing.md)), and the component renders with the htmx SSE params to connect to it. On the declared interval — seconds, fractional or whole, with the same 1ms floor as `refreshes` — the server re-renders the component and pushes the result down the open connection.
273
+
274
+ A new subscriber receives an immediate snapshot frame, then the regular cadence. When that snapshot would mislead — the component renders expensive state that's only computed on the push cycle, say — `immediate: false` opts into polling-cadence semantics instead: the first frame arrives after one full interval. Pushed frames swap into the component's *interior* (`innerHTML`) — the wrapper element holds the SSE connection, so it must persist across updates.
275
+
276
+ A failing push walks the component's [`recovers` chain](error-handling.md#error-handling-on-live-streams) and delivers the recovery component's content as the frame, so a live card shows a visible error state instead of silently going stale. A stream that fails `attempts:` times in a row (default: the [`push_attempts`](configuration.md#push_attempts) setting, 3) pushes a final frame, tells the browser to stop reconnecting, and closes — a durably broken component costs a bounded number of attempts, and the `reopen_stream:` preset gives users a one-click way back in.
277
+
278
+ Pages include the htmx SSE extension script automatically when any component declares `pushes` (the [`include_sse_ext`](configuration.md#include_sse_ext) setting).
279
+
280
+ ### `recovers` — declare error behavior
281
+
282
+ ```ruby
283
+ recovers from: Weft::Unprocessable do |params, error|
284
+ { error_message: error.message }
149
285
  end
286
+ recovers from: Weft::Unauthorized, with: LoginPage
287
+ recovers from: ActiveRecord::RecordNotFound, with: NotFoundPage, status: 404
150
288
  ```
151
289
 
152
- Sugar for `performs` with `method: :delete, swap: :delete`: on success, the component is removed from the page entirely. The callable, if given, runs for side effects. If it raises, Weft overrides the destructive swap (via `HX-Reswap`) so the error rendering appears where the component was, rather than the element silently vanishing.
290
+ Declares how this component or page responds when a render or action raises. `from:` matches by exception class, HTTP status code, status range, or an array of those; `with:` names what renders instead; `status:` declares what a non-Weft error means on the wire, so your app's own exceptions recover with honest semantics (the branded 404 above). The gem ships default recoveries, so this is opt-in refinement. The complete model — matching, chain order, auto-injected params is in [Error handling](error-handling.md).
153
291
 
154
292
  ### `triggers` — announce to the rest of the page
155
293
 
156
294
  ```ruby
157
- triggers "delivery-completed"
295
+ triggers "delivery-completed" # every action
296
+ triggers "order-updated", on: :advance # that action only — arrays too
158
297
  ```
159
298
 
160
299
  Every action response from this component carries the named event in its `HX-Trigger` header. Other components subscribe with `refreshes on: "delivery-completed"` — a decoupled way to say "when this changes, those refresh," without the components knowing about each other. Multiple `triggers` declarations accumulate.
161
300
 
301
+ `on:` maps an event to the actions it belongs to. Without it, an event is welded to *every* action the component has — fine when there's one, rarely what a component with several means. A header that both advances an order and hands the region over to an editor would otherwise announce a status change when someone merely clicked Edit, and every subscriber would refetch for nothing. Naming the action keeps the two apart:
302
+
303
+ ```ruby
304
+ triggers "order-updated", on: :advance # the status machine ran
305
+ triggers "order-editing", on: :edit # responsibility handed to the editor
306
+ ```
307
+
308
+ There is deliberately no `when:` counterpart, though [`includes`](#includes--companions-in-the-same-response) has one. `HX-Trigger` announces what a *callable* did, so a render-context filter has nothing to say about it: "fire when I render as a transfer target" describes a render that ran no callable, and "fire when I transfer away" is already `on: :that_action`.
309
+
310
+ Events follow the *action*, not the rendering: on a [`transfers`](#transfers--actions-that-render-something-else) response the declaring component's events fire — its callable is what ran — while the target's own events wait for the target's own actions. (The same rule is why a `dismisses` response, which renders no body at all, still announces.)
311
+
162
312
  ### `includes` — companions in the same response
163
313
 
164
314
  ```ruby
165
- includes Oms::OrderHeader # alongside every response
166
- includes Oms::OrderHeader, on: :advance # only for the :advance action
167
- includes Oms::OrderHeader do |attrs| # with explicit attr mapping
168
- { order_id: attrs.order_id, compact: true }
315
+ includes Oms::OrderHeader # alongside every response
316
+ includes Oms::OrderHeader, on: :advance # own action(s) only arrays too
317
+ includes Oms::OrderHeader, when: :transferred # only as a transfer target
318
+ includes Oms::OrderHeader do |params| # adjust this companion's params
319
+ { order_id: params.order_id, compact: true }
169
320
  end
170
321
  ```
171
322
 
172
- Sometimes one interaction changes two things: completing a shipment updates the shipment card *and* the order header above it. `includes` declares that relationship — whenever this component responds to an action or pushes an SSE frame, the included component renders too, marked out-of-band (`hx-swap-oob`) so htmx routes it to its own DOM slot by id.
323
+ Sometimes one interaction changes two things: completing a shipment updates the shipment card *and* the order header above it. `includes` declares that relationship — when this component renders a response, the included component renders too, marked out-of-band (`hx-swap-oob`) so htmx routes it to its own DOM slot by id.
173
324
 
174
- Without a block, the included component resolves its attributes from the same request parameters. With a block, the block receives the primary component's resolved attrs and returns the wire attrs for the included one. With `on:`, the inclusion applies only to that named action (and not to SSE pushes; unfiltered inclusions apply to both).
325
+ A companion is an **OOB-delivered child**: it renders against the same request, and it inherits the primary's params exactly as a child built inside the primary's `build` would rich values included, so an `Order` the primary already derived is shared, not fetched once per companion. The block, if given, receives the primary's params and returns a *delta* overlaid on that picture for this companion alone (an explicit `nil` clears a value; one companion's delta is invisible to the next). Blockless is simply an empty delta.
175
326
 
176
- ### `recovers` — declare error behavior
327
+ Unfiltered inclusions ride every response the component renders in: its own action responses, its SSE pushes, and its arrivals as a [`transfers`](#transfers--actions-that-render-something-else) target. Filters enumerate contexts, and declaring both is a union either fires:
328
+
329
+ - **`on:`** names this component's **own** actions (a symbol or an array). It never matches another component's action names — an action arriving via transfer isn't consulted — and it doesn't apply to pushes. Note that `on:` *replaces* the unfiltered default rather than narrowing it, which is why naming your own [`transfers`](#transfers--actions-that-render-something-else) action works: the companion rides that response even though the target, not you, is what renders. Your *unfiltered* inclusions stay home on a transfer, since "every response I render in" is false when you don't render.
330
+ - **`when: :transferred`** fires only when this component renders as a transfer target: for companions that should ride the arrival, not every response.
331
+
332
+ **One slot, one fragment.** An out-of-band swap is addressed by DOM id, so two companions resolving to the same id can't both land — the second would swap straight over the first. Weft keeps the first, logs a warning naming both declaration sites, and doesn't render the loser at all: the slot is claimed as each fragment builds, so a companion that has already lost stops before its own `build` body runs. The primary claims its slot first, so a companion can never swap over the fragment the response is actually about. Companions differing in an [identifying param](#params) derive different ids and both ride, which is what makes a left eye and a right eye a pair rather than a clash. The case worth knowing: two declarations whose deltas differ only in a *non*-identifying value look distinct in the source and collide in the DOM. When a transferring component and its target both include the same companion, the target's declaration keeps the slot — the response is the target's.
333
+
334
+ **A companion is a courtesy, not a contract.** If a companion raises, the response still belongs to the component the request was about: its render, its status, and its headers are untouched, and the failing companion shows *its own* recovery in *its own* slot. That's what keeps an action honest — a stale card that can't re-render is a display problem, not grounds for reporting a committed change as a failure. See [when a companion fails](error-handling.md#when-a-companion-fails).
335
+
336
+ ### The callable contract
337
+
338
+ Action callables receive one argument — the component's resolved `params`, the same bag its `build` reads. A callable can read the component's own `derives` and `defines`, so the lookup a component already declares doesn't get written a second time inside every action:
177
339
 
178
340
  ```ruby
179
- recovers from: Weft::Unprocessable do |attrs, error|
180
- { error_message: error.message }
341
+ param :order_id
342
+ derives(:order) { |p| Oms::Order.find(p.order_id) }
343
+
344
+ performs :advance do |params|
345
+ Oms::AdvanceOrder.call(params.order) # the same order the render below will show
181
346
  end
182
- recovers from: Weft::Unauthorized, with: LoginPage
183
347
  ```
184
348
 
185
- Declares how this component or page responds when a render or action raises. `from:` matches by exception class, HTTP status code, status range, or an array of those; `with:` names what renders instead. The gem ships default recoveries, so this is opt-in refinement. The complete model — matching, chain order, auto-injected attributes — is in [Error handling](error-handling.md).
349
+ A derivation the callable forces stays forced for the rest of the response, so that's one query serving the action, the re-render, and any companions riding along you don't have to hand the record forward to avoid a refetch.
350
+
351
+ The return value directs what happens next:
352
+
353
+ - **`nil`** (or any ignored value): re-render with the original params. The common case — the callable did its side effect; the fresh render reflects it.
354
+ - **a `Hash`**: an overlay on the request. The returned keys override wire values for *everything* the response renders — the component, its nested children, its OOB companions — an explicit `nil` clears a value, and a rich object pre-empts matching `derives` down the tree. Use this to change state on the way through: `performs :filter do |params| { page: 1 } end`. Because *any* hash return is an overlay, watch your last expression — `Hash#delete` and `merge!` return hashes, and a callable ending on one silently applies it. End a side-effect-only callable with an explicit `nil`.
355
+ - **a `Weft::Redirect`**: navigate away instead of re-rendering. Build one with `Weft.redirect`:
356
+
357
+ ```ruby
358
+ performs :create do |params|
359
+ order = Oms::CreateOrder.call(params.to_h)
360
+ Weft.redirect(OrderDetailPage, order_id: order.id)
361
+ end
362
+ ```
363
+
364
+ `Weft.redirect` takes a `Weft::Page` subclass plus params (interpolated into the page's path pattern), or a plain URL string. The Router handles transport: htmx requests get an `HX-Redirect` header, traditional form submissions get a 302.
365
+
366
+ Like every verb block — the action callable here, and the blocks for `transfers`, `recovers`, and `includes` — the callable runs against a [sandboxed `self`](#derives--lazy-server-side-derivations): `params` and lexical constants are in reach and `Kernel` is available, but nothing component-specific is. Do your side effects through the objects you call (`Oms::AdvanceOrder.call(order)`), never through a method on the component.
367
+
368
+ If the callable raises, the error walks the component's recovery chain — see [Error handling](error-handling.md).
186
369
 
187
370
  ### Other class-body declarations
188
371
 
@@ -204,9 +387,26 @@ The leading `@` in the symbol is required, as a reminder that *you* must assign
204
387
 
205
388
  **`abstract!` / `routable!`** — override the class's routing eligibility in either direction. Covered in [Routing](routing.md#abstract-and-routable).
206
389
 
390
+ **`title`** (pages only) — declares what goes in the browser tab. A static value, or a block computed from the page's params — the block is a `(params) → value` function run in the same sandbox as every other verb block:
391
+
392
+ ```ruby
393
+ class OrderDetailPage < Weft::Page
394
+ self.page_path = "/orders/:order_id"
395
+ param :order_id
396
+ derives(:order) { |p| Order.find(p.order_id) }
397
+
398
+ title { |params| "Order ##{params.order.number}" }
399
+ end
400
+ ```
401
+
402
+ The nearest declaration in the class ancestry wins — declare a static `title "My App"` on your base page and each concrete page overrides it (or doesn't, and inherits the app-wide default). With no declaration anywhere, the title is `"Weft"`. This is the only title channel: there's nothing to set in `build`, and nothing renders before `super` — the declaration is available to the head assembly no matter where in the lifecycle it's needed. One nearby name to keep straight: *inside* `build`, a bare `title "x"` is [Arbre](arbre.md)'s HTML tag builder and inserts a literal `<title>` element into the body — the page-title declaration lives in the class body.
403
+
207
404
  ## Element kwargs
208
405
 
209
- Inside `build` (and inside blocks nested under it), any element accepts Weft kwargs alongside its normal HTML attributes. Weft intercepts them at render time and expands them into htmx wiring.
406
+ Inside `build` (and inside blocks nested under it), any element accepts Weft kwargs alongside its normal HTML attributes. Weft intercepts them at render time and expands them into htmx wiring. The vocabulary has two ranks:
407
+
408
+ - **Interaction kwargs** say what request the element makes — [`action:`](#action), [`navigate:`](#navigate), [`loads:`](#loads), or any [preset](#presets). One per element, and the *value shape* is part of the claim: a Symbol `action:` is Weft's, while a String `action:` on a form is plain HTML.
409
+ - **Modifier kwargs** adjust the wiring the interaction generates. `target:` and `swap:` override where the response lands and how it swaps — whatever the interaction, and whatever its declaration or preset would have used. [`trigger:`](#trigger), [`push_url:`](#push_url), and [`confirm:`](#confirm) do the same and *also* work standalone, on an element that makes no request of its own, because htmx lets those attributes inherit from a containing element.
210
410
 
211
411
  ### `action:`
212
412
 
@@ -214,9 +414,15 @@ Inside `build` (and inside blocks nested under it), any element accepts Weft kwa
214
414
  button "Advance", action: :advance, class: "btn btn-primary"
215
415
  ```
216
416
 
217
- Wires the element to a declared `performs`/`transfers` action on the nearest enclosing component that declares it. Expands to the full htmx set: the request (`hx-post` etc. to the action's route), the target (the component's own element, unless the action declared `target:`), the swap, and the component's current attrs as the payload (`hx-vals`).
417
+ Wires the element to a declared `performs`/`transfers` action on the nearest enclosing component that declares it. Expands to the full htmx set: the request (`hx-post` etc. to the action's route), the target (the component's own element, unless the action declared `target:`), the swap, and the component's current params as the payload (`hx-vals`). A Symbol that matches no enclosing component's declarations raises — a typo can't silently produce a dead button. And it isn't just for buttons and forms: `action:` works on any element — a `div` serving as a modal underlay, a table row, a badge — with htmx's default trigger (click) applying.
218
418
 
219
- On a `form` element, `action:` additionally emits plain HTML `action` and `method` attributes, so the form still submits without JavaScriptand the field values themselves become the payload:
419
+ Add `target:` / `swap:` alongside to override, for this element only, where the response lands and how it swaps — the declaration's values stay the defaults for every other call site:
420
+
421
+ ```ruby
422
+ button "Advance", action: :advance, target: "#detail-pane", swap: :fill
423
+ ```
424
+
425
+ On a `form` element, `action:` additionally emits plain HTML `action` and `method` params, so the form still submits without JavaScript — and the field values themselves become the payload:
220
426
 
221
427
  ```ruby
222
428
  form(action: :create) do
@@ -228,10 +434,14 @@ end
228
434
  ### `navigate:`
229
435
 
230
436
  ```ruby
231
- button "Next", navigate: { page: attrs.page + 1 }
437
+ button "Next", navigate: { page: params.page + 1 }
232
438
  ```
233
439
 
234
- Re-fetches the enclosing component with some of its attrs changed — a GET to the component's own route with the overridden values, replacing the component. This is the idiom for filters, sorting, and pagination: same component, different wire state. Pass `nil` to drop an attr from the URL. Pairs naturally with `push_url:` when the new state should be reflected in the address bar.
440
+ Re-fetches the enclosing component with some of its params changed — a GET to the component's own route with the overridden values, replacing the component. This is the idiom for filters, sorting, and pagination: same component, different wire state. Pass `nil` to drop a param from the URL. Pairs naturally with `push_url:` when the new state should be reflected in the address bar, and takes `target:` / `swap:` overrides like any interaction kwarg.
441
+
442
+ **`navigate:` or `performs`?** `navigate:` is pure wire-state navigation: no side effects, no route of its own, honest GET semantics. The moment an interaction *does* something — writes, calls a service — it's a `performs`. And when you find yourself repeating the same override hash at many call sites, prefer a named `performs` returning that hash even without side effects: the declaration names the pattern once instead of scattering it.
443
+
444
+ Because the re-fetch renders the component standalone, only its own declared `param`s survive the round trip — so every key you override must be one the component (or a class ancestor) declares. Anything else raises `Weft::InvalidUsage` at render time; to change an *ancestor's* state instead, target the ancestor itself ([`enclosing`](arbre.md#reaching-enclosing-components) + `loads:`).
235
445
 
236
446
  ### `loads:`
237
447
 
@@ -241,7 +451,26 @@ button "Show manifest", loads: Logistics::ShipmentManifest,
241
451
  swap: :fill, target: "#detail-pane"
242
452
  ```
243
453
 
244
- Loads a *different* component into a chosen DOM location on click (or whatever `trigger:` you add). `swap:` and `target:` are required — `loads:` is the fully-explicit primitive underneath the [shorthands](#shorthands), which exist to fill those in for common patterns. `with:` supplies the target component's wire attrs; omitted, it defaults to the enclosing component's current attrs.
454
+ Loads a *different* component into a chosen DOM location on click (or whatever `trigger:` you add). `swap:` and `target:` are required — `loads:` is the fully-explicit primitive underneath the [presets](#presets), which exist to fill those in for common patterns. `with:` supplies the target component's wire params; omitted, it defaults to the enclosing component's current params. That default cuts both ways: the encloser's params are *baked into the generated URL at render time*, so when they overlap the target's own schema (or a value the browser appends, like a select's), the stale baked value competes with the fresh one. When the target should fetch clean, say so explicitly with `with: {}`.
455
+
456
+ The target must be [routable](routing.md#what-routes--and-what-doesnt) — the click fetches it at its own URL. A non-routable target (here or as a preset's Class value) raises `Weft::InvalidUsage` at render time rather than wiring a fetch that could only 404; a purely presentational target opts in with `routable!`.
457
+
458
+ ### The kwarg rules
459
+
460
+ A kwarg that is unmistakably Weft's but can't make sense **raises `Weft::InvalidUsage`** at render time rather than leaking into your HTML — a mistyped action name, a `navigate:` key the component doesn't declare, a `with:` with nothing to feed. A **`nil` value always means "not this time"** (`tooltip: maybe_class`) and renders nothing. Everything else — `class:`, `data:`, raw `hx-*` strings, a real HTML `target:` on a link — passes through to the element untouched.
461
+
462
+ | Kwarg | Rank | Weft's when… | Otherwise |
463
+ | --- | --- | --- | --- |
464
+ | `action:` | interaction | the value is a Symbol naming a declared action | String values are plain HTML (`form action: "/path"`); an unmatched Symbol raises |
465
+ | `navigate:` | interaction | the value is a Hash of param overrides | any other value raises; so does re-fetching a non-routable component |
466
+ | `loads:` | interaction | the value is a component Class | any other value raises; so does a non-routable target |
467
+ | preset names (`tooltip:`, …) | interaction | the value is a Class or URL String | any other value raises; so does a non-routable Class target |
468
+ | `target:` | modifier | an interaction kwarg is present | plain HTML (`target: "_blank"` on a link works as ever) |
469
+ | `swap:` | modifier | an interaction kwarg is present | passes through as an attribute, with a one-time warning |
470
+ | `trigger:` | modifier | always | — |
471
+ | `push_url:` | modifier | always | — |
472
+ | `confirm:` | modifier | always | — |
473
+ | `with:` | feeds `loads:`/presets | `loads:` or a preset is alongside | raises |
245
474
 
246
475
  ### `trigger:`
247
476
 
@@ -250,7 +479,18 @@ div(loads: Preview, with: { id: id }, swap: :fill, target: :self,
250
479
  trigger: :visible)
251
480
  ```
252
481
 
253
- Sets when the element's request fires. Accepts the semantic symbols in the [trigger table](#trigger-values) or any raw [htmx trigger string](https://htmx.org/attributes/hx-trigger/) for full control (`"mouseenter once from:closest .card"`). Works standalone or alongside `action:` / `navigate:` / `loads:` / a shorthand.
482
+ Sets when the element's request fires. Accepts the semantic symbols in the [trigger table](#trigger-values) or any raw [htmx trigger string](https://htmx.org/params/hx-trigger/) for full control (`"mouseenter once from:closest .card"`). Works standalone or alongside `action:` / `navigate:` / `loads:` / a preset. One thing to expect when inspecting output: a raw string's special characters render HTML-escaped (`keyup[altKey&&key=='A']` emits as `hx-trigger="keyup[altKey&amp;&amp;key=='A']"`) — that's correct HTML, and htmx reads it as written.
483
+
484
+ ### Trigger values
485
+
486
+ | Semantic | htmx equivalent | Fires… |
487
+ | --- | --- | --- |
488
+ | `:click` | `click` | on click |
489
+ | `:click_once` | `click once` | on the first click, then never again |
490
+ | `:change` | `change` | when the value changes (selects, checkboxes) |
491
+ | `:hover` | `mouseenter once` | on first hover |
492
+ | `:visible` | `revealed` | when scrolled into view |
493
+ | `:input` | `input changed delay:300ms` | as the user types, debounced |
254
494
 
255
495
  ### `push_url:`
256
496
 
@@ -260,6 +500,23 @@ button label, action: :filter, push_url: "/orders?status=#{status}"
260
500
 
261
501
  Pushes a URL into the browser's address bar when the request completes, keeping the location shareable and the back button meaningful. Pass the URL string, or `true` to push the request's own URL.
262
502
 
503
+ ### `confirm:`
504
+
505
+ ```ruby
506
+ button "Delete", action: :destroy, confirm: "Delete this order?"
507
+ ```
508
+
509
+ Shows the browser's native confirmation dialog before the request fires; Cancel means no request at all. Works alongside any interaction kwarg — actions, navigations, loads, presets — or standalone on a container, where htmx inheritance applies it to every request fired from inside:
510
+
511
+ ```ruby
512
+ div confirm: "This affects the live feed. Continue?" do
513
+ button "Pause", action: :pause
514
+ button "Reset", action: :reset
515
+ end
516
+ ```
517
+
518
+ There is deliberately no `prompt:` counterpart yet — htmx delivers the typed reply in a request header that action callables can't read today; the kwarg arrives once they can.
519
+
263
520
  ### Swap values
264
521
 
265
522
  Weft accepts semantic swap names (preferred), the htmx-native names as symbols, or any raw string:
@@ -275,22 +532,13 @@ Weft accepts semantic swap names (preferred), the htmx-native names as symbols,
275
532
  | `:remove` | `delete` | Remove the target |
276
533
  | `:none` | `none` | Don't swap anything |
277
534
 
278
- ### Trigger values
279
-
280
- | Semantic | htmx equivalent | Fires… |
281
- | --- | --- | --- |
282
- | `:click` | `click` | on click |
283
- | `:hover` | `mouseenter once` | on first hover |
284
- | `:visible` | `revealed` | when scrolled into view |
285
- | `:input` | `input changed delay:300ms` | as the user types, debounced |
286
-
287
535
  ### Targets
288
536
 
289
537
  Wherever a `target:` is accepted: `:self` targets the element itself, a string is a CSS selector passed through to htmx (including forms like `"closest tr"`), and an Arbre element reference targets that element by its id. In verb declarations (`performs`/`transfers`), only the selector-string form applies — `:self` and element references describe elements, which don't exist yet at class-declaration time.
290
538
 
291
- ## Shorthands
539
+ ## Presets
292
540
 
293
- Shorthands are named presets over the `loads:` machinery — one kwarg that says what the interaction *is*, with the trigger and swap details baked in:
541
+ Presets bundle the `loads:` machinery into named interaction patterns — one kwarg that says what the interaction *is*, with the trigger and swap details baked in:
294
542
 
295
543
  ```ruby
296
544
  button "▸", inline_expand: Oms::OrderInlineDetail,
@@ -298,12 +546,12 @@ button "▸", inline_expand: Oms::OrderInlineDetail,
298
546
  target: "closest tr"
299
547
  ```
300
548
 
301
- The kwarg's value is the component class to load (`with:` supplies its attrs, same as `loads:`). The gem ships these presets:
549
+ The kwarg's value is the component class to load (`with:` supplies its params, same as `loads:`). The gem ships these presets:
302
550
 
303
- | Shorthand | Trigger | Swap | Target | Example |
551
+ | Preset | Trigger | Swap | Target | Example |
304
552
  | --- | --- | --- | --- | --- |
305
553
  | `tooltip:` | `:hover` | `:fill` | supply `target:` | [Tooltip](examples/tooltip.md) |
306
- | `inline_expand:` | `:click` | `:after` | supply `target:` | [Inline Expansion](examples/inline-expansion.md) |
554
+ | `inline_expand:` | `:click_once` | `:after` | supply `target:` | [Inline Expansion](examples/inline-expansion.md) |
307
555
  | `lazy:` | `:visible` | `:fill` | `:self` | [Lazy Loading](examples/lazy-loading.md) |
308
556
  | `modal:` | `:click` | `:fill` | supply `target:` | [Modal Dialog](examples/modal-dialog.md) |
309
557
  | `load_more:` | `:click` | `:replace` | `:self` | [Click to Load](examples/click-to-load.md) |
@@ -311,19 +559,22 @@ The kwarg's value is the component class to load (`with:` supplies its attrs, sa
311
559
  | `live_search:` | `:input` | `:fill` | supply `target:` | [Active Search](examples/active-search.md) |
312
560
  | `tabs:` | `:click` | `:fill` | supply `target:` | [Tabs](examples/tabs.md) |
313
561
  | `retry:` | `:click` | `:replace` | `closest .weft-error` | — |
562
+ | `reopen_stream:` | `:click` | `:replace` | `closest [sse-swap]` | — |
314
563
 
315
564
  Where the table says "supply `target:`", the preset has no universally-right answer for where the content lands, so the call site provides it (omitting it raises immediately, with a message saying so). Explicit `swap:` and `target:` kwargs always override the preset.
316
565
 
317
- `retry:` is the odd one out: its value is a **URL string** rather than a component class — the failing component's own GET URL, as injected into error components via the `:retry_url` recovery attribute (see [Error handling](error-handling.md)). Its baked-in target replaces the enclosing `.weft-error` box with the freshly-rendered component:
566
+ `retry:` and `reopen_stream:` are the odd ones out: their value is a **URL string** rather than a component class — the failing component's own GET URL, as injected into error components via the `:retry_url` recovery param (see [Error handling](error-handling.md)). `retry:`'s baked-in target replaces the enclosing `.weft-error` box with the freshly-rendered component; `reopen_stream:` targets a closed SSE stream's wrapper so the fresh render reconnects it:
318
567
 
319
568
  ```ruby
320
- button "Retry", retry: attrs.retry_url
569
+ button "Retry", retry: params.retry_url
321
570
  ```
322
571
 
323
572
  ### Registering your own
324
573
 
325
574
  ```ruby
326
- Weft.register_shorthand :paginate, trigger: :click, swap: :replace
575
+ Weft.register_preset :paginate, trigger: :click, swap: :replace
327
576
  ```
328
577
 
329
- A registration names the preset and provides any of `trigger:`, `swap:`, and `target:`. From then on, `paginate:` works as an element kwarg everywhere — same machinery, your vocabulary. Naming interactions after their intent keeps call sites readable: `button "Next", paginate: OrdersPanel, with: { page: 2 }` says more than the four htmx attributes it expands to.
578
+ A registration names the preset and provides any of `trigger:`, `swap:`, and `target:`. From then on, `paginate:` works as an element kwarg everywhere — same machinery, your vocabulary. Naming interactions after their intent keeps call sites readable: `button "Next", paginate: OrdersPanel, with: { page: 2 }` says more than the four htmx params it expands to.
579
+
580
+ Names are checked at registration. One that collides with Weft's own element-kwarg vocabulary (`action`, `navigate`, `loads`, `trigger`, `push_url`, `swap`, `target`, `with`, `confirm` — and `prompt`, reserved) raises `Weft::InvalidDefinition`: a preset by that name would shadow the grammar itself. One that shadows a standard HTML attribute (`title`, `href`, …) registers but logs a warning — elements passing a Class or String value for that kwarg will expand as your preset instead of rendering the attribute.