plutonium 0.49.1 → 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 (206) 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 +37 -0
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1323 -1184
  14. data/app/assets/plutonium.js.map +4 -4
  15. data/app/assets/plutonium.min.js +50 -49
  16. data/app/assets/plutonium.min.js.map +4 -4
  17. data/app/views/plutonium/_resource_header.html.erb +4 -4
  18. data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
  19. data/app/views/resource/_resource_grid.html.erb +1 -0
  20. data/config/brakeman.ignore +25 -2
  21. data/docs/.vitepress/config.ts +37 -27
  22. data/docs/getting-started/index.md +22 -29
  23. data/docs/getting-started/installation.md +37 -80
  24. data/docs/getting-started/tutorial/index.md +4 -5
  25. data/docs/guides/adding-resources.md +66 -377
  26. data/docs/guides/authentication.md +94 -463
  27. data/docs/guides/authorization.md +124 -370
  28. data/docs/guides/creating-packages.md +94 -296
  29. data/docs/guides/custom-actions.md +121 -441
  30. data/docs/guides/index.md +22 -42
  31. data/docs/guides/multi-tenancy.md +116 -187
  32. data/docs/guides/nested-resources.md +103 -431
  33. data/docs/guides/search-filtering.md +123 -240
  34. data/docs/guides/testing.md +5 -4
  35. data/docs/guides/theming.md +157 -407
  36. data/docs/guides/troubleshooting.md +5 -3
  37. data/docs/guides/user-invites.md +106 -425
  38. data/docs/guides/user-profile.md +76 -243
  39. data/docs/index.md +1 -1
  40. data/docs/reference/app/generators.md +517 -0
  41. data/docs/reference/app/index.md +158 -0
  42. data/docs/reference/app/packages.md +146 -0
  43. data/docs/reference/app/portals.md +377 -0
  44. data/docs/reference/auth/accounts.md +230 -0
  45. data/docs/reference/auth/index.md +88 -0
  46. data/docs/reference/auth/profile.md +185 -0
  47. data/docs/reference/behavior/controllers.md +395 -0
  48. data/docs/reference/behavior/index.md +22 -0
  49. data/docs/reference/behavior/interactions.md +341 -0
  50. data/docs/reference/behavior/policies.md +417 -0
  51. data/docs/reference/index.md +56 -49
  52. data/docs/reference/resource/actions.md +423 -0
  53. data/docs/reference/resource/definition.md +508 -0
  54. data/docs/reference/resource/index.md +50 -0
  55. data/docs/reference/resource/model.md +348 -0
  56. data/docs/reference/resource/query.md +305 -0
  57. data/docs/reference/tenancy/entity-scoping.md +361 -0
  58. data/docs/reference/tenancy/index.md +36 -0
  59. data/docs/reference/tenancy/invites.md +393 -0
  60. data/docs/reference/tenancy/nested-resources.md +267 -0
  61. data/docs/reference/testing/index.md +287 -0
  62. data/docs/reference/ui/assets.md +400 -0
  63. data/docs/reference/ui/components.md +165 -0
  64. data/docs/reference/ui/displays.md +104 -0
  65. data/docs/reference/ui/forms.md +284 -0
  66. data/docs/reference/ui/index.md +30 -0
  67. data/docs/reference/ui/layouts.md +106 -0
  68. data/docs/reference/ui/pages.md +189 -0
  69. data/docs/reference/ui/tables.md +117 -0
  70. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
  71. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
  72. data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
  73. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  74. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  75. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  76. data/gemfiles/rails_7.gemfile.lock +1 -1
  77. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  78. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  79. data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
  80. data/lib/generators/pu/invites/install_generator.rb +1 -0
  81. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
  82. data/lib/plutonium/action/base.rb +44 -1
  83. data/lib/plutonium/action/interactive.rb +1 -1
  84. data/lib/plutonium/configuration.rb +4 -0
  85. data/lib/plutonium/definition/actions.rb +3 -0
  86. data/lib/plutonium/definition/base.rb +8 -0
  87. data/lib/plutonium/definition/index_views.rb +95 -0
  88. data/lib/plutonium/definition/metadata.rb +40 -0
  89. data/lib/plutonium/helpers/turbo_helper.rb +12 -1
  90. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  91. data/lib/plutonium/interaction/response/redirect.rb +1 -1
  92. data/lib/plutonium/query/base.rb +8 -0
  93. data/lib/plutonium/query/filters/association.rb +30 -8
  94. data/lib/plutonium/query/filters/boolean.rb +5 -0
  95. data/lib/plutonium/resource/controller.rb +1 -0
  96. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  97. data/lib/plutonium/resource/controllers/presentable.rb +11 -2
  98. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  99. data/lib/plutonium/resource/definition.rb +42 -0
  100. data/lib/plutonium/resource/policy.rb +7 -0
  101. data/lib/plutonium/resource/query_object.rb +64 -6
  102. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  103. data/lib/plutonium/testing/resource_definition.rb +2 -2
  104. data/lib/plutonium/ui/action_button.rb +4 -2
  105. data/lib/plutonium/ui/component/kit.rb +12 -0
  106. data/lib/plutonium/ui/component/methods.rb +4 -0
  107. data/lib/plutonium/ui/display/base.rb +3 -1
  108. data/lib/plutonium/ui/display/resource.rb +109 -25
  109. data/lib/plutonium/ui/display/theme.rb +2 -1
  110. data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
  111. data/lib/plutonium/ui/empty_card.rb +1 -1
  112. data/lib/plutonium/ui/form/base.rb +35 -3
  113. data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
  114. data/lib/plutonium/ui/form/components/json.rb +58 -0
  115. data/lib/plutonium/ui/form/components/resource_select.rb +133 -1
  116. data/lib/plutonium/ui/form/components/secure_association.rb +105 -24
  117. data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
  118. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  119. data/lib/plutonium/ui/form/resource.rb +45 -10
  120. data/lib/plutonium/ui/form/theme.rb +1 -1
  121. data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
  122. data/lib/plutonium/ui/grid/card.rb +235 -0
  123. data/lib/plutonium/ui/grid/resource.rb +149 -0
  124. data/lib/plutonium/ui/layout/base.rb +38 -1
  125. data/lib/plutonium/ui/layout/header.rb +1 -2
  126. data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
  127. data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
  128. data/lib/plutonium/ui/layout/sidebar.rb +12 -24
  129. data/lib/plutonium/ui/layout/topbar.rb +100 -0
  130. data/lib/plutonium/ui/modal/base.rb +109 -0
  131. data/lib/plutonium/ui/modal/centered.rb +21 -0
  132. data/lib/plutonium/ui/modal/slideover.rb +26 -0
  133. data/lib/plutonium/ui/page/base.rb +18 -6
  134. data/lib/plutonium/ui/page/edit.rb +13 -1
  135. data/lib/plutonium/ui/page/index.rb +40 -1
  136. data/lib/plutonium/ui/page/interactive_action.rb +8 -39
  137. data/lib/plutonium/ui/page/new.rb +13 -1
  138. data/lib/plutonium/ui/page/show.rb +8 -1
  139. data/lib/plutonium/ui/page_header.rb +8 -13
  140. data/lib/plutonium/ui/panel.rb +10 -19
  141. data/lib/plutonium/ui/sidebar_menu.rb +2 -25
  142. data/lib/plutonium/ui/tab_list.rb +29 -7
  143. data/lib/plutonium/ui/table/base.rb +106 -0
  144. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
  145. data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
  146. data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
  147. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
  148. data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
  149. data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
  150. data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
  151. data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
  152. data/lib/plutonium/ui/table/resource.rb +158 -89
  153. data/lib/plutonium/ui/table/theme.rb +14 -5
  154. data/lib/plutonium/version.rb +1 -1
  155. data/lib/plutonium.rb +14 -0
  156. data/lib/tasks/release.rake +15 -1
  157. data/package.json +10 -10
  158. data/src/css/components.css +304 -131
  159. data/src/css/slim_select.css +4 -0
  160. data/src/css/tokens.css +101 -85
  161. data/src/js/controllers/autosubmit_controller.js +24 -0
  162. data/src/js/controllers/bulk_actions_controller.js +15 -16
  163. data/src/js/controllers/capture_url_controller.js +14 -0
  164. data/src/js/controllers/filter_panel_controller.js +77 -19
  165. data/src/js/controllers/frame_navigator_controller.js +34 -6
  166. data/src/js/controllers/icon_rail_controller.js +22 -0
  167. data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
  168. data/src/js/controllers/register_controllers.js +16 -0
  169. data/src/js/controllers/resource_tab_list_controller.js +56 -3
  170. data/src/js/controllers/row_click_controller.js +21 -0
  171. data/src/js/controllers/slim_select_controller.js +61 -0
  172. data/src/js/controllers/table_column_menu_controller.js +43 -0
  173. data/src/js/controllers/table_header_controller.js +16 -0
  174. data/src/js/controllers/view_switcher_controller.js +29 -0
  175. data/src/js/turbo/turbo_actions.js +33 -0
  176. data/yarn.lock +553 -543
  177. metadata +71 -32
  178. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  179. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  180. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  181. data/.claude/skills/plutonium-definition/SKILL.md +0 -1138
  182. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  183. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  184. data/.claude/skills/plutonium-installation/SKILL.md +0 -325
  185. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  186. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  187. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  188. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  189. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  190. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  191. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  192. data/.claude/skills/plutonium-views/SKILL.md +0 -592
  193. data/docs/reference/assets/index.md +0 -496
  194. data/docs/reference/controller/index.md +0 -412
  195. data/docs/reference/definition/actions.md +0 -449
  196. data/docs/reference/definition/fields.md +0 -383
  197. data/docs/reference/definition/index.md +0 -268
  198. data/docs/reference/definition/query.md +0 -351
  199. data/docs/reference/generators/index.md +0 -648
  200. data/docs/reference/interaction/index.md +0 -449
  201. data/docs/reference/model/features.md +0 -248
  202. data/docs/reference/model/index.md +0 -218
  203. data/docs/reference/policy/index.md +0 -456
  204. data/docs/reference/portal/index.md +0 -379
  205. data/docs/reference/views/forms.md +0 -411
  206. data/docs/reference/views/index.md +0 -501
