plutonium 0.62.2 → 0.63.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 (255) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -0
  3. data/.claude/skills/plutonium-app/SKILL.md +3 -3
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
  5. data/.claude/skills/plutonium-auth/SKILL.md +36 -0
  6. data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
  7. data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
  8. data/.claude/skills/plutonium-resource/SKILL.md +259 -12
  9. data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
  10. data/.claude/skills/plutonium-ui/SKILL.md +115 -14
  11. data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
  12. data/CHANGELOG.md +67 -0
  13. data/CLAUDE.md +87 -0
  14. data/Rakefile +34 -0
  15. data/SECURITY.md +1 -1
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +685 -102
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/rodauth/_login_form.html.erb +13 -0
  22. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  23. data/docs/.vitepress/config.ts +81 -3
  24. data/docs/.vitepress/theme/blog.data.ts +44 -0
  25. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  26. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  27. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  28. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  29. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  30. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  31. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  32. data/docs/.vitepress/theme/index.ts +8 -4
  33. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  34. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  35. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  36. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  37. data/docs/blog/index.md +13 -0
  38. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  39. data/docs/blog/introducing-plutonium.md +253 -0
  40. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  41. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  42. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  43. data/docs/blog/two-forms-one-dom-id.md +69 -0
  44. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  45. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  46. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  47. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  48. data/docs/guides/authentication.md +73 -0
  49. data/docs/guides/authorization.md +2 -0
  50. data/docs/guides/creating-packages.md +5 -3
  51. data/docs/guides/custom-actions.md +74 -12
  52. data/docs/guides/customizing-ui.md +9 -2
  53. data/docs/guides/index.md +1 -0
  54. data/docs/guides/kanban.md +7 -5
  55. data/docs/guides/nested-resources.md +11 -1
  56. data/docs/guides/performance.md +104 -0
  57. data/docs/guides/user-invites.md +1 -1
  58. data/docs/guides/wizards.md +9 -1
  59. data/docs/index.md +3 -3
  60. data/docs/public/images/home/tour-actions.png +0 -0
  61. data/docs/public/images/home/tour-async.png +0 -0
  62. data/docs/public/images/home/tour-kanban.png +0 -0
  63. data/docs/public/images/home/tour-tenancy.png +0 -0
  64. data/docs/public/images/home/tour-wizard.png +0 -0
  65. data/docs/public/images/reference/async-progress-page.png +0 -0
  66. data/docs/public/images/reference/async-running-banner.png +0 -0
  67. data/docs/public/templates/experimental.rb +34 -0
  68. data/docs/public/templates/pluton8.rb +14 -0
  69. data/docs/reference/app/portals.md +15 -3
  70. data/docs/reference/auth/accounts.md +19 -0
  71. data/docs/reference/behavior/async-interactions.md +295 -0
  72. data/docs/reference/behavior/controllers.md +17 -4
  73. data/docs/reference/behavior/index.md +7 -1
  74. data/docs/reference/behavior/interactions.md +152 -22
  75. data/docs/reference/configuration.md +5 -0
  76. data/docs/reference/index.md +1 -0
  77. data/docs/reference/kanban/dsl.md +7 -4
  78. data/docs/reference/kanban/index.md +1 -1
  79. data/docs/reference/kanban/positioning.md +26 -4
  80. data/docs/reference/positioning.md +568 -0
  81. data/docs/reference/resource/actions.md +97 -4
  82. data/docs/reference/resource/definition.md +181 -9
  83. data/docs/reference/tenancy/invites.md +1 -1
  84. data/docs/reference/tenancy/nested-resources.md +60 -2
  85. data/docs/reference/ui/assets.md +4 -0
  86. data/docs/reference/ui/components.md +57 -4
  87. data/docs/reference/ui/displays.md +20 -10
  88. data/docs/reference/ui/index.md +1 -1
  89. data/docs/reference/wizard/dsl.md +33 -0
  90. data/docs/reference/wizard/storage-config.md +1 -0
  91. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  93. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  95. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  97. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  98. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  99. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  100. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  101. data/gemfiles/postgres.gemfile.lock +85 -85
  102. data/gemfiles/rails_7.gemfile.lock +322 -140
  103. data/gemfiles/rails_8.0.gemfile.lock +125 -115
  104. data/gemfiles/rails_8.1.gemfile.lock +126 -116
  105. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  106. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  107. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  108. data/lib/generators/pu/invites/install_generator.rb +3 -3
  109. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  110. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  112. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  113. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  114. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  115. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  116. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  118. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  119. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  120. data/lib/plutonium/action/base.rb +71 -9
  121. data/lib/plutonium/action/interactive.rb +9 -0
  122. data/lib/plutonium/attachments.rb +254 -0
  123. data/lib/plutonium/configuration.rb +82 -1
  124. data/lib/plutonium/core/controller.rb +50 -7
  125. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  126. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  127. data/lib/plutonium/definition/base.rb +51 -0
  128. data/lib/plutonium/definition/display_layout.rb +112 -0
  129. data/lib/plutonium/definition/index_views.rb +8 -7
  130. data/lib/plutonium/definition/input_aliases.rb +38 -0
  131. data/lib/plutonium/definition/page_widths.rb +65 -0
  132. data/lib/plutonium/definition/positioning.rb +126 -0
  133. data/lib/plutonium/definition/sorting.rb +17 -2
  134. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  135. data/lib/plutonium/interaction/README.md +61 -24
  136. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  137. data/lib/plutonium/interaction/async/context.rb +419 -0
  138. data/lib/plutonium/interaction/async/executor.rb +422 -0
  139. data/lib/plutonium/interaction/async/job.rb +80 -0
  140. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  141. data/lib/plutonium/interaction/async/run.rb +394 -0
  142. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  143. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  144. data/lib/plutonium/interaction/base.rb +34 -7
  145. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  146. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  147. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  148. data/lib/plutonium/kanban/board.rb +14 -0
  149. data/lib/plutonium/kanban/column.rb +4 -2
  150. data/lib/plutonium/kanban/dsl.rb +4 -1
  151. data/lib/plutonium/kanban/grouping.rb +9 -22
  152. data/lib/plutonium/kanban/positioning.rb +5 -65
  153. data/lib/plutonium/positioning/config.rb +94 -0
  154. data/lib/plutonium/positioning/model.rb +128 -0
  155. data/lib/plutonium/positioning.rb +25 -86
  156. data/lib/plutonium/railtie.rb +1 -0
  157. data/lib/plutonium/resource/controller.rb +118 -38
  158. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  159. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  160. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  161. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  162. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  163. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  164. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  165. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  166. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  167. data/lib/plutonium/resource/policy.rb +33 -0
  168. data/lib/plutonium/resource/query_object.rb +36 -0
  169. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  170. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  171. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  172. data/lib/plutonium/testing/resource_policy.rb +6 -2
  173. data/lib/plutonium/ui/action_button.rb +12 -7
  174. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  175. data/lib/plutonium/ui/block.rb +21 -1
  176. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  177. data/lib/plutonium/ui/component/methods.rb +5 -0
  178. data/lib/plutonium/ui/component/positionable.rb +112 -0
  179. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  180. data/lib/plutonium/ui/component/section.rb +185 -0
  181. data/lib/plutonium/ui/display/base.rb +13 -1
  182. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  183. data/lib/plutonium/ui/display/components/section.rb +18 -0
  184. data/lib/plutonium/ui/display/resource.rb +141 -22
  185. data/lib/plutonium/ui/display/theme.rb +20 -1
  186. data/lib/plutonium/ui/export_button.rb +1 -1
  187. data/lib/plutonium/ui/form/base.rb +8 -7
  188. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  189. data/lib/plutonium/ui/form/components/section.rb +7 -62
  190. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  191. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  192. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  193. data/lib/plutonium/ui/form/query.rb +2 -4
  194. data/lib/plutonium/ui/form/resource.rb +92 -15
  195. data/lib/plutonium/ui/form/theme.rb +17 -0
  196. data/lib/plutonium/ui/form/wizard.rb +25 -1
  197. data/lib/plutonium/ui/grid/card.rb +79 -11
  198. data/lib/plutonium/ui/grid/resource.rb +47 -5
  199. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  200. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  201. data/lib/plutonium/ui/kanban/card.rb +2 -1
  202. data/lib/plutonium/ui/kanban/column.rb +12 -6
  203. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  204. data/lib/plutonium/ui/layout/base.rb +10 -3
  205. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  206. data/lib/plutonium/ui/page/base.rb +19 -0
  207. data/lib/plutonium/ui/page/edit.rb +4 -1
  208. data/lib/plutonium/ui/page/index.rb +69 -18
  209. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  210. data/lib/plutonium/ui/page/new.rb +4 -1
  211. data/lib/plutonium/ui/page/show.rb +27 -10
  212. data/lib/plutonium/ui/page/wizard.rb +10 -1
  213. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  214. data/lib/plutonium/ui/page_width.rb +58 -0
  215. data/lib/plutonium/ui/table/base.rb +34 -1
  216. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  217. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  218. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  219. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  220. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  221. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  222. data/lib/plutonium/ui/table/resource.rb +50 -5
  223. data/lib/plutonium/ui/table/theme.rb +59 -2
  224. data/lib/plutonium/ui/wizard/review.rb +4 -2
  225. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  226. data/lib/plutonium/version.rb +1 -1
  227. data/lib/plutonium/wizard/attachments.rb +32 -197
  228. data/lib/plutonium/wizard/base.rb +6 -1
  229. data/lib/plutonium/wizard/configuration.rb +12 -0
  230. data/lib/plutonium/wizard/controller.rb +14 -0
  231. data/lib/plutonium/wizard/driving.rb +99 -15
  232. data/lib/plutonium/wizard/dsl.rb +23 -0
  233. data/lib/plutonium/wizard/resume.rb +127 -49
  234. data/lib/plutonium/wizard/runner.rb +46 -2
  235. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  236. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  237. data/lib/plutonium.rb +21 -0
  238. data/lib/rodauth/features/session_isolation.rb +92 -0
  239. data/lib/rodauth/plugins.rb +1 -0
  240. data/package.json +2 -1
  241. data/plutonium.gemspec +29 -11
  242. data/src/css/components.css +89 -1
  243. data/src/css/slim_select.css +20 -0
  244. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  245. data/src/js/controllers/bulk_actions_controller.js +10 -2
  246. data/src/js/controllers/kanban_controller.js +30 -21
  247. data/src/js/controllers/positioned_controller.js +452 -0
  248. data/src/js/controllers/register_controllers.js +6 -0
  249. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  250. data/src/js/controllers/run_progress_controller.js +73 -0
  251. data/src/js/drag/sortable.js +186 -0
  252. data/yarn.lock +108 -63
  253. metadata +103 -16
  254. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  255. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
