plutonium 0.50.0 → 0.51.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +572 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +163 -300
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +6 -5
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +27 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1009 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +37 -27
  18. data/docs/getting-started/index.md +22 -29
  19. data/docs/getting-started/installation.md +37 -80
  20. data/docs/getting-started/tutorial/index.md +4 -5
  21. data/docs/guides/adding-resources.md +66 -377
  22. data/docs/guides/authentication.md +94 -463
  23. data/docs/guides/authorization.md +124 -370
  24. data/docs/guides/creating-packages.md +94 -296
  25. data/docs/guides/custom-actions.md +121 -441
  26. data/docs/guides/index.md +22 -42
  27. data/docs/guides/multi-tenancy.md +116 -187
  28. data/docs/guides/nested-resources.md +103 -431
  29. data/docs/guides/search-filtering.md +123 -240
  30. data/docs/guides/testing.md +5 -4
  31. data/docs/guides/theming.md +157 -407
  32. data/docs/guides/troubleshooting.md +5 -3
  33. data/docs/guides/user-invites.md +106 -425
  34. data/docs/guides/user-profile.md +76 -243
  35. data/docs/index.md +1 -1
  36. data/docs/reference/app/generators.md +517 -0
  37. data/docs/reference/app/index.md +158 -0
  38. data/docs/reference/app/packages.md +146 -0
  39. data/docs/reference/app/portals.md +377 -0
  40. data/docs/reference/auth/accounts.md +230 -0
  41. data/docs/reference/auth/index.md +88 -0
  42. data/docs/reference/auth/profile.md +185 -0
  43. data/docs/reference/behavior/controllers.md +395 -0
  44. data/docs/reference/behavior/index.md +22 -0
  45. data/docs/reference/behavior/interactions.md +341 -0
  46. data/docs/reference/behavior/policies.md +417 -0
  47. data/docs/reference/index.md +56 -49
  48. data/docs/reference/resource/actions.md +423 -0
  49. data/docs/reference/resource/definition.md +508 -0
  50. data/docs/reference/resource/index.md +50 -0
  51. data/docs/reference/resource/model.md +348 -0
  52. data/docs/reference/resource/query.md +305 -0
  53. data/docs/reference/tenancy/entity-scoping.md +361 -0
  54. data/docs/reference/tenancy/index.md +36 -0
  55. data/docs/reference/tenancy/invites.md +393 -0
  56. data/docs/reference/tenancy/nested-resources.md +267 -0
  57. data/docs/reference/testing/index.md +287 -0
  58. data/docs/reference/ui/assets.md +400 -0
  59. data/docs/reference/ui/components.md +165 -0
  60. data/docs/reference/ui/displays.md +104 -0
  61. data/docs/reference/ui/forms.md +284 -0
  62. data/docs/reference/ui/index.md +30 -0
  63. data/docs/reference/ui/layouts.md +106 -0
  64. data/docs/reference/ui/pages.md +189 -0
  65. data/docs/reference/ui/tables.md +117 -0
  66. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  67. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  68. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  69. data/gemfiles/rails_7.gemfile.lock +1 -1
  70. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  72. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  73. data/lib/generators/pu/invites/install_generator.rb +1 -0
  74. data/lib/plutonium/definition/base.rb +1 -1
  75. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  76. data/lib/plutonium/helpers/turbo_helper.rb +11 -0
  77. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  78. data/lib/plutonium/resource/controller.rb +1 -0
  79. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  80. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  81. data/lib/plutonium/resource/policy.rb +7 -0
  82. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  83. data/lib/plutonium/ui/component/methods.rb +4 -0
  84. data/lib/plutonium/ui/form/base.rb +6 -2
  85. data/lib/plutonium/ui/form/components/json.rb +58 -0
  86. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  87. data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
  88. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  89. data/lib/plutonium/ui/form/resource.rb +0 -4
  90. data/lib/plutonium/ui/grid/resource.rb +1 -1
  91. data/lib/plutonium/ui/layout/base.rb +1 -0
  92. data/lib/plutonium/ui/page/base.rb +0 -7
  93. data/lib/plutonium/ui/page/index.rb +4 -4
  94. data/lib/plutonium/ui/table/resource.rb +1 -1
  95. data/lib/plutonium/version.rb +1 -1
  96. data/lib/plutonium.rb +8 -0
  97. data/lib/tasks/release.rake +15 -1
  98. data/package.json +10 -10
  99. data/src/css/slim_select.css +4 -0
  100. data/src/js/controllers/slim_select_controller.js +61 -0
  101. data/src/js/turbo/turbo_actions.js +33 -0
  102. data/yarn.lock +553 -543
  103. metadata +44 -33
  104. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  105. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  106. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  107. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  108. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  109. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  110. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  111. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  112. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  113. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  114. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  115. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  116. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  117. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  118. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  119. data/docs/reference/assets/index.md +0 -496
  120. data/docs/reference/controller/index.md +0 -412
  121. data/docs/reference/definition/actions.md +0 -462
  122. data/docs/reference/definition/fields.md +0 -383
  123. data/docs/reference/definition/index.md +0 -326
  124. data/docs/reference/definition/query.md +0 -351
  125. data/docs/reference/generators/index.md +0 -648
  126. data/docs/reference/interaction/index.md +0 -449
  127. data/docs/reference/model/features.md +0 -248
  128. data/docs/reference/model/index.md +0 -218
  129. data/docs/reference/policy/index.md +0 -456
  130. data/docs/reference/portal/index.md +0 -379
  131. data/docs/reference/views/forms.md +0 -411
  132. data/docs/reference/views/index.md +0 -544
