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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fada6be56bfe906aa7c6f8d30f2e759b750517440027eada9b98b3afcff9e4d9
4
- data.tar.gz: bd66b0c5d17e658de87ebc058ca393a5d94632b1cb5251c5f43fb9a757d35656
3
+ metadata.gz: 9159a7fb47cb6e20e124522b7c3d4d704678dc5c39464039d2e5d0e06243ecd9
4
+ data.tar.gz: eb64c59d4c7468349b6dfb3b2eaf081e71b2412d27b4504adb3b2991d7bca603
5
5
  SHA512:
6
- metadata.gz: 376016a9b727cf23334ca98410778b3c90f4aa2d38a1cffe84ac91b4b32e82d2afb148a84bc370309962406c536e2dc458c3f0310ea2020cfb40b75a2ba5e657
7
- data.tar.gz: f9c26e0e7514cb92356ed4a78b515f2f367dcb92126c259318d14378469e2d26ffe9af1ee64440f671a18239cfdb10312942c12bec862518a2dbe63446c30b39
6
+ metadata.gz: 9f4a553ba0e98279dd39d704b8b30b21d0d4e4dd6d5cf58f6eb89d79cd0825217bb0ae5ee141910d664c609e5c9560d04de2d346c73444c73a47d4e412962ae8
7
+ data.tar.gz: f336d96dd78e65e14058c934a879f57c85278e0665267d5ff3c854d52bc8ce86af915fb49b4f11333a7e40e032458d3b80023bdf8f5c680a9a4e9d49e8bb789a
data/CHANGELOG.md CHANGED
@@ -1,37 +1,143 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.0 (2026-08-11)
4
+
5
+ Weft's inputs model grows up: four declared ways to get a component what it needs, values that flow down the render tree, and one universe of state per request. Also typed wire params, one-call app loading, self-healing streams, and 404s you can brand.
6
+
7
+ ### New Features:
8
+
9
+ - **Four Doors Into `params`** – Declare what a component needs; read it back the same way whatever the source. Full model in [the DSL reference](docs/dsl.md#params).
10
+ - `param` – wire state from the query string, path, or body
11
+ - `receives` – rich objects the call site hands over; required unless you declare a default
12
+ - `derives` – a block that runs at most once per render, and only if something reads it
13
+ - `defines` – static values a subclass pins; sugar over `derives`
14
+ - Two doors on one key resolve either way: handed over when embedded, self-fetching when standalone
15
+ - `dependent!` marks a component that only makes sense inside a parent
16
+
17
+ - **Typed Wire Params** (`type:`) – `param :page, type: :integer` reads `?page=2` as an Integer, with `:string`, `:float`, `:boolean`, and `:decimal` completing the vocabulary. Bad declarations fail at class-load time, not mid-request.
18
+
19
+ - **Ancestor Navigation** (`closest` / `enclosing`) – A nested component finds an ancestor and reads its identity, so it can aim at that ancestor instead of being hand-fed a target at every call site. Bang variants raise `Weft::AncestorNotFound`.
20
+
21
+ - **One-Call App Loading** (`Weft.configure_autoloading`) – Point Weft at your app directories and [Zeitwerk](https://github.com/fxn/zeitwerk) takes over; every component and page is registered and routable before the first request.
22
+ - `reload: true` applies edits, new files, and deletions on the next request, route registry in sync
23
+ - `Weft.registry.evict` and `Weft.configuration.refresh_stale_classes!` are public, for hand-rolled reloaders
24
+
25
+ - **Self-Healing Live Streams** – `recovers` now protects SSE pushes like any other render, and a stream that keeps failing no longer errors forever.
26
+ - Error budget set gem-wide or per-component
27
+ - Recovery components can declare `param :attempts_remaining` to tell "still retrying" apart from "gave up"
28
+ - A new `reopen_stream:` preset, providing one-click resume for a failed stream
29
+ - `pushes immediate: false` holds the first frame one interval, for snapshots that only mean something after a push cycle
30
+
31
+ - **Branded Not-Found Pages** – `recovers from: Weft::NotFound, with: YourNotFoundPage` now works the way it was always documented, on every 404 pathway; mounted as middleware, a downstream app's own 404s pass through untouched.
32
+ - **Map your own errors** (`status:`) – `recovers from: ActiveRecord::RecordNotFound, with: NotFoundPage, status: 404` gives your exceptions honest wire semantics
33
+
34
+ - **One Universe Per Request** – A response resolves against the request's own wire params end to end — action re-render, `transfers` hand-off, or recovery — so a nested component keeps reading what it declares with no encloser relaying it. Hashes returned from verb blocks overlay that universe for everything the response renders.
35
+
36
+ - **Actions That Read What Your Component Knows** – Every verb block now sees the same `params` that `build` does, so a lookup a component already declares isn't written again inside each action. (`receives` stays out: a request over the wire has no call site.)
37
+ - One query per response — a record the callable loads is already loaded for the re-render, its companions, and any transfer target
38
+ - A `recovers` block is handed the state the request had reached when it broke
39
+ - Two one-time warnings name a `derives` block that can never run
40
+
41
+ - **Companions Ride Along Smarter** (`includes`) – A companion renders as an out-of-band child of the component it accompanies, inheriting its params, with its block returning a delta for that companion alone.
42
+ - `on:` takes arrays, and matches your own `transfers` actions
43
+ - `when: :transferred` scopes a companion to transfer arrivals; declare both for a union
44
+ - A companion that raises walks its **own** `recovers` chain into its **own** slot while the primary's render, status, and announcement stand — streams included, and it never spends their attempts budget
45
+
46
+ - **Announcements That Name Their Action** (`triggers ..., on:`) – Events are no longer welded to every action a component has, so subscribers stop refetching for an action that changed nothing. Arrays accepted.
47
+
48
+ - **Deletions Without Ceremony** (`dismisses`) – A successful delete-swap responds with an empty body, retiring the guard clause against the just-deleted record. Companions still ride along, and `dismisses` now takes `target:`.
49
+ - A recovery fragment adopts the failing component's wrapper tag, so a failed row delete yields an error `<tr>`, not a `<div>` wedged into a table
50
+
51
+ - **Call-Site Wiring Overrides** (`target:` / `swap:`) – Every kwarg that wires a request now honors per-call overrides: the declaration supplies the default, the call site gets the final word.
52
+
53
+ - **Confirmation Dialogs** (`confirm:`) – Shows the browser-native dialog and fires the request only on OK. Works alongside any interaction kwarg, or standalone on a container.
54
+
55
+ - **Loud Wiring Failures** – A kwarg that is unmistakably Weft's but can't resolve raises `Weft::InvalidUsage` instead of rendering as a junk HTML attribute you would notice only when clicking did nothing. A `nil` value still means "not this time."
56
+
57
+ - **Declarative Page Titles** (`title`) – `title "Orders"`, or `title { |params| "Order ##{params.order.number}" }` when the tab should name the record. Inherited down a page hierarchy.
58
+
59
+ - **Two More Semantic Triggers** – `:change` fires when a select or checkbox changes value; `:click_once` caps an interaction at a single firing, the right default for anything that inserts rather than replaces. `inline_expand:` now bakes it in.
60
+
61
+ - **Inline Head Scripts** (`register_inline_js`) – The JavaScript sibling of `register_inline_css`, landing after the registered external scripts.
62
+
63
+ ### Breaking Changes:
64
+
65
+ - **Renames** – All mechanical; rename call sites.
66
+ - `attribute` → `param` and `attrs` → `params`, including every verb's block argument. Arbre's own HTML attributes are untouched
67
+ - `shorthand` → `preset`: `Weft::Shorthands` → `Weft::Presets`, `register_shorthand` → `register_preset`, `Weft.shorthand` → `Weft.preset`. The element kwargs are unchanged
68
+ - `Weft::Page.register_css` → `register_inline_css`
69
+ - `weft_id` → `weft_dom_id`, `weft_id_for` → `weft_dom_id_for`; derived values unchanged
70
+ - `Weft::Resolver#resolve` is now a class method, and `Weft::Params.extract_from` is removed
71
+
72
+ - **In-page param passing removed** – Components resolve their declared `param`s from the request at any nesting depth, so `orders_panel(status: params.status, page: params.page)` collapses to `orders_panel`. A builder kwarg naming a declared param now renders as a plain HTML attribute with a one-time warning; rich objects get `receives` instead.
73
+ - `params` resolves at construction, so a `build` body can read it before `super`
74
+ - `Weft::Context.new` accepts `wire_params:` for rendering outside the Router
75
+ - `Component.render` / `Page.render` kwargs are now exactly what a query string would carry
76
+
77
+ - **The params bag flows down the render tree** – Each component starts from a copy of its nearest ancestor's resolved params: everything above it, nothing beside it. Its own sources still win, in a fixed order ([precedence table](docs/dsl.md#how-the-doors-combine)), and only its own declared `param`s serialize.
78
+
79
+ - **Defaults belong to whoever declares them** – A `default:` no longer travels: a child, or a `transfers` target, falls back to its own rather than an ancestor's. Everything actually supplied still flows down. Declare the value where it's meant to come from.
80
+
81
+ - **Wire coercion follows `type:`, not the default** – An untyped param passes its wire value through as a string. Add `type:` wherever a default used to do the coercing — flag params especially, since `"false"` is truthy without `type: :boolean`.
82
+
83
+ - **Removed: `auto_reload` / `reload_paths`** – Use `Weft.configure_autoloading(reload: true)`, which reloads more and keeps the routing registry in sync. `sinatra-contrib` is dropped, `zeitwerk` added; hand-rolled reloaders now call `Weft.registry.evict` explicitly.
84
+
85
+ - **The element-kwarg surface fails loudly** – Miswired kwargs raise `Weft::InvalidUsage` where they used to fall through as HTML attributes, and the `loads:`/preset "requires `swap:`/`target:`" errors move there from `ArgumentError`. Follow the messages — each names the kwarg and the repair.
86
+
87
+ - **Non-routable load targets raise** – A `loads:`, preset, or `navigate:` aimed at a non-routable class now raises at render time instead of 404ing at click time with nothing in the logs. Mark purely presentational targets `routable!`.
88
+
89
+ - **DOM ids skip unusable suffixes** – The id suffix rides only for non-blank scalars, so `""`, `nil`, and non-scalar values all derive the bare class id (`member-roster-[]` used to break `querySelector`). `false` now suffixes like `true`. Update CSS or tests matching the old forms.
90
+
91
+ - **Destructive-swap responses are empty** – A successful delete-swap responds `200` with no body where it used to carry a render htmx discarded. Out-of-band fragments still arrive.
92
+
93
+ - **Page titles are declared, not extracted** – `Weft::Page` no longer reads `:title` from the build attributes; a page still setting it leaves the tab reading "Weft" and a stray `title="..."` on `<html>`. Use the class-body `title`.
94
+
95
+ - **`inline_expand:` fires once** – The default trigger is now `:click_once`, so a repeat click can't insert a second copy. Drop any hand-written `trigger: "click once"`; declare `trigger: :click` to keep re-triggering.
96
+
97
+ - **Transfers responses carry the target's companions** – The *rendered* component's `includes` fire, not the transferring one's, and `on:` matches only a component's own action names. Scope arrivals with `when: :transferred`.
98
+
99
+ - **A failed render walks the chain of whatever was rendering** – A `transfers` target that raises during its own `build` is handled by the target's `recovers` chain. Declare the edge on the target, or on a shared base class.
100
+
101
+ - **The declarer's schema stays its own** – A transfer or recovery target projects its own declared schema and its own defaults; what crosses over is the state the request composed, so nothing already loaded is fetched twice.
102
+
103
+ - **One DOM slot, one companion** – Two companions resolving to the same DOM id can't both land, so Weft keeps the first, warns naming both declaration sites, and never builds the loser. A component's first param now decides which companions can coexist, not only where each lands.
104
+
105
+ - **Inclusion blocks return deltas, not replacements** – The hash adjusts a companion's picture instead of defining its entire wire. Clear a key explicitly (`{ key: nil }`) where a block used to withhold it.
106
+
107
+ - **Recovery fragments always wear the failing component's id** – Weft stamps identity onto every recovery fragment, so a target lands correctly whether or not it knows about any of this. `:component_id` is gone from the auto-injected params (six remain); drop the declaration and any `weft_dom_id` override that read it.
108
+
3
109
  ## v0.1.0 (2026-07-12)
4
110
 
5
111
  First usable release. Weft is component-oriented hypermedia for Ruby: components declare their structure, their data, and their interactive behaviors, and the framework derives the routing, request handling, and client-side wiring automatically.
6
112
 
7
113
  ### New Features:
8
114
 
9
- - **Components and pages** – Subclass `Weft::Component` and `Weft::Page`, declare `attribute`s, and build HTML in Ruby via Arbre:
10
- - Components render standalone fragments or compose into pages through generated builder methods (`builder_method :name`)
11
- - Declared attributes resolve from request parameters with defaults and type coercion, and reach your code as `attrs.whatever`
12
- - Pages carry the document shell: title, stylesheets, scripts, inline CSS, all inheritable down the page hierarchy
13
- - **The verb DSL** – One-line declarations for dynamic behavior:
14
- - `performs :name` – user-initiated actions: run your callable, re-render the component
15
- - `transfers :name, to: Other` – actions that render a different component in the caller's place
16
- - `dismisses :name` – actions that remove the component from the DOM
17
- - `refreshes every:` / `refreshes on:` – client re-fetches on a timer (whole or fractional seconds, down to a 1ms floor) or when a page event fires
18
- - `pushes every:` – the server streams re-renders over SSE, with an immediate first frame for new subscribers
19
- - `triggers "event"` – announce action responses to the rest of the page
20
- - `includes Other` – companion components ride along out-of-band in action responses
21
- - `recovers from:, with:` – declarative error behavior per class
22
- - **Auto-routing** – Components route at `/_components/<name>` and pages at name-derived paths, with the conventional class-name suffix stripped (`OrdersPanelComponent`, `OrdersPanel`, and `DashboardPage`, `Dashboard` all route without ceremony):
23
- - Explicit overrides via `self.page_path` and `self.component_path =`; global knobs for the component prefix and stream suffix
24
- - Routability inferred from declared state, with `abstract!` / `routable!` as escape hatches
25
- - SSE stream endpoints generated automatically for every pushing component
26
- - **Route collision detection** – Two routable classes resolving to the same effective path raise `Weft::InvalidDefinition` naming both, lazily on the first request. Code reloaders that redefine a class prune the stale registration automatically; `Weft.registry.clear` gives reload integrations and tests a full reset
27
- - **Element kwargs** – `action:`, `loads:`, `trigger:`, `navigate:`, and `push_url:` work on any element at any nesting depth, with `target:` and `swap:` refinements on `loads:` and the shorthands. Raw htmx attributes pass through untouched, side by side with what the kwargs expand to
28
- - **Interaction shorthands** – `tooltip:`, `modal:`, `lazy:`, `load_more:`, `infinite_scroll:`, `live_search:`, `tabs:`, `inline_expand:`, and `retry:` — named presets over the `loads:` machinery with the trigger and swap details baked in. Register your own vocabulary with `Weft.register_shorthand`
29
- - **Error handling** – A semantic error family under `Weft::Error` (`InvalidConfiguration`, `InvalidDefinition`, `InvalidUsage`, and the `HTTPError` classes such as `Weft::NotFound` and `Weft::Unprocessable`):
30
- - The `recovers` chain renders declared fallbacks with semantic status codes (a validation failure is a `422` whose body is the component wearing its error state)
31
- - Recovery targets receive schema-gated context — `:exception`, `:request_path`, `:status_code`, `:component_id`, `:retry_url` — only where declared
32
- - Brand the defaults app-wide via the `error_component` / `error_page` / `not_found_page` / `not_found_component` knobs, or per class with explicit `recovers` declarations
33
- - The gem-default error components offer one-click retry via the `retry:` shorthand
34
- - **Configuration** – `Weft.configure` covers development reloading (`auto_reload`, `reload_paths`), logging (`Weft.logger`, stdout by default; `log_level`, `router_logging`), static asset bundles (`static_assets` with named bundles, path-containment checks, and `assets:` resolution on `register_stylesheet` / `register_script`), htmx delivery (`include_htmx`, `include_sse_ext`), and routing (`component_path`, `stream_suffix`)
35
- - **Security posture** – The htmx core and SSE-extension scripts Weft includes are subresource-integrity pinned; `register_script` forwards `integrity:` / `crossorigin:` (and any other attributes) to the tag for your own CDN scripts
115
+ - **Components and Pages** (`Weft::Component`, `Weft::Page`) Build HTML in Ruby with a component DSL over Arbre:
116
+ - Describe structure in a `build` block and declare a component's inputs with `attribute` — they arrive from request parameters, coerced to type and filled with your defaults, and reach your code as `attrs.whatever`
117
+ - Render a component as a standalone fragment or drop it into a page through a generated builder method (`builder_method :name`)
118
+ - Pages carry the whole document shell title, stylesheets, scripts, inline CSS inheritable down a page hierarchy, so a shared layout costs nothing at each page
119
+ - **Interactive Behaviors** – One-line declarations that wire up dynamic behavior, no routes or JavaScript written by hand:
120
+ - `performs :name` – a user-initiated action: run your callable, then re-render the component in place
121
+ - `transfers :name, to: Other` – an action whose response renders a *different* component where the caller was
122
+ - `dismisses :name` – an action that removes the component from the DOM
123
+ - `refreshes every:` / `refreshes on:` – the client re-fetches the component on a timer (whole or fractional seconds, down to a 1ms floor) or whenever a named page event fires
124
+ - `pushes every:` – the server streams re-renders over SSE, with an immediate first frame for every new subscriber
125
+ - `triggers "event"` – announce an action's result to the rest of the page for other components to react to
126
+ - `includes Other` – a companion component rides along out-of-band, updating a second region in the same response
127
+ - `recovers from:, with:` – declare per-class error behavior
128
+ - **Automatic Routing** – Every component and page gets a URL with no route table to maintain: components at `/_components/<name>`, pages at name-derived paths, the conventional class-name suffix stripped (`OrdersPanelComponent` and `OrdersPanel`, `DashboardPage` and `Dashboard`, all route without ceremony):
129
+ - Override explicitly with `self.page_path` and `self.component_path =`; tune the component prefix and stream suffix gem-wide
130
+ - Routability is inferred from what a class declares, with `abstract!` / `routable!` to force it either way
131
+ - Every pushing component gets its SSE stream endpoint generated automatically
132
+ - **Collision-Safe Routing** – If two routable classes would answer at the same URL, Weft raises `Weft::InvalidDefinition` naming both on the first request, so you find out immediately. Code reloaders that redefine a class prune the stale registration automatically, and `Weft.registry.clear` gives reload integrations and tests a clean slate
133
+ - **Element-Level Wiring** – Attach behavior to any element at any nesting depth with `action:`, `loads:`, `trigger:`, `navigate:`, and `push_url:`, plus `target:` and `swap:` to refine where `loads:` and the shorthands land their response. Raw htmx attributes pass straight through, side by side with what the kwargs expand to
134
+ - **Interaction Shorthands** – Named one-word wirings over the `loads:` machinery, with the trigger and swap details baked in: `tooltip:`, `modal:`, `lazy:`, `load_more:`, `infinite_scroll:`, `live_search:`, `tabs:`, `inline_expand:`, and `retry:`. Register your own vocabulary with `Weft.register_shorthand`
135
+ - **Semantic Error Handling** – A full error family under `Weft::Error` (`InvalidConfiguration`, `InvalidDefinition`, `InvalidUsage`, and `HTTPError` classes like `Weft::NotFound` and `Weft::Unprocessable`), and a recovery system that renders the right fallback with the right status code:
136
+ - The `recovers` chain renders declared fallbacks with semantic status codes a validation failure becomes a `422` whose body is the component wearing its error state
137
+ - Recovery targets receive schema-gated context — `:exception`, `:request_path`, `:status_code`, `:component_id`, `:retry_url` — only where they declare it
138
+ - Brand the defaults app-wide via `error_component` / `error_page` / `not_found_page` / `not_found_component`, or override per class with explicit `recovers` declarations
139
+ - The built-in error components offer one-click retry through the `retry:` shorthand
140
+ - **Configuration** – `Weft.configure` covers the operational surface: development reloading (`auto_reload`, `reload_paths`), logging (`Weft.logger`, stdout by default; `log_level`, `router_logging`), static asset bundles (`static_assets` with named bundles, path-containment checks, and `assets:` resolution on `register_stylesheet` / `register_script`), htmx delivery (`include_htmx`, `include_sse_ext`), and routing (`component_path`, `stream_suffix`)
141
+ - **Secure Script Delivery** – The htmx core and SSE-extension scripts Weft serves are subresource-integrity pinned out of the box, and `register_script` forwards `integrity:` / `crossorigin:` (and any other attributes) to the tag for your own CDN scripts
36
142
  - **Documentation** – A complete set under `docs/`: a build-your-first-app tutorial; references for the DSL, routing, error handling, configuration, and the Arbre HTML layer; an application-patterns guide (service objects, databases, background jobs, authentication, CSRF, testing); and a twenty-one-page examples catalog with captured wire traffic that deliberately covers the ground of htmx's own examples
37
- - **Demo app** – A complete Sinatra + Weft application under `demo/`, exercising the feature surface end to end
143
+ - **Demo Application** – A complete Sinatra + Weft application under `demo/`, exercising the feature surface end to end
data/README.md CHANGED
@@ -6,28 +6,48 @@ Weft lets you write your application in terms of its interface: components decla
6
6
 
7
7
  ```ruby
8
8
  class DeliveryStatus < Weft::Component
9
- attribute :delivery_id
9
+ param :delivery_id, type: :integer
10
10
 
11
- refreshes every: 5.seconds
11
+ derives(:delivery) { |params| Delivery.find(params.delivery_id) }
12
12
 
13
- performs :cancel do |attrs|
14
- delivery = Delivery.find(attrs.delivery_id)
15
- CancelDelivery.call(delivery)
16
- end
13
+ performs(:cancel) { |params| CancelDelivery.call(params.delivery) }
14
+
15
+ refreshes every: 5.seconds
17
16
 
18
17
  def build(attributes = {})
19
18
  super
20
- delivery = Delivery.find(attrs.delivery_id)
21
- div(class: "delivery-status") do
22
- progress value: delivery.progress, max: 100
23
- button "Cancel", action: :cancel if delivery.cancelable?
24
- span "Arriving #{delivery.eta}"
19
+ add_class "delivery-status"
20
+
21
+ span "Delivery ##{params.delivery_id}"
22
+ div(class: "delivery-detail") do
23
+ progress_bar value: params.delivery.progress_percent, max: 100
24
+ span "Arriving #{params.delivery.eta}"
25
+ button "Cancel", action: :cancel if params.delivery.cancellable?
25
26
  end
26
27
  end
27
28
  end
28
29
  ```
29
30
 
30
- That's a complete, interactive UI component. It polls for updates every 5 seconds. The cancel button invokes a service and re-renders the result. There's no routes file, no controller, no custom JavaScript — just Ruby describing what the UI is and what it does. The UI is the source of truth; the plumbing is implied.
31
+ That's a complete, interactive UI component. The cancel button invokes a service and re-renders the result; the card polls for fresh state every 5 seconds. There's no routes file, no controller, no custom JavaScript — just Ruby describing what the UI is and what it does. The UI is the source of truth; the plumbing is implied.
32
+
33
+ Here is everything that renders — htmx wiring and all:
34
+
35
+ ```html
36
+ <div id="delivery-status-4471" hx-get="/_components/delivery_status?delivery_id=4471"
37
+ hx-trigger="every 5s" hx-swap="outerHTML" class="delivery-status">
38
+ <span>Delivery #4471</span>
39
+ <div class="delivery-detail">
40
+ <div id="progress-bar" class="progress">
41
+ <div class="progress-fill" style="width: 62%"></div>
42
+ </div>
43
+ <span>Arriving today, 4:15 PM</span>
44
+ <button hx-post="/_components/delivery_status/cancel" hx-target="#delivery-status-4471"
45
+ hx-swap="outerHTML" hx-vals="{&quot;delivery_id&quot;:4471}">Cancel</button>
46
+ </div>
47
+ </div>
48
+ ```
49
+
50
+ Every attribute above was derived from those four declarations: the routes (`GET /_components/delivery_status` for the component, `POST /_components/delivery_status/cancel` for the action), the DOM id that keeps this delivery individually addressable, the polling on the wrapper, and the button's whole request — where the response lands, how it swaps, and which params ride along with it. `progress_bar` is a child component with declarations of its own, rendered inline, wrapper and all.
31
51
 
32
52
  Weft is built on [Arbre](https://github.com/activeadmin/arbre) for HTML generation and [htmx](https://htmx.org) for hypermedia interactions. It runs standalone as a lightweight Sinatra-backed server, or mounts as middleware inside any existing Rack app. No build step, no npm, no hydration — just Ruby, HTML, and HTTP.
33
53
 
@@ -47,13 +67,14 @@ Components declare their dynamic behaviors with verbs:
47
67
  | `includes Other` | Companion components ride along in action responses, out-of-band |
48
68
  | `recovers from: Err, with: Fallback` | Declares what renders when something raises |
49
69
 
50
- Elements get their own vocabulary — `action:`, `loads:`, `trigger:` kwargs and interaction shorthands like `tooltip:`, `modal:`, `lazy:`, `infinite_scroll:` — all covered in [the DSL reference](docs/dsl.md).
70
+ Elements get their own vocabulary — `action:`, `loads:`, `trigger:` kwargs and interaction presets like `tooltip:`, `modal:`, `lazy:`, `infinite_scroll:` — all covered in [the DSL reference](docs/dsl.md).
51
71
 
52
72
  ## Documentation
53
73
 
54
74
  - **[Build your first Weft app](docs/tutorial.md)** — the tutorial: empty directory to a working app with pages, components, a validated form action, and live updates.
55
75
  - **[Examples](docs/examples/README.md)** — twenty-one worked patterns with captured wire traffic. Coming from htmx? This catalog deliberately covers the ground of htmx's own examples.
56
- - **[The Weft DSL](docs/dsl.md)** — every verb, element kwarg, and interaction shorthand.
76
+ - **[The Weft DSL](docs/dsl.md)** — every verb, element kwarg, and interaction preset.
77
+ - **[How params flow](docs/params.md)** — the data lifecycle: a request comes in, each component pulls what it needs through `param`/`receives`/`derives`/`defines`, and renders with enough of its own wire state to refresh or act on its own.
57
78
  - **[Application patterns](docs/app-patterns.md)** — the app around the components: service objects, databases, background jobs, authentication, CSRF, assets, and testing.
58
79
  - **[Arbre: the HTML layer](docs/arbre.md)** — the HTML builder inside every `build` method, in depth.
59
80
  - **[Routing](docs/routing.md)** — how classes become URLs, what's routable, and collision detection.
@@ -64,8 +85,9 @@ Elements get their own vocabulary — `action:`, `loads:`, `trigger:` kwargs and
64
85
 
65
86
  | Version | Features | Status |
66
87
  |---------|---------|--------|
67
- | v0.1.0 | First usable release: the verb DSL, auto-routing with collision detection, interaction shorthands, SSE, error recovery, full documentation set | **Current** |
68
- | v0.2 | Attribute hydration (resolver reification), child-component ergonomics, SSE error recovery, Zeitwerk integration | Next |
88
+ | v0.1.0 | First usable release: the verb DSL, auto-routing with collision detection, interaction presets, SSE, error recovery, full documentation set | Shipped |
89
+ | v0.2.0 | The inputs model: four declared doors into `params`, typed wire params, values flowing down the render tree, one-call app loading, self-healing streams, brandable 404s | **Current** |
90
+ | v0.3 | The request–response lifecycle: how a request is addressed, carried, observed, and composed into a response | Next |
69
91
 
70
92
  ## Installation
71
93
 
@@ -113,16 +135,17 @@ run MyApp
113
135
 
114
136
  ### Configuration
115
137
 
116
- `Weft.configure` exposes gem-level settings the two you'll want on day one are the development reloader flags:
138
+ The call you'll want on day one is `Weft.configure_autoloading` it puts Zeitwerk in charge of loading your app's directories, and with `reload: true` your edits (new files and deletions included) apply without restarting the server:
117
139
 
118
140
  ```ruby
119
- Weft.configure do |c|
120
- c.auto_reload = (ENV.fetch("RACK_ENV", "production") == "development")
121
- c.reload_paths = [File.expand_path("app/**/*.rb", __dir__)]
122
- end
141
+ Weft.configure_autoloading(
142
+ paths: [File.expand_path("app/components", __dir__),
143
+ File.expand_path("app/pages", __dir__)],
144
+ reload: ENV.fetch("RACK_ENV", "production") == "development"
145
+ )
123
146
  ```
124
147
 
125
- Everything else — static asset bundles, error presentation, routing overrides, logging — is in [the configuration reference](docs/configuration.md).
148
+ Gem-level settings live on its sibling, `Weft.configure` — static asset bundles, error presentation, routing overrides, logging — all in [the configuration reference](docs/configuration.md).
126
149
 
127
150
  ### Customizing error and not-found pages
128
151
 
@@ -139,7 +162,7 @@ Per-class `recovers` declarations override the app-wide fallbacks where you need
139
162
 
140
163
  ## Contributing
141
164
 
142
- Bug reports and pull requests are welcome on GitHub at https://github.com/rusterholz/weft. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md).
165
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rusterholz/weft. The [development guide](docs/development.md) covers setup, the test suites, and the release process. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md).
143
166
 
144
167
  ## License
145
168
 
data/docs/app-patterns.md CHANGED
@@ -35,7 +35,7 @@ The ordering carries the same rule the tutorial explains: files that *define* th
35
35
 
36
36
  Components own what the user **sees and does** — the markup, the affordances, and the immediate response to an action. They should not own your business rules.
37
37
 
38
- The dividing line runs through the action callable. A callable is a translation layer: attributes in, one operation invoked, a hash out for the re-render. The moment the middle step grows past a few lines — multiple records, a transaction, an email, a job — it belongs in a plain Ruby service object:
38
+ The dividing line runs through the action callable. A callable is a translation layer: params in, one operation invoked, a hash out for the re-render. The moment the middle step grows past a few lines — multiple records, a transaction, an email, a job — it belongs in a plain Ruby service object:
39
39
 
40
40
  ```ruby
41
41
  # app/services/comment_poster.rb
@@ -53,16 +53,17 @@ end
53
53
 
54
54
  ```ruby
55
55
  # in the component
56
- performs :post do |attrs|
57
- CommentPoster.call(author: attrs.author, body: attrs.body)
56
+ performs :post do |params|
57
+ CommentPoster.call(author: params.author, body: params.body)
58
58
  { author: nil, body: nil }
59
59
  end
60
60
  ```
61
61
 
62
62
  The service knows nothing about components or HTML; the component knows nothing about how a post actually happens. Two details worth noticing:
63
63
 
64
- - **End the callable with the hash you mean.** A callable's return value merges into the attributes for the re-render *if it's a hash* — anything else is discarded (see [the callable contract](dsl.md#the-callable-contract)). Delegating to a service and then returning your own hash, as above, keeps the wire state deliberate even when the service's return value changes.
65
- - **The service returns plain Ruby values** (`:posted`, `:blank`, a record, a result object — whatever fits). When the component needs to branch on the outcome, branch in the callable and translate to attributes; the service still shouldn't know what a DOM id is.
64
+ - **End the callable with the hash you mean.** A callable's return value merges into the params for the re-render *if it's a hash* — anything else is discarded (see [the callable contract](dsl.md#the-callable-contract)). Delegating to a service and then returning your own hash, as above, keeps the wire state deliberate even when the service's return value changes.
65
+ - **The service returns plain Ruby values** (`:posted`, `:blank`, a record, a result object — whatever fits). When the component needs to branch on the outcome, branch in the callable and translate to params; the service still shouldn't know what a DOM id is.
66
+ - **Feed the service from your declarations, not a second lookup.** A callable reads the same `derives` its `build` does, so `CommentPoster.call(post: params.post)` uses the post the component already knows how to find — one declaration, and one query for the whole response. Writing `Post.find(params.post_id)` inside the callable duplicates a rule that will drift.
66
67
 
67
68
  ## Databases
68
69
 
@@ -76,7 +77,7 @@ Weft doesn't run jobs, but it has a natural shape for showing their progress: **
76
77
 
77
78
  The pattern in three steps:
78
79
 
79
- 1. **The action dispatches and returns.** A user action that starts long work shouldn't wait for it — the callable enqueues the job (Sidekiq, SolidQueue, GoodJob, a `Thread` in development — Weft doesn't care) and returns immediately with whatever attributes render the "started" state.
80
+ 1. **The action dispatches and returns.** A user action that starts long work shouldn't wait for it — the callable enqueues the job (Sidekiq, SolidQueue, GoodJob, a `Thread` in development — Weft doesn't care) and returns immediately with whatever params render the "started" state.
80
81
  2. **The job writes progress to the shared store** as it works: a status column, a percentage, a result row. The job knows nothing about components.
81
82
  3. **The component re-renders on a cadence** — [`refreshes every:`](dsl.md#refreshes--the-client-re-fetches) polls, [`pushes every:`](dsl.md#pushes--the-server-sends-updates) streams over SSE — and each render just reads the store. Completion isn't an event to handle; it's data the next render picks up.
82
83
 
@@ -86,7 +87,7 @@ The [Progress Bar](examples/progress-bar.md) example is this exact lifecycle, ve
86
87
 
87
88
  Weft is deliberately session-agnostic: no cookie handling, no `current_user`, no login machinery. Identity is your app's concern, handled with standard Rack pieces in front of the Router. What Weft *does* define is the seam, and it's narrower than you might expect:
88
89
 
89
- > Components and callables receive exactly their **resolved attributes** — values from request parameters, filtered through each component's declared schema. Session state and request headers are not part of that channel.
90
+ > Components and callables receive exactly their **resolved params** — values from request parameters, filtered through each component's declared schema. Session state and request headers are not part of that channel.
90
91
 
91
92
  So per-request identity needs its own channel. The pattern that fits — the same one Rails blesses as `Current` — is a [`CurrentAttributes`](https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html) object set by middleware. Weft already depends on ActiveSupport, so it's available without adding anything:
92
93
 
data/docs/arbre.md CHANGED
@@ -12,6 +12,7 @@ Arbre's own upstream documentation is famously thin, so this document doesn't as
12
12
  - [Inside `build`: the component contract](#inside-build-the-component-contract)
13
13
  - [Receiving caller content](#receiving-caller-content)
14
14
  - [Working with the tree](#working-with-the-tree)
15
+ - [Reaching enclosing components](#reaching-enclosing-components)
15
16
  - [Forms](#forms)
16
17
  - [Testing components](#testing-components)
17
18
  - [Arbre 1.x vs 2.x](#arbre-1x-vs-2x)
@@ -139,32 +140,33 @@ A component describes its structure in `build`. The Weft-idiomatic shape takes a
139
140
  class EventSummary < Weft::Component
140
141
  builder_method :event_summary
141
142
 
142
- attribute :event_id
143
+ param :event_id
143
144
 
144
145
  def build(attributes = {})
145
146
  super
146
- event = EventStore.find(attrs.event_id)
147
+ event = EventStore.find(params.event_id)
147
148
  h3 event.name
148
149
  para "#{event.date} — #{event.location}"
149
150
  end
150
151
  end
151
152
  ```
152
153
 
153
- **Arguments arrive positionally — always.** When a call site writes `event_summary(event_id: "bbq", class: "compact")`, Arbre collects the arguments and passes them positionally to `build`; the keywords become one trailing hash. Declaring Ruby keyword parameters (`def build(event_id:)`) raises `ArgumentError: wrong number of arguments`. Take the hash.
154
+ **Arguments arrive positionally — always.** When a call site writes `event_summary(class: "compact")`, Arbre collects the arguments and passes them positionally to `build`; the keywords become one trailing hash. Declaring Ruby keyword parameters `def build(compact:)` raises `ArgumentError: wrong number of arguments`. Take the hash. Note what's *not* in that call: the component's `event_id` param. Params travel their own channel — from the request, or down the render tree from an enclosing page — never through the builder call. What you pass here is HTML chrome for the wrapper.
154
155
 
155
- **`super` is where the hash becomes reality.** In a Weft component, `super` extracts your declared attributes into `attrs`, applies whatever remains as HTML attributes on the wrapper element (that's where `class: "compact"` went), and sets the wrapper's DOM id ([derived from your first attribute](dsl.md#attributes)). Skip `super` and none of that happens — the classic symptom is a component that ignores the `class:` you pass it.
156
+ **`super` applies the hash and wires the wrapper.** Your params are already resolved before `build` runs — Weft resolves them when the component is constructed, so you can read `params` even above the `super` call (deriving a heading from a record looked up by param, say). What `super` does is apply the trailing hash as HTML attributes on the wrapper element (that's where `class: "compact"` went), set the wrapper's DOM id ([derived from your first param](dsl.md#params)), and attach any refresh or push wiring. Skip `super` and none of that happens — the classic symptom is a component that ignores the `class:` you pass it.
156
157
 
157
- **Rich objects ride the same hash — pull them out first.** Wire attributes (declared with `attribute`) are for values that travel in URLs. When a call site already holds a rich object, passing it in the hash is finejust `delete` it before `super` so it doesn't get sprayed onto the wrapper as an HTML attribute:
158
+ **Rich objects come through `receives`.** Wire params (declared with `param`) are for values small enough to travel in a URL. When a call site hands the component a rich object it already holds — a record, a computed value, anything that can't ride a query string declare it with [`receives`](dsl.md#receives--caller-hand-offs) and read it from `params` like any other input. The value is handed straight across: it never serializes into a URL, and it never lands on the wrapper as an HTML attribute.
158
159
 
159
160
  ```ruby
160
161
  class AttendeeRow < Weft::Component
161
162
  builder_method :attendee_row
162
163
 
164
+ receives :attendee
165
+
163
166
  def build(attributes = {})
164
- @attendee = attributes.delete(:attendee) # rich object out first
165
167
  super
166
- td @attendee.name
167
- td @attendee.answer
168
+ td params.attendee.name
169
+ td params.attendee.answer
168
170
  end
169
171
 
170
172
  def tag_name
@@ -182,7 +184,7 @@ end
182
184
  Composable components take a block of caller content:
183
185
 
184
186
  ```ruby
185
- event_card(event_id: event.id) do
187
+ event_card do
186
188
  para "Bring a dish to share!"
187
189
  end
188
190
  ```
@@ -196,11 +198,11 @@ class EventCard < Weft::Component
196
198
  builder_method :event_card
197
199
  adds_children_to :@body
198
200
 
199
- attribute :event_id
201
+ param :event_id
200
202
 
201
203
  def build(attributes = {})
202
204
  super
203
- h3 EventStore.find(attrs.event_id).name # structure — lands on the wrapper
205
+ h3 EventStore.find(params.event_id).name # structure — lands on the wrapper
204
206
  @body = div(class: "event-card-body") # caller content lands in here
205
207
  end
206
208
  end
@@ -244,13 +246,41 @@ end # <div>replacement</div>
244
246
 
245
247
  It's the right tool for wholesale swaps and a footgun everywhere else. To add without destroying, create elements normally or use `text_node`.
246
248
 
249
+ ## Reaching enclosing components
250
+
251
+ `parent` and `ancestors` walk the *element* tree — the divs and spans. Weft adds a **component**-level parallel: a nested component can reach an enclosing component or page and read its identity. This is how a child affects an ancestor. A pager, for instance, needs its enclosing panel's route and DOM id to aim its "next page" swap at the panel — rather than being hand-fed all of that at the call site, it reaches for it:
252
+
253
+ ```ruby
254
+ class Pager < Weft::Component
255
+ def build(attributes = {})
256
+ super
257
+ panel = enclosing(Weft::Component) # the nearest ancestor component
258
+ button "Next →", loads: panel.class, target: "##{panel.weft_dom_id}"
259
+ end
260
+ end
261
+ ```
262
+
263
+ **`closest` and `enclosing`.** `closest(matcher)` returns the nearest matching node, *self included*, walking upward — impedance-matched to the DOM's `element.closest()`. `enclosing(matcher)` is the same walk but strictly above self — the natural read for "my enclosing X." Both return `nil` when nothing matches; the `!` variants (`closest!` / `enclosing!`) raise `Weft::AncestorNotFound` instead, for a component that genuinely requires the ancestor (pair it with [`dependent!`](routing.md#abstract-and-routable)).
264
+
265
+ What you can match on:
266
+
267
+ - a **Class or Module** — matched `is_a?`, so subclasses and included modules count: `enclosing(Weft::Page)` finds the nearest page of any kind, `enclosing(OrdersPanel)` a specific type, `enclosing(Paginatable)` anything that mixes in that role.
268
+ - a **Symbol** — matched against the tag name: `closest(:section)` hands back the nearest enclosing `<section>`, no need to know Arbre's internal element classes.
269
+ - an optional **block** that *refines*: a candidate must match the positional matcher **and** the block — `closest(Weft::Component) { |c| c.params.key?(:order_id) }`.
270
+
271
+ The return value is the matching node — a component for a class match, a plain element for a tag.
272
+
273
+ **What to rely on.** The ancestor you reach mid-`build` is itself mid-build, above you on the stack — so lean on its *identity* and *params* (its class, `weft_dom_id`, route, resolved params, all fixed at construction), not on instance variables its own `build` may not have set yet. And mind one edge of the refining block: it runs only on nodes that already matched the positional, but `closest(Weft::Component, &:paginatable?)` still raises if a matched component doesn't define `paginatable?` — prefer matching the **role module** (`closest(Paginatable)`) over a predicate where you can.
274
+
275
+ One include-self subtlety: because `closest` includes self and *every* component is a `Weft::Component`, `closest(Weft::Component)` returns **self**. "My nearest ancestor component" is `enclosing(Weft::Component)` (or `closest(Weft::Component, include_self: false)`).
276
+
247
277
  ## Forms
248
278
 
249
279
  Forms are ordinary Arbre: `form`, `label`, `input`, `select`, `option`, `textarea` are tag builders like any other, and you compose them like any other markup. What Weft adds is the wiring — `form(action: :submit)` connects the form to a declared action, with htmx submission and a no-JavaScript fallback emitted for free ([the DSL reference](dsl.md#action) has the mechanics; the [tutorial](tutorial.md#7-taking-rsvps) builds a full working form).
250
280
 
251
281
  Two field-level idioms worth knowing:
252
282
 
253
- - **Field names pair with declared attributes.** In a Weft form, an `input name: "answer"` reaches the action callable as `attrs.answer` when the component declares `attribute :answer` — and component attributes that *aren't* form fields need a hidden input to travel. The [tutorial](tutorial.md#7-taking-rsvps) walks through both halves.
283
+ - **Field names pair with declared params.** In a Weft form, an `input name: "answer"` reaches the action callable as `params.answer` when the component declares `param :answer` — and component params that *aren't* form fields need a hidden input to travel. The [tutorial](tutorial.md#7-taking-rsvps) walks through both halves.
254
284
  - **Array parameters use the `name[]` convention:** `input type: "checkbox", name: "toppings[]", value: "olives"` — submitted values arrive as an array.
255
285
 
256
286
  ## Testing components
@@ -267,22 +297,23 @@ RSpec.describe AttendeeList do
267
297
  end
268
298
  ```
269
299
 
270
- `Component.render(**attrs)` is the gem-provided entry point and covers most component testing. When you want the element tree rather than the string — asserting on classes, structure, or specific descendants — build a context and search it:
300
+ `Component.render` is the gem-provided entry point and covers most component testing — its keyword arguments are exactly the wire params a request would carry. When you want the element tree rather than the string — asserting on classes, structure, or specific descendants — build a `Weft::Context` and search it. Its `wire_params:` argument stands in for the request, so the component's declared params resolve just as they would over the wire:
271
301
 
272
302
  ```ruby
273
- ctx = Arbre::Context.new do
274
- attendee_list(event_id: "trivia-night")
303
+ ctx = Weft::Context.new({}, nil, wire_params: { "event_id" => "trivia-night" }) do
304
+ attendee_list
275
305
  end
276
306
  list = ctx.children.first
277
307
  expect(list.class_list).to include("roster")
278
308
  expect(ctx.find_by_tag("li").length).to eq(2)
279
309
  ```
280
310
 
281
- Three Arbre-specific notes for test code:
311
+ A value the component `receives` is handed the way it is in production — as a builder kwarg, `attendee_list(roster: some_roster)` — since a declared `receives` key consumes the kwarg rather than letting it fall through to an HTML attribute. Capture that value into a local first: the block runs *inside* the context, so a bare `let` name isn't in scope there.
312
+
313
+ Two Arbre-specific notes for test code:
282
314
 
283
315
  - **Give test component classes real names.** `builder_method` resolves its class by name at call time, so an anonymous class (`Class.new(Weft::Component)`) with a stubbed `name` raises `NameError` the first time its builder is invoked — and under Arbre 1.x, even `insert_tag` with a truly anonymous class crashes. Define named classes (a `TestCard = Class.new(...)` constant works) rather than fighting it.
284
- - **Pass data via assigns, not local-variable capture,** when a context block needs outside data: `Arbre::Context.new(event: event) { ... }` makes `event` resolve through Arbre's lookup chain the same resolution production code uses where a captured local would quietly bypass it.
285
- - **The helpers slot** (`Arbre::Context.new(assigns, helpers)`) makes any object's methods callable inside the block. Weft renders components without helpers, so tests should too, unless you're testing raw Arbre code that expects them.
316
+ - **`assigns` and `helpers` are Arbre's channels, not Weft's.** `Weft::Context.new(assigns, helpers, wire_params:)` still carries Arbre's two data slots — `assigns` (resolved through Arbre's lookup chain) and `helpers` (an object whose methods become callable bare in the block). Weft components read `params` and use neither; reach for these only when a block holds raw Arbre code that expects them.
286
317
 
287
318
  ## Arbre 1.x vs 2.x
288
319