@@ -31,6 +31,8 @@ module Plutonium
31
31
  :resource_class,
32
32
  :resource_record!,
33
33
  :resource_record?,
34
+ :singular_resource_context?,
35
+ :singular_resource_route_for?,
34
36
  :resource_name,
35
37
  :resource_name_plural,
36
38
  :nestable_resource_name_plural,
@@ -48,6 +50,7 @@ module Plutonium
48
50
  :in_frame?,
49
51
  :in_modal?,
50
52
  :in_secondary_modal?,
53
+ :in_kanban_modal?,
51
54
  :turbo_scoped_dom_id,
52
55
  :current_interactive_action,
53
56
  :current_engine,
@@ -58,6 +61,8 @@ module Plutonium
58
61
  :root_path,
59
62
  :make_page_title,
60
63
  :resource_logo_tag,
64
+ :current_page_path,
65
+ :current_page_url,
61
66
  to: :view_context
62
67
  end
63
68
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Component
6
+ # Drag-to-reorder plumbing shared by every index surface that renders a
7
+ # collection of a `position_on` resource — the table (Table::Resource) and
8
+ # the card grid (Grid::Resource).
9
+ #
10
+ # Extracted rather than copied because the two halves of this feature have
11
+ # to agree EXACTLY: the client only offers a drag under the same condition
12
+ # the server accepts one, and the URL template the client substitutes into
13
+ # has to be the collection's own path. Two copies of that reasoning is two
14
+ # places for the client and the server to drift apart.
15
+ #
16
+ # A host must supply `resource_definition`, `current_query_object`,
17
+ # `current_page_path` and `policy_for` — everything Component::Base's
18
+ # index views already have.
19
+ module Positionable
20
+ private
21
+
22
+ # The resource's positioning strategy, or nil when it declares none /
23
+ # declares `position_on false`. Everything else here hangs off this.
24
+ def position_config
25
+ return @position_config if defined?(@position_config)
26
+
27
+ config = resource_definition.defined_position_config
28
+ @position_config = (config && !config.disabled?) ? config : nil
29
+ end
30
+
31
+ # Whether a drop can be honoured RIGHT NOW.
32
+ #
33
+ # Dropping "between the two records either side of me" only describes a
34
+ # position when the visual order IS the stored order. Under a title sort
35
+ # the neighbours say nothing; under a DESCENDING position sort they say
36
+ # the opposite of what the write would assume. The server rejects both
37
+ # (422, no write) — this is the client half of that same rule, and it
38
+ # deliberately reuses the server's own predicate rather than restating it.
39
+ def position_drag_enabled?
40
+ return false unless position_config
41
+
42
+ current_query_object.sorted_ascending_only_by?(position_config.attribute)
43
+ end
44
+
45
+ # The URL that puts the collection back into ascending position order —
46
+ # what the disabled grip links to. Built off the registered sort (see
47
+ # Definition::Positioning#position_on), which flips to ASC from a
48
+ # descending position sort and starts at ASC from any other sort.
49
+ def position_sort_url
50
+ current_query_object.sort_params_for(position_config.attribute)[:url]
51
+ end
52
+
53
+ # What to hand a grip: nil while dragging is live, otherwise the sort URL
54
+ # that makes it live. Memoized because it depends on the collection's
55
+ # sort, not on the record — re-deriving it per row would rebuild the same
56
+ # URL for every row of every page.
57
+ def position_grip_sort_url
58
+ return @position_grip_sort_url if defined?(@position_grip_sort_url)
59
+
60
+ @position_grip_sort_url = position_drag_enabled? ? nil : position_sort_url
61
+ end
62
+
63
+ # The member reposition path with an __ID__ placeholder for the client to
64
+ # substitute. Mirrors Kanban::Resource#kanban_move_url_template, but off
65
+ # `current_page_path` rather than `request.path`: after a rebalance the
66
+ # endpoint re-renders this very collection from a POST to
67
+ # /things/5/reposition, and a template derived from THAT request would
68
+ # send every subsequent drop to a nested path that does not exist.
69
+ def position_url_template
70
+ "#{current_page_path.delete_suffix("/")}/__ID__/reposition"
71
+ end
72
+
73
+ # Whether THIS viewer may reorder THIS record. Has to run per record —
74
+ # it is what keeps someone who may not reorder a given record from being
75
+ # offered a grip that can only ever answer 403.
76
+ def repositionable?(record)
77
+ policy_for(record:).allowed_to?(:reposition?)
78
+ end
79
+
80
+ # Which positioning GROUP a record sits in, or nil when every record on
81
+ # the surface shares one list.
82
+ #
83
+ # A scoped resource (`positioned_on :position, scope: :product_id`) keeps
84
+ # an independent 1..n sequence per group, so a top-level index that spans
85
+ # groups interleaves several sequences. A drop between two records from
86
+ # DIFFERENT groups describes no position at all, and the server refuses
87
+ # it (see PositionActions#resolve_position_neighbour, which rejects a
88
+ # neighbour whose scope attribute differs). This is the client half of
89
+ # that same rule — the value goes on the row so the drag can refuse
90
+ # before the user commits, instead of snapping back afterwards.
91
+ #
92
+ # Guarded on `delegate?` exactly as the server is: Mode B owns its own
93
+ # notion of a group, and nothing out here may second-guess it.
94
+ def position_group_for(record)
95
+ return nil unless position_config&.delegate?
96
+
97
+ attr = record.class.positioning_scope_attr
98
+ attr && record[attr]
99
+ end
100
+
101
+ # Passed to the collection components, which have no access to the
102
+ # definition and must not grow any. nil when the resource is unscoped, so
103
+ # the attribute is omitted entirely rather than emitted empty.
104
+ def position_group_resolver
105
+ return nil unless position_config&.delegate?
106
+
107
+ method(:position_group_for)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Component
6
+ # Turns a resolved `as:` into a component, for every surface that renders a
7
+ # field: forms, displays, tables, filter forms and the wizard summary.
8
+ #
9
+ # `as:` is a union — a built-in alias (Symbol/String, dispatched to the
10
+ # builder's `#{alias}_tag` method) or a component Class rendered directly
11
+ # (see {Plutonium::Definition::InputAliases}). Every surface has to handle
12
+ # BOTH, so the branch lives here instead of being re-implemented per view —
13
+ # a table or filter form that hand-rolled only the alias half raised
14
+ # `NoMethodError: MyPickerComponent_tag` on an `as:` a form rendered
15
+ # happily.
16
+ #
17
+ # A component Class is instantiated with the field builder (the contract
18
+ # every built-in tag component follows), so `as:` takes FIELD components; a
19
+ # component with its own constructor goes through the block form
20
+ # (`input :x do |field| MyComponent.new(value: field.value) end`).
21
+ #
22
+ # Mixed into the form and display field builders, so it is available on
23
+ # every `f` a view yields (including the table's display builder).
24
+ module ResolvesTags
25
+ # @param tag [Symbol, String, Class, nil] a built-in alias, a component
26
+ # class, or nil to infer the tag from the field's type.
27
+ # @param attributes [Hash] tag attributes. Reach a component class the
28
+ # same way they reach an alias tag — via `Components::Base#attributes`
29
+ # — so `as:` declarations honour the surface's own attributes (the
30
+ # filter panel's `class: "w-full"`, a form's `pre_submit` action)
31
+ # instead of silently dropping them.
32
+ # @return [Phlex::SGML] the component to render.
33
+ def component_for(tag, **attributes, &)
34
+ tag ||= inferred_field_component
35
+ return create_component(tag, component_theme_key(tag), **attributes, &) if tag.is_a?(Class)
36
+
37
+ send(:"#{tag}_tag", **attributes, &)
38
+ end
39
+
40
+ private
41
+
42
+ # Theme key for a directly-rendered component class:
43
+ # `Admin::ColorPickerComponent` themes off `:color_picker`.
44
+ #
45
+ # The `_?` matters — dropping a bare `component$` left the separator
46
+ # behind, so the key was `:color_picker_` and a theme entry written for
47
+ # the obvious `:color_picker` silently never matched. Nothing in the
48
+ # framework keys off this (built-in tags pass their key to
49
+ # `create_component` explicitly); it is reached only for a user's `as:`
50
+ # class, which is exactly the case that was broken.
51
+ def component_theme_key(component_class)
52
+ component_class.name.demodulize.underscore.sub(/_?component$/, "").to_sym
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Component
6
+ # Shared chrome for a declared layout section — a card with a header row
7
+ # (accent bar, heading, optional description, optional collapse caret)
8
+ # over a body that holds the fields. Subclassed (not copied) by both
9
+ # {Plutonium::UI::Form::Components::Section} and
10
+ # {Plutonium::UI::Display::Components::Section} so a form section and a
11
+ # show-page section always read the same; the only difference between the
12
+ # two is which grid class the caller passes in.
13
+ #
14
+ # Each section is its OWN card rather than a band inside one shared card.
15
+ # The card boundary does the grouping — no amount of heading typography
16
+ # separates two field groups as unambiguously as an actual edge does, and
17
+ # the header row then only has to name the group rather than carry the
18
+ # whole structural signal. Callers therefore must NOT wrap sections in a
19
+ # card of their own; they supply a plain stacking container.
20
+ #
21
+ # The caller supplies the fields as a block and owns what a "field" means.
22
+ class Section < Plutonium::UI::Component::Base
23
+ # Default section chrome, merged into BOTH Form::Theme and
24
+ # Display::Theme so the two read identically out of the box while each
25
+ # stays independently overridable — the same contract every other
26
+ # themed key has. Defined here rather than written out twice so the
27
+ # shipped defaults cannot drift between the form and the show page.
28
+ #
29
+ # Structure (which element gets which key, the `group`/[open]
30
+ # mechanics) stays in this component; themes own the classes.
31
+ DEFAULT_THEME = {
32
+ # Merged into the section's {Plutonium::UI::Block} card — Block
33
+ # supplies `pu-card` itself, so this only adds what is specific to a
34
+ # section. `overflow-hidden` keeps the header row's fill and border
35
+ # inside the card's rounded corners.
36
+ section_wrapper: "overflow-hidden",
37
+
38
+ # Header row. Its bottom border is the line between the header and
39
+ # the fields — the card edge already separates one section from the
40
+ # next, so nothing else has to.
41
+ section_header:
42
+ "px-4 py-3 flex items-center gap-3 " \
43
+ "border-b border-[var(--pu-border)] bg-[var(--pu-surface-alt)]",
44
+
45
+ # A short primary bar at the head of the row. A standalone element
46
+ # rather than a border on the text block, so the heading and its
47
+ # description stay vertically centred against it.
48
+ section_accent: "shrink-0 w-1 h-5 rounded-full bg-primary-500",
49
+
50
+ # Sized to sit under the page title (text-xl semibold) while
51
+ # out-ranking display values (text-lg regular) and field labels.
52
+ # With the card doing the separating, the heading no longer has to
53
+ # shout to be read as a header.
54
+ section_heading: "text-base font-bold tracking-tight text-[var(--pu-text)]",
55
+ section_description: "text-sm font-normal text-[var(--pu-text-muted)]",
56
+
57
+ # `list-none` + the WebKit marker reset remove the native disclosure
58
+ # triangle, which the browser pins to the LEFT of the summary — where
59
+ # it would displace the accent bar and knock the heading out of the
60
+ # alignment every other section keeps. The caret is re-drawn on the
61
+ # right instead, so headers stay identical whether or not a section
62
+ # happens to be collapsible.
63
+ # The divider only exists to separate the header from the fields, so
64
+ # a COLLAPSED section must not draw one — there is nothing below it,
65
+ # and the line would land directly on the card's own bottom border.
66
+ # `group-open:` keys it to the parent <details>'s [open] state.
67
+ section_summary:
68
+ "px-4 py-3 flex items-center gap-3 cursor-pointer select-none " \
69
+ "border-b-0 group-open:border-b border-[var(--pu-border)] " \
70
+ "bg-[var(--pu-surface-alt)] " \
71
+ "list-none [&::-webkit-details-marker]:hidden",
72
+
73
+ section_caret:
74
+ "shrink-0 w-3 h-3 text-[var(--pu-text-muted)] " \
75
+ "transition-transform duration-200 group-open:rotate-180",
76
+
77
+ # Padding box between the card edge and the field grid. Roomier than
78
+ # the stock `pu-card-body` (16px): a section card holds inputs, not
79
+ # the dense label/value rows a card body is sized for, and the form
80
+ # this replaced was a single `p-8` card — at 16px the fields sit
81
+ # noticeably tighter against the edge than they used to.
82
+ section_body: "p-6"
83
+ }.freeze
84
+
85
+ # The theme this section's chrome resolves against. Subclasses name
86
+ # their own, so a form section follows Form::Theme and a show-page
87
+ # section follows Display::Theme.
88
+ def self.theme_class
89
+ raise NotImplementedError, "#{self} must implement .theme_class"
90
+ end
91
+
92
+ def initialize(resolved, grid_class:)
93
+ @section = resolved.section
94
+ @grid_class = grid_class
95
+ end
96
+
97
+ # Every section is a {Plutonium::UI::Block} — the shared card primitive
98
+ # — so a section card and any other card on the page are the same
99
+ # surface by construction rather than by two lists of classes that
100
+ # happen to agree today.
101
+ def view_template(&fields_block)
102
+ Block(class: themed_section(:section_wrapper)) do
103
+ if @section.collapsible?
104
+ # `group` lets the caret rotate off the <details>'s [open] state.
105
+ details(open: !@section.collapsed?, class: "group") do
106
+ # <summary> must be the first child of <details> and can't be
107
+ # wrapped, so it IS the header row.
108
+ summary(class: themed_section(:section_summary)) do
109
+ span(class: themed_section(:section_accent))
110
+ heading_block
111
+ render_caret
112
+ end
113
+ body(&fields_block)
114
+ end
115
+ else
116
+ header_row
117
+ body(&fields_block)
118
+ end
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def themed_section(key)
125
+ self.class.theme_class.instance.resolve_theme(key)
126
+ end
127
+
128
+ # An unlabelled `ungrouped` bucket gets no header row at all — just a
129
+ # card holding its leftover fields.
130
+ def header_row
131
+ return if headerless?
132
+ div(class: themed_section(:section_header)) do
133
+ span(class: themed_section(:section_accent))
134
+ heading_block
135
+ end
136
+ end
137
+
138
+ def headerless? = @section.ungrouped? && @section.options[:label].nil?
139
+
140
+ # Title over description, taking the free space in the row so the caret
141
+ # is pushed to the far right.
142
+ def heading_block
143
+ div(class: "min-w-0 flex-1") do
144
+ h3(class: themed_section(:section_heading)) { heading_text }
145
+ describe
146
+ end
147
+ end
148
+
149
+ def heading_text = @section.label
150
+
151
+ def describe
152
+ return unless @section.description
153
+ p(class: themed_section(:section_description)) { @section.description }
154
+ end
155
+
156
+ # Points down when open, up when closed (rotate-180). Decorative — the
157
+ # <summary> element already carries the expand/collapse semantics for
158
+ # assistive tech, so this is aria-hidden.
159
+ def render_caret
160
+ svg(
161
+ class: themed_section(:section_caret),
162
+ aria_hidden: "true",
163
+ xmlns: "http://www.w3.org/2000/svg",
164
+ fill: "none",
165
+ viewbox: "0 0 10 6"
166
+ ) do |s|
167
+ s.path(
168
+ stroke: "currentColor",
169
+ stroke_linecap: "round",
170
+ stroke_linejoin: "round",
171
+ stroke_width: "2",
172
+ d: "M1 1l4 4 4-4"
173
+ )
174
+ end
175
+ end
176
+
177
+ def body(&fields_block)
178
+ div(class: themed_section(:section_body)) do
179
+ div(class: @grid_class, &fields_block)
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -8,6 +8,7 @@ module Plutonium
8
8
 
