plutonium 0.65.0 → 0.66.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +43 -43
  3. data/.claude/skills/plutonium-app/SKILL.md +101 -59
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
  5. data/.claude/skills/plutonium-auth/SKILL.md +119 -53
  6. data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
  7. data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
  8. data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
  9. data/.claude/skills/plutonium-resource/SKILL.md +144 -133
  10. data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
  11. data/.claude/skills/plutonium-testing/SKILL.md +130 -33
  12. data/.claude/skills/plutonium-ui/SKILL.md +151 -96
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +21 -0
  15. data/README.md +9 -9
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/docs/.vitepress/sync-skills.mjs +6 -3
  19. data/docs/blog/introducing-plutonium-dashboards.md +4 -5
  20. data/docs/blog/introducing-plutonium-i18n.md +4 -5
  21. data/docs/getting-started/installation.md +5 -5
  22. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  23. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  24. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  25. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  26. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  27. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  28. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  29. data/docs/getting-started/tutorial/index.md +1 -1
  30. data/docs/guides/adding-resources.md +10 -7
  31. data/docs/guides/authentication.md +25 -25
  32. data/docs/guides/authorization.md +24 -24
  33. data/docs/guides/creating-packages.md +17 -17
  34. data/docs/guides/custom-actions.md +32 -32
  35. data/docs/guides/customizing-ui.md +29 -26
  36. data/docs/guides/dashboards.md +1 -1
  37. data/docs/guides/index.md +3 -3
  38. data/docs/guides/kanban.md +55 -55
  39. data/docs/guides/multi-tenancy.md +35 -22
  40. data/docs/guides/nested-resources.md +21 -21
  41. data/docs/guides/performance.md +3 -3
  42. data/docs/guides/search-filtering.md +13 -13
  43. data/docs/guides/testing.md +16 -12
  44. data/docs/guides/theming.md +32 -17
  45. data/docs/guides/troubleshooting.md +2 -2
  46. data/docs/guides/user-invites.md +17 -17
  47. data/docs/guides/user-profile.md +51 -24
  48. data/docs/guides/wizards.md +55 -55
  49. data/docs/reference/app/generators.md +22 -22
  50. data/docs/reference/app/index.md +15 -18
  51. data/docs/reference/app/packages.md +8 -8
  52. data/docs/reference/app/portals.md +75 -29
  53. data/docs/reference/auth/accounts.md +15 -15
  54. data/docs/reference/auth/index.md +12 -12
  55. data/docs/reference/auth/profile.md +67 -29
  56. data/docs/reference/behavior/async-interactions.md +24 -24
  57. data/docs/reference/behavior/controllers.md +28 -28
  58. data/docs/reference/behavior/index.md +5 -5
  59. data/docs/reference/behavior/interactions.md +44 -44
  60. data/docs/reference/behavior/policies.md +48 -28
  61. data/docs/reference/configuration.md +6 -6
  62. data/docs/reference/dashboard/dsl.md +2 -2
  63. data/docs/reference/dashboard/index.md +1 -1
  64. data/docs/reference/generators/lite.md +7 -7
  65. data/docs/reference/i18n.md +23 -0
  66. data/docs/reference/index.md +1 -1
  67. data/docs/reference/kanban/authorization.md +9 -9
  68. data/docs/reference/kanban/dsl.md +32 -32
  69. data/docs/reference/kanban/index.md +1 -1
  70. data/docs/reference/kanban/positioning.md +17 -15
  71. data/docs/reference/resource/actions.md +51 -51
  72. data/docs/reference/resource/definition.md +73 -73
  73. data/docs/reference/resource/export.md +6 -6
  74. data/docs/reference/resource/index.md +16 -16
  75. data/docs/reference/resource/model.md +24 -24
  76. data/docs/reference/resource/positioning.md +78 -76
  77. data/docs/reference/resource/query.md +13 -13
  78. data/docs/reference/tenancy/entity-scoping.md +65 -35
  79. data/docs/reference/tenancy/index.md +11 -11
  80. data/docs/reference/tenancy/invites.md +20 -20
  81. data/docs/reference/tenancy/nested-resources.md +13 -13
  82. data/docs/reference/testing/index.md +116 -22
  83. data/docs/reference/ui/assets.md +57 -25
  84. data/docs/reference/ui/components.md +20 -20
  85. data/docs/reference/ui/displays.md +14 -14
  86. data/docs/reference/ui/forms.md +35 -35
  87. data/docs/reference/ui/index.md +17 -15
  88. data/docs/reference/ui/layouts.md +21 -21
  89. data/docs/reference/ui/pages.md +22 -22
  90. data/docs/reference/ui/tables.md +7 -7
  91. data/docs/reference/wizard/anchoring-resume.md +33 -32
  92. data/docs/reference/wizard/dsl.md +44 -44
  93. data/docs/reference/wizard/index.md +6 -6
  94. data/docs/reference/wizard/one-time.md +18 -18
  95. data/docs/reference/wizard/registration-launch.md +32 -32
  96. data/docs/reference/wizard/storage-config.md +23 -23
  97. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  98. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  99. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  100. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  101. data/lib/plutonium/version.rb +1 -1
  102. data/package.json +1 -1
  103. data/src/css/components.css +10 -10
  104. metadata +2 -2
@@ -1,10 +1,10 @@
1
1
  # Registration & launch
2
2
 
3
- A wizard reaches a user one of two ways: as a **resource action** (the `wizard` macro on a definition) or as a **route-mounted entry** (`register_wizard`) — inside a portal *or* on the main application. A portal mount inherits the portal's authentication, tenant scoping entity, layout, and Phlex rendering, exactly like a resource; a main-app mount runs standalone (you supply the auth — see [Hosting & the controller override hook](#hosting-the-controller-override-hook)).
3
+ A wizard reaches a user one of two ways: as a **resource action** (the `wizard` macro on a definition) or as a **route-mounted entry** (`register_wizard`), inside a portal *or* on the main application. A portal mount inherits the portal's authentication, tenant scoping entity, layout, and Phlex rendering, exactly like a resource; a main-app mount runs standalone (you supply the auth); see [Hosting & the controller override hook](#hosting-the-controller-override-hook).
4
4
 