@@ -0,0 +1,189 @@
1
+ # Pages
2
+
3
+ Each definition has nested page classes for index / show / new / edit / interactive-action. Override the ones you need.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ Definition
9
+ ├── IndexPage → renders Table
10
+ ├── ShowPage → renders Display
11
+ ├── NewPage → renders Form
12
+ ├── EditPage → renders Form
13
+ └── InteractiveActionPage → renders Form
14
+ ```
15
+
16
+ ## Page classes
17
+
18
+ ```ruby
19
+ class PostDefinition < ResourceDefinition
20
+ class IndexPage < IndexPage; end
21
+ class ShowPage < ShowPage; end
22
+ class NewPage < NewPage; end
23
+ class EditPage < EditPage; end
24
+ class InteractiveActionPage < InteractiveActionPage; end
25
+ class Form < Form; end
26
+ class Table < Table; end
27
+ class Display < Display; end
28
+ end
29
+ ```
30
+
31
+ ## Page titles, descriptions, breadcrumbs
32
+
33
+ ```ruby
34
+ class PostDefinition < ResourceDefinition
35
+ index_page_title "Blog Posts"
36
+ index_page_description "Manage all published articles"
37
+ show_page_title "Article Details"
38
+ show_page_title -> { current_record!.title } # dynamic
39
+ new_page_title "Create Post"
40
+ edit_page_title -> { "Edit: #{current_record!.title}" }
41
+
42
+ breadcrumbs true # global default
43
+ index_page_breadcrumbs false # per-page override
44
+ show_page_breadcrumbs true
45
+ new_page_breadcrumbs true
46
+ edit_page_breadcrumbs true
47
+ interactive_action_page_breadcrumbs true
48
+ end
49
+ ```
50
+
51
+ ## Page hooks (preferred over `view_template`)
52
+
53
+ Every page inherits these — use them instead of overriding `view_template` to preserve breadcrumbs, header, and DynaFrame behavior:
54
+
55
+ | Hook | Position |
56
+ |---|---|
57
+ | `render_before_header` / `_after_header` | wraps the entire header section |
58
+ | `render_before_breadcrumbs` / `_after_breadcrumbs` | around the breadcrumb row |
59
+ | `render_before_page_header` / `_after_page_header` | around the title + actions block |
60
+ | `render_before_toolbar` / `_after_toolbar` | around the action toolbar |
61
+ | `render_before_content` / `_after_content` | around main content |
62
+ | `render_before_footer` / `_after_footer` | around footer/pagination |
63
+
64
+ Example:
65
+
66
+ ```ruby
67
+ class ShowPage < ShowPage
68
+ private
69
+
70
+ def page_title
71
+ "#{object.title} — #{object.author.name}"
72
+ end
73
+
74
+ def render_before_content
75
+ div(class: "alert alert-info") do
76
+ plain "This post has #{object.comments.count} comments"
77
+ end
78
+ end
79
+
80
+ def render_after_content
81
+ render RelatedPostsComponent.new(post: object)
82
+ end
83
+
84
+ def render_toolbar
85
+ div(class: "flex gap-2") do
86
+ button(class: "pu-btn pu-btn-md pu-btn-secondary") { "Preview" }
87
+ button(class: "pu-btn pu-btn-md pu-btn-primary") { "Publish" }
88
+ end
89
+ end
90
+ end
91
+ ```
92
+
93
+ ## Custom ERB views (full replacement)
94
+
95
+ For total control, drop the page class entirely with an ERB view at the controller path:
96
+
97
+ ```
98
+ app/views/posts/show.html.erb
99
+ packages/admin_portal/app/views/admin_portal/posts/show.html.erb
100
+ ```
101
+
102
+ The default view simply renders the page class:
103
+
104
+ ```erb
105
+ <%= render current_definition.show_page_class.new %>
106
+ ```
107
+
108
+ Mix — keep the default and add chrome around it:
109
+
110
+ ```erb
111
+ <div class="announcement-banner">Special announcement</div>
112
+ <%= render current_definition.show_page_class.new %>
113
+ <div class="related"><%= render partial: "related" %></div>
114
+ ```
115
+
116
+ ## Detecting render context
117
+
118
+ | Helper | True when |
119
+ |---|---|
120
+ | `in_frame?` | Request targets a turbo-frame |
121
+ | `in_modal?` | Request renders inside a modal/slideover (primary or secondary) |
122
+ | `in_secondary_modal?` | Request renders inside the stacked secondary modal |
123
+
124
+ Use to pin action strips, omit nav chrome, or swap layouts.
125
+
126
+ ### Stacked modals (secondary frame)
127
+
128
+ Association inputs include an inline `+` button. When the parent form is itself rendered in a modal, the `+` opens a **second stacked modal** in `Plutonium::REMOTE_MODAL_SECONDARY_FRAME` instead of replacing the primary modal. On successful create, the secondary closes and the primary frame reloads so the new record appears in the select — no developer wiring.
129
+
130
+ For custom flows: `helpers.turbo_stream_close_frame(frame_id)` and `helpers.turbo_stream_reload_frame(frame_id)` are available.
131
+
132
+ See [Forms › Association inputs](./forms#association-inputs).
133
+
134
+ ## Modals & slideovers
135
+
136
+ The framework's `:new` / `:edit` actions render inline inside a modal. Choose the chrome per-resource via the definition:
137
+
138
+ ```ruby
139
+ class PostDefinition < ResourceDefinition
140
+ modal :slideover # default — slide-in panel from the right
141
+ # modal :centered # centered dialog
142
+ # modal false # full standalone pages (no modal)
143
+ end
144
+ ```
145
+
146
+ Custom interactive actions render in their own dialog with their own per-action `modal:` option (`:centered` default, or `:slideover`). See [Resource › Actions](/reference/resource/actions#action-options).
147
+
148
+ ## Tabs on the show page
149
+
150
+ Show pages with `permitted_associations` (see [Behavior › Policy](/reference/behavior/policies#association-permissions)) render a tablist: **Details** tab first, then one tab per association. The active tab is reflected in the URL hash (`#products`, `#refund-requests`) so the page deep-links and the active state survives reload / back navigation. Tab rows scroll horizontally on narrow viewports — they don't wrap.
151
+
152
+ ## Portal-specific overrides
153
+
154
+ Each portal can override page classes independently. The portal definition inherits from the base definition, and its nested classes inherit from the base's nested classes:
155
+
156
+ ```ruby
157
+ class AdminPortal::PostDefinition < ::PostDefinition
158
+ class ShowPage < ShowPage # inherits from ::PostDefinition::ShowPage
159
+ def render_after_content
160
+ super
161
+ render AdminOnlySection.new(post: object)
162
+ end
163
+ end
164
+ end
165
+ ```
166
+
167
+ ## Available context
168
+
169
+ Inside any page / form / display / Phlex component, the same set of helpers is available — model accessors, definition/policy methods, URL helpers, `current_user`. For the full list, see [Behavior › Controllers › Key methods](/reference/behavior/controllers#key-methods) — pages inherit the same surface.
170
+
171
+ In Phlex components, Rails helpers are accessed via the `helpers` proxy:
172
+
173
+ ```ruby
174
+ class MyComponent < Plutonium::UI::Component::Base
175
+ def view_template
176
+ helpers.link_to(...)
177
+ helpers.number_to_currency(...)
178
+ end
179
+ end
180
+ ```
181
+
182
+ ## Related
183
+
184
+ - [Forms](./forms) — Form class, field builder, themes
185
+ - [Displays](./displays) — show-page Display class
186
+ - [Tables](./tables) — index-page Table class
187
+ - [Components](./components) — built-in component kit, custom Phlex components, DynaFrame
188
+ - [Layouts](./layouts) — overall shell, eject, ResourceLayout
189
+ - [Resource › Definition](/reference/resource/definition) — page titles, breadcrumbs, modal mode, metadata panel
@@ -0,0 +1,117 @@
1
+ # Tables
2
+
3
+ The index page's table rendering. Override the `Table` nested class in your definition for custom layouts (e.g. card grids).
4
+
5
+ ## Custom table template
6
+
7
+ ```ruby
8
+ class PostDefinition < ResourceDefinition
9
+ class Table < Table
10
+ def view_template
11
+ render_search_bar
12
+ render_scopes_bar
13
+
14
+ if collection.empty?
15
+ render_empty_card
16
+ else
17
+ # Replace the table with a card grid
18
+ div(class: "grid grid-cols-3 gap-4") do
19
+ collection.each { |post| render PostCardComponent.new(post:) }
20
+ end
21
+ end
22
+
23
+ render_footer
24
+ end
25
+ end
26
+ end
27
+ ```
28
+
29
+ ## Methods
30
+
31
+ | Method | Purpose |
32
+ |---|---|
33
+ | `render_search_bar` | Toolbar search input |
34
+ | `render_scopes_bar` | Quick-filter scope buttons |
35
+ | `render_table` | Default table rendering |
36
+ | `render_empty_card` | Empty state |
37
+ | `render_footer` | Pagination |
38
+ | `collection` | Paginated records |
39
+ | `resource_fields` | Column field names |
40
+
41
+ ## Per-column customization
42
+
43
+ Prefer declaring column behavior in the **definition** rather than overriding the entire `Table`:
44
+
45
+ ```ruby
46
+ class PostDefinition < ResourceDefinition
47
+ column :title, align: :start # default
48
+ column :status, align: :center
49
+ column :amount, align: :end
50
+
51
+ # formatter — receives just the value
52
+ column :description, formatter: ->(value) { value&.truncate(30) }
53
+ column :price, formatter: ->(value) { "$%.2f" % value if value }
54
+
55
+ # block — receives the full record
56
+ column :full_name do |record|
57
+ "#{record.first_name} #{record.last_name}"
58
+ end
59
+ end
60
+ ```
61
+
62
+ See [Resource › Definition › Column options](/reference/resource/definition#column-options).
63
+
64
+ ## Grid view
65
+
66
+ For card-based layouts as a switchable alternative to the table, use the built-in Grid view — declare `grid_fields` in the definition:
67
+
68
+ ```ruby
69
+ class UserDefinition < ResourceDefinition
70
+ grid_fields(
71
+ image: :avatar,
72
+ header: :name,
73
+ subheader: :email,
74
+ body: :bio,
75
+ meta: [:role, :status],
76
+ footer: :last_seen_at
77
+ )
78
+
79
+ default_index_view :grid
80
+ end
81
+ ```
82
+
83
+ See [Resource › Definition › Index views](/reference/resource/definition#index-views-table-grid). You only need a custom `Table` class when you want something neither Table nor Grid covers.
84
+
85
+ ## Theming
86
+
87
+ Override the theme via a nested `Theme` class:
88
+
89
+ ```ruby
90
+ class PostDefinition < ResourceDefinition
91
+ class Table < Table
92
+ class Theme < Plutonium::UI::Table::Theme
93
+ def self.theme
94
+ super.merge(
95
+ wrapper: "pu-table-wrapper",
96
+ base: "pu-table",
97
+ header: "pu-table-header",
98
+ header_cell: "pu-table-header-cell",
99
+ body_row: "pu-table-body-row",
100
+ body_cell: "pu-table-body-cell"
101
+ )
102
+ end
103
+ end
104
+ end
105
+ end
106
+ ```
107
+
108
+ ### Theme keys
109
+
110
+ `wrapper`, `base`, `header`, `header_cell`, `body_row`, `body_cell`, `sort_icon`.
111
+
112
+ ## Related
113
+
114
+ - [Pages](./pages) — `IndexPage` render hooks (a lighter alternative for top/bottom chrome)
115
+ - [Components](./components) — `PostCardComponent` and other reusable Phlex pieces
116
+ - [Resource › Definition](/reference/resource/definition) — column configuration, grid view
117
+ - [Resource › Query](/reference/resource/query) — search, filters, scopes, sort
@@ -0,0 +1,203 @@
1
+ # Typeahead Endpoint Design
2
+
3
+ **Status:** Approved (2026-05-09)
4
+ **Author:** stefan
5
+ **Scope:** New backend-driven typeahead/autocomplete primitive for resource form inputs and index filter inputs.
6
+
7
+ ## Goal
8
+
9
+ Add an async typeahead endpoint to every Plutonium resource so association-backed selects (and any future typeahead-capable input) can fetch matching records from the server instead of materialising up to `DEFAULT_CHOICE_LIMIT` options into the page at render time. This unblocks association pickers over large tables (where the existing 100-row cap silently truncates) without forcing every input into a custom JS solution.
10
+
11
+ ## Non-goals
12
+
13
+ - Pagination of typeahead results (we use a hard cap with an overflow indicator; pagination can be added later if a real need surfaces).
14
+ - Rich result rows (subtitle, icon, avatar). MVP returns minimal `{value, label}` per row; richer payloads are a separate iteration.
15
+ - Replacing the existing eager-list ResourceSelect; the eager path stays as the fallback / small-table mode.
16
+
17
+ ## Architecture
18
+
19
+ Three layers, mirroring how `Plutonium::Resource::Controllers::InteractiveActions` is composed today.
20
+
21
+ ### 1. Routing — `Plutonium::Routing::MapperExtensions`
22
+
23
+ Two routes are added to the existing `interactive_resource_actions` concern (auto-mounted on every Plutonium resource alongside `record_actions`, `bulk_actions`, etc.):
24
+
25
+ ```
26
+ GET /<resource>/typeahead/input/:name?q=… → typeahead_input
27
+ GET /<resource>/typeahead/filter/:name?q=… → typeahead_filter
28
+ ```
29
+
30
+ Both collection-level. **No member variant** — authorization on the parent resource class is sufficient (see "Authorization" below).
31
+
32
+ ### 2. Controller concern — `Plutonium::Resource::Controllers::Typeahead`
33
+
34
+ Two thin actions plus a single `before_action` for auth.
35
+
36
+ ```ruby
37
+ module Plutonium::Resource::Controllers::Typeahead
38
+ extend ActiveSupport::Concern
39
+
40
+ included do
41
+ before_action :authorize_typeahead!, only: %i[typeahead_input typeahead_filter]
42
+ end
43
+
44
+ def typeahead_input
45
+ name = params[:name].to_sym
46
+ defn = current_definition.defined_inputs[name]
47
+ return head(:not_found) unless defn
48
+
49
+ render_typeahead_response(defn)
50
+ end
51
+
52
+ def typeahead_filter
53
+ name = params[:name].to_sym
54
+ filter = current_query_object.filter_definitions[name]
55
+ return head(:not_found) unless filter
56
+
57
+ defn = filter.defined_inputs[:value]
58
+ return head(:not_found) unless defn
59
+
60
+ render_typeahead_response(defn)
61
+ end
62
+
63
+ private
64
+
65
+ def render_typeahead_response(defn)
66
+ klass = lookup_input_class(defn)
67
+ return render(json: { error: "input is not typeahead-capable" }, status: :bad_request) unless klass < Plutonium::UI::Form::Components::Searchable
68
+
69
+ widget = klass.build_for_typeahead(defn[:options] || {})
70
+ results, has_more = widget.typeahead(
71
+ query: params[:q].to_s,
72
+ limit: TYPEAHEAD_LIMIT,
73
+ controller: self
74
+ )
75
+ render json: { results: results, has_more: has_more }
76
+ end
77
+
78
+ def authorize_typeahead!
79
+ authorize! resource_class, to: :typeahead?
80
+ end
81
+
82
+ # Maps the input definition's :as symbol (e.g. :resource_select) to a
83
+ # component class. Backed by an explicit registry — only inputs that
84
+ # opted in by including Searchable register here, so anything not in
85
+ # the registry falls through to the 400 branch.
86
+ def lookup_input_class(defn)
87
+ Plutonium::UI::Form::Components::Searchable.registry[defn[:options]&.[](:as)&.to_sym]
88
+ end
89
+ end
90
+ ```
91
+
92
+ `TYPEAHEAD_LIMIT` is a module-level constant (default `50`). Easy to tune.
93
+
94
+ ### 3. Search behavior — `Plutonium::UI::Form::Components::Searchable`
95
+
96
+ A small mixin. Mixed into `ResourceSelect` (and into any future input that wants typeahead). Two-method public surface:
97
+
98
+ ```ruby
99
+ module Plutonium::UI::Form::Components::Searchable
100
+ extend ActiveSupport::Concern
101
+
102
+ # Maps :as symbol -> component class. Each typeahead-capable widget
103
+ # populates this when it includes Searchable so the controller can
104
+ # dispatch by name without a brittle inflection convention.
105
+ def self.registry
106
+ @registry ||= {}
107
+ end
108
+
109
+ class_methods do
110
+ # Subclasses call this to claim their :as symbol in the registry.
111
+ def typeahead_input_name(name)
112
+ Plutonium::UI::Form::Components::Searchable.registry[name.to_sym] = self
113
+ end
114
+
115
+ # Allocates the widget and assigns just the ivars #typeahead needs.
116
+ # Bypasses Phlex's render-time build_attributes pipeline so we don't
117
+ # need a field/form context to run the search.
118
+ def build_for_typeahead(options)
119
+ allocate.tap { |w| w.send(:apply_typeahead_options, options) }
120
+ end
121
+ end
122
+
123
+ # Returns [results_array, has_more_bool]. results entries are { value:, label: }.
124
+ def typeahead(query:, limit:, controller:)
125
+ raw = collect_typeahead_candidates(query, controller: controller)
126
+ over = raw.length > limit
127
+ [raw.first(limit).map { |r| serialize_typeahead_row(r) }, over]
128
+ end
129
+ end
130
+ ```
131
+
132
+ `ResourceSelect` implements `apply_typeahead_options`, `collect_typeahead_candidates`, and `serialize_typeahead_row`:
133
+
134
+ - `apply_typeahead_options(options)` reads `@association_class`, `@raw_choices`, `@choice_limit`, `@skip_authorization` from the input definition's options hash — the same keys the existing `build_attributes` consumes at render time.
135
+ - `collect_typeahead_candidates` branches:
136
+ - if `@raw_choices` (static list) — `@raw_choices.select { |label, _| label.to_s.downcase.include?(query.downcase) }`. No auth: choices are static, definition-author-controlled.
137
+ - elsif `@association_class` — runs the search through `controller.send(:authorized_resource_scope, @association_class)` so the associated resource's `policy.relation_scope` enforces row-level auth, then applies the associated resource definition's `search` block if present, else `LIKE` on the column backing `to_label` (or skips filtering when query is blank).
138
+ - `serialize_typeahead_row(row)` returns `{ value: row.to_signed_global_id.to_s, label: row.to_label }` for records, or `{ value: raw_value, label: raw_label }` for static choices.
139
+
140
+ The cap is **`limit + 1`** at the SQL level (`LIMIT 51` for a `limit: 50` request) so we can detect overflow without a separate `COUNT`.
141
+
142
+ ## Authorization
143
+
144
+ Two gates, layered:
145
+
146
+ 1. **Parent gate** — `policy.typeahead?` on the resource hosting the endpoint. Defaults to `index?` (collection-shaped — typeahead is "list/search records of this class", not "show one record"). Override per-resource if needed (e.g. `def typeahead? = create? || update?` to require write intent).
147
+ 2. **Row gate** — when the input is association-backed, results are scoped through the *associated* resource's `policy.relation_scope` via the existing `authorized_resource_scope` helper. So a user can typeahead Authors only if they're allowed to read Authors, regardless of whether they can edit Posts.
148
+
149
+ Static `choices` lists bypass the row gate (they're not records, they're definition-author-controlled enumerations).
150
+
151
+ ## Data flow
152
+
153
+ ```
154
+ Browser (Stimulus controller)
155
+ fetch GET /widgets/typeahead/input/author?q=ali
156
+
157
+ Typeahead#typeahead_input
158
+ authorize_typeahead! → policy.typeahead? on Widget [parent gate]
159
+ defn = current_definition.defined_inputs[:author]
160
+ widget = ResourceSelect.build_for_typeahead(defn[:options])
161
+ widget.typeahead(query: "ali", limit: 50, controller: self)
162
+ authorized_resource_scope(User).where("name LIKE ?", "%ali%").limit(51) [row gate]
163
+ serialize each → { value: sgid, label: to_label }
164
+ render json: { results: [...], has_more: false }
165
+ ```
166
+
167
+ ## Components
168
+
169
+ | File | Responsibility |
170
+ |---|---|
171
+ | `lib/plutonium/routing/mapper_extensions.rb` | Add 2 routes to the `interactive_resource_actions` concern. |
172
+ | `lib/plutonium/resource/controllers/typeahead.rb` | **New.** Controller concern with `typeahead_input`/`typeahead_filter` actions, auth, dispatch, JSON serialization. |
173
+ | `lib/plutonium/resource/controller.rb` | Include `Controllers::Typeahead`. |
174
+ | `lib/plutonium/resource/policy.rb` | Add `typeahead?` defaulting to `index?`. |
175
+ | `lib/plutonium/ui/form/components/searchable.rb` | **New.** `Searchable` mixin (class-level `build_for_typeahead`, instance-level `typeahead`). |
176
+ | `lib/plutonium/ui/form/components/resource_select.rb` | Include `Searchable`, call `typeahead_input_name :resource_select` to register. Implement `apply_typeahead_options`, `collect_typeahead_candidates`, `serialize_typeahead_row`. Wire Stimulus controller + remote URL data attrs into the rendered `<select>`. |
177
+ | `src/js/controllers/resource_select_controller.js` | **New.** Stimulus controller: debounced fetch, populates options on the underlying `<select>`, surfaces overflow hint, handles network errors. |
178
+
179
+ ## Error handling
180
+
181
+ - Unknown input/filter name → `404 Not Found`.
182
+ - Input class registered but doesn't include `Searchable` → `400 Bad Request` with `{error: "input is not typeahead-capable"}`.
183
+ - Authorization failure → existing `ActionPolicy::Unauthorized` flow → `403`.
184
+ - Empty/blank `q` → return all candidates within the cap (so initial dropdown open shows something useful, mirroring the eager mode).
185
+ - Network/parse errors on the JS side → controller leaves the existing `<select>` options intact and shows a small "couldn't search" inline notice; user can retry.
186
+
187
+ ## Testing
188
+
189
+ - **Unit — `Searchable#typeahead` (ResourceSelect):** static choices filter case-insensitively; association case routes through `authorized_resource_scope`; overflow detection (`limit+1` rows in DB → `has_more: true`); blank query returns top-N.
190
+ - **Controller — `Typeahead#typeahead_input` / `typeahead_filter`:** happy path renders correct JSON envelope; unknown name → 404; non-searchable input class → 400; auth denied → 403.
191
+ - **Integration:** full request through `admin_portal` hitting a registered resource, verifying SGID round-trip (the value in the response is accepted by ResourceSelect on form submit).
192
+ - **JS — Stimulus controller:** debounces input, handles `has_more`, handles network errors. Lightweight, behavior-focused.
193
+
194
+ ## Migration & rollout
195
+
196
+ - Existing eager `ResourceSelect` keeps working — typeahead is opt-in per render via a flag on the input definition (e.g. `as: :resource_select, typeahead: true`). When unset, the component renders today's eager list. The Stimulus controller only attaches when `data-resource-select-typeahead-url-value` is present.
197
+ - Filter inputs default to typeahead when the underlying input class supports it (filters are the worst pain point for the 100-row cap).
198
+
199
+ ## Open questions / deferred
200
+
201
+ - Server-driven sort order beyond what `relation_scope` returns (e.g. recency, fuzzy-rank). Out of scope for MVP.
202
+ - Multi-select typeahead UX (chips, paste-multiple). MVP supports `multiple: true` mechanically (the array of SGIDs round-trips fine), but the dropdown UX is single-select-shaped. Iteration.
203
+ - Caching/coalescing repeated queries client-side. Defer.
@@ -0,0 +1,99 @@
1
+ # Skill Compaction & Consolidation Design
2
+
3
+ **Date:** 2026-05-12
4
+ **Status:** Approved (pending implementation)
5
+
6
+ ## Problem
7
+
8
+ The `.claude/skills/` directory currently holds 19 Plutonium skills totaling ~7,846 lines. Several issues:
9
+
10
+ - Skills are too verbose for a stable-API framework. Plutonium rarely changes shape, so re-explaining concepts has little ROI.
11
+ - Several skills are read together for any non-trivial task (e.g. create-resource + model + definition). Loading them separately wastes context.
12
+ - Some skills duplicate content (Rails-isms, philosophy preambles, repeated DSL explanations).
13
+
14
+ Skills are written for **developers using the framework**, not for first-time Rails users. They should read like reference + decision rules, not tutorials.
15
+
16
+ ## Goals
17
+
18
+ 1. Reduce total skill volume by ~45% (target: ~4,150 lines from 7,846).
19
+ 2. Merge skills that are almost always loaded together.
20
+ 3. Keep skills self-contained with inline code examples (chosen over linking to `test/dummy`).
21
+ 4. Preserve high-value reference material (option/DSL/field tables).
22
+
23
+ ## Non-Goals
24
+
25
+ - Restructuring user-facing `docs/` site.
26
+ - Changing the framework API.
27
+ - Splitting examples into separate files outside the skill.
28
+
29
+ ## Target Skill Map
30
+
31
+ From 19 skills to 8:
32
+
33
+ | New skill | Merges | Est. lines |
34
+ |---|---|---|
35
+ | `plutonium` | (router, kept) | ~150 |
36
+ | `plutonium-app` | installation + portal + package | ~600 |
37
+ | `plutonium-resource` | create-resource + model + definition | ~800 |
38
+ | `plutonium-behavior` | controller + policy + interaction | ~700 |
39
+ | `plutonium-ui` | views + forms + assets | ~700 |
40
+ | `plutonium-auth` | (kept, compacted) | ~350 |
41
+ | `plutonium-tenancy` | entity-scoping + nested-resources + invites | ~600 |
42
+ | `plutonium-testing` | (kept, compacted) | ~250 |
43
+
44
+ **Total: ~4,150 lines.**
45
+
46
+ ### Rationale per merge
47
+
48
+ - **plutonium-app** — installation, portal creation, and package creation are the setup arc. Always done together on a new app.
49
+ - **plutonium-resource** — model declarations, scaffold options, and definition DSL are the core "build a resource" workflow.
50
+ - **plutonium-behavior** — controllers, policies, and interactions form the request/authorization/business-logic layer.
51
+ - **plutonium-ui** — views, forms, and assets all touch presentation. Assets covers the toolchain backing both views and forms.
52
+ - **plutonium-tenancy** — entity-scoping is the core mechanic; nested-resources and invites are both consumers of that mechanic.
53
+ - **plutonium-auth** stays solo at the user's request (rodauth/profile is distinct enough from tenancy/invites).
54
+ - **plutonium-testing** stays solo (orthogonal concern, loaded only for test work).
55
+
56
+ ## Compaction Rules
57
+
58
+ Applied to every skill during merge.
59
+
60
+ **Cut:**
61
+ - Rails/Ruby basics — assume reader knows Rails.
62
+ - Philosophy/motivation preambles.
63
+ - Duplicated content across merged skills (one canonical location per concept).
64
+ - Verbose prose where a 10-line snippet shows the same thing.
65
+ - Marketing copy ("Plutonium gives you...").
66
+
67
+ **Keep:**
68
+ - Decision rules ("use X when…, Y when…").
69
+ - Non-obvious gotchas and constraints.
70
+ - Short canonical inline snippets.
71
+ - **Option/field/DSL tables** — high-value reference, kept verbatim.
72
+ - Cross-references to other skills via `[[plutonium-resource]]` style links.
73
+
74
+ ## Format per merged skill
75
+
76
+ 1. **Header paragraph** — what this covers + when to load.
77
+ 2. **Sub-sections per merged topic** — each with: decision rules → minimal inline example → gotchas → tables (where applicable).
78
+ 3. **Cross-references** at bottom.
79
+
80
+ ## Rollout
81
+
82
+ 1. **Pilot:** `plutonium-resource` first (largest, hardest — biggest signal on whether the template works).
83
+ 2. **Review pilot together** — adjust template if needed.
84
+ 3. **Apply pattern** to the remaining merges. One PR per merged skill OR all-in-one (TBD with user).
85
+ 4. **Update `plutonium` router skill** last — it references the new names.
86
+ 5. **Delete old skill directories** only after the new one lands.
87
+
88
+ Skills require a gem release to take effect for users (per `CLAUDE.md`), so this ships as a single release regardless of how PRs are split.
89
+
90
+ ## Risks
91
+
92
+ - **Loss of granularity for context loading** — a single merged skill loads more tokens even when only one sub-topic is needed. Mitigated by aggressive compaction (loose budget but still much smaller than today's biggest individual skills).
93
+ - **Cross-references breaking** — the `plutonium` router skill and any external references must update at the same time as the merge.
94
+ - **Drift from `docs/`** — the user-facing docs site may still reference old skill structure; out of scope for this spec but worth noting.
95
+
96
+ ## Open Questions
97
+
98
+ - Should the merge land as one PR or eight? (Deferred to rollout time.)
99
+ - Are there external references to the old skill names (other repos, marketplace listings) that need updating?