9
9
  class Builder < Builder
10
10
  include Plutonium::UI::Display::Options::InferredTypes
11
+ include Plutonium::UI::Component::ResolvesTags
11
12
 
12
13
  def association_tag(**, &)
13
14
  create_component(Plutonium::UI::Display::Components::Association, :association, **, &)
@@ -26,6 +27,13 @@ module Plutonium
26
27
  create_component(Plutonium::UI::Display::Components::PhlexiRender, :phlexi_render, **, &)
27
28
  end
28
29
 
30
+ # Themed as :string so a formatted value looks identical to any other
31
+ # string field (same wrapper + text-lg), just with the value passed
32
+ # through the `formatter:` proc.
33
+ def formatted_value_tag(**, &)
34
+ create_component(Plutonium::UI::Display::Components::FormattedValue, :string, **, &)
35
+ end
36
+
29
37
  def boolean_tag(**, &)
30
38
  create_component(Plutonium::UI::Display::Components::Boolean, :boolean, **, &)
31
39
  end
@@ -53,8 +61,12 @@ module Plutonium
53
61
 
54
62
  private
55
63
 
64
+ # A card (the shared {Plutonium::UI::Block} primitive) around the field
65
+ # grid. Block owns what a card IS — surface, border, radius, shadow —
66
+ # so callers just ask for a fields_wrapper and cannot end up with a
67
+ # card that disagrees with the ones beside it.
56
68
  def fields_wrapper(&)
