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
@@ -20,7 +20,10 @@ for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
20
20
  if (!existsSync(source)) continue // skip dirs that aren't skills (no SKILL.md)
21
21
  let content = readFileSync(source, "utf8")
22
22
 
23
- const description = content.match(/^description:\s*(.+)$/m)?.[1] ?? ""
23
+ // Descriptions are single-quoted YAML scalars (they contain ": "), so strip
24
+ // the quotes and unescape doubled single quotes.
25
+ const rawDescription = content.match(/^description:\s*(.+)$/m)?.[1] ?? ""
26
+ const description = rawDescription.match(/^'(.*)'$/)?.[1].replaceAll("''", "'") ?? rawDescription
24
27
 
25
28
  // Wiki-style [[skill-name]] cross-links become relative markdown links so
26
29
  // crawlers can follow them between the published files.
@@ -34,11 +37,11 @@ skills.sort((a, b) => (a.name === "plutonium" ? -1 : b.name === "plutonium" ? 1
34
37
 
35
38
  const index = `# Plutonium Skills
36
39
 
37
- Task-focused guides for AI agents working with the [Plutonium](https://radioactive-labs.github.io/plutonium-core/) Rails RAD framework. Each file is self-contained markdown. Start with \`plutonium.md\` — it routes to the others.
40
+ Task-focused guides for AI agents working with the [Plutonium](https://radioactive-labs.github.io/plutonium-core/) Rails RAD framework. Each file is self-contained markdown. Start with \`plutonium.md\`; it routes to the others.
38
41
 
39
42
  These are the same skills the gem installs into projects via \`rails g pu:skills:sync\` (Claude Code loads them automatically from \`.claude/skills/\`). Any agent can fetch them directly from the URLs below.
40
43
 
41
- ${skills.map((s) => `- [${s.name}](${s.name}.md) — ${s.description}`).join("\n")}
44
+ ${skills.map((s) => `- [${s.name}](${s.name}.md): ${s.description}`).join("\n")}
42
45
  `
43
46
 
44
47
  writeFileSync(join(outDir, "index.md"), index)
@@ -1,18 +1,17 @@
1
1
  ---
2
2
  title: "Introducing Plutonium dashboards: metric cards and charts for your Rails admin"
3
3
  titleTemplate: "Plutonium Blog"
4
- date: 2026-10-01
4
+ date: 2026-10-02
5
5
  description: Plutonium now ships dashboards. Declare metric, chart and free-form cards in one Ruby class, mount it with one routes line, and every card loads in its own turbo frame inside your portal's auth and tenancy.
6
6
  author: Stefan Froelich
7
7
  tags: [announcement, dashboards, charts]
8
- draft: true
9
8
  ---
10
9
 
11
10
  # Introducing Plutonium dashboards: metric cards and charts for your Rails admin
12
11
 
13
12
  <BlogMeta />
14
13
 
15
- Every portal Plutonium generates opens on a Dashboard page that lists your resources with a record count each. Anything past that, the numbers and charts an admin actually opens the app to see, was yours to build: a controller action, some instance variables, a view. The next release ships it. A dashboard is a Ruby class of cards, mounted with one line in your routes.
14
+ Every portal Plutonium generates opens on a Dashboard page that lists your resources with a record count each. Anything past that, the numbers and charts an admin actually opens the app to see, was yours to build: a controller action, some instance variables, a view. Plutonium 0.65 ships it. A dashboard is a Ruby class of cards, mounted with one line in your routes.
16
15
 
17
16
  ![A dashboard with four metric cards, an area chart of signups per day, a donut chart and a full-width welcome card](/images/blog/dashboards-overview.png)
18
17
 
@@ -20,7 +19,7 @@ Every portal Plutonium generates opens on a Dashboard page that lists your resou
20
19
 
21
20
  - A class-level DSL with three kinds of card: `metric` for headline numbers, `chart` for charts, `card` for anything else.
22
21
  - `register_dashboard`, which draws the routes and a controller that inherits your portal's authentication, tenant scoping and layout.
23
- - Lazy loading: every card is fetched in its own turbo frame, behind a skeleton.
22
+ - Lazy loading: by default every card is fetched in its own turbo frame, behind a skeleton.
24
23
  - A 12-column grid with a default width per kind of card, plus per-card refresh intervals, conditions, links and icons.
25
24
  - A `pu:dashboard` generator, and a Dashboards group in the portal sidebar.
26
25
 
@@ -102,7 +101,7 @@ The browser fetches each frame as it scrolls into view, and the card's block run
102
101
 
103
102
  ![The same dashboard before its frames have loaded: every card is a grey skeleton except the Conversion metric, which is already showing 12.5%](/images/blog/dashboards-loading.png)
104
103
 
105
- The Conversion card in that screenshot is declared `lazy: false`, which makes it inline: its block runs in the page request and the finished card is part of the page response, with no frame, no skeleton and no second request. The price is that the page waits for it. Use it for a number cheap enough that a round trip costs more than the query, and leave everything else lazy. An inline card never refreshes, since there is no frame to reload.
104
+ The Conversion card in that screenshot is declared `lazy: false`, which makes it inline: its block runs in the page request and the finished card is part of the page response, with no frame, no skeleton and no second request. The price is that the page waits for it. Use it for a number cheap enough that a round trip costs more than the query, and leave everything else lazy. An inline card never refreshes, since there is no frame to reload, and in development and test an exception in its block fails the whole page rather than one frame.
106
105
 
107
106
  `refresh 60` on the dashboard reloads every lazy card once a minute. `refresh: 10` on a card overrides it, and `refresh: false` keeps an expensive card out of it. Reloads pause while the tab is hidden and catch up when it becomes visible again, so a dashboard left open overnight is not running your aggregates all night.
108
107
 
@@ -1,11 +1,10 @@
1
1
  ---
2
2
  title: "Introducing Plutonium i18n: every string translatable, the Rails way"
3
3
  titleTemplate: "Plutonium Blog"
4
- date: 2026-09-17
4
+ date: 2026-10-02
5
5
  description: The text a framework renders has always been the framework's, not yours. Plutonium now routes every string it draws through a locale file, so translating the UI is a matter of YAML and nothing else.
6
6
  author: Stefan Froelich
7
7
  tags: [announcement, i18n, rails]
8
- draft: true
9
8
  ---
10
9
 
11
10
  # Introducing Plutonium i18n: every string translatable, the Rails way
@@ -14,7 +13,7 @@ draft: true
14
13
 
15
14
  You have always been able to translate your own app. Rails has shipped `I18n.t`, per-model attribute names and localized dates since long before you needed them. What you could not translate was the framework on top. The button that says "Create", the "Search..." in the filter box, the flash that a record was saved, the empty-state line, the sentence under the paginator: an admin framework ships those in English, baked into views you adopted the framework precisely so you would never open. Translating them meant forking them.
16
15
 
17
- Plutonium now routes every string it renders through a locale file, and resolves every label it derives from a key through the Rails i18n conventions you already know. Translating the UI is a matter of YAML. Nothing in a definition, a policy or a controller changes.
16
+ Plutonium now routes every string it renders through a locale file, and resolves every label it derives from a key through the Rails i18n conventions you already know. Translating the UI is a matter of YAML. Nothing in a definition or a policy changes.
18
17
 
19
18
  ## The strings come from YAML
20
19
 
@@ -29,7 +28,7 @@ en:
29
28
  "false": "Off"
30
29
  ```
31
30
 
32
- That one move covers renaming "On" to "Active" in English and translating it to another language. There is no second mechanism to learn for the second case.
31
+ That one move covers rewording the default "Yes" and "No" in English and translating them to another language. There is no second mechanism to learn for the second case.
33
32
 
34
33
  ## The labels you never wrote translate too
35
34
 
@@ -82,7 +81,7 @@ The Stimulus controllers bundled with the gem read their strings from a JSON blo
82
81
 
83
82
  ## Ship a half-translated locale
84
83
 
85
- You do not have to finish a language before you use it. The dummy demo translates the blog-posts screen and leaves the rest of the app, the dates, and Rodauth's own flashes to fall back to English through `config.i18n.fallbacks`. That matters more than it sounds: the test environment turns on `raise_on_missing_translations`, so a page under a half-done locale would otherwise blow up on the first key you had not reached yet. With fallbacks on, a found-via-fallback string is not missing. Translate the screens your users live on first, ship it, and fill in the rest as you go.
84
+ You do not have to finish a language before you use it. The dummy demo translates the blog-posts screen and leaves the rest of the app, the dates, and Rodauth's own flashes to fall back to English through `config.i18n.fallbacks`. That matters more than it sounds: the dummy's test environment turns on `raise_on_missing_translations`, as many apps do, so a page under a half-done locale would otherwise blow up on the first key you had not reached yet. With fallbacks on, a found-via-fallback string is not missing. Translate the screens your users live on first, ship it, and fill in the rest as you go.
86
85
 
87
86
  ## Adding a language
88
87
 
@@ -9,7 +9,7 @@ rails new myapp -a propshaft -j esbuild -c tailwind \
9
9
  -m https://radioactive-labs.github.io/plutonium-core/templates/plutonium.rb
10
10
  ```
11
11
 
12
- This sets up Rails with Propshaft, esbuild, TailwindCSS, and Plutonium — plus Rodauth auth, asset pipeline, and initial migrations.
12
+ This sets up Rails with Propshaft, esbuild, TailwindCSS, and Plutonium, plus Rodauth auth, asset pipeline, and initial migrations.
13
13
 
14
14
  After the template completes:
15
15
 
@@ -62,7 +62,7 @@ For account options and customization, see [Reference › Auth](/reference/auth/
62
62
  rails generate pu:core:assets
63
63
  ```
64
64
 
65
- Installs npm packages, creates `tailwind.config.js` extending Plutonium's config, imports Plutonium CSS, registers Stimulus controllers. Required if you want to customize the theme — see [Reference › UI › Assets](/reference/ui/assets) and [Guides › Theming](/guides/theming).
65
+ Installs npm packages, creates `tailwind.config.js` extending Plutonium's config, imports Plutonium CSS, registers Stimulus controllers (`registerControllers(application)`, which your own JS bundle must keep), and points `config.assets.stylesheet` / `script` at your `application` bundles. Until then the app serves the gem's prebuilt CSS and JS, so it is required for custom CSS, brand colors or your own Stimulus controllers. It needs `app/assets/stylesheets/application.tailwind.css` and `app/javascript/controllers/index.js` (an app created with `-j esbuild -c tailwind` plus Stimulus). See [Reference › UI › Assets](/reference/ui/assets) and [Guides › Theming](/guides/theming).
66
66
 
67
67
  ## Verify
68
68
 
@@ -99,6 +99,6 @@ bin/dev
99
99
 
100
100
  ## Next steps
101
101
 
102
- - [Tutorial](./tutorial/) — build a complete blog application step-by-step
103
- - [Adding resources](/guides/adding-resources) — create your first resource
104
- - [Creating packages](/guides/creating-packages) — organize code into feature and portal packages
102
+ - [Tutorial](./tutorial/): build a complete blog application step-by-step
103
+ - [Adding resources](/guides/adding-resources): create your first resource
104
+ - [Creating packages](/guides/creating-packages): organize code into feature and portal packages
@@ -118,7 +118,7 @@ rails db:prepare
118
118
 
119
119
  ## Creating a Portal
120
120
 
121
- Resources need a portal to be accessible via the web. Let's create a public admin portal so we can explore the UI right away — we'll add authentication in [Chapter 3](./03-authentication).
121
+ Resources need a portal to be accessible via the web. Let's create a public admin portal so we can explore the UI right away; we'll add authentication in [Chapter 3](./03-authentication).
122
122
 
123
123
  ```bash
124
124
  rails generate pu:pkg:portal admin --public
@@ -149,9 +149,9 @@ Visit `http://localhost:3000/admin/blogging/posts`. You should see an empty post
149
149
 
150
150
  ![Empty posts index](/images/tutorial/02-empty-index.png)
151
151
 
152
- Click "New" — the form is automatically generated from your model's attributes. By default Plutonium opens it as a slideover (right) so you keep the index visible; visiting `/admin/blogging/posts/new` directly renders the same form as a standalone page (left):
152
+ Click "New": the form is automatically generated from your model's attributes. By default Plutonium opens it as a slideover (right) so you keep the index visible; visiting `/admin/blogging/posts/new` directly renders the same form as a standalone page (left):
153
153
 
154
- | Default — slideover from index | Standalone page (direct URL) |
154
+ | Default: slideover from index | Standalone page (direct URL) |
155
155
  |:--:|:--:|
156
156
  | ![Slideover new form](/images/tutorial/02-new-form-modal.png) | ![Standalone new form](/images/tutorial/02-new-form.png) |
157
157
 
@@ -14,7 +14,7 @@ Plutonium integrates Rodauth seamlessly with its portal system.
14
14
 
15
15
  ## Installing Rodauth
16
16
 
17
- Run the Plutonium Rodauth installer once per app — it creates the Rodauth app, plugin, and initializer:
17
+ Run the Plutonium Rodauth installer once per app. It creates the Rodauth app, plugin, and initializer:
18
18
 
19
19
  ```bash
20
20
  rails generate pu:rodauth:install
@@ -24,7 +24,7 @@ rails generate pu:rodauth:install
24
24
 
25
25
  ## Creating an Account Type
26
26
 
27
- Plutonium supports multiple account types. For admins, use the dedicated `pu:rodauth:admin` generator — it's a preset on top of `pu:rodauth:account` that enables 2FA, lockout, audit logging, and disables public signup:
27
+ Plutonium supports multiple account types. For admins, use the dedicated `pu:rodauth:admin` generator. It's a preset on top of `pu:rodauth:account` that enables 2FA, lockout, audit logging, and disables public signup:
28
28
 
29
29
  ```bash
30
30
  rails generate pu:rodauth:admin admin
@@ -71,8 +71,8 @@ rails generate pu:pkg:portal admin --auth=admin --force
71
71
 
72
72
  This updates two files:
73
73
 
74
- - `packages/admin_portal/app/controllers/admin_portal/concerns/controller.rb` — swaps `include Plutonium::Auth::Public` for `include Plutonium::Auth::Rodauth(:admin)`, giving you `current_user`, `logout_url`, and `profile_url` helpers throughout the portal.
75
- - `packages/admin_portal/config/routes.rb` — wraps the engine mount in a routes-level constraint:
74
+ - `packages/admin_portal/app/controllers/admin_portal/concerns/controller.rb`: swaps `include Plutonium::Auth::Public` for `include Plutonium::Auth::Rodauth(:admin)`, giving you `current_user`, `logout_url`, and `profile_url` helpers throughout the portal.
75
+ - `packages/admin_portal/config/routes.rb`: wraps the engine mount in a routes-level constraint:
76
76
 
77
77
  ```ruby
78
78
  constraints Rodauth::Rails.authenticate(:admin) do
@@ -80,9 +80,9 @@ This updates two files:
80
80
  end
81
81
  ```
82
82
 
83
- The routes constraint is what actually gates access — unauthenticated requests to `/admin/*` are redirected to `/admins/login` before they hit any controller or policy.
83
+ The routes constraint is what actually gates access: unauthenticated requests to `/admin/*` are redirected to `/admins/login` before they hit any controller or policy.
84
84
 
85
- (If you prefer not to regenerate, you can apply both edits by hand — they're shown above.)
85
+ (If you prefer not to regenerate, you can apply both edits by hand; they're shown above.)
86
86
 
87
87
  ## Testing Authentication
88
88
 
@@ -164,7 +164,7 @@ Now we can add the author relationship to our Post model. Generate a migration:
164
164
  rails generate migration AddUserToBloggingPosts user:belongs_to
165
165
  ```
166
166
 
167
- Update the migration to add the foreign key:
167
+ Update the migration to add the foreign key (keep the `Migration[x.y]` version Rails generated for your app):
168
168
 
169
169
  ```ruby
170
170
  class AddUserToBloggingPosts < ActiveRecord::Migration[8.0]
@@ -28,8 +28,13 @@ Let's implement basic CRUD permissions:
28
28
 
29
29
  ```ruby
30
30
  class Blogging::PostPolicy < Blogging::ResourcePolicy
31
- # Anyone can view published posts
31
+ # Anyone can open the list; relation_scope (below) decides which posts it holds
32
32
  def read?
33
+ true
34
+ end
35
+
36
+ # A single post: published, or your own
37
+ def show?
33
38
  record.published? || owner?
34
39
  end
35
40
 
@@ -56,6 +61,8 @@ class Blogging::PostPolicy < Blogging::ResourcePolicy
56
61
  end
57
62
  ```
58
63
 
64
+ `read?` also backs `index?`, which is checked against the `Blogging::Post` class rather than a post, so `record.published?` there would raise `NoMethodError` on the list page. Record-state checks go in `show?`, `update?` and `destroy?`, which always receive the post.
65
+
59
66
  ## Understanding the Policy Context
60
67
 
61
68
  Inside a policy, you have access to:
@@ -63,13 +70,13 @@ Inside a policy, you have access to:
63
70
  | Accessor | Description |
64
71
  |----------|-------------|
65
72
  | `user` | The current authenticated user |
66
- | `record` | The resource being authorized |
73
+ | `record` | The resource being authorized (the resource class on collection routes such as index and new) |
67
74
  | `entity_scope` | Parent record for scoping (e.g., Organization in multi-tenant apps) |
68
75
 
69
76
  ```ruby
70
77
  def some_permission?
71
78
  user # => Current user (from authentication)
72
- record # => The Post instance being checked
79
+ record # => The Post instance being checked (Blogging::Post on index/new)
73
80
  entity_scope # => Parent record for multi-tenancy (or nil)
74
81
  end
75
82
  ```
@@ -101,6 +108,12 @@ class Blogging::PostPolicy < Blogging::ResourcePolicy
101
108
  [:title] # Limited view for unpublished posts
102
109
  end
103
110
  end
111
+
112
+ # The table is built from the class, not a post, so this must not touch `record`
113
+ # (without it, index falls back to permitted_attributes_for_read and raises)
114
+ def permitted_attributes_for_index
115
+ [:title, :published, :created_at, :user]
116
+ end
104
117
  end
105
118
  ```
106
119
 
@@ -108,7 +121,7 @@ end
108
121
 
109
122
  Control which records appear in listings:
110
123
 
111
- `relation_scope` is a macro — it takes a block. Writing it as a plain instance
124
+ `relation_scope` is a macro: it takes a block. Writing it as a plain instance
112
125
  method (`def relation_scope(relation)`) overrides nothing and your scoping is
113
126
  silently ignored, so Plutonium raises if you try.
114
127
 
@@ -148,6 +161,10 @@ class AdminPortal::Blogging::PostPolicy < ::Blogging::PostPolicy
148
161
  true
149
162
  end
150
163
 
164
+ def show?
165
+ true
166
+ end
167
+
151
168
  def update?
152
169
  true
153
170
  end
@@ -79,11 +79,11 @@ end
79
79
 
80
80
  ## Testing the Action
81
81
 
82
- Open any unpublished post and click **Actions** in the top-right — the "Publish Post" item appears with its Tabler icon:
82
+ Open any unpublished post and click **Actions** in the top-right; the "Publish Post" item appears with its Tabler icon:
83
83
 
84
84
  ![Publish action in the show page menu](/images/tutorial/05-actions-menu.png)
85
85
 
86
- It also shows on each table row's `⋮` menu — same action, available wherever the record is rendered:
86
+ It also shows on each table row's `⋮` menu, same action, available wherever the record is rendered:
87
87
 
88
88
  ![Publish action in the row menu](/images/tutorial/05-row-actions.png)
89
89
 
@@ -13,8 +13,11 @@ Nested resources are resources that belong to a parent resource. In our blog:
13
13
 
14
14
  ```bash
15
15
  rails generate pu:res:scaffold Comment body:text user:belongs_to Blogging/Post:belongs_to --dest=blogging
16
+ rails db:prepare
16
17
  ```
17
18
 
19
+ Run the migration before connecting the resource to a portal: `pu:res:conn` reads the table's columns to seed the policy's attribute lists.
20
+
18
21
  ## Setting Up the Association
19
22
 
20
23
  Update the Post model to add the `has_many` association:
@@ -69,11 +72,11 @@ class Blogging::PostPolicy < Blogging::ResourcePolicy
69
72
  end
70
73
  ```
71
74
 
72
- The post show page now has tabs — **Details** and **Comments** — driven by the associations you permit:
75
+ The post show page now has tabs, **Details** and **Comments**, driven by the associations you permit:
73
76
 
74
77
  ![Post show page with Details and Comments tabs](/images/tutorial/06-post-with-comments.png)
75
78
 
76
- Clicking **Comments** opens the nested index for that post — a complete sub-resource view with its own paginated table, "New" button, and row actions:
79
+ Clicking **Comments** opens the nested index for that post: a complete sub-resource view with its own paginated table, "New" button, and row actions:
77
80
 
78
81
  ![Nested comments index](/images/tutorial/06-comments-tab.png)
79
82
 
@@ -168,11 +168,11 @@ Now you have two portals:
168
168
  | Admin | `/admin` | Admin | All posts |
169
169
  | Author | `/author` | User | Own posts only |
170
170
 
171
- Log in at `/users/login` with the user account and you land on the Author Portal dashboard — the same chrome as the Admin Portal but mounted at `/author`, gated by `Rodauth::Rails.authenticate(:user)`:
171
+ Log in at `/users/login` with the user account and you land on the Author Portal dashboard, the same chrome as the Admin Portal but mounted at `/author`, gated by `Rodauth::Rails.authenticate(:user)`:
172
172
 
173
173
  ![Author Portal dashboard](/images/tutorial/07-author-dashboard.png)
174
174
 
175
- The posts list lives at `/author/blogging/posts` — same `Blogging::Post` resource, different portal context (and once you add the scoping policy below, scoped to the logged-in author):
175
+ The posts list lives at `/author/blogging/posts`: same `Blogging::Post` resource, different portal context (and once you add the scoping policy below, scoped to the logged-in author):
176
176
 
177
177
  ![Author Portal posts index](/images/tutorial/07-author-portal.png)
178
178
 
@@ -112,39 +112,39 @@ class Blogging::PostDefinition < Blogging::ResourceDefinition
112
112
  index_page_title "Blog Posts"
113
113
  index_page_description "Manage your blog content"
114
114
 
115
- show_page_title { |record| record.title }
116
115
  show_page_description "View post details"
117
116
  end
118
117
  ```
119
118
 
119
+ These setters take a literal, or the definition's lazy `t("some.key")` to translate per request. They have no record context, so a title built from the record goes in a `page_title` override on the page class (below).
120
+
120
121
  The default "Posts" heading becomes your branded title and description:
121
122
 
122
123
  ![Customized index page title](/images/tutorial/08-customized-index.png)
123
124
 
124
- For more advanced customization, you can create custom page classes that inherit from Plutonium's page components:
125
+ For more advanced customization, override the page class nested in the definition:
125
126
 
126
127
  ```ruby
127
- # packages/admin_portal/app/views/admin_portal/blogging/posts/index_page.rb
128
- class AdminPortal::Blogging::Posts::IndexPage < Blogging::PostDefinition::IndexPage
129
- private
130
-
131
- def page_title
132
- "Blog Posts"
133
- end
128
+ class Blogging::PostDefinition < Blogging::ResourceDefinition
129
+ class ShowPage < ShowPage
130
+ private
134
131
 
135
- def page_description
136
- "Manage your blog content"
137
- end
132
+ def page_title
133
+ object.title
134
+ end
138
135
 
139
- # Add content after the page header
140
- def render_after_page_header
141
- div(class: "mb-4 p-4 bg-blue-50 rounded") do
142
- p { "Custom content here" }
136
+ # Add content after the page header
137
+ def render_after_page_header
138
+ div(class: "pu-alert pu-alert-info", role: "status") do
139
+ div(class: "pu-alert-message") { t("blogging.posts.show.comment_count", count: object.comments.count) }
140
+ end
143
141
  end
144
142
  end
145
143
  end
146
144
  ```
147
145
 
146
+ `pu-alert pu-alert-<success|warning|danger|info>` is the banner the flash messages use, with dark-mode colors built in. The text comes from a locale key (see [i18n](/reference/i18n#your-own-components-and-pages)).
147
+
148
148
  ## Custom Form Layout
149
149
 
150
150
  Control form layout using wrapper options in definitions:
@@ -171,20 +171,35 @@ end
171
171
 
172
172
  ## Theming with TailwindCSS
173
173
 
174
- Plutonium uses TailwindCSS 4. Customize the theme:
174
+ Plutonium uses TailwindCSS 4. Out of the box the app serves the gem's prebuilt CSS, so first switch it to your own build:
175
175
 
176
- ```css
177
- /* app/assets/stylesheets/application.css */
178
- @import "tailwindcss";
179
- @import "gem:plutonium/src/css/plutonium.css";
176
+ ```bash
177
+ rails generate pu:core:assets
178
+ ```
179
+
180
+ Brand colors are Tailwind palette colors compiled into the CSS, so change them in the generated `tailwind.config.js` through `plutoniumTailwindConfig.merge`, then rebuild:
181
+
182
+ ```javascript
183
+ // tailwind.config.js
184
+ theme: plutoniumTailwindConfig.merge(plutoniumTailwindConfig.theme, {
185
+ extend: {
186
+ colors: {
187
+ primary: { 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca' }, // Indigo
188
+ },
189
+ },
190
+ }),
191
+ ```
180
192
 
181
- @theme {
182
- --color-primary-500: #6366f1; /* Indigo */
183
- --color-primary-600: #4f46e5;
184
- --color-primary-700: #4338ca;
193
+ Surfaces, borders, radii and shadows are `--pu-*` tokens. Override them after the Plutonium import in `app/assets/stylesheets/application.tailwind.css`, and repeat every one you change in a `.dark` block so dark mode gets its own value:
194
+
195
+ ```css
196
+ :root {
197
+ --pu-radius-md: 0.5rem;
198
+ --pu-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
199
+ }
185
200
 
186
- --radius-md: 0.5rem;
187
- --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
201
+ .dark {
202
+ --pu-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
188
203
  }
189
204
  ```
190
205
 
@@ -201,9 +216,9 @@ class StatusBadge < Plutonium::UI::Component::Base
201
216
 
202
217
  def view_template
203
218
  if @published
204
- span(class: "px-2 py-1 text-xs bg-green-100 text-green-800 rounded") { "Published" }
219
+ span(class: "pu-badge pu-badge-success") { t("blogging.posts.status.published") }
205
220
  else
206
- span(class: "px-2 py-1 text-xs bg-yellow-100 text-yellow-800 rounded") { "Draft" }
221
+ span(class: "pu-badge pu-badge-warning") { t("blogging.posts.status.draft") }
207
222
  end
208
223
  end
209
224
  end
@@ -224,7 +239,7 @@ class CustomLayout < Plutonium::UI::Layout::ResourceLayout
224
239
 
225
240
  # Customize body classes
226
241
  def body_attributes
227
- {class: "antialiased min-h-screen bg-white dark:bg-gray-900"}
242
+ {class: "antialiased pu-min-h-viewport bg-[var(--pu-surface)] text-[var(--pu-text)]"}
228
243
  end
229
244
 
230
245
  # Add content before the main section
@@ -60,7 +60,7 @@ Customize forms, tables, and views to match your requirements.
60
60
  If you get stuck:
61
61
 
62
62
  - Check the [Guides](/guides/) for task-oriented walkthroughs.
63
- - Browse the [Reference](/reference/) for full API surface — [App](/reference/app/), [Resource](/reference/resource/), [Behavior](/reference/behavior/), [UI](/reference/ui/), [Auth](/reference/auth/), [Tenancy](/reference/tenancy/), [Testing](/reference/testing/).
63
+ - Browse the [Reference](/reference/) for full API surface: [App](/reference/app/), [Resource](/reference/resource/), [Behavior](/reference/behavior/), [UI](/reference/ui/), [Auth](/reference/auth/), [Tenancy](/reference/tenancy/), [Testing](/reference/testing/).
64
64
  - Visit [GitHub Issues](https://github.com/radioactive-labs/plutonium-core/issues).
65
65
 
66
66
  [Begin Chapter 1: Project Setup →](./01-setup)
@@ -40,6 +40,8 @@ rails g pu:res:conn Post --dest=admin_portal
40
40
 
41
41
  This creates the portal-specific controller, policy, and definition, plus registers the resource in the portal's routes. Until you do this, the resource has no URL.
42
42
 
43
+ Run it after the migration. When there is no base policy to inherit from, `pu:res:conn` seeds the policy's attribute lists from the table's columns; on an unmigrated table it logs an error and writes empty lists.
44
+
43
45
  For singular resources (`/profile`, `/settings`), add `--singular`:
44
46
 
45
47
  ```bash
@@ -48,7 +50,7 @@ rails g pu:res:conn Profile --dest=customer_portal --singular
48
50
 
49
51
  ### 5. Trim the generated policy
50
52
 
51
- The generator is liberal — it seeds `permitted_attributes_for_*` from your model columns. Open `packages/admin_portal/app/policies/admin_portal/post_policy.rb` and:
53
+ The generator is liberal: it seeds `permitted_attributes_for_*` from your model columns. Open `packages/admin_portal/app/policies/admin_portal/post_policy.rb` and:
52
54
 
53
55
  - Drop `_id` fields when the form should use the association name (e.g. `:user`, not `:user_id`).
54
56
  - Replace `:price_cents` with `:price` if the model uses `has_cents`.
@@ -83,7 +85,7 @@ You should see:
83
85
 
84
86
  Two paths:
85
87
 
86
- **Migration only.** Add a new column with a standard Rails migration. Plutonium auto-detects it — appears in all CRUD pages.
88
+ **Migration only.** Add a new column with a standard Rails migration. Plutonium auto-detects it and shows it in all CRUD pages.
87
89
 
88
90
  **Field with custom rendering.** Add the column, then declare it in the definition:
89
91
 
@@ -117,6 +119,7 @@ rails g pu:res:conn Post --dest=admin_portal
117
119
 
118
120
  ```bash
119
121
  rails g pu:res:scaffold Blogging::Post title:string --dest=blogging
122
+ rails db:prepare
120
123
  rails g pu:res:conn Blogging::Post --dest=admin_portal
121
124
  ```
122
125
 
@@ -132,8 +135,8 @@ The `blogging/post` syntax expands to `Blogging::Post`.
132
135
 
133
136
  ## Related
134
137
 
135
- - [Reference › App › Generators](/reference/app/generators) — full generator catalog
136
- - [Reference › Resource](/reference/resource/) — model + definition + query + actions
137
- - [Reference › App › Portals](/reference/app/portals) — `pu:res:conn` details
138
- - [Creating packages](./creating-packages) — resources in feature packages
139
- - [Nested resources](./nested-resources) — parent/child relationships
138
+ - [Reference › App › Generators](/reference/app/generators): full generator catalog
139
+ - [Reference › Resource](/reference/resource/): model + definition + query + actions
140
+ - [Reference › App › Portals](/reference/app/portals): `pu:res:conn` details
141
+ - [Creating packages](./creating-packages): resources in feature packages
142
+ - [Nested resources](./nested-resources): parent/child relationships