5
- ## On a resource — the `wizard` macro
5
+ ## On a resource: the `wizard` macro
6
6
 
7
- A `wizard` macro in a resource definition registers a wizard and synthesizes its launching action — sugar over the Action system.
7
+ A `wizard` macro in a resource definition registers a wizard and synthesizes its launching action, sugar over the Action system.
8
8
 
9
9
  ```ruby
10
10
  class CompanyDefinition < Plutonium::Resource::Definition
@@ -14,19 +14,19 @@ class CompanyDefinition < Plutonium::Resource::Definition
14
14
  end
15
15
  ```
16
16
 
17
- - **Placement is dictated by the wizard, not chosen** — an **anchored** wizard is a **record** action (it needs a record, the anchor); a **non-anchored** wizard is a collection-level **resource** action (index header). A record-action wizard surfaces on BOTH the record's show page *and* each list row (`collection_record_action`, scoped to that row's record), exactly like `edit`/`destroy`. The only thing you configure is **where a record action shows** (see the table); a flag that doesn't apply to the wizard's kind (e.g. `resource_action:` on an anchored wizard) **raises**.
18
- - **Auto-mounted on the resource controller** — the `wizard` macro's routes are drawn on the resource's own controller, exactly like interactive record/resource actions. There is nothing else to wire up.
19
- - **The anchor is IDOR-safe** — an anchored (record) wizard resolves its anchor through the resource controller's scoped, policy-gated `resource_record!`. A record outside the portal's authorized scope (another tenant's, or a non-existent id) **404s**; it is never loaded via an unscoped `find_by`.
20
- - **Bulk wizards are not supported** — wizards are inherently per-instance flows. Use a bulk interaction instead.
21
- - **Authorization mirrors actions** — a resource policy predicate named after the wizard key gates it (`def configure? = update?`, `def onboard? = create?`).
17
+ - **Placement is dictated by the wizard, not chosen**: an **anchored** wizard is a **record** action (it needs a record, the anchor); a **non-anchored** wizard is a collection-level **resource** action (index header). A record-action wizard surfaces on BOTH the record's show page *and* each list row (`collection_record_action`, scoped to that row's record), exactly like `edit`/`destroy`. The only thing you configure is **where a record action shows** (see the table); a flag that doesn't apply to the wizard's kind (e.g. `resource_action:` on an anchored wizard) **raises**.
18
+ - **Auto-mounted on the resource controller**: the `wizard` macro's routes are drawn on the resource's own controller, exactly like interactive record/resource actions. There is nothing else to wire up.
19
+ - **The anchor is IDOR-safe**: an anchored (record) wizard resolves its anchor through the resource controller's scoped, policy-gated `resource_record!`. A record outside the portal's authorized scope (another tenant's, or a non-existent id) **404s**; it is never loaded via an unscoped `find_by`.
20
+ - **Bulk wizards are not supported**: wizards are inherently per-instance flows. Use a bulk interaction instead.
21
+ - **Authorization mirrors actions**: a resource policy predicate named after the wizard key gates it (`def configure? = update?`, `def onboard? = create?`).
22
22
 
23
23
  | Option | Meaning |
24
24
  |---|---|
25
25
  | `record_action:` | **(Record wizards only)** show on the record's **show page**. Default `true`; `false` removes it from there. |
26
26
  | `collection_record_action:` | **(Record wizards only)** show on each **list row** (scoped to that row's record). Default `true`; `false` keeps it on the show page but off the list. |
27
- | `label:` / `icon:` / `position:` / `category:` / `confirmation:` / `turbo_frame:` | Standard action chrome — any `action` option passes through. |
27
+ | `label:` / `icon:` / `position:` / `category:` / `confirmation:` / `turbo_frame:` | Standard action chrome, any `action` option passes through. |
28
28
 
29
- Placement isn't an option — it follows `anchored?`. Passing a flag that doesn't apply (`resource_action:` on a record wizard, or `record_action:`/`collection_record_action:` on a resource wizard) raises.
29
+ Placement isn't an option; it follows `anchored?`. Passing a flag that doesn't apply (`resource_action:` on a record wizard, or `record_action:`/`collection_record_action:` on a resource wizard) raises.
30
30
 
31
31
  ### Synthesized routes (resource-mounted)
32
32
 
@@ -42,9 +42,9 @@ GET/POST /companies/wizards/:wizard_name(/:token)/:step
42
42
 
43
43
  The synthesized launch action points at the **bare** wizard URL (no step). A `GET` there resolves the run (minting the per-run `:token` for a tokened wizard, or resolving the keyed identity) and redirects to its current step: the **resumed cursor** for an in-progress keyed run, else the **first step**, with the token already in the URL. So clicking the launch button resumes where the user left off (rather than jumping back to step 1) and never forks a fresh run on a first-step reload.
44
44
 
45
- ## Route-mounted — `register_wizard`
45
+ ## Route-mounted: `register_wizard`
46
46
 
47
- For a wizard not tied to a single resource (onboarding, welcome, set-up), register it with `register_wizard` — **inside a portal engine's routes** (most common) or **on the main application's routes**, alongside `register_resource`:
47
+ For a wizard not tied to a single resource (onboarding, welcome, set-up), register it with `register_wizard`, **inside a portal engine's routes** (most common) or **on the main application's routes**, alongside `register_resource`:
48
48
 
49
49
  ```ruby
50
50
  # packages/admin_portal/config/routes.rb
@@ -55,7 +55,7 @@ AdminPortal::Engine.routes.draw do
55
55
  register_resource ::Company
56
56
  end
57
57
 
58
- # config/routes.rb — a main-app (portal-less) wizard
58
+ # config/routes.rb: a main-app (portal-less) wizard
59
59
  Rails.application.routes.draw do
60
60
  register_wizard ::AppOnboardingWizard, at: "onboarding" # main-app default → :basic
61
61
  end
@@ -66,21 +66,21 @@ end
66
66
  | `at:` (required) | The host-relative base path for the wizard's steps. |
67
67
  | `as:` | Override the route helper name prefix (defaults to `at:`, then the wizard's name, e.g. `OnboardOrganizationWizard` → `onboarding`). |
68
68
  | `public:` | Mount on a **public (unauthenticated) route** for an [`anonymous`](#public-mount-for-anonymous-wizards) wizard. Defaults to the wizard's own `anonymous?` flag. |
69
- | `layout:` | The Rails [layout](#layout) to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Defaults by host — **portal → the resource shell**, **main-app → `:basic`**. |
69
+ | `layout:` | The Rails [layout](#layout) to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Defaults by host, **portal → the resource shell**, **main-app → `:basic`**. |
70
70
 
71
71
  This draws the wizard's step routes within the host (so a portal mount inherits the portal's scope/auth/layout) and dispatches them to a wizard controller. It provides `<name>_wizard_path` / `_url` helpers.
72
72
 
73
73
  ### Hosting & the controller override hook
74
74
 
75
- `register_wizard` resolves the controller it dispatches to **override-first**: if you've defined the conventional controller it is used as-is; otherwise one is synthesized. This is the same "app owns the controller" contract as `register_resource` — there is no hand-written file unless you want to customize.
75
+ `register_wizard` resolves the controller it dispatches to **override-first**: if you've defined the conventional controller it is used as-is; otherwise one is synthesized. This is the same "app owns the controller" contract as `register_resource`, there is no hand-written file unless you want to customize.
76
76
 
77
77
  | Host | Controller | Base / auth |
78
78
  |---|---|---|
79
79
  | **Portal** | `<Portal>::WizardsController` if defined, else synthesized | the portal's `PlutoniumController` (inherits its auth/scope/layout) |
80
- | **Main app, authenticated** | `::WizardsController` — **you define it** | yours: a base + `include Plutonium::Auth::Rodauth(:account)` |
80
+ | **Main app, authenticated** | `::WizardsController`, **you define it** | yours: a base + `include Plutonium::Auth::Rodauth(:account)` |
81
81
  | **Main app, public** (`anonymous`) | `::PublicWizardsController` (synthesized) | a bare base + `Plutonium::Auth::Public` (guest) |
82
82
 
83
- The synthesized **main-app** controller is **bare** — rooted in `ApplicationController`/`ActionController::Base`, deliberately *not* in `::PlutoniumController` (which portals inherit and may carry auth, which would leak into a guest flow). A bare controller has **no `current_user`**, so an **authenticated** main-app wizard requires you to define `::WizardsController` yourself with the auth concern:
83
+ The synthesized **main-app** controller is **bare**, rooted in `ApplicationController`/`ActionController::Base`, deliberately *not* in `::PlutoniumController` (which portals inherit and may carry auth, which would leak into a guest flow). A bare controller has **no `current_user`**, so an **authenticated** main-app wizard requires you to define `::WizardsController` yourself with the auth concern:
84
84
 
85
85
  ```ruby
86
86
  # app/controllers/wizards_controller.rb
@@ -94,17 +94,17 @@ end
94
94
 
95
95
  ### Layout
96
96
 
97
- `layout:` is the **Rails layout** the wizard renders in — a layout *name*, exactly like the controller `layout` macro. It's applied at render time, so it works regardless of which controller serves the wizard (without touching the `Page` component):
97
+ `layout:` is the **Rails layout** the wizard renders in, a layout *name*, exactly like the controller `layout` macro. It's applied at render time, so it works regardless of which controller serves the wizard (without touching the `Page` component):
98
98
 
99
99
  | `layout:` | Appearance | Layout |
100
100
  |---|---|---|
101
- | `:basic` | no sidebar/topbar — e.g. "set up your organization" | `basic` (`BasicLayout`) |
101
+ | `:basic` | no sidebar/topbar, e.g. "set up your organization" | `basic` (`BasicLayout`) |
102
102
  | `:resource` | sidebar + topbar + wizard (in-app) | the `resource` shell layout |
103
103
  | *(any app layout)* | whatever that layout renders | passed straight to Rails |
104
- | *(omitted)* | host default — see below | — |
104
+ | *(omitted)* | host default, see below | - |
105
105
 
106
106
  - **Defaults by host:** portal → the `resource` shell; main-app → `:basic` (a bare main-app host has no shell to embed in). Pass `layout:` to override.
107
- - **Turbo-frame requests are always layout-less** regardless of the setting — that's the embedded/modal path (how resource-anchored `wizard`-macro launches render).
107
+ - **Turbo-frame requests are always layout-less** regardless of the setting; that's the embedded/modal path (how resource-anchored `wizard`-macro launches render).
108
108
  - `layout:` is a **`register_wizard` option only**; it travels with the mount, not the wizard class. Resource-defined wizards take no `layout:` (always embedded).
109
109
 
110
110
  ### Synthesized routes
@@ -123,7 +123,7 @@ The POST `_direction` param carries `next` / `back` / `cancel`. **Where Cancel s
123
123
 
124
124
  ## Entry authorization
125
125
 
126
- A portal-level wizard has no resource policy, so gate entry with an `authorize?` instance method on the wizard — checked before each request:
126
+ A `register_wizard` wizard (portal or main-app) has no resource policy, so gate entry with an `authorize?` instance method on the wizard, checked before each request:
127
127
 
128
128
  ```ruby
129
129
  class WelcomeWizard < Plutonium::Wizard::Base
@@ -135,12 +135,12 @@ end
135
135
 
136
136
  A falsy return → `ActionPolicy::Unauthorized` (403). Resource-attached wizards instead use their action's policy predicate (`def onboard?` etc.).
137
137
 
138
- ::: danger A portal-level wizard with no `authorize?` is open to ANY authenticated portal user
139
- A portal-level wizard (registered with `register_wizard`) has **no resource policy** and **defaults to allowed** — so if you omit `authorize?`, every authenticated user of that portal can run it. **Always define `def authorize?`** for anything privileged (admin-only flows, per-user gating, tenant checks). The default-allow is only safe for flows that are genuinely fine for any signed-in portal user (e.g. self-service onboarding).
138
+ ::: danger A `register_wizard` wizard with no `authorize?` is open to ANY authenticated user
139
+ A wizard registered with `register_wizard` (in a portal or on the main app) has **no resource policy**, and the base class defines `def authorize? = true`. If you omit it, every authenticated user of that portal or app can run it. **Define `def authorize?` on every `register_wizard` wizard.** A gated `one_time` wizard is no exception: the gate forces users *into* the wizard but doesn't decide who may run it. For a flow that is genuinely fine for any signed-in user (e.g. self-service onboarding), say so with `def authorize? = true`.
140
140
  :::
141
141
 
142
142
  ::: warning As-built: `authorize?` is an instance method
143
- Define `def authorize?` on the wizard. The controller checks it only when the wizard responds to it (default: allowed).
143
+ Define `def authorize?` on the wizard. The controller checks it before every request; the inherited default returns `true`.
144
144
  :::
145
145
 
146
146
  ## Public mount for `anonymous` wizards
@@ -154,12 +154,12 @@ register_wizard ::GuestSignupWizard, at: "signup", public: true
154
154
  ```
155
155
 
156
156
  - `public: true` is the **default for an `anonymous` wizard**; you can pass it explicitly for clarity.
157
- - A **non-`anonymous`** wizard may **not** be mounted public (raises) — and an **`anonymous`** wizard may **not** be mounted authenticated (it would be unreachable pre-login).
157
+ - A **non-`anonymous`** wizard may **not** be mounted public (raises), and an **`anonymous`** wizard may **not** be mounted authenticated (it would be unreachable pre-login).
158
158
  - The public route dispatches to a synthesized top-level `::PublicWizardsController` (a **distinct** const from an authenticated main-app `::WizardsController`, so the two never collapse onto each other) that renders **full-page** with a standalone layout (no resource sidebar / user menu) and treats the request as a guest via `Plutonium::Auth::Public`.
159
159
 
160
160
  ## One-time gating
161
161
 
162
- To make a wizard run once and gate a controller behind it, see [One-time wizards](/reference/wizard/one-time) — a `concurrency_key` + `one_time` + the `Plutonium::Wizard::Gate` concern (`ensure_wizard_completed`).
162
+ To make a wizard run once and gate a controller behind it, see [One-time wizards](/reference/wizard/one-time), a `concurrency_key` + `one_time` + the `Plutonium::Wizard::Gate` concern (`ensure_wizard_completed`).
163
163
 
164
164
  For a **one-time** wizard, the launch action this macro synthesizes also **hides itself once the current user has completed it** (via a render-time action `condition:`); a custom `condition:` composes with that check. See [The launch action hides itself once completed](/reference/wizard/one-time#the-launch-action-hides-itself-once-completed).
165
165
 
@@ -167,11 +167,11 @@ For a **one-time** wizard, the launch action this macro synthesizes also **hides
167
167
 
168
168
  - **`with:`-anchored wizards mount on the resource, not route-level.** Register a `with:`-anchored wizard on the anchored resource's definition with the `wizard` macro (it auto-mounts a member action whose anchor is the scoped `resource_record!`). Passing a `with:`-anchored wizard to **`register_wizard`** raises: a route-level mount has no resource record to anchor to. A **`via:`-anchored** (context-anchored) wizard *does* mount route-level; its anchor is resolved by a controller method, not a URL `:id`.
169
169
  - **An authenticated main-app wizard needs an app-defined controller.** The synthesized main-app controller is bare (no `current_user`); supply your own `::WizardsController` with an auth concern (see [Hosting & the controller override hook](#hosting-the-controller-override-hook)). Portal mounts and `anonymous` public mounts need nothing.
170
- - **Route-helper names must be unique across public mounts.** A public (`anonymous`) wizard's route is drawn on the main app, keyed by its helper name (`as:` → `at:` → class-derived). Two distinct public wizards resolving to the **same** helper name **raise** at draw time — give one an explicit `as:`. (Re-drawing the *same* wizard on a route reload is a no-op, not a collision.)
170
+ - **Route-helper names must be unique across public mounts.** A public (`anonymous`) wizard's route is drawn on the main app, keyed by its helper name (`as:` → `at:` → class-derived). Two distinct public wizards resolving to the **same** helper name **raise** at draw time, give one an explicit `as:`. (Re-drawing the *same* wizard on a route reload is a no-op, not a collision.)
171
171
 
172
172
  ## Related
173
173
 
174
- - [DSL reference](/reference/wizard/dsl) — `authorize?`, the wizard body.
175
- - [Anchoring & resume](/reference/wizard/anchoring-resume) — anchors, instance keys.
176
- - [One-time wizards](/reference/wizard/one-time) — completion + gating.
177
- - [Custom actions guide](/guides/custom-actions) — the Action system the `wizard` macro builds on.
174
+ - [DSL reference](/reference/wizard/dsl): `authorize?`, the wizard body.
175
+ - [Anchoring & resume](/reference/wizard/anchoring-resume): anchors, instance keys.
176
+ - [One-time wizards](/reference/wizard/one-time): completion + gating.
177
+ - [Custom actions guide](/guides/custom-actions): the Action system the `wizard` macro builds on.
@@ -23,25 +23,25 @@ rails db:migrate
23
23
 
24
24
  | Config | Default | Meaning |
25
25
  |---|---|---|
26
- | `config.wizards.enabled` | `false` | The subsystem's master switch. Registers the gem migration (so `rails db:migrate` creates the table) **and** draws wizard routes — both `register_wizard` and the resource-mounted `wizard`-macro actions. While `false`, `register_wizard` is a no-op (it logs a warning so a registered-but-disabled wizard isn't a silent 404) and no wizard routes are mounted. Required to use wizards. |
26
+ | `config.wizards.enabled` | `false` | The subsystem's master switch. Registers the gem migration (so `rails db:migrate` creates the table) **and** draws wizard routes, both `register_wizard` and the resource-mounted `wizard`-macro actions. While `false`, `register_wizard` is a no-op (it logs a warning so a registered-but-disabled wizard isn't a silent 404) and no wizard routes are mounted. Required to use wizards. |
27
27
  | `config.wizards.cleanup_after` | `14.days` | Global default idle TTL for the abandonment sweep; overridable per wizard via `cleanup_after`. |
28
- | `config.wizards.database` | `:primary` | Which database connection the wizard table lives on. **v1 supports the primary database only** — see below. |
28
+ | `config.wizards.database` | `:primary` | Which database connection the wizard table lives on. **v1 supports the primary database only**, see below. |
29
29
  | `config.wizards.encrypt_data` | `false` | Encrypt **every** wizard's staged `data` at rest by default. Off by default because it needs ActiveRecord encryption keys; a wizard still overrides it individually with `encrypt_data` / `encrypt_data false`. See [Encryption](#encryption). |
30
- | `config.wizards.attachment_backend` | `nil` | Storage backend for **server-side** [attachment](/reference/wizard/dsl#attachment-fields) staging (a plain `as: :file` field). `nil` auto-detects — active_shrine installed → `:shrine`, else `:active_storage`. Override per field with `input …, backend:`. Direct-upload fields ignore it (they arrive as a token). |
30
+ | `config.wizards.attachment_backend` | `nil` | Storage backend for **server-side** [attachment](/reference/wizard/dsl#attachment-fields) staging (a plain `as: :file` field). `nil` auto-detects, active_shrine installed → `:shrine`, else `:active_storage`. Override per field with `input …, backend:`. Direct-upload fields ignore it (they arrive as a token). |
31
31
 
32
32
  ## Gem-shipped migration
33
33
 
34
- The migration ships **in the gem** and Rails runs it **in place** — there is no copy-into-your-app step (unlike `pu:rodauth`/`pu:invites`, which are app-customized templates). Enabling `config.wizards.enabled` registers the gem migration path; `rails db:migrate` then runs it.
34
+ The migration ships **in the gem** and Rails runs it **in place**; there is no copy-into-your-app step (unlike `pu:rodauth`/`pu:invites`, which are app-customized templates). Enabling `config.wizards.enabled` registers the gem migration path; `rails db:migrate` then runs it.
35
35
 
36
36
  - Once run, the table is dumped into your `schema.rb` / `structure.sql` like any other, so `db:schema:load` on fresh/CI databases recreates it normally.
37
37
  - Disable later → the path isn't registered; the existing table is left alone (never auto-dropped).
38
- - `db:migrate:status` shows the migration's file living in the gem (cosmetic; reads "file missing" if the gem is later removed) — standard for gem-shipped migrations.
38
+ - `db:migrate:status` shows the migration's file living in the gem (cosmetic; reads "file missing" if the gem is later removed), standard for gem-shipped migrations.
39
39
 
40
40
  ::: warning v1 supports the primary database only
41
- The wizard table lives on your app's **primary** database in v1. `config.wizards.database` is **reserved for future use** — multi-database routing for wizard sessions is a roadmap follow-up. Setting it to anything other than `:primary` (while wizards are enabled) **raises at boot**, rather than silently registering the migration on the primary database.
41
+ The wizard table lives on your app's **primary** database in v1. `config.wizards.database` is **reserved for future use**, multi-database routing for wizard sessions is a roadmap follow-up. Setting it to anything other than `:primary` (while wizards are enabled) **raises at boot**, rather than silently registering the migration on the primary database.
42
42
  :::
43
43
 
44
- ## The table — `plutonium_wizard_sessions`
44
+ ## The table: `plutonium_wizard_sessions`
45
45
 
46
46
  One framework-owned table serves everything; **no changes to your models.**
47
47
 
@@ -51,7 +51,7 @@ One framework-owned table serves everything; **no changes to your models.**
51
51
  | `status` | `in_progress` \| `completing` \| `completed` (see the note below). |
52
52
  | `current_step` | The step cursor. |
53
53
  | `instance_key` (unique) | The deterministic identity digest (see [Anchoring & resume](/reference/wizard/anchoring-resume#instance-identity)). |
54
- | `owner_type` / `owner_id` | The user (nullable — `null` for an `anonymous`/guest run). Authenticated lookups are owner-scoped against this. |
54
+ | `owner_type` / `owner_id` | The user (nullable, `null` for an `anonymous`/guest run). Authenticated lookups are owner-scoped against this. |
55
55
  | `anchor_type` / `anchor_id` | The anchor record (nullable). |
56
56
  | `scope_type` / `scope_id` | The portal scoping entity / tenant (nullable). |
57
57
  | `engine` | The portal (engine class name) the run was launched in, e.g. `"OrgPortal::Engine"`. The "continue where you left off" listing only shows (and links) runs whose `engine` matches the portal being viewed (two portals can share an entity scope, so `scope` alone can't identify the portal). |
@@ -64,14 +64,14 @@ One framework-owned table serves everything; **no changes to your models.**
64
64
 
65
65
  What the single table powers:
66
66
 
67
- - **Resume** — look up the `in_progress` row by `instance_key`.
68
- - **One-time check** — does a `completed` row exist for `(wizard, owner)` or `(wizard, anchor)`.
69
- - **In-progress listing** — by owner, portal (`engine`), and tenant scope, so a run is only ever listed by the portal it was launched in.
70
- - **Multi-tenancy** — the portal scoping entity is folded into `instance_key` and stored as `scope_*`, so the same user's same non-anchored wizard doesn't collide across tenants.
71
- - **Sweep** — idle `in_progress`/`completing` rows past `expires_at`.
67
+ - **Resume**: look up the `in_progress` row by `instance_key`.
68
+ - **One-time check**: does a `completed` row exist for `(wizard, owner)` or `(wizard, anchor)`.
69
+ - **In-progress listing**: by owner, portal (`engine`), and tenant scope, so a run is only ever listed by the portal it was launched in.
70
+ - **Multi-tenancy**: the portal scoping entity is folded into `instance_key` and stored as `scope_*`, so the same user's same non-anchored wizard doesn't collide across tenants.
71
+ - **Sweep**: idle `in_progress`/`completing` rows past `expires_at`.
72
72
 
73
73
  ::: tip The `persisted` / `tracked_records` naming
74
- The column is `tracked_records`, not `persisted` — an AR attribute named `persisted` collides with `ActiveRecord::Persistence#persisted?`. The author-facing accessor stays `persisted[:key]`; the store maps it to the column.
74
+ The column is `tracked_records`, not `persisted`: an AR attribute named `persisted` collides with `ActiveRecord::Persistence#persisted?`. The author-facing accessor stays `persisted[:key]`; the store maps it to the column.
75
75
  :::
76
76
 
77
77
  ## Encryption
@@ -85,7 +85,7 @@ class CheckoutWizard < Plutonium::Wizard::Base
85
85
  end
86
86
  ```
87
87
 
88
- This encrypts the `data` column (the staged step values) — off by default. The `tracked_records` column (record GlobalIDs only) and the queried `owner`/`anchor`/`scope`/`token` columns stay plaintext.
88
+ This encrypts the `data` column (the staged step values), off by default. The `tracked_records` column (record GlobalIDs only) and the queried `owner`/`anchor`/`scope`/`token` columns stay plaintext.
89
89
 
90
90
  **Encrypt everything by default.** Once your app has ActiveRecord encryption keys, you can flip encryption on for *all* wizards with one global flag, then override per wizard:
91
91
 
@@ -99,7 +99,7 @@ class PublicSurveyWizard < Plutonium::Wizard::Base
99
99
  end
100
100
  ```
101
101
 
102
- Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard always wins; a wizard that declares neither inherits `config.wizards.encrypt_data` (off unless you set it). It stays opt-in globally because it requires keys — see the warning below.
102
+ Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard always wins; a wizard that declares neither inherits `config.wizards.encrypt_data` (off unless you set it). It stays opt-in globally because it requires keys, see the warning below.
103
103
 
104
104
  **How it works.** Because `data` is one shared `jsonb` column across all wizards (some opting in, some not), a static model-level `encrypts :data` doesn't fit (it would encrypt every row, and fights the `jsonb` type). Instead, the store encrypts at write time using **ActiveRecord's configured encryptor** (`ActiveRecord::Encryption.encryptor`, the same keys as `encrypts`) and stores a self-describing envelope inside the column:
105
105
 
@@ -107,17 +107,17 @@ Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard alwa
107
107
  { "_enc": "<ciphertext>" }
108
108
  ```
109
109
 
110
- A row therefore decrypts based on its **own shape**, independent of the wizard's current `encrypt_data?` — so toggling the flag never strands existing runs.
110
+ A row therefore decrypts based on its **own shape**, independent of the wizard's current `encrypt_data?`, so toggling the flag never strands existing runs.
111
111
 
112
112
  ::: warning Requires ActiveRecord encryption keys
113
- `encrypt_data` reuses your app's ActiveRecord encryption keys (`active_record.encryption.primary_key` / `deterministic_key` / `key_derivation_salt`, typically via credentials). If a wizard declares `encrypt_data` but no keys are configured, the **first write raises** a `Configuration` error naming the wizard — rather than ActiveRecord's later, context-free failure. Set the keys (`bin/rails db:encryption:init`) before enabling it.
113
+ `encrypt_data` reuses your app's ActiveRecord encryption keys (`active_record.encryption.primary_key` / `deterministic_key` / `key_derivation_salt`, typically via credentials). If a wizard declares `encrypt_data` but no keys are configured, the **first write raises** a `Configuration` error naming the wizard, rather than ActiveRecord's later, context-free failure. Set the keys (`bin/rails db:encryption:init`) before enabling it.
114
114
  :::
115
115
 
116
116
  ## Files
117
117
 
118
118
  A file can't sit in the JSON `data` column, so a wizard stages only the backend's **upload token** (an ActiveStorage `signed_id`, or Shrine cached-file data) and `execute` assigns it to the model's attachment. This works for both server-side and direct uploads, ActiveStorage and active_shrine. See [DSL › Attachment fields](/reference/wizard/dsl#attachment-fields) and the [guide](/guides/wizards#file-uploads-attachments) for the full surface (`backend:`, `multiple:`, direct upload).
119
119
 
120
- A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Each storage backend's own unattached-cache cleanup reaps it — the wizard `SweepJob` does not** (it only tracks records registered via `persist`). Ensure that backend cleanup runs.
120
+ A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Each storage backend's own unattached-cache cleanup reaps it, the wizard `SweepJob` does not** (it only tracks records registered via `persist`). Ensure that backend cleanup runs.
121
121
 
122
122
  ## Cleanup & the SweepJob
123
123
 
@@ -126,7 +126,7 @@ A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Eac
126
126
  `Plutonium::Wizard::SweepJob` reaps idle `in_progress` / `completing` rows past `expires_at`: for each it runs the wizard's cleanup (each step's `on_rollback` if declared, then always destroy every tracked record, in reverse order) and deletes the row. Completed rows are never touched. The job is idempotent and safe to re-run.
127
127
 
128
128
  ::: tip The `completing` state and its grace window
129
- A healthy finalize flips the row to `completing` and runs `execute` **outside** the completion lock (so a long `execute` doesn't block other requests), without bumping `expires_at`. To avoid sweeping a finalize that's still running, the sweep only reaps a `completing` row once it's been idle for a 15-minute grace window — long enough that a still-`completing` row past it is a *crashed* finalize, not a slow one. Keep individual `execute`s well under 15 minutes (offload long work to a job).
129
+ A healthy finalize flips the row to `completing` and runs `execute` **outside** the completion lock (so a long `execute` doesn't block other requests), without bumping `expires_at`. To avoid sweeping a finalize that's still running, the sweep only reaps a `completing` row once it's been idle for a 15-minute grace window, long enough that a still-`completing` row past it is a *crashed* finalize, not a slow one. Keep individual `execute`s well under 15 minutes (offload long work to a job).
130
130
  :::
131
131
 
132
132
  ### SweepJob is load-bearing for save-as-you-go
@@ -147,6 +147,6 @@ On completion of a one-time wizard, the row is kept as the durable marker but it
147
147
 
148
148
  ## Related
149
149
 
150
- - [Anchoring & resume](/reference/wizard/anchoring-resume) — `instance_key`, resume.
151
- - [DSL reference](/reference/wizard/dsl) — `cleanup_after`, `encrypt_data`, `persist`.
152
- - [One-time wizards](/reference/wizard/one-time) — durable completion markers.
150
+ - [Anchoring & resume](/reference/wizard/anchoring-resume): `instance_key`, resume.
151
+ - [DSL reference](/reference/wizard/dsl): `cleanup_after`, `encrypt_data`, `persist`.
152
+ - [One-time wizards](/reference/wizard/one-time): durable completion markers.
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.64.0)
4
+ plutonium (0.65.0)
5
5
  action_policy (~> 0.7.0)
6
6
  csv
7
7
  listen (~> 3.8)
@@ -65,6 +65,12 @@ module Pu
65
65
  user.#{profile_association}.nil?
66
66
  end
67
67
 
68
+ # The base update? delegates to create?, which is false once the profile
69
+ # exists. relation_scope already limits this to the user's own profile.
70
+ def update?
71
+ true
72
+ end
73
+
68
74
  def destroy?
69
75
  false
70
76
  end
@@ -6,6 +6,12 @@ module Plutonium
6
6
  module AssociatedWith
7
7
  extend ActiveSupport::Concern
8
8
 
9
+ # Raised when more than one association links the two classes and no
10
+ # `associated_with_<record>` scope says which one to use. Picking the
11
+ # first would scope by declaration order, so a reordered model could
12
+ # silently widen what a tenant sees.
13
+ class AmbiguousAssociationError < StandardError; end
14
+
9
15
  included do
10
16
  scope :associated_with, ->(record) do
11
17
  # If scoping to same class, just match by ID (e.g., Team scoped to Team)
@@ -45,21 +51,27 @@ module Plutonium
45
51
 
46
52
  class_methods do
47
53
  def find_association_from_self_to_record(record)
48
- reflect_on_all_associations.find do |assoc|
54
+ matches = reflect_on_all_associations.select do |assoc|
49
55
  assoc.klass.name == record.class.name unless assoc.polymorphic?
50
56
  rescue
51
57
  assoc.check_validity!
52
58
  raise
53
59
  end
60
+ raise_ambiguous_association_error(self, record.class, matches, record) if matches.size > 1
61
+
62
+ matches.first
54
63
  end
55
64
 
56
65
  def find_association_to_self_from_record(record)
57
- record.class.reflect_on_all_associations.find do |assoc|
66
+ matches = record.class.reflect_on_all_associations.select do |assoc|
58
67
  assoc.klass.name == name
59
68
  rescue
60
69
  assoc.check_validity!
61
70
  raise
62
71
  end
72
+ raise_ambiguous_association_error(record.class, self, matches, record) if matches.size > 1
73
+
74
+ matches.first
63
75
  end
64
76
 
65
77
  def query_based_on_association(assoc, record)
@@ -75,6 +87,15 @@ module Plutonium
75
87
  end
76
88
  end
77
89
 
90
+ def raise_ambiguous_association_error(from, to, matches, record)
91
+ named_scope = :"associated_with_#{record.model_name.singular}"
92
+ raise AmbiguousAssociationError,
93
+ "#{from.name} has multiple associations to #{to.name}: #{matches.map(&:name).join(", ")}. " \
94
+ "Plutonium cannot tell which one scopes #{name} to a #{record.class.name}.\n\n" \
95
+ "Define a named scope on #{name} e.g.\n\n" \
96
+ "scope :#{named_scope}, ->(#{record.model_name.singular}) { do_something_here }"
97
+ end
98
+
78
99
  def raise_unresolvable_association_error(record, named_scope)
79
100
  raise "Could not resolve the association between '#{name}' and '#{record.class.name}'\n\n" \
80
101
  "Define\n" \
@@ -64,6 +64,11 @@ module Plutonium
64
64
  return nil unless name
65
65
 
66
66
  route_key = resource_class.model_name.route_key
67
+ # Path-scoped portals prefix every route name with the entity
68
+ # param key (`organization_scoped_`) and take the entity as the
69
+ # first positional arg, the same as Controller#resource_url_for.
70
+ path_scoped = current_engine.scoped_to_entity? && current_engine.scoped_entity_strategy == :path
71
+ route_key = "#{current_engine.scoped_entity_param_key}_#{route_key}" if path_scoped
67
72
  helper = (kind == :filter) ? :"typeahead_filter_#{route_key}_path" : :"typeahead_input_#{route_key}_path"
68
73
 
69
74
  # Engine route helpers are the source of truth for routes
@@ -74,7 +79,8 @@ module Plutonium
74
79
  # uses its eager list.
75
80
  url_helpers = current_engine.routes.url_helpers
76
81
  return nil unless url_helpers.respond_to?(helper)
77
- url_helpers.public_send(helper, name: name)
82
+ helper_args = path_scoped ? [view_context.current_scoped_entity.to_param] : []
83
+ url_helpers.public_send(helper, *helper_args, name: name)
78
84
  end
79
85
  end
80
86
  end
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.65.0"
2
+ VERSION = "0.66.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.65.0",
3
+ "version": "0.66.0",
4
4
  "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
@@ -971,7 +971,7 @@ html.pu-rail-pinned .icon-rail-pin-expand {
971
971
  @apply flex items-center p-4 mb-4 rounded-lg;
972
972
  }
973
973
  .dark .pu-alert {
974
- @apply bg-stone-300;
974
+ @apply bg-gray-800;
975
975
  }
976
976
 
977
977
  .pu-alert-message {
@@ -982,7 +982,7 @@ html.pu-rail-pinned .icon-rail-pin-expand {
982
982
  @apply ms-auto -mx-1.5 -my-1.5 rounded-lg focus:ring-2 p-1.5 inline-flex items-center justify-center h-8 w-8;
983
983
  }
984
984
  .dark .pu-alert-close {
985
- @apply bg-stone-300 hover:bg-gray-700;
985
+ @apply bg-gray-800 hover:bg-gray-700;
986
986
  }
987
987
 
988
988
  /* --- Variants: success / warning / danger / info ---
@@ -995,11 +995,11 @@ html.pu-rail-pinned .icon-rail-pin-expand {
995
995
  .pu-toast-success .pu-toast-icon { @apply text-success-500 bg-success-100; }
996
996
  .dark .pu-toast-success .pu-toast-icon { @apply bg-success-800 text-success-200; }
997
997
  .pu-toast-success .pu-toast-close { @apply bg-success-50 text-success-400 hover:bg-success-200 focus:ring-success-400; }
998
- .dark .pu-toast-success .pu-toast-close { @apply text-success-400; }
998
+ .dark .pu-toast-success .pu-toast-close { @apply bg-gray-800 text-success-400 hover:bg-gray-700; }
999
999
  .pu-alert-success { @apply bg-success-50 text-success-800; }
1000
1000
  .dark .pu-alert-success { @apply text-success-400; }
1001
1001
  .pu-alert-success .pu-alert-close { @apply bg-success-50 text-success-500 hover:bg-success-200 focus:ring-success-400; }
1002
- .dark .pu-alert-success .pu-alert-close { @apply text-success-400; }
1002
+ .dark .pu-alert-success .pu-alert-close { @apply bg-gray-800 text-success-400 hover:bg-gray-700; }
1003
1003
 
1004
1004
  /* warning */
1005
1005
  .pu-toast-warning { @apply bg-warning-50; }
@@ -1007,11 +1007,11 @@ html.pu-rail-pinned .icon-rail-pin-expand {
1007
1007
  .pu-toast-warning .pu-toast-icon { @apply text-warning-500 bg-warning-100; }
1008
1008
  .dark .pu-toast-warning .pu-toast-icon { @apply bg-warning-800 text-warning-200; }
1009
1009
  .pu-toast-warning .pu-toast-close { @apply bg-warning-50 text-warning-400 hover:bg-warning-200 focus:ring-warning-400; }
1010
- .dark .pu-toast-warning .pu-toast-close { @apply text-warning-400; }
1010
+ .dark .pu-toast-warning .pu-toast-close { @apply bg-gray-800 text-warning-400 hover:bg-gray-700; }
1011
1011
  .pu-alert-warning { @apply bg-warning-50 text-warning-800; }
1012
1012
  .dark .pu-alert-warning { @apply text-warning-400; }
1013
1013
  .pu-alert-warning .pu-alert-close { @apply bg-warning-50 text-warning-500 hover:bg-warning-200 focus:ring-warning-400; }
1014
- .dark .pu-alert-warning .pu-alert-close { @apply text-warning-400; }
1014
+ .dark .pu-alert-warning .pu-alert-close { @apply bg-gray-800 text-warning-400 hover:bg-gray-700; }
1015
1015
 
1016
1016
  /* danger */
1017
1017
  .pu-toast-danger { @apply bg-danger-50; }
@@ -1019,11 +1019,11 @@ html.pu-rail-pinned .icon-rail-pin-expand {
1019
1019
  .pu-toast-danger .pu-toast-icon { @apply text-danger-500 bg-danger-100; }
1020
1020
  .dark .pu-toast-danger .pu-toast-icon { @apply bg-danger-800 text-danger-200; }
1021
1021
  .pu-toast-danger .pu-toast-close { @apply bg-danger-50 text-danger-400 hover:bg-danger-200 focus:ring-danger-400; }
1022
- .dark .pu-toast-danger .pu-toast-close { @apply text-danger-400; }
1022
+ .dark .pu-toast-danger .pu-toast-close { @apply bg-gray-800 text-danger-400 hover:bg-gray-700; }
1023
1023
  .pu-alert-danger { @apply bg-danger-50 text-danger-800; }
1024
1024
  .dark .pu-alert-danger { @apply text-danger-400; }
1025
1025
  .pu-alert-danger .pu-alert-close { @apply bg-danger-50 text-danger-500 hover:bg-danger-200 focus:ring-danger-400; }
1026
- .dark .pu-alert-danger .pu-alert-close { @apply text-danger-400; }
1026
+ .dark .pu-alert-danger .pu-alert-close { @apply bg-gray-800 text-danger-400 hover:bg-gray-700; }
1027
1027
 
1028
1028
  /* info (default / :notice) */
1029
1029
  .pu-toast-info { @apply bg-info-50; }
@@ -1031,11 +1031,11 @@ html.pu-rail-pinned .icon-rail-pin-expand {
1031
1031
  .pu-toast-info .pu-toast-icon { @apply text-info-500 bg-info-100; }
1032
1032
  .dark .pu-toast-info .pu-toast-icon { @apply bg-info-800 text-info-200; }
1033
1033
  .pu-toast-info .pu-toast-close { @apply bg-info-50 text-info-400 hover:bg-info-200 focus:ring-info-400; }
1034
- .dark .pu-toast-info .pu-toast-close { @apply text-info-400; }
1034
+ .dark .pu-toast-info .pu-toast-close { @apply bg-gray-800 text-info-400 hover:bg-gray-700; }
1035
1035
  .pu-alert-info { @apply bg-info-50 text-info-800; }
1036
1036
  .dark .pu-alert-info { @apply text-info-400; }
1037
1037
  .pu-alert-info .pu-alert-close { @apply bg-info-50 text-info-500 hover:bg-info-200 focus:ring-info-400; }
1038
- .dark .pu-alert-info .pu-alert-close { @apply text-info-400; }
1038
+ .dark .pu-alert-info .pu-alert-close { @apply bg-gray-800 text-info-400 hover:bg-gray-700; }
1039
1039
 
1040
1040
  /* ===========================================================================
1041
1041
  Wizard — horizontal "steps" stepper (numbered nodes on a connector track)
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plutonium
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.65.0
4
+ version: 0.66.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-02 00:00:00.000000000 Z
10
+ date: 2026-10-08 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: zeitwerk