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,62 +1,63 @@
1
1
  ---
2
2
  name: plutonium-wizard
3
- description: Use BEFORE building any multi-step Plutonium flow — onboarding, checkout, multi-model create, branching questionnaire. Covers the wizard DSL (steps, branching, using:, review, attachment/file-upload fields, per-step on_submit/persist/rollback, execute), anchoring & resume, one-time wizards + gate, registration (wizard macro + register_wizard), guest/anonymous flows, at-rest data encryption, and storage/config. The single source for "how do I build a wizard".
3
+ description: 'Use BEFORE building any multi-step Plutonium flow: onboarding, checkout, multi-model create, branching questionnaire. Covers the wizard DSL (steps, branching, using:, review, attachment/file-upload fields, per-step on_submit/persist/rollback, execute), anchoring & resume, one-time wizards + gate, registration (wizard macro + register_wizard), guest/anonymous flows, at-rest data encryption, and storage/config. The single source for "how do I build a wizard".'
4
4
  ---
5
5
 
6
6
  # Plutonium Wizards
7
7
 
8
- A wizard is a multi-step flow authored as a single class — `class X < Plutonium::Wizard::Base`. It collects typed `data` across ordered `step`s, optionally branches with `condition:`, and commits at the end via `execute`. It reuses the existing field DSL, form rendering, actions, and policies — no parallel stack.
8
+ A wizard is a multi-step flow authored as a single class: `class X < Plutonium::Wizard::Base`. It collects typed `data` across ordered `step`s, optionally branches with `condition:`, and commits at the end via `execute`. It reuses the existing field DSL, form rendering, actions, and policies, no parallel stack.
9
9
 
10
10
  For the field/input vocabulary used inside a step, load [[plutonium-resource]]. For the Outcome / `succeed`/`failed` pattern and the Action system wizards register through, load [[plutonium-behavior]].
11
11
 
12
12
  ## 🚨 Critical (read first)
13
13
 
14
- - **Enable the subsystem first.** `rails g pu:wizards:install` flips `config.wizards.enabled = true` and schedules `SweepJob`; then `rails db:migrate`. (By hand: the flag in `config/initializers/plutonium.rb`.) It's `false` by default — without it there's no `plutonium_wizard_sessions` table.
15
- - **Use bang methods** (`create!`/`update!`/`save!`) in `on_submit` and `execute`. Failure is signalled by a **raised exception** — a non-bang `false` advances the wizard and silently loses data. Or call `fail!("msg")`.
14
+ - **Enable the subsystem first.** `rails g pu:wizards:install` flips `config.wizards.enabled = true` and schedules `SweepJob`; then `rails db:migrate`. (By hand: the flag in `config/initializers/plutonium.rb`.) It's `false` by default; without it there's no `plutonium_wizard_sessions` table.
15
+ - **Use bang methods** (`create!`/`update!`/`save!`) in `on_submit` and `execute`. Failure is signalled by a **raised exception**: a non-bang `false` advances the wizard and silently loses data. Or call `fail!("msg")`.
16
16
  - **`data` is step-keyed:** `data.<step>.<field>` (e.g. `data.company.name`, `data.plan.plan`). Each step has its own typed sub-object, so two steps may share a field name without colliding. Read a field through its owning step everywhere (`condition:`/`on_submit`/`execute`).
17
17
  - **`condition:` lambdas must be nil-safe.** They run against `data` at every transition, including before their deciding step is filled (value is `nil`). `-> { data.plan.plan == "pro" }` ✓; `-> { data.plan.plan.upcase == "PRO" }` raises on nil ✗.
18
18
  - **`review` must be the LAST step.** A step declared after `review` raises at load.
19
- - **`using:` targets a MODEL only** — not an interaction, not a bare definition. Selectors `fields:`/`only:`/`except:`.
19
+ - **`using:` targets a MODEL only**, not an interaction, not a bare definition. Selectors `fields:`/`only:`/`except:`.
20
20
  - **No generator.** Author wizards by hand, like interactions. They live in `app/wizards/`.
21
- - **A wizard is a presentation object** — it's built with `view_context:`, so anything reachable only through `execute`/`on_submit` is reachable only from a wizard run. Logic may *start* there; the **second caller** (a job, an API, a rake task, the console) is the trigger to move it onto the **model**. See [[plutonium-behavior]] › Part 3 › Where the logic goes.
22
- - **Wizards are portal- *or* main-app-hosted.** A `register_wizard` mount inside a portal inherits the portal's auth/scoping/layout. A `register_wizard` mount on the **main app** runs standalone — for an **authenticated** main-app wizard you MUST define your own `::WizardsController` (include `Plutonium::Wizard::Controller` + your auth concern); the synthesized fallback is **bare (no auth)**. Resource-anchored (`wizard` macro) wizards always run embedded on the resource controller.
23
- - **Schedule `SweepJob`** — `pu:wizards:install` does it for you when Solid Queue is in the bundle; otherwise add a periodic job/cron yourself. It reaps abandoned/expired sessions — always good hygiene (stale `in_progress` rows pile up otherwise), and **load-bearing** for `on_submit`/`persist` wizards: it's the only thing that rolls back the partial domain records an abandoned save-as-you-go run leaves behind.
21
+ - **A wizard is a presentation object**: it's built with `view_context:`, so anything reachable only through `execute`/`on_submit` is reachable only from a wizard run. Logic may *start* there; the **second caller** (a job, an API, a rake task, the console) is the trigger to move it onto the **model**. See [[plutonium-behavior]] › Part 3 › Where the logic goes.
22
+ - **Wizards are portal- *or* main-app-hosted.** A `register_wizard` mount inside a portal inherits the portal's auth/scoping/layout. A `register_wizard` mount on the **main app** runs standalone; for an **authenticated** main-app wizard you MUST define your own `::WizardsController` (include `Plutonium::Wizard::Controller` + your auth concern); the synthesized fallback is **bare (no auth)**. Resource-anchored (`wizard` macro) wizards always run embedded on the resource controller.
23
+ - **Define `def authorize?` on every `register_wizard` wizard** (portal or main-app). Its default is `def authorize? = true`, and a route mount has no resource policy behind it, so without the override any authenticated user of that host can launch it. A gated `one_time` wizard is no exception: the gate forces users *into* the wizard but doesn't decide who may run it. `false` → 403. Resource (`wizard` macro) mounts are also gated by the action policy.
24
+ - **Schedule `SweepJob`**: `pu:wizards:install` does it for you when Solid Queue is in the bundle; otherwise add a periodic job/cron yourself. It reaps abandoned/expired sessions, always good hygiene (stale `in_progress` rows pile up otherwise), and **load-bearing** for `on_submit`/`persist` wizards: it's the only thing that rolls back the partial domain records an abandoned save-as-you-go run leaves behind.
24
25
 
25
26
  ---
26
27
 
27
- ## 🛑 Before you author: confirm the configuration (ASK — don't infer)
28
+ ## 🛑 Before you author: confirm the configuration (ASK: don't infer)
28
29
 
29
- Wizard configuration is dense and the dimensions **interact** — guess wrong about mounting, anchoring, or run identity and you get a wizard that compiles but misbehaves: it forks a new run on every visit, 404s on resume, leaks across tenants, or can't be gated. A one-line request ("a checkout wizard", "onboarding") does **not** determine these.
30
+ Wizard configuration is dense and the dimensions **interact**: guess wrong about mounting, anchoring, or run identity and you get a wizard that compiles but misbehaves: it forks a new run on every visit, 404s on resume, leaks across tenants, or can't be gated. A one-line request ("a checkout wizard", "onboarding") does **not** determine these.
30
31
 