57
- div(class: themed(:fields_wrapper)) {
69
+ render Plutonium::UI::Block.new(class: themed(:fields_wrapper)) {
58
70
  div(class: themed(:fields_inner)) {
59
71
  yield
60
72
  }
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Display
6
+ module Components
7
+ # Renders a value transformed by a user-supplied `formatter:` proc, themed
8
+ # like a plain string. `formatter:` is a general column/display option, but
9
+ # only the string component ever consumed it — handed to a typed component
10
+ # (boolean pill, currency, badge…) the Proc leaked into the element's HTML
11
+ # attributes and Phlex rejected it. The resource/table render layers route
12
+ # every formatter-bearing field here instead.
13
+ #
14
+ # Unlike Phlexi's String component — which stringifies the value *before*
15
+ # the formatter runs (so a boolean `false` would arrive as the truthy
16
+ # string "false") — this keeps the raw value, matching the documented
17
+ # contract that a formatter "receives just the value".
18
+ class FormattedValue < Phlexi::Display::Components::String
19
+ # Keep the raw value so the formatter sees a real boolean / number /
20
+ # object rather than its `to_s`.
21
+ def normalize_value(value) = value
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Display
6
+ module Components
7
+ # A show page's declared `display_layout` section. All chrome lives in
8
+ # the shared {Plutonium::UI::Component::Section} so a show-page section
9
+ # and a form section cannot drift apart; the display contributes only
10
+ # its grid class (the themed :section_grid) and the block that renders
11
+ # the fields.
12
+ class Section < Plutonium::UI::Component::Section
13
+ def self.theme_class = Plutonium::UI::Display::Theme
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -35,27 +35,143 @@ module Plutonium
35
35
  present_associations? && resource_associations.present?
36
36
  end
37
37
 
38
+ # Stacked in `sections_wrapper` — the hooks and the fields card each
39
+ # own their own card with no margin, so an override that renders one
40
+ # (e.g. Async::RunDefinition's progress panel) would otherwise butt flush
41
+ # against the fields card.
38
42
  def render_fields
39
- if metadata_fields.any?
40
- div(class: "grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_320px] gap-6 items-start") do
41
- div { render_main_field_card }
42
- aside { render_metadata_panel }
43
- end
44
- else
45
- render_main_field_card
43
+ div(class: themed(:sections_wrapper)) do
44
+ render_before_fields
45
+ render_default_fields
46
+ render_after_fields
47
+ end
48
+ end
49
+
50
+ # Hooks for content that belongs to the Details tab alone. render_fields
51
+ # is the tab body when associations exist, and the whole display when
52
+ # they don't, so these fire in the Details context either way and never
53
+ # in an association tab. The page-level render_before_content /
54
+ # render_after_content hooks sit outside the tablist and would show
55
+ # regardless of the active tab.
56
+ def render_before_fields
57
+ end
58
+
59
+ def render_after_fields
60
+ end
61
+
62
+ def render_default_fields
63
+ # Inside a kanban card's modal we render a compact detail: no metadata
64
+ # at all. render_main_field_card already subtracts metadata_fields, so
65
+ # those fields stay hidden rather than folding into the main card.
66
+ return render_main_field_card if metadata_fields.none? || in_kanban_modal?
67
+
68
+ # In a (non-kanban) modal the metadata stacks BELOW the details
69
+ # instead of beside them. The rail is a fixed 320px column and its
70
+ # `lg:` breakpoint is a viewport query, so it would split inside the
71
+ # dialog no matter how narrow the dialog is — leaving the main column
72
+ # crushed against it. Stacking sidesteps that entirely, and means the
73
+ # dialog does not have to grow just to hold a rail.
74
+ if in_modal?
75
+ # A stacking container, not two bare siblings: the cards carry no
76
+ # margin of their own, so without it they butt straight up against
77
+ # each other with no seam between the details and the metadata.
78
+ return div(class: themed(:sections_wrapper)) {
79
+ render_main_field_card
80
+ render_metadata_panel
81
+ }
82
+ end
83
+
84
+ div(class: "grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_320px] gap-6 items-start") do
85
+ div { render_main_field_card }
86
+ aside { render_metadata_panel }
46
87
  end
47
88
  end
48
89
 
49
90
  def render_main_field_card
50
- Block do
51
- fields_wrapper do
52
- # Skip fields claimed by the metadata panel — rendering
53
- # them in both places duplicates information.
54
- (resource_fields - metadata_fields).each do |name|
55
- render_resource_field name
56
- end
91
+ # Skip fields claimed by the metadata panel — rendering them in both
92
+ # places duplicates information.
93
+ fields = resource_fields - metadata_fields
94
+ sections = resolve_display_layout(fields)
95
+
96
+ # Unsectioned: one card holding one grid. fields_wrapper is itself a
97
+ # Block now, so this is the same card primitive the sections and the
98
+ # metadata panel use.
99
+ if sections.nil?
100
+ return fields_wrapper do
101
+ fields.each { |name| render_resource_field name }
57
102
  end
58
103
  end
104
+
105
+ # Sectioned: each section is its own card, so there is deliberately
106
+ # no Block/card wrapper here — one would nest cards inside a card.
107
+ div(class: themed(:sections_wrapper)) do
108
+ sections.each { |rs| render_display_section(rs) }
109
+ end
110
+ end
111
+
112
+ # Resolve the whole display layout for THIS render: drop
113
+ # condition-hidden sections and sections left with no fields (e.g.
114
+ # every declared field was filtered out by the permitted set).
115
+ # Returns nil when no display_layout is declared (caller falls back
116
+ # to a single grid).
117
+ def resolve_display_layout(fields)
118
+ sections = resource_definition.resolve_display_sections(fields)
119
+ return nil if sections.nil?
120
+
121
+ sections.filter_map do |resolved|
122
+ section = resolved.section
123
+ condition = section.condition
124
+ next if condition && !instance_exec(&condition)
125
+ next if resolved.fields.empty?
126
+
127
+ Plutonium::Definition::FormLayout::ResolvedSection.new(
128
+ section: Plutonium::Definition::FormLayout::Section.new(
129
+ key: section.key,
130
+ fields: section.fields,
131
+ options: resolve_section_option_procs(section.options).freeze
132
+ ),
133
+ fields: resolved.fields
134
+ )
135
+ end
136
+ end
137
+
138
+ # Resolve proc-valued section options for THIS render, by the same
139
+ # arity rule the form uses (Form::Resource#resolve_option_procs): a
140
+ # zero-arity proc is called plainly and keeps its own binding, while a
141
+ # one-arity proc is handed this display for `object`/`params`/helpers.
142
+ #
143
+ # Without this a `collapsed: -> { ... }` would reach the component as
144
+ # the Proc itself, and `collapsed?` — a plain `!!` — would be true no
145
+ # matter what the proc returns, silently pinning the section shut.
146
+ #
147
+ # `condition:` is excluded for the same reason it is on the form: it
148
+ # asks "should this render here, now?", which is resolved separately
149
+ # and against this display, not turned into a value up front.
150
+ def resolve_section_option_procs(options)
151
+ return options if options.blank?
152
+
153
+ options.to_h do |key, value|
154
+ resolvable = key != :condition && value.is_a?(Proc)
155
+ next [key, value] unless resolvable
156
+
157
+ [key, value.arity.zero? ? value.call : value.call(self)]
158
+ end
159
+ end
160
+
161
+ # Pure presentation — the section is already resolved (visible) by
162
+ # resolve_display_layout.
163
+ # Every section renders into the SAME responsive grid (themed
164
+ # :section_grid, which matches the unsectioned path's :fields_inner
165
+ # minus the padding its wrapper now owns). display_layout groups
166
+ # fields; it does not resize them — that stays a per-field concern,
167
+ # via `display :x, wrapper: {class: "col-span-2"}`, which works
168
+ # identically inside a section and outside one.
169
+ def render_display_section(resolved)
170
+ render Plutonium::UI::Display::Components::Section.new(
171
+ resolved, grid_class: themed(:section_grid)
172
+ ) do
173
+ resolved.fields.each { |name| render_resource_field name }
174
+ end
59
175
  end
60
176
 
61
177
  # Renders the declared metadata fields as a vertical stack beside
@@ -64,6 +180,9 @@ module Plutonium
64
180
  # only difference from the main card is the wrapper — a single
65
181
  # column of fields instead of the form's multi-column grid.
66
182
  def render_metadata_panel
183
+ # Same card primitive as the details card(s) beside it, so the two
184
+ # sit on one surface — this panel used to be a borderless,
185
+ # heavier-shadowed Block and read as a different kind of thing.
67
186
  Block do
68
187
  div(class: "pu-card-body flex flex-col gap-6") do
69
188
  metadata_fields.each { |name| render_resource_field(name) }
@@ -189,14 +308,14 @@ module Plutonium
189
308
  field_options = field_options.merge(field_level_options)
190
309
 
191
310
  tag_attributes = display_options.except(:wrapper, :as, :condition, *field_level_keys)
192
- tag_block = display_definition[:block] || ->(f) {
193
- tag ||= f.inferred_field_component
194
- if tag.is_a?(Class)
195
- f.send :create_component, tag, tag.name.demodulize.underscore.sub(/component$/, "").to_sym
196
- else
197
- f.send(:"#{tag}_tag", **tag_attributes)
198
- end
199
- }
311
+
312
+ # A `formatter:` produces the display string itself, so render it
313
+ # through the formatted-value component regardless of the field's
314
+ # type. Otherwise the Proc would be handed to a typed component
315
+ # (boolean pill, currency…) and leak into its HTML attributes.
316
+ tag = :formatted_value if tag_attributes.key?(:formatter)
317
+
318
+ tag_block = display_definition[:block] || ->(f) { f.component_for(tag, **tag_attributes) }
200
319
 
201
320
  wrapper_options = display_options[:wrapper] || {}
202
321