@@ -0,0 +1,103 @@
1
+ {
2
+ "planPath": "docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md",
3
+ "tasks": [
4
+ {
5
+ "id": 7,
6
+ "subject": "Task 0: Density tokens",
7
+ "status": "pending",
8
+ "description": "**Goal:** Codify balanced density scale.\n\n```json:metadata\n{\"files\":[\"src/css/tokens.css\",\"src/css/components.css\",\"lib/plutonium/ui/component/tokens.rb\"],\"verifyCommand\":\"yarn build && bundle exec appraisal rails-8.1 rake test\",\"acceptanceCriteria\":[\"density vars defined\",\"button/input sizes updated\",\"card padding 16px\",\"tests pass\"],\"requiresUserVerification\":false}\n```"
9
+ },
10
+ {
11
+ "id": 8,
12
+ "subject": "Task 1: PageHeader redesign (Stripe-style)",
13
+ "status": "pending",
14
+ "blockedBy": [7],
15
+ "description": "**Goal:** Tighter Stripe-style PageHeader.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/page_header.rb\",\"lib/plutonium/ui/page/base.rb\",\"test/plutonium/ui/page_header_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/page_header_test.rb -v\",\"acceptanceCriteria\":[\"title 18-20px\",\"description muted\",\"actions right-aligned\",\"tabs flush\"],\"requiresUserVerification\":false}\n```"
16
+ },
17
+ {
18
+ "id": 9,
19
+ "subject": "Task 2: IconRail component",
20
+ "status": "pending",
21
+ "blockedBy": [7],
22
+ "description": "**Goal:** 56px icon-only nav.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/layout/icon_rail.rb\",\"test/plutonium/ui/layout/icon_rail_test.rb\",\"src/css/components.css\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/icon_rail_test.rb -v\",\"acceptanceCriteria\":[\"aside 56px\",\"slots present\",\"active styling\",\"mobile-hidden\"],\"requiresUserVerification\":false}\n```"
23
+ },
24
+ {
25
+ "id": 10,
26
+ "subject": "Task 3: Topbar component",
27
+ "status": "pending",
28
+ "blockedBy": [7],
29
+ "description": "**Goal:** Sticky 48px topbar.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/layout/topbar.rb\",\"lib/plutonium/ui/breadcrumbs.rb\",\"test/plutonium/ui/layout/topbar_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/topbar_test.rb -v\",\"acceptanceCriteria\":[\"48px height\",\"breadcrumbs/search/actions slots\",\"hamburger wired\",\"empty-breadcrumbs handled\"],\"requiresUserVerification\":false}\n```"
30
+ },
31
+ {
32
+ "id": 11,
33
+ "subject": "Task 4: Wire ResourceLayout to new shell",
34
+ "status": "pending",
35
+ "blockedBy": [9, 10],
36
+ "description": "**Goal:** Replace partials with IconRail+Topbar; drop legacy.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/layout/resource_layout.rb\",\"lib/plutonium/ui/layout/base.rb\",\"lib/plutonium/ui/layout/header.rb\",\"lib/plutonium/ui/layout/sidebar.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 rake test\",\"acceptanceCriteria\":[\"new shell wired\",\"old partials removed\",\"tests pass\"],\"requiresUserVerification\":false}\n```"
37
+ },
38
+ {
39
+ "id": 12,
40
+ "subject": "Task 5: Index toolbar",
41
+ "status": "pending",
42
+ "blockedBy": [7, 8],
43
+ "description": "**Goal:** New Toolbar component (view switcher, filter, group, search, column-config, overflow).\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/table/components/toolbar.rb\",\"lib/plutonium/ui/table/components/view_switcher.rb\",\"lib/plutonium/ui/table/resource.rb\",\"test/plutonium/ui/table/components/toolbar_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/table/components/toolbar_test.rb -v\",\"acceptanceCriteria\":[\"toolbar order\",\"search wired\",\"filter popover\",\"disabled segments\"],\"requiresUserVerification\":false}\n```"
44
+ },
45
+ {
46
+ "id": 13,
47
+ "subject": "Task 6: Active filter pills + result count",
48
+ "status": "pending",
49
+ "blockedBy": [12],
50
+ "description": "**Goal:** Removable filter pills + result count strip.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/table/components/filter_pills.rb\",\"lib/plutonium/ui/table/resource.rb\",\"lib/plutonium/resource/query_object.rb\",\"test/plutonium/ui/table/components/filter_pills_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/table/components/filter_pills_test.rb -v\",\"acceptanceCriteria\":[\"pill per filter\",\"add-filter pill\",\"clear URL\",\"result count\"],\"requiresUserVerification\":false}\n```"
51
+ },
52
+ {
53
+ "id": 14,
54
+ "subject": "Task 7: Column-header sort with multi-sort + ⋯ menu",
55
+ "status": "pending",
56
+ "blockedBy": [12],
57
+ "description": "**Goal:** Sort in column headers, shift-click multi-sort, per-column ⋯ menu.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/resource/query_object.rb\",\"lib/plutonium/ui/table/resource.rb\",\"lib/plutonium/ui/table/theme.rb\",\"src/js/controllers/table_controller.js\",\"test/plutonium/resource/query_object_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/query_object_test.rb -v\",\"acceptanceCriteria\":[\"sort_params_for has multi_url\",\"click vs shift-click\",\"priority badges\",\"column menu\"],\"requiresUserVerification\":false}\n```"
58
+ },
59
+ {
60
+ "id": 15,
61
+ "subject": "Task 8: Floating bulk action bar",
62
+ "status": "pending",
63
+ "blockedBy": [13],
64
+ "description": "**Goal:** Bulk action bar replaces pills strip when rows selected.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/table/components/bulk_action_bar.rb\",\"lib/plutonium/ui/table/resource.rb\",\"src/js/controllers/bulk_actions_controller.js\",\"test/plutonium/ui/table/components/bulk_action_bar_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/table/components/bulk_action_bar_test.rb -v\",\"acceptanceCriteria\":[\"bar/pills mutual exclusion\",\"selection count\",\"danger tone\",\"clear deselect\"],\"requiresUserVerification\":false}\n```"
65
+ },
66
+ {
67
+ "id": 16,
68
+ "subject": "Task 9: Show page redesign",
69
+ "status": "pending",
70
+ "blockedBy": [8],
71
+ "description": "**Goal:** Single-column show + reserved aside slot.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/page/show.rb\",\"lib/plutonium/ui/page/base.rb\",\"lib/plutonium/ui/display/resource.rb\",\"lib/plutonium/ui/panel.rb\",\"test/plutonium/ui/page/show_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/page/show_test.rb -v\",\"acceptanceCriteria\":[\"max-width 960\",\"card panels\",\"aside slot reserved\",\"tabs flush\"],\"requiresUserVerification\":false}\n```"
72
+ },
73
+ {
74
+ "id": 17,
75
+ "subject": "Task 10: Form page redesign + sticky footer",
76
+ "status": "pending",
77
+ "blockedBy": [8],
78
+ "description": "**Goal:** Centered narrow form column + sticky footer + inline validation.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/page/new.rb\",\"lib/plutonium/ui/page/edit.rb\",\"lib/plutonium/ui/page/interactive_action.rb\",\"lib/plutonium/ui/form/resource.rb\",\"lib/plutonium/ui/form/interaction.rb\",\"lib/plutonium/ui/form/theme.rb\",\"lib/plutonium/ui/form/components/sticky_footer.rb\",\"test/plutonium/ui/form/sticky_footer_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/form -v\",\"acceptanceCriteria\":[\"580px centered\",\"sticky footer\",\"inline validation\",\"modal mode skips footer\"],\"requiresUserVerification\":false}\n```"
79
+ },
80
+ {
81
+ "id": 18,
82
+ "subject": "Task 11: Slideover modal mode + per-interaction option",
83
+ "status": "pending",
84
+ "blockedBy": [17],
85
+ "description": "**Goal:** Add slideover mode; opt-in via interactive_action :name, modal: :slideover.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/resource/interaction.rb\",\"lib/plutonium/interaction/base.rb\",\"src/js/controllers/remote_modal_controller.js\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction -v\",\"acceptanceCriteria\":[\"modal: kwarg accepted\",\"two outer containers\",\"slideover transition\",\"mobile full-screen\"],\"requiresUserVerification\":false}\n```"
86
+ },
87
+ {
88
+ "id": 19,
89
+ "subject": "Task 12: Per-resource modal declaration",
90
+ "status": "pending",
91
+ "blockedBy": [18],
92
+ "description": "**Goal:** modal :slideover on resource definition for new/edit modal mode.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/resource/definition.rb\",\"test/plutonium/resource/definition_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/definition_test.rb -v\",\"acceptanceCriteria\":[\"modal DSL on definition\",\"invalid raises\",\"modal frame uses declared mode\",\"page URLs unchanged\"],\"requiresUserVerification\":false}\n```"
93
+ },
94
+ {
95
+ "id": 20,
96
+ "subject": "Task 13: Documentation + changelog",
97
+ "status": "pending",
98
+ "blockedBy": [16, 19],
99
+ "description": "**Goal:** Document overhaul; update skills.\n\n```json:metadata\n{\"files\":[\"docs/guides/ui-overhaul-2026.md\",\"CHANGELOG.md\",\".claude/skills/plutonium-views.md\"],\"verifyCommand\":\"yarn docs:build\",\"acceptanceCriteria\":[\"upgrade guide written\",\"skills updated\",\"changelog entry\",\"docs build passes\"],\"requiresUserVerification\":false}\n```"
100
+ }
101
+ ],
102
+ "lastUpdated": "2026-05-07"
103
+ }
@@ -0,0 +1,270 @@
1
+ # Plutonium UI Layout Overhaul — Design Spec
2
+
3
+ **Date:** 2026-05-07
4
+ **Scope:** Visual + structural redesign of Plutonium's app shell, page header, index/show/form pages. Code-level component refactoring (slot APIs, hook reduction, partial-to-Phlex conversion) is intentionally out of scope here — this spec captures the *target UI* only. A separate refactor pass can re-architect the Phlex internals to deliver this target.
5
+
6
+ ## Goals
7
+
8
+ 1. Modernize the look and feel to match contemporary admin tools (Linear, Stripe Dashboard, Vercel, Plane).
9
+ 2. Increase information density without sacrificing scannability.
10
+ 3. Establish a coherent visual vocabulary across all four page types (index / show / form / interactive-action).
11
+ 4. Leave clean extension points for upcoming features (metadata side panel, view switchers).
12
+
13
+ ## Non-Goals
14
+
15
+ - Theming / token rebuild (deferred — current `--pu-*` token system stays).
16
+ - Component API consolidation (separate effort).
17
+ - Mobile-first redesign (mobile must work, but desktop is the optimization target).
18
+ - New colors / typography (use existing tokens unless a decision below requires a new one).
19
+
20
+ ---
21
+
22
+ ## 1. App Shell — Icon Rail + Topbar
23
+
24
+ A narrow icon-only left rail plus a topbar replaces the current expanded sidebar.
25
+
26
+ **Left rail**
27
+ - Width: ~56px, fixed.
28
+ - Icon-only nav items with tooltips on hover.
29
+ - Top: brand mark / portal switcher.
30
+ - Middle: primary nav (resources grouped by section, dividers between groups).
31
+ - Bottom: settings, theme toggle.
32
+ - Active item: filled background, primary tone.
33
+ - Mobile (<lg breakpoint): rail collapses to hamburger drawer.
34
+
35
+ **Topbar**
36
+ - Height: ~48px, sticky.
37
+ - Left: breadcrumbs (resource path; replaces in-content breadcrumbs).
38
+ - Center: global search input (filled, ~360px max).
39
+ - Right: notifications, user menu.
40
+
41
+ **Removed**
42
+ - Current expanded sidebar (240px) — labels now live in tooltips and breadcrumbs.
43
+ - In-page breadcrumbs above title — moved to topbar.
44
+
45
+ ---
46
+
47
+ ## 2. Page Header — Stripe-Style
48
+
49
+ Every page renders a unified header below the topbar.
50
+
51
+ ```
52
+ ┌────────────────────────────────────────────────────┐
53
+ │ Customers [Cancel] [Save] │
54
+ │ Manage customer accounts and contact details │
55
+ ├────────────────────────────────────────────────────┤
56
+ │ Overview │ Orders │ Invoices │ Activity │
57
+ └────────────────────────────────────────────────────┘
58
+ ```
59
+
60
+ - Title: 18–20px, semibold.
61
+ - Description: 13px, muted, optional, sits directly below title.
62
+ - Actions: right-aligned at title's vertical level. Primary as filled button, secondary as outline. Overflow into a `⋯` dropdown after 2 visible actions.
63
+ - Tabs: connected strip directly under header (no gap), 1px bottom border becomes the active-tab indicator's baseline.
64
+
65
+ The header is uniform across index / show / form / interactive-action.
66
+
67
+ ---
68
+
69
+ ## 3. Index Page — Hybrid Toolbar + Pills + Column Sort
70
+
71
+ ### Toolbar (single 36-40px row above the table)
72
+
73
+ Order, left to right:
74
+ 1. **View switcher** — segmented control (Grid / Cards / Kanban — Cards/Kanban are placeholders for now; only Grid is wired initially).
75
+ 2. Vertical divider.
76
+ 3. **Filter** button (popover).
77
+ 4. **Group** button (popover).
78
+ 5. Spacer (`flex-grow`).
79
+ 6. **Search input** — visible, ~220px wide, expands on focus.
80
+ 7. Vertical divider.
81
+ 8. **Column config** icon button (`⊞`).
82
+ 9. **Overflow** icon button (`⋯`) — exports, advanced options.
83
+
84
+ The "Sort" button is intentionally absent — sort is column-driven (see below).
85
+
86
+ ### Active Filter Strip (below toolbar, only when filters are active)
87
+
88
+ - Each active filter renders as a removable pill: `<field> <op> <value>` with `✕`.
89
+ - After the last pill: `+ Filter` dashed pill that opens the same popover as the toolbar Filter button.
90
+ - Right-aligned: result count (e.g., "147 results").
91
+
92
+ ### Table — Column-Header Sort
93
+
94
+ - Click a column header: sorts asc → desc → none (cycles).
95
+ - Shift-click: adds a secondary/tertiary sort (multi-sort).
96
+ - Active sort columns show: arrow (↑/↓) + small priority badge (1, 2, 3) when more than one column is active.
97
+ - Each header has a `⋯` menu: Sort asc / Sort desc / Clear sort / Group by / Filter by / Hide column.
98
+ - Row height: 32px (balanced density). Header height: 32px.
99
+ - Selection: leftmost column is a 12px checkbox.
100
+
101
+ ### Bulk Action Bar
102
+
103
+ - Appears as a 36px strip *replacing* the active-filter strip when ≥1 row is selected.
104
+ - Tinted background (primary-50 light / primary-950/30 dark).
105
+ - Left: count + "Clear selection".
106
+ - After spacer: action buttons (Export, Archive, Delete) — Delete uses danger tone.
107
+
108
+ ### Pagination Footer
109
+
110
+ - Sticky-ish strip below the table.
111
+ - Left: "Showing N–M of Total".
112
+ - Right: prev / page indicator / next.
113
+
114
+ ---
115
+
116
+ ## 4. Show Page — Single Column + Tabs
117
+
118
+ A single content column under the page header. Nested resources render as tabs.
119
+
120
+ ### Structure
121
+
122
+ ```
123
+ PageHeader (title, description, actions, tab strip)
124
+ └── content
125
+ ├── [Aside slot — empty by default; reserved]
126
+ └── Main column
127
+ ├── Field panel: Details
128
+ ├── Field panel: Address
129
+ └── ...
130
+ ```
131
+
132
+ - Field panels: card-styled (1px border, radius-md, white surface) with uppercase 9px section labels.
133
+ - Default content max-width: ~960px, centered if rail+topbar leaves wider area.
134
+ - Tabs render nested resources (the existing tab strip mechanism).
135
+
136
+ ### Reserved Aside Slot (Future Hook)
137
+
138
+ The page layout reserves a `render_aside` slot that is empty by default. A future `metadata` DSL on resource definitions will populate this slot:
139
+
140
+ ```ruby
141
+ class CustomerDefinition < Plutonium::Resource::Definition
142
+ metadata do
143
+ field :status, badge: true
144
+ field :owner
145
+ field :created_at
146
+ end
147
+ end
148
+ ```
149
+
150
+ When populated, the aside renders as a 200–240px right side panel with a sticky 16px-padded background-`surface-alt` column. Implementation of the DSL itself is a separate task — this spec only requires the layout to leave room.
151
+
152
+ ---
153
+
154
+ ## 5. Form Page — Centered Narrow + Sticky Footer
155
+
156
+ For new / edit / interactive-action.
157
+
158
+ ### Structure
159
+
160
+ ```
161
+ PageHeader (title, description; no actions in header)
162
+ └── content (max-width ~580px, centered)
163
+ ├── Card: Section 1 (uppercase 9px label + fields)
164
+ ├── Card: Section 2
165
+ └── ...
166
+ StickyFooter (full width, right-aligned [Cancel] [Save])
167
+ ```
168
+
169
+ - Form column max-width: 580px.
170
+ - Card-style sections, same chrome as show-page panels.
171
+ - Inline validation: errors render as 12px danger text directly under each field. No toasts for field-level errors. Toast/flash only for form-level outcomes.
172
+ - Sticky footer: 56px tall, white surface, top border, sticks to viewport bottom when form scrolls.
173
+ - Cancel: outline button. Save: primary filled button. Right-aligned.
174
+
175
+ ### Modal Variant
176
+
177
+ The same form can render in a modal when triggered as a quick-create / quick-edit (e.g., `+ New` from an index toolbar that targets the remote modal frame, or a row-edit action):
178
+ - No sticky footer; the modal's own footer bar holds Cancel / Save.
179
+ - Internal layout otherwise identical (card sections, inline validation).
180
+ - Modal mode (`:centered` vs `:slideover`) is configurable per resource definition — see §7.
181
+ - Page-level new/edit URLs always render the full page form (§5); modal rendering is invoked via the modal turbo frame.
182
+
183
+ ---
184
+
185
+ ## 6. Density
186
+
187
+ **Balanced (Stripe / Vercel-class)** as the framework default.
188
+
189
+ | Token | Value |
190
+ |------------------|---------------|
191
+ | Table row height | 32px |
192
+ | Body text | 14px |
193
+ | Section gap | 16px |
194
+ | Field gap | 12px |
195
+ | Button (md) | 32px height, 14px text, 12px horizontal padding |
196
+ | Button (sm) | 28px, 13px, 10px |
197
+ | Input height | 36px (forms), 32px (toolbars) |
198
+ | Card padding | 16px |
199
+ | Page side padding | 24px |
200
+
201
+ These values become the canonical scale; spot-deviations are allowed but should be rare.
202
+
203
+ ---
204
+
205
+ ## 7. Modals — Both Modes, Per-Action Opt-In
206
+
207
+ Two modal modes ship as siblings.
208
+
209
+ ### Default: Centered Dialog
210
+ - Max-width 520px, max-height 80vh, centered, dimmed backdrop.
211
+ - Header: dialog title + close (✕).
212
+ - Body: form / content with internal scroll.
213
+ - Footer: 56px strip, right-aligned [Cancel] [Confirm].
214
+ - Use cases: short forms, confirmations, most interactive actions.
215
+
216
+ ### Opt-In: Right Slide-Over Panel
217
+ - Slides in from right, full height, 480px wide on desktop, full-screen on mobile.
218
+ - Header / body / footer same as centered.
219
+ - Underlying list visible (dimmed); user keeps context.
220
+ ### Configuration
221
+
222
+ **Per interaction** — defaults to `:centered`, opt into `:slideover`:
223
+ ```ruby
224
+ interactive_action :reschedule, modal: :slideover
225
+ interactive_action :archive # implicit modal: :centered
226
+ ```
227
+
228
+ **Per resource (for quick-create / quick-edit modals)** — definition declares the mode used when new/edit is rendered through the modal turbo frame:
229
+ ```ruby
230
+ class CustomerDefinition < Plutonium::Resource::Definition
231
+ modal :slideover # default :centered
232
+ end
233
+ ```
234
+
235
+ The page-level new/edit URLs always render the full §5 page layout. Whether `+ New` opens a modal or navigates to the page is a per-context call-site choice (e.g., index toolbar can target the modal frame for quick-create; a "Create customer" landing CTA navigates to the full page).
236
+
237
+ Both modal modes share the same Phlex modal component; only the outer container varies.
238
+
239
+ ---
240
+
241
+ ## 8. Compatibility & Migration Notes
242
+
243
+ - **`Layout::ResourceLayout`** currently uses Rails partials for `resource_header` / `resource_sidebar`. Conversion to Phlex is implicit in this work — the icon rail and topbar must be Phlex components.
244
+ - **`Page::Base` hook explosion** (~12 before/after hooks) — most apps don't use these. The redesign assumes apps that override `render_breadcrumbs` etc. continue to work; new slot APIs are additive. Hook deprecation is a future cleanup.
245
+ - **Existing CSS classes** — `.pu-input`, `.pu-btn`, `.pu-card` keep their names; sizes shift to the density table above. Apps that hard-code Tailwind utilities on top will need cosmetic touch-ups but no breakage.
246
+ - **Breadcrumbs** — moving from in-page to topbar means `Plutonium::UI::Breadcrumbs` becomes a topbar component. The definition-level `breadcrumbs` toggle stays; "off" means hidden in topbar (or replaced with title only).
247
+
248
+ ---
249
+
250
+ ## 9. Out-of-Scope Followups (referenced, not designed here)
251
+
252
+ - **Metadata DSL** for show-page side panel (§4).
253
+ - **View switcher** wiring beyond Grid (Cards, Kanban) — placeholders in toolbar; implementation deferred.
254
+ - **Code-level Phlex refactor** — slot API, hook reduction, asset registry — separate spec.
255
+ - **Token / theme rebuild** — separate spec.
256
+
257
+ ---
258
+
259
+ ## 10. Acceptance Checklist
260
+
261
+ - [ ] Icon rail (56px) replaces expanded sidebar; topbar adds breadcrumbs + search + user.
262
+ - [ ] Page header is a single component (`PageHeader`) used by every page type, supporting title / description / actions / tabs.
263
+ - [ ] Index page renders the toolbar in the order of §3 with no Sort button.
264
+ - [ ] Active filters render as removable pills below the toolbar with a result count.
265
+ - [ ] Column headers sort on click (asc/desc/none) with shift-click multi-sort and priority badges.
266
+ - [ ] Bulk action bar replaces the filter strip when rows are selected.
267
+ - [ ] Show page is single-column with tab strip; an empty aside slot is reserved.
268
+ - [ ] Form pages use a 580px centered column with sticky footer; modal variant uses dialog footer.
269
+ - [ ] Density tokens (§6) are codified in CSS / Phlex constants and used consistently.
270
+ - [ ] Modal component supports both `:centered` (default) and `:slideover` via per-action / per-form opt-in.
@@ -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.