31
- **STOP and ask the user — use `AskUserQuestion` — before writing the class.** Resolve each decision below (skip one only when the user already stated it), then restate the resolved shape in a sentence and confirm:
32
+ **STOP and ask the user (use `AskUserQuestion`) before writing the class.** Resolve each decision below (skip one only when the user already stated it), then restate the resolved shape in a sentence and confirm:
32
33
 
33
- 1. **End result — what does `execute` do?** Create a new record, update an existing one, touch several models, or fire a side effect (email/charge/API)? This drives anchoring *and* persistence.
34
+ 1. **End result: what does `execute` do?** Create a new record, update an existing one, touch several models, or fire a side effect (email/charge/API)? This drives anchoring *and* persistence.
34
35
  2. **Anchored or fresh?** Does it operate on an **existing** record or create something new?
35
36
  - existing record from the URL → `anchored with: Model` (resource-mounted member route)
36
37
  - existing context (the tenant, the current user) → `anchored via: :current_scoped_entity`
37
38
  - brand new → non-anchored
38
39
  3. **Mount, host & shell.** A resource action (`wizard` macro), a **portal** entry (`register_wizard` in a portal engine), or a **main-app** entry (`register_wizard` on the app)? Authenticated, or **public/guest pre-login** (`anonymous`, e.g. signup)? For a route mount, what **layout** (`layout: :basic` for a bare screen, `:resource` for the shell, or omit for the host default)? (Resource wizards are always embedded; an authenticated main-app wizard needs an app-defined `::WizardsController`.)
39
- 4. **Run identity.** Resume the user's **one** in-progress run (keyed — `concurrency_key`), or start a **fresh** run each launch (tokened/repeatable)? (Anchored wizards default to one run per `[anchor, current_user]`.)
40
- 5. **One-time?** Run at most once and keep a completed marker (`one_time`) — e.g. to **gate** a page behind it?
40
+ 4. **Run identity.** Resume the user's **one** in-progress run (keyed by `concurrency_key`), or start a **fresh** run each launch (tokened/repeatable)? (Anchored wizards default to one run per `[anchor, current_user]`.)
41
+ 5. **One-time?** Run at most once and keep a completed marker (`one_time`), e.g. to **gate** a page behind it?
41
42
  6. **Persistence model.** Write everything atomically at `execute` (default, simplest), or **save-as-you-go** with per-step `on_submit`/`persist` (then `SweepJob` must be scheduled, and `on_rollback` added for any *uncompensated* side effect)?
42
43
  7. **Steps & branching.** Which steps/fields/validations? Any step shown only under a `condition:`?
43
- 8. **Tenancy.** Is the host portal entity-scoped? (The tenant folds into run identity automatically — don't thread it by hand.)
44
+ 8. **Tenancy.** Is the host portal entity-scoped? (The tenant folds into run identity automatically, so don't thread it by hand.)
44
45
 
45
46
  These compound: *anchored ⇒ keyed by default*; *anonymous ⇒ no owner ⇒ tokened*; *one-time ⇒ keyed + gateable*; *save-as-you-go ⇒ SweepJob + rollback*. Surface the implication when you confirm ("public ⇒ guest ⇒ session-keyed, ownerless"). The DSL sections below map each decision to its macro.
46
47
 
47
- ## ✅ Before you author: verify the ground truth (CHECK — read it, don't ask for it)
48
+ ## ✅ Before you author: verify the ground truth (CHECK: read it, don't ask for it)
48
49
 
49
- The ASK gate resolves the *design*; this confirms the app can actually *run* it. You have file access — inspect these yourself before writing the class (don't ask the user to confirm what you can read):
50
+ The ASK gate resolves the *design*; this confirms the app can actually *run* it. You have file access: inspect these yourself before writing the class (don't ask the user to confirm what you can read):
50
51
 
51
52
  | Check | How | Why it matters |
52
53
  |---|---|---|
53
- | Subsystem enabled | grep `config/initializers/plutonium.rb` for `wizards.enabled = true`; confirm `plutonium_wizard_sessions` exists (`db:migrate`) | **OFF by default — without it nothing works** (the #1 gotcha) |
54
+ | Subsystem enabled | grep `config/initializers/plutonium.rb` for `wizards.enabled = true`; confirm `plutonium_wizard_sessions` exists (`db:migrate`) | **OFF by default: without it nothing works** (the #1 gotcha) |
54
55
  | Anchor model exists & reachable | Read the model an `anchored` wizard runs against | Missing/unreadable anchor ⇒ 404 / `NotAnchoredError` |
55
56
  | Host portal exists & its scoping | Read the portal engine (`scope_to_entity`?) + its real module name | Tenant folds into run identity; a guessed portal name breaks `register_wizard` |
56
57
  | Guest-flow prereqs | AR encryption keys if `encrypt_data`; no `concurrency_key`/`one_time` with `anonymous` | First write raises otherwise |
57
58
  | `on_submit` ⇒ SweepJob scheduled | grep `config/recurring.yml` for `sweep_abandoned_wizards` | Abandoned mid-flow records pile up forever |
58
59
 
59
- **Don't author the class until `config.wizards.enabled` is confirmed and the anchor/target model + portal are read.** Until then, any class you show is provisional — say so; don't present a guessed field/column mapping as final.
60
+ **Don't author the class until `config.wizards.enabled` is confirmed and the anchor/target model + portal are read.** Until then, any class you show is provisional. Say so; don't present a guessed field/column mapping as final.
60
61
 
61
62
  ---
62
63
 
@@ -92,13 +93,13 @@ class CompanyOnboardingWizard < Plutonium::Wizard::Base
92
93
  end
93
94
  ```
94
95
 
95
- - `presents label:/icon:/description:` — launch button label + icon (same as interactions); the optional `description:` renders as the wizard's header subheading.
96
- - A `step :key, label: do ... end` is one screen; the block uses the field DSL ([[plutonium-resource]]). `step` (and `review`) also take an optional `description:` — a sub-label under the heading. `label:` defaults to `key.to_s.humanize`.
96
+ - `presents label:/icon:/description:`: launch button label + icon (same as interactions); the optional `description:` renders as the wizard's header subheading.
97
+ - A `step :key, label: do ... end` is one screen; the block uses the field DSL ([[plutonium-resource]]). `step` (and `review`) also take an optional `description:`: a sub-label under the heading. `label:` defaults to `key.to_s.humanize`.
97
98
  - `data.<step>.<field>` reads the **typed** value (cast to declared type) for that step, e.g. `data.company.name`.
98
- - `review` — built-in terminal step: auto-summary + gated Finish. Must be last.
99
- - `execute` — runs once at the end in one transaction; returns `succeed(...)` / `failed(...)`.
99
+ - `review`: built-in terminal step: auto-summary + gated Finish. Must be last.
100
+ - `execute`: runs once at the end in one transaction; returns `succeed(...)` / `failed(...)`.
100
101
 
101
- ⚠️ **`execute` is a presentation boundary, same as an interaction's.** A wizard is built with `view_context:` too, so anything reachable only through `execute` is reachable only from a wizard run. Steps and `execute` own the *flow* — which screens, in what order, writing what. What the write **means** belongs on the model as soon as a second caller wants it (the API signup that skips onboarding, an admin backfill, an importer): `Company.create!(...)` inline is fine for a one-off; `Company.onboard!(...)` is what you reach for when it isn't. Same for `on_submit`/`on_rollback` — they're flow hooks, not a home for domain logic. Full rule: [[plutonium-behavior]] › Part 3 › Where the logic goes.
102
+ ⚠️ **`execute` is a presentation boundary, same as an interaction's.** A wizard is built with `view_context:` too, so anything reachable only through `execute` is reachable only from a wizard run. Steps and `execute` own the *flow*: which screens, in what order, writing what. What the write **means** belongs on the model as soon as a second caller wants it (the API signup that skips onboarding, an admin backfill, an importer): `Company.create!(...)` inline is fine for a one-off; `Company.onboard!(...)` is what you reach for when it isn't. Same for `on_submit`/`on_rollback`: they're flow hooks, not a home for domain logic. Full rule: [[plutonium-behavior]] › Part 3 › Where the logic goes.
102
103
 
103
104
  ## Wizard-level macros
104
105
 
@@ -110,14 +111,14 @@ end
110
111
  | `on_relaunch :new` | Bare-relaunching a **tokened** wizard with pending runs shows a "resume or start new" chooser by default (`:prompt`) instead of silently forking; `:new` opts out (always fresh). No-op for keyed/`anonymous` (already auto-resume). |
111
112
  | `anchored with: Model` / `anchored via: :method` | Run against an existing record (read via `anchor`). `with:` = URL `:id` (resource-mounted); `via:` = a controller method (portal-level, context). |
112
113
  | `cleanup_after <ttl> \| :never` | Idle TTL before the sweep reaps the session + rolls back tracked records. Default `config.wizards.cleanup_after`. |
113
- | `concurrency_key { … }` | Key a run by the returned value(s) (tenant folded in). The keyed `in_progress` row is the lock — a second launch resumes, never forks. Omit → unlimited `wizard_token`-keyed runs — **except `anchored`**, which defaults to `{ [anchor, current_user] }` (one draft per user per record). `{ anchor }` = one per record any-user; `{ wizard_token }` = repeatable. |
114
+ | `concurrency_key { … }` | Key a run by the returned value(s) (tenant folded in). The keyed `in_progress` row is the lock: a second launch resumes, never forks. Omit → unlimited `wizard_token`-keyed runs, **except `anchored`**, which defaults to `{ [anchor, current_user] }` (one draft per user per record). `{ anchor }` = one per record any-user; `{ wizard_token }` = repeatable. |
114
115
  | `one_time` | Retain the completed row at the `concurrency_key` → run once (gate-able). **Requires `concurrency_key`.** Omit → row deleted on complete (repeatable). |
115
116
  | `completed do \|wizard\| … end` | Custom body for the "already completed" page a finished **one-time** wizard shows when re-opened (replaces the default confirmation). |
116
- | `encrypt_data` | Encrypt the staged `data` column at rest via ActiveRecord's encryption keys (PII flows). Requires `active_record.encryption` keys — first write raises (naming the wizard) if unconfigured. Unset inherits `config.wizards.encrypt_data` (global default, off); `encrypt_data false` opts out when that default is on. |
117
- | `width <size>` | Width of this wizard's step pages: `:sm` `:md` `:lg` `:xl` `:full` (`:full` = unconstrained). Unset inherits `config.wizards.width` (default `:md`). Independent of `config.default_page_width` — resource page width does not move wizards. |
118
- | `anonymous` | Opt into **guest (unauthenticated) access.** Default = auth required. A guest wizard may authenticate only at its terminal `execute`; never mid-flow. Mount it `public: true` (the default for `anonymous`). **Mutually exclusive with `concurrency_key`/`one_time`** — a guest's identity is its session token (already session-keyed/repeatable); whichever macro is declared last raises. |
117
+ | `encrypt_data` | Encrypt the staged `data` column at rest via ActiveRecord's encryption keys (PII flows). Requires `active_record.encryption` keys; first write raises (naming the wizard) if unconfigured. Unset inherits `config.wizards.encrypt_data` (global default, off); `encrypt_data false` opts out when that default is on. |
118
+ | `width <size>` | Width of this wizard's step pages: `:sm` `:md` `:lg` `:xl` `:full` (`:full` = unconstrained). Unset inherits `config.wizards.width` (default `:md`). Independent of `config.default_page_width`: resource page width does not move wizards. |
119
+ | `anonymous` | Opt into **guest (unauthenticated) access.** Default = auth required. A guest wizard may authenticate only at its terminal `execute`; never mid-flow. Mount it `public: true` (the default for `anonymous`). **Mutually exclusive with `concurrency_key`/`one_time`**: a guest's identity is its session token (already session-keyed/repeatable); whichever macro is declared last raises. |
119
120
 
120
- ## Branching — `condition:`
121
+ ## Branching: `condition:`
121
122
 
122
123
  Subtractive: a falsy `condition:` removes the step from the visible path.
123
124
 
@@ -131,11 +132,11 @@ end
131
132
 
132
133
  `condition:` can also read `anchor`. Branch-hidden steps' data is pruned before `execute`. **Must be nil-safe** (see Critical).
133
134
 
134
- Conditions are re-evaluated against the submission that was just staged, so a step (including `review`) may be gated on an answer from the step immediately before it — the revealed steps become reachable on that same POST. When a step's answer reveals nothing after it, that POST ends the flow and runs `execute`.
135
+ Conditions are re-evaluated against the submission that was just staged, so a step (including `review`) may be gated on an answer from the step immediately before it; the revealed steps become reachable on that same POST. When a step's answer reveals nothing after it, that POST ends the flow and runs `execute`.
135
136
 
136
- ### Revealing a field within a step — `pre_submit:`
137
+ ### Revealing a field within a step: `pre_submit:`
137
138
 
138
- A step-level `condition:` branches whole steps against *stored* data. To show or hide a field as the user edits a **sibling field on the same step**, mark the deciding input `pre_submit: true` — changing it re-renders the step form from the **just-submitted** values (same mechanism as resource forms and interactive actions):
139
+ A step-level `condition:` branches whole steps against *stored* data. To show or hide a field as the user edits a **sibling field on the same step**, mark the deciding input `pre_submit: true`: changing it re-renders the step form from the **just-submitted** values (same mechanism as resource forms and interactive actions):
139
140
 
140
141
  ```ruby
141
142
  step :details do
@@ -149,14 +150,14 @@ step :details do
149
150
  end
150
151
  ```
151
152
 
152
- A `pre_submit` is **render-only**: it never persists, never marks the step submitted, and never moves the cursor — abandon the page and nothing durable is left. **Attachment fields are exempt** from the re-render's seeding: a file input doesn't re-post on a sibling's `change`, so an already-staged upload survives untouched instead of being blanked out of the form. Uploads stage only on a real submit.
153
+ A `pre_submit` is **render-only**: it never persists, never marks the step submitted, and never moves the cursor: abandon the page and nothing durable is left. **Attachment fields are exempt** from the re-render's seeding: a file input doesn't re-post on a sibling's `change`, so an already-staged upload survives untouched instead of being blanked out of the form. Uploads stage only on a real submit.
153
154
 
154
- ## Field reuse — `using:` a model
155
+ ## Field reuse: `using:` a model
155
156
 
156
157
  `using:` is a **step option** (not a block method) and targets a **model only**.
157
158
 
158
159
  ```ruby
159
- # Whole-step import — no block needed.
160
+ # Whole-step import, no block needed.
160
161
  step :branding, label: "Branding", using: Company, fields: %i[logo brand_color]
161
162
 
162
163
  # Mix imported + wizard-local fields.
@@ -176,7 +177,7 @@ Imports: field universe + types from `Model.attribute_names`/`attribute_types`;
176
177
  | `layout: false` | Skip inherited `form_layout`. |
177
178
  | `validation_context:` | Run `valid?(context)`. |
178
179
 
179
- **Declaration reuse only** — never the model's persistence. Data stages into `data`; `execute` does the writes.
180
+ **Declaration reuse only**: never the model's persistence. Data stages into `data`; `execute` does the writes.
180
181
 
181
182
  ## Step internals
182
183
 
@@ -201,7 +202,7 @@ end
201
202
 
202
203
  Repeater rows rehydrate from staged `data` on GET (resume / back re-renders filled rows).
203
204
 
204
- Validations drive the form's field affordances just like a resource form: `presence` → the required marker (`*`); `length`/`numericality`/`format`/`inclusion` → `maxlength`/`min`/`max`/`pattern`/auto-choices. This holds for validations imported via `using:` too. (Structured-input sub-fields are the exception — they carry no validators, so no markers there.)
205
+ Validations drive the form's field affordances just like a resource form: `presence` → the required marker (`*`); `length`/`numericality`/`format`/`inclusion` → `maxlength`/`min`/`max`/`pattern`/auto-choices. This holds for validations imported via `using:` too. (Structured-input sub-fields are the exception: they carry no validators, so no markers there.)
205
206
 
206
207
  ### Options that depend on the run
207
208
 
@@ -224,13 +225,13 @@ A one-argument proc still takes the form, as on any other form, for `object` (th
224
225
 
225
226
  Three limits worth knowing:
226
227
 
227
- - The **field set** is still fixed at class load — a proc varies an option, not which fields exist. To collect a shape known only at runtime, declare one `structured_input` and let a custom component render the inner controls. For bespoke markup pass a **block** to `input` instead; it renders in the form's context with the field yielded.
228
+ - The **field set** is still fixed at class load: a proc varies an option, not which fields exist. To collect a shape known only at runtime, declare one `structured_input` and let a custom component render the inner controls. For bespoke markup pass a **block** to `input` instead; it renders in the form's context with the field yielded.
228
229
  - A **step's** `condition:` runs against the wizard; a **field's** `condition:` runs against the form (`object` = that step's staged data). Only the field-level one is excluded from the resolution above.
229
- - **Proc options resolve on every form** ([[plutonium-resource]]), by one rule with no wizard exception: a zero-argument proc keeps its own binding, a one-argument one gets the form. A step block is `instance_exec`'d against an internal field recorder, so `-> { anchor.x }` raises `NameError` — take the form and use `form.wizard`. Same trade a `form_layout` section option makes; an `input` line keeps its meaning when moved between a definition and a step.
230
+ - **Proc options resolve on every form** ([[plutonium-resource]]), by one rule with no wizard exception: a zero-argument proc keeps its own binding, a one-argument one gets the form. A step block is `instance_exec`'d against an internal field recorder, so `-> { anchor.x }` raises `NameError`; take the form and use `form.wizard`. Same trade a `form_layout` section option makes; an `input` line keeps its meaning when moved between a definition and a step.
230
231
 
231
232
  ## Attachment fields (file uploads)
232
233
 
233
- A step can collect a file. Declare it like any field — a **`:string`** attribute (it holds the upload **token**, not the bytes) + a file input:
234
+ A step can collect a file. Declare it like any field: a **`:string`** attribute (it holds the upload **token**, not the bytes) + a file input:
234
235
 
235
236
  ```ruby
236
237
  step :photo, label: "Photo" do
@@ -239,7 +240,7 @@ step :photo, label: "Photo" do
239
240
  end
240
241
  ```
241
242
 
242
- The file is staged into `data` as a backend **token** (an ActiveStorage signed_id, or active_shrine/Shrine cached-file data) — never the bytes (they can't ride JSON `data` across steps). `execute` assigns that token to the model's attachment **natively** (both backends accept it):
243
+ The file is staged into `data` as a backend **token** (an ActiveStorage signed_id, or active_shrine/Shrine cached-file data), never the bytes (they can't ride JSON `data` across steps). `execute` assigns that token to the model's attachment **natively** (both backends accept it):
243
244
 
244
245
  ```ruby
245
246
  def execute
@@ -250,19 +251,19 @@ def execute
250
251
  end
251
252
  ```
252
253
 
253
- The review summary and the step's preview (on Back/resume) render the file automatically — `data.<step>.<field>` resolves the token to a displayable attachment at the boundary; nothing extra to write.
254
+ The review summary and the step's preview (on Back/resume) render the file automatically: `data.<step>.<field>` resolves the token to a displayable attachment at the boundary; nothing extra to write.
254
255
 
255
- **Two upload modes** — same field, differ only by `direct_upload:`:
256
+ **Two upload modes**: same field, differ only by `direct_upload:`:
256
257
 
257
258
  | Mode | Declare | Notes |
258
259
  |---|---|---|
259
260
  | **Server-side** (default) | `input :file, as: :file` | the file rides the step POST; the wizard uploads it to the backend's cache while staging. Simplest; works for **AS *and* active_shrine**; no JS/endpoint needed. |
260
261
  | **Direct upload** | `input :file, as: :uppy, direct_upload: true, endpoint: "/upload"` | the browser uploads to the endpoint and posts back a token (async progress UI). Needs that endpoint reachable (AS direct-uploads, or Shrine's `upload_endpoint`). |
261
262
 
262
- - **Backend** (server-side mode): defaults to `config.wizards.attachment_backend`, which **auto-detects** active_shrine → `:shrine`, else `:active_storage`. Override per field with `backend:`. It **must match the model `execute` assigns to** — an AS model ⇒ `:active_storage`, an active_shrine model ⇒ `:shrine` (an AS model won't accept a Shrine token, and vice-versa).
263
- - **Uploader** (Shrine only): `input :photo, as: :file, backend: :shrine, uploader: PhotoUploader` caches through that uploader (running its cache-stage plugins — mime/dimension/location/processing — instead of base `Shrine`). The token stays uploader-agnostic, so display + `execute` promotion are unchanged. Server-side only; raises for `:active_storage`. The uploader's `:cache` storage must be the one the model's attacher promotes from (the default global `Shrine.storages[:cache]`). **Validations are enforced on the step** (when Shrine's optional `validation`/`validation_helpers` plugin is loaded — else a clean no-op): the file is validated against the field's effective uploader (its `uploader:`, else base `Shrine`), so a failing file is rejected with a field error — not deferred to `execute`. (`Uploader.upload` itself runs no validations; the step's validation pass does.)
263
+ - **Backend** (server-side mode): defaults to `config.wizards.attachment_backend`, which **auto-detects** active_shrine → `:shrine`, else `:active_storage`. Override per field with `backend:`. It **must match the model `execute` assigns to**: an AS model ⇒ `:active_storage`, an active_shrine model ⇒ `:shrine` (an AS model won't accept a Shrine token, and vice-versa).
264
+ - **Uploader** (Shrine only): `input :photo, as: :file, backend: :shrine, uploader: PhotoUploader` caches through that uploader (running its cache-stage plugins (mime/dimension/location/processing) instead of base `Shrine`). The token stays uploader-agnostic, so display + `execute` promotion are unchanged. Server-side only; raises for `:active_storage`. The uploader's `:cache` storage must be the one the model's attacher promotes from (the default global `Shrine.storages[:cache]`). **Validations are enforced on the step** (when Shrine's optional `validation`/`validation_helpers` plugin is loaded, else a clean no-op): the file is validated against the field's effective uploader (its `uploader:`, else base `Shrine`), so a failing file is rejected with a field error, not deferred to `execute`. (`Uploader.upload` itself runs no validations; the step's validation pass does.)
264
265
  - **Multiple:** an array attribute + `multiple: true` → the staged value is an array of tokens.
265
- - **Cleanup:** a staged-then-abandoned upload is an unattached blob / cached Shrine file — each backend's own unattached cleanup reaps it (the wizard `SweepJob` doesn't touch it).
266
+ - **Cleanup:** a staged-then-abandoned upload is an unattached blob / cached Shrine file: each backend's own unattached cleanup reaps it (the wizard `SweepJob` doesn't touch it).
266
267
 
267
268
  ## The review step
268
269
 
@@ -285,14 +286,14 @@ Always lists invalid/unvisited steps as fix-this jump links; Finish disabled unt
285
286
  | **Complete**, `summary: false` + block | block **replaces** the summary |
286
287
  | **Complete**, `summary: false`, no block | built-in "ready to complete" panel |
287
288
 
288
- - `summary:` (default true) — show the auto-summary of completed steps. `false` hands the complete-state body to your block (or the "ready to complete" panel). The summary always shows in the incomplete state.
289
- - `header:` (default true) — the step-header section (label + the "check everything over" prompt, shown only when the summary is). `false` drops it for a chromeless finish. Pair with `stepper false` for no chrome at all.
289
+ - `summary:` (default true): show the auto-summary of completed steps. `false` hands the complete-state body to your block (or the "ready to complete" panel). The summary always shows in the incomplete state.
290
+ - `header:` (default true): the step-header section (label + the "check everything over" prompt, shown only when the summary is). `false` drops it for a chromeless finish. Pair with `stepper false` for no chrome at all.
290
291
 
291
- The custom block runs **in the Phlex view context** (`self` is the component), so it may return a String, emit Phlex (`div`, `render Component.new(...)`), and reach helpers via `helpers.*`; it's yielded the `wizard` (`data`/`anchor`/`persisted`/`current_user`). Don't both emit markup and return a String — Phlex renders the returned String too, double-rendering it.
292
+ The custom block runs **in the Phlex view context** (`self` is the component), so it may return a String, emit Phlex (`div`, `render Component.new(...)`), and reach helpers via `helpers.*`; it's yielded the `wizard` (`data`/`anchor`/`persisted`/`current_user`). Don't both emit markup and return a String: Phlex renders the returned String too, double-rendering it.
292
293
 
293
- **The summary resolves choice labels.** A field declared with the `choices:` option summarises as the label its `<option>` carried, not the stored value — `42` reads as "Alice", `"cash"` as "Cash". Every collection shape the input accepts works (pair arrays, `{value => label}` hashes, ranges, sets, AR relations, procs returning any of those), because resolution goes through the same `Phlexi::Form::SimpleChoicesMapper` the input uses. **Caveat:** choices supplied inside a *block* (`input(:x) { |f| f.select_tag choices: … }`) are computed at render time and aren't visible to the summary — those fields still show the raw value. Use the declarative `choices:` option when you want the review page to read well.
294
+ **The summary resolves choice labels.** A field declared with the `choices:` option summarises as the label its `<option>` carried, not the stored value: `42` reads as "Alice", `"cash"` as "Cash". Every collection shape the input accepts works (pair arrays, `{value => label}` hashes, ranges, sets, AR relations, procs returning any of those), because resolution goes through the same `Phlexi::Form::SimpleChoicesMapper` the input uses. **Caveat:** choices supplied inside a *block* (`input(:x) { |f| f.select_tag choices: … }`) are computed at render time and aren't visible to the summary; those fields still show the raw value. Use the declarative `choices:` option when you want the review page to read well.
294
295
 
295
- ## Per-step writes — `on_submit` / `persist` / `on_rollback`
296
+ ## Per-step writes: `on_submit` / `persist` / `on_rollback`
296
297
 
297
298
  `execute` is the default (atomic). Use `on_submit` **only** when a real record must exist mid-flow (external handoff, reviewer sees partials, payload too large for the row).
298
299
 
@@ -315,11 +316,11 @@ step :billing, label: "Billing" do
315
316
  end
316
317
  ```
317
318
 
318
- **`persist` always cleans up.** On any rollback (Cancel, sweep, branch-prune) the engine **always** destroys every `persist`'d record via `destroy!` (respects a model's soft-delete override). `on_rollback` is **optional, additive** — it compensates side effects the engine can't see, runs **before** the destroy, and a side-effect-only step (no `persist`) still runs its `on_rollback`. To keep a partial record, make the model soft-delete or use `cleanup_after :never`.
319
+ **`persist` always cleans up.** On any rollback (Cancel, sweep, branch-prune) the engine **always** destroys every `persist`'d record via `destroy!` (respects a model's soft-delete override). `on_rollback` is **optional, additive**: it compensates side effects the engine can't see, runs **before** the destroy, and a side-effect-only step (no `persist`) still runs its `on_rollback`. To keep a partial record, make the model soft-delete or use `cleanup_after :never`.
319
320
 
320
321
  `on_submit` is not atomic across steps (HTTP), which is why `cleanup_after` + `SweepJob` exist.
321
322
 
322
- **Keep the hook to flow, not domain.** `on_submit`/`on_rollback` belong to one wizard step and can't be called from anywhere else, so they should say *when* and *what gets tracked* — one call to a model, as above. Once "authorize a card and record the billing row" is something the API does too, it becomes `Billing.authorize!(company:, token:)` and the hook shrinks to `persist Billing.authorize!(...)`.
323
+ **Keep the hook to flow, not domain.** `on_submit`/`on_rollback` belong to one wizard step and can't be called from anywhere else, so they should say *when* and *what gets tracked*: one call to a model, as above. Once "authorize a card and record the billing row" is something the API does too, it becomes `Billing.authorize!(company:, token:)` and the hook shrinks to `persist Billing.authorize!(...)`.
323
324
 
324
325
  ## Accessors
325
326
 
@@ -331,14 +332,14 @@ end
331
332
  | `succeed(v)` / `failed(errs)` | Outcome helpers (alias `success`). `.with_message`, `.with_redirect_response` chainable. |
332
333
  | `fail!(msg)` / `fail!(:field, msg)` | Raise a `StepError` from `on_submit`/`execute`. |
333
334
 
334
- Available inside steps, `condition:`, `on_submit`, `on_rollback` and `execute` — and, via `form.wizard`, inside a step's **proc-valued field/input options** (see **Step internals → Options that depend on the run**).
335
+ Available inside steps, `condition:`, `on_submit`, `on_rollback` and `execute`, and, via `form.wizard`, inside a step's **proc-valued field/input options** (see **Step internals → Options that depend on the run**).
335
336
 
336
337
  ## Anchoring
337
338
 
338
339
  ```ruby
339
340
  anchored with: Company # single type
340
341
  anchored with: [Company, Org] # polymorphic
341
- anchored # generic — type bound at registration
342
+ anchored # generic, type bound at registration
342
343
  # omit # pure create flow (no anchor)
343
344
  ```
344
345
 
@@ -379,15 +380,15 @@ module AdminPortal
379
380
  end
380
381
  ```
381
382
 
382
- Un-completed user → redirected into the wizard (destination stashed); on completion → bounced back (PRG). Completed users pass through. The gate recomputes the wizard's `instance_key` from its `concurrency_key` (resolved on the host controller — `current_user`/`current_scoped_entity`/custom available) and checks `completed?(instance_key:)`. Only one-time wizards are gateable. Use `concurrency_key { anchor }` for "set up this record once".
383
+ Un-completed user → redirected into the wizard (destination stashed); on completion → bounced back (PRG). Completed users pass through. The gate recomputes the wizard's `instance_key` from its `concurrency_key` (resolved on the host controller: `current_user`/`current_scoped_entity`/custom available) and checks `completed?(instance_key:)`. Only one-time wizards are gateable. Use `concurrency_key { anchor }` for "set up this record once".
383
384
 
384
385
  **Gating an anchored wizard:** the gate needs the anchor to recompute the key. A `via:`-anchored wizard is resolved automatically (the gate calls its `anchor_via` method on the controller); otherwise pass `ensure_wizard_completed Wizard, anchor: :method_or_proc`. An anchor it can't resolve raises (no silent loop). Anchor-keyed wizards are only gateable where the anchor is reconstructable.
385
386
 
386
- **Re-opening a completed one-time wizard** doesn't re-run it (the retained row's `data` is cleared) — it renders an "already completed" page (success badge + label + Continue). Override the body with `completed do |wizard| … end`. Repeatable wizards have no completed page (re-launch starts fresh).
387
+ **Re-opening a completed one-time wizard** doesn't re-run it (the retained row's `data` is cleared); it renders an "already completed" page (success badge + label + Continue). Override the body with `completed do |wizard| … end`. Repeatable wizards have no completed page (re-launch starts fresh).
387
388
 
388
389
  ## Registration & launch
389
390
 
390
- **(a) On a resource definition** — the `wizard` macro synthesizes the launch action AND auto-mounts the wizard's routes on the resource's own controller. Placement mirrors interactions: anchored → record (member) action, non-anchored → resource (collection) action; no bulk:
391
+ **(a) On a resource definition**: the `wizard` macro synthesizes the launch action AND auto-mounts the wizard's routes on the resource's own controller. Placement mirrors interactions: anchored → record (member) action, non-anchored → resource (collection) action; no bulk:
391
392
 
392
393
  ```ruby
393
394
  class CompanyDefinition < Plutonium::Resource::Definition
@@ -398,7 +399,7 @@ end
398
399
 
399
400
  The anchored member action resolves its anchor through the resource controller's scoped, policy-gated `resource_record!` (IDOR-safe: out-of-scope / non-existent ids 404). Gate it with a policy predicate named after the wizard key (`def configure? = update?`).
400
401
 
401
- **(b) Route-mounted** — `register_wizard`, in a **portal** engine's routes or on the **main app**, alongside `register_resource`:
402
+ **(b) Route-mounted**: `register_wizard`, in a **portal** engine's routes or on the **main app**, alongside `register_resource`:
402
403
 
403
404
  ```ruby
404
405
  AdminPortal::Engine.routes.draw do
@@ -418,7 +419,7 @@ Draws `GET /onboarding` (canonical launch) + `GET/POST /onboarding(/:token)/:ste
418
419
  | `at:` (required) | Host-relative base path for the steps. |
419
420
  | `as:` | Override the route-helper prefix (defaults to `at:`, then the wizard's name). |
420
421
  | `public:` | Mount on a **public (unauthenticated)** route for an `anonymous` wizard. Defaults to the wizard's `anonymous?` flag. |
421
- | `layout:` | The Rails layout to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Default by host — portal → the resource shell, main-app → `:basic`. Turbo-frame requests are always layout-less regardless. |
422
+ | `layout:` | The Rails layout to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Default by host: portal → the resource shell, main-app → `:basic`. Turbo-frame requests are always layout-less regardless. |
422
423
 
423
424
  ### Hosting & the controller override hook
424
425
 
@@ -427,7 +428,7 @@ Draws `GET /onboarding` (canonical launch) + `GET/POST /onboarding(/:token)/:ste
427
428
  | Host | Controller used | Auth |
428
429
  |---|---|---|
429
430
  | Portal | `<Portal>::WizardsController` if defined, else synthesized on the portal's `PlutoniumController` | the portal's (inherited) |
430
- | Main app, **authenticated** | `::WizardsController` — **you must define it** | **yours** — `include Plutonium::Auth::Rodauth(:account)` |
431
+ | Main app, **authenticated** | `::WizardsController`: **you must define it** | **yours**: `include Plutonium::Auth::Rodauth(:account)` |
431
432
  | Main app, **public** (`anonymous`) | synthesized `::PublicWizardsController` (bare + `Auth::Public`) | none (guest) |
432
433
 
433
434
  ```ruby
@@ -438,19 +439,19 @@ class WizardsController < ApplicationController
438
439
  end
439
440
  ```
440
441
 
441
- `Plutonium::Wizard::Controller` is the whole contract — including it on any base yields a renderable wizard controller (it pulls in `Core::Controller` and contributes the `"plutonium"` view prefix, so even a bare `ActionController::Base` host renders the shared partials). For an app that needs no custom auth base there's a ready-made `Plutonium::Wizard::BaseController` (`< ActionController::Base` + the module) to subclass. The module is the mechanism; the class is sugar.
442
+ `Plutonium::Wizard::Controller` is the whole contract: including it on any base yields a renderable wizard controller (it pulls in `Core::Controller` and contributes the `"plutonium"` view prefix, so even a bare `ActionController::Base` host renders the shared partials). For an app that needs no custom auth base there's a ready-made `Plutonium::Wizard::BaseController` (`< ActionController::Base` + the module) to subclass. The module is the mechanism; the class is sugar.
442
443
 
443
444
  > [!TIP]
444
- > **`with:`-anchored wizards mount on the resource, not portal-level.** Register a `with:`-anchored wizard on the anchored resource's definition with the `wizard` macro — it auto-mounts a record (member) action whose anchor is the scoped `resource_record!`. Passing a `with:`-anchored wizard to `register_wizard` **raises** (no resource record). A **`via:`-anchored** (context) wizard mounts portal-level fine — its anchor is a controller method (e.g. `via: :current_scoped_entity`).
445
+ > **`with:`-anchored wizards mount on the resource, not portal-level.** Register a `with:`-anchored wizard on the anchored resource's definition with the `wizard` macro: it auto-mounts a record (member) action whose anchor is the scoped `resource_record!`. Passing a `with:`-anchored wizard to `register_wizard` **raises** (no resource record). A **`via:`-anchored** (context) wizard mounts portal-level fine; its anchor is a controller method (e.g. `via: :current_scoped_entity`).
445
446
 
446
447
  > [!DANGER]
447
- > **A portal-level wizard with no `authorize?` is runnable by ANY authenticated portal user** — it has no resource policy and defaults to allowed. **Always define `def authorize?`** for anything privileged (admin-only, per-user gating, tenant checks).
448
+ > **A portal-level wizard with no `authorize?` is runnable by ANY authenticated portal user**: it has no resource policy and defaults to allowed. **Always define `def authorize?`** for anything privileged (admin-only, per-user gating, tenant checks).
448
449
 
449
450
  ## Authentication
450
451
 
451
- **Auth is required by default** — entry without a `current_user` is rejected. Authenticated lookups are **owner-scoped**: a run id leaked in a URL can't be resumed by another logged-in user (foreign row → 404).
452
+ **Auth is required by default**: entry without a `current_user` is rejected. Authenticated lookups are **owner-scoped**: a run id leaked in a URL can't be resumed by another logged-in user (foreign row → 404).
452
453
 
453
- Where `current_user` comes from depends on the host: a **portal** mount inherits the portal's auth concern; a **main-app authenticated** mount needs the auth on your own `::WizardsController` (see *Hosting & the controller override hook* above — a bare synthesized main-app controller has no `current_user`, so a non-anonymous wizard there would be rejected by the auth gate). The wizard module supplies a `current_user` default that **defers to the host's auth concern** when present and is `nil` on a bare host.
454
+ Where `current_user` comes from depends on the host: a **portal** mount inherits the portal's auth concern; a **main-app authenticated** mount needs the auth on your own `::WizardsController` (see *Hosting & the controller override hook* above; a bare synthesized main-app controller has no `current_user`, so a non-anonymous wizard there would be rejected by the auth gate). The wizard module supplies a `current_user` default that **defers to the host's auth concern** when present and is `nil` on a bare host.
454
455
 
455
456
  Opt into guest access with `anonymous`, and mount it on a **public route**:
456
457
 
@@ -464,48 +465,56 @@ class GuestSignupWizard < Plutonium::Wizard::Base
464
465
  end
465
466
  end
466
467
 
467
- # in a portal's routes — drawn on a public (unauthenticated) route automatically
468
+ # in a portal's routes, drawn on a public (unauthenticated) route automatically
468
469
  register_wizard ::GuestSignupWizard, at: "signup", public: true
469
470
  ```
470
471
 
471
- The guest run-id lives in the **Rails session** (`session["plutonium_wizards"][<wizard_key>]`) — **no cookie, no TTL** (the row's `cleanup_after` is the lifetime). It's browser-close ephemeral, **auto-cleared on login/logout** (Rodauth `reset_session`), cleared on completion, and **never in a URL**. Authenticated repeatable runs keep their URL `:token` segment instead (owner-scoped). **No mid-flow auth crossing**: a guest wizard never stamps an owner mid-flow or carries a token across login — it only ever authenticates at `execute`.
472
+ The guest run-id lives in the **Rails session** (`session["plutonium_wizards"][<wizard_key>]`): **no cookie, no TTL** (the row's `cleanup_after` is the lifetime). It's browser-close ephemeral, **auto-cleared on login/logout** (Rodauth `reset_session`), cleared on completion, and **never in a URL**. Authenticated repeatable runs keep their URL `:token` segment instead (owner-scoped). **No mid-flow auth crossing**: a guest wizard never stamps an owner mid-flow or carries a token across login; it only ever authenticates at `execute`.
472
473
 
473
474
  ## Listing in-progress wizards
474
475
 
475
- `Plutonium::Wizard.in_progress_for(view_context)` (→ `Resume.entries_for(view_context)`) takes the `view_context` (as interactions do) and derives the run owner (`current_user`), tenant scope, and **portal** from it — returning that user's in-progress runs **for the current portal**, newest-first, for a "continue where you left off" dashboard. A run is only ever listed (and linked) by the portal it was launched in: a non-scoped portal lists only unscoped runs, a scoped portal narrows to the current tenant. (Two portals can share an entity scope, so the launching portal — the `engine` — is recorded per-run; scope alone can't identify it.)
476
+ `Plutonium::Wizard.in_progress_for(view_context)` (→ `Resume.entries_for(view_context)`) takes the `view_context` (as interactions do) and derives the run owner (`current_user`), tenant scope, and **portal** from it, returning that user's in-progress runs **for the current portal**, newest-first, for a "continue where you left off" dashboard. A run is only ever listed (and linked) by the portal it was launched in: a non-scoped portal lists only unscoped runs, a scoped portal narrows to the current tenant. (Two portals can share an entity scope, so the launching portal (the `engine`) is recorded per-run; scope alone can't identify it.)
476
477
 
477
- Each entry exposes the wizard's `label`/`icon`, `current_step` (+ `current_step_label`), `updated_at`, the raw `session` row, and a `resume_url` built through the **current portal's** routes — the named route for a `register_wizard` mount, `resource_url_for(record, wizard:, step:)` for a `wizard`-macro **anchored** mount, and for a **non-anchored** `wizard`-macro run the resource whose definition registers that wizard class; `nil` + `resume_unresolved_reason` when the row can't be resolved here.
478
+ Each entry exposes the wizard's `label`/`icon`, `current_step` (+ `current_step_label`), `updated_at`, the raw `session` row, and a `resume_url` built through the **current portal's** routes: the named route for a `register_wizard` mount, `resource_url_for(record, wizard:, step:)` for a `wizard`-macro **anchored** mount, and for a **non-anchored** `wizard`-macro run the resource whose definition registers that wizard class; `nil` + `resume_unresolved_reason` when the row can't be resolved here.
478
479
 
479
- Each entry also exposes a **`cancel_url`** — the `DELETE` target that abandons the run — resolved from the *same* mount through its own named cancel route. Never derive it by string-munging `resume_url`: that drops query params and mis-resolves for a run with no `current_step` (whose resume URL is the bare launch path). The two resolve **independently**, so an unresumable row is still cancellable rather than stranded in the list. Render it as a **form**, not a link — cancelling runs every step's `on_rollback`, destroys its `persist`'d records, and deletes the row:
480
+ Each entry also exposes a **`cancel_url`**, the `DELETE` target that abandons the run, resolved from the *same* mount through its own named cancel route. Never derive it by string-munging `resume_url`: that drops query params and mis-resolves for a run with no `current_step` (whose resume URL is the bare launch path). The two resolve **independently**, so an unresumable row is still cancellable rather than stranded in the list. Render it as a **form**, not a link: cancelling runs every step's `on_rollback`, destroys its `persist`'d records, and deletes the row:
480
481
 
481
482
  ```ruby
482
483
  form(action: entry.cancel_url, method: "post") do
483
484
  input(type: "hidden", name: "_method", value: "delete")
484
485
  input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
485
- # `turbo_confirm`, NOT `confirm` — `data-confirm` is Rails UJS and never fires under Turbo.
486
+ # `turbo_confirm`, NOT `confirm`: `data-confirm` is Rails UJS and never fires under Turbo.
486
487
  button(type: "submit", data: {turbo_confirm: "Discard this draft? This can't be undone."}) { "Cancel" }
487
488
  end
488
489
  ```
489
490
 
490
- **Narrowing.** For the per-record / per-wizard resume widget ("does this record have an unfinished draft of wizard X?"), pass the optional `anchor:`/`wizard:` filters — they narrow **in the query, before enrichment**, so discarded rows are never URL-resolved or anchor-loaded (cheaper than `select`-ing the array, which enriches every row first). They compose, and the `wizard + anchor` pair is index-covered: `…in_progress_for(vc, wizard: ConfigureCompanyWizard, anchor: company).first`. Don't reach into `e.session.anchor` to filter (a polymorphic load per row). For ad-hoc post-filtering the array still works — `e.wizard_class` is already on each entry.
491
+ **Narrowing to one wizard or one record.** The full signature is `in_progress_for(view_context, anchor: nil, wizard: nil)`. To list only one wizard's runs, pass the class:
492
+
493
+ ```erb
494
+ <% Plutonium::Wizard.in_progress_for(self, wizard: ConfigureWidgetWizard).each do |entry| %>
495
+ ```
496
+
497
+ Both filters become `WHERE` clauses (`wizard: wizard.name`, `anchor: anchor`) **before** any row is enriched. Enrichment is the costly part: every returned row gets its resume URL and cancel URL resolved and its anchor loaded. Fetching the full list and then `select { |e| e.wizard_class == X }` pays that cost for every draft you throw away. The filters compose, and `wizard + anchor` is index-covered: `in_progress_for(vc, wizard: ConfigureCompanyWizard, anchor: company).first` answers "does this record have an unfinished draft of wizard X?". Don't filter on `e.session.anchor` either (a polymorphic load per row).
498
+
499
+ The one case for post-filtering: the **same render** already calls the unfiltered `in_progress_for` (e.g. a full "continue where you left off" list plus a per-wizard callout on one page). Those entries are already enriched, so `select` on `e.wizard_class` is free, while a second `wizard:` call would query and enrich those rows again. If the callout stands alone, or the full list isn't on that page, use `wizard:`.
491
500
 
492
501
  ## Storage & config
493
502
 
494
503
  ```ruby
495
504
  # config/initializers/plutonium.rb
496
505
  Plutonium.configure do |config|
497
- config.wizards.enabled = true # false by default — required
506
+ config.wizards.enabled = true # false by default, required
498
507
  config.wizards.cleanup_after = 14.days # global default sweep TTL
499
508
  config.wizards.encrypt_data = false # encrypt every wizard's data at rest (needs AR encryption keys)
500
- config.wizards.database = :primary # reserved — v1 supports :primary only (else raises at boot)
509
+ config.wizards.database = :primary # reserved: v1 supports :primary only (else raises at boot)
501
510
  config.wizards.attachment_backend = nil # server-side attachment staging backend (nil = auto-detect active_shrine/AS)
502
- config.wizards.width = :md # default step page width (:sm/:md/:lg/:xl/:full) — NOT tied to default_page_width
511
+ config.wizards.width = :md # default step page width (:sm/:md/:lg/:xl/:full), NOT tied to default_page_width
503
512
  end
504
513
  ```
505
514
 
506
515
  - One framework table `plutonium_wizard_sessions` (gem-shipped migration, runs in place on `rails db:migrate`). No changes to your models.
507
516
  - DB-backed → resume across devices, in-progress listing, durable one-time markers.
508
- - **`Plutonium::Wizard::SweepJob`** (an `ActiveJob`) reaps idle/expired sessions and rolls back their tracked records. **Schedule it** for every wizard app — stale rows pile up otherwise, and for `on_submit`/`persist` wizards it's the *only* thing that rolls back abandoned mid-flow records. In a Solid Queue app (`rails g pu:lite:solid_queue` sets up the backend), add it to `config/recurring.yml`:
517
+ - **`Plutonium::Wizard::SweepJob`** (an `ActiveJob`) reaps idle/expired sessions and rolls back their tracked records. **Schedule it** for every wizard app: stale rows pile up otherwise, and for `on_submit`/`persist` wizards it's the *only* thing that rolls back abandoned mid-flow records. In a Solid Queue app (`rails g pu:lite:solid_queue` sets up the backend), add it to `config/recurring.yml`:
509
518
 
510
519
  ```yaml
511
520
  # config/recurring.yml
@@ -514,7 +523,7 @@ end
514
523
  schedule: every 15 minutes
515
524
  ```
516
525
 
517
- (Or any recurring mechanism your app already has — sidekiq-cron, `whenever`, a cron'd rake task. `perform` takes no required args.)
526
+ (Or any recurring mechanism your app already has: sidekiq-cron, `whenever`, a cron'd rake task. `perform` takes no required args.)
518
527
 
519
528
  ## Common gotchas
520
529
 
@@ -526,13 +535,15 @@ end
526
535
  - **`one_time` without `concurrency_key`** → raises (no stable row to retain).
527
536
  - **`anonymous` + `concurrency_key`/`one_time`** → raises (a guest is already session-keyed; whichever is declared last raises).
528
537
  - **`encrypt_data` without AR encryption keys** → first write raises (naming the wizard). Run `bin/rails db:encryption:init`.
538
+ - **`register_wizard` wizard with no `authorize?`** → runnable by any authenticated user of that portal/app (default `true`, no resource policy).
539
+ - **Filtering `in_progress_for` results with `select`** → enriches every draft first. Pass `wizard:`/`anchor:` instead, unless the same render already loaded the full list.
529
540
  - **Gating a non-one-time wizard** (`ensure_wizard_completed` on a repeatable wizard) → raises.
530
541
  - **`on_submit` wizard without scheduled SweepJob** → abandoned partial records pile up.
531
542
  - **Rotating `secret_key_base`** → invalidates every `instance_key` digest (it's salted with the app secret): in-progress runs become unresumable and one-time gates re-open. Only affects rows live at rotation time.
532
543
 
533
544
  ## Related Skills
534
545
 
535
- - [[plutonium-resource]] — the `attribute`/`input`/`validates`/`structured_input`/`form_layout` field DSL used inside a step.
536
- - [[plutonium-behavior]] — Outcomes (`succeed`/`failed`), the Action system the `wizard` macro builds on, policies.
537
- - [[plutonium-app]] — portal engines and `register_wizard` placement (alongside `register_resource`).
538
- - [[plutonium-testing]] — integration-testing wizard flows.
546
+ - [[plutonium-resource]]: the `attribute`/`input`/`validates`/`structured_input`/`form_layout` field DSL used inside a step.
547
+ - [[plutonium-behavior]]: Outcomes (`succeed`/`failed`), the Action system the `wizard` macro builds on, policies.
548
+ - [[plutonium-app]]: portal engines and `register_wizard` placement (alongside `register_resource`).
549
+ - [[plutonium-testing]]: integration-testing wizard flows.
data/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.66.0] - 2026-10-08
6
+
7
+ ### Bug Fixes
8
+
9
+ - [**breaking**] Raise when associated_with finds more than one association
10
+ - Generate update? in the pu:profile:conn policy
11
+ - Use a dark background for alerts in dark mode
12
+ - Deny inherited custom actions in the public storefront portal
13
+ - Dark close buttons on alerts and toasts; test profile policy update?
14
+ - Resolve typeahead URLs in path-scoped portals
15
+
16
+ ### Documentation
17
+
18
+ - Publish dashboards and i18n announcements
19
+ - Correct and extend skills from eval findings
20
+ - Fix incorrect guidance and remove em dashes
21
+
22
+ ### Miscellaneous Tasks
23
+
24
+ - Bump plutonium version in rails 8.1 appraisal lockfile
25
+
5
26
  ## [0.65.0] - 2026-10-02
6
27
 
7
28
  ### Bug Fixes
data/README.md CHANGED
@@ -20,7 +20,7 @@ Then scaffold a resource, create a portal, and connect them:
20
20
  ```bash
21
21
  cd myapp
22
22
 
23
- # Scaffold a resource — model, migration, definition, policy
23
+ # Scaffold a resource: model, migration, definition, policy
24
24
  rails g pu:res:scaffold Post title:string body:text published_at:datetime --dest=main_app
25
25
  rails db:prepare
26
26
 
@@ -31,7 +31,7 @@ rails g pu:res:conn Post --dest=app_portal
31
31
  bin/dev
32
32
  ```
33
33
 
34
- Visit `http://localhost:3000/app/posts` — you have a complete CRUD interface.
34
+ Visit `http://localhost:3000/app/posts`: you have a complete CRUD interface.
35
35
 
36
36
  ## What You Stop Writing
37
37
 
@@ -44,7 +44,7 @@ rails g pu:res:scaffold Post ... # Plutonium: full CRUD + search + filters
44
44
 
45
45
  And it doesn't stop at scaffolds:
46
46
 
47
- **Resource-oriented architecture** — models, policies, definitions, and controllers that work together:
47
+ **Resource-oriented architecture**: models, policies, definitions, and controllers that work together:
48
48
 
49
49
  ```ruby
50
50
  # Policy controls WHO can do WHAT
@@ -66,7 +66,7 @@ class PostDefinition < ResourceDefinition
66
66
  end
67
67
  ```
68
68
 
69
- **Packages and portals** — split your app into feature engines and themed web interfaces:
69
+ **Packages and portals**: split your app into feature engines and themed web interfaces:
70
70
 
71
71
  ```bash
72
72
  rails g pu:pkg:package blogging # Business logic
@@ -107,10 +107,10 @@ end
107
107
 
108
108
  ## Why Plutonium
109
109
 
110
- - **Convention over configuration** — extended to resources, policies, portals, and tenancy, not just routes and views.
111
- - **It's just Rails** — generated code lives in your repo. Edit it, override it, delete it. The "magic" is regular Ruby mixins you can read.
112
- - **Multi-tenant ready** — path or domain tenancy, scoped relations, invites and memberships out of the box.
113
- - **AI-readable** — predictable file layout and naming, plus built-in [Claude Code skills](.claude/skills) that teach AI assistants the patterns.
110
+ - **Convention over configuration**: extended to resources, policies, portals, and tenancy, not just routes and views.
111
+ - **It's just Rails**: generated code lives in your repo. Edit it, override it, delete it. The "magic" is regular Ruby mixins you can read.
112
+ - **Multi-tenant ready**: path or domain tenancy, scoped relations, invites and memberships out of the box.
113
+ - **AI-readable**: predictable file layout and naming, plus built-in [Claude Code skills](.claude/skills) that teach AI assistants the patterns.
114
114
 
115
115
  ## Documentation
116
116
 
@@ -133,4 +133,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
133
133
 
134
134
  ## License
135
135
 
136
- MIT License — see [LICENSE.txt](LICENSE.txt).
136
+ MIT License, see [LICENSE.txt](LICENSE.txt).
data/SECURITY.md CHANGED
@@ -12,7 +12,7 @@ please upgrade before reporting an issue to confirm it still reproduces.
12
12
 
13
13
  | Version | Supported |
14
14
  | ------- | ------------------ |
15
- | Latest release (`0.65.x`) | :white_check_mark: |
15
+ | Latest release (`0.66.x`) | :white_check_mark: |
16
16
  | Older releases | :x: |
17
17
 
18
18
  ## Reporting a Vulnerability