nitro_kit 0.9.0 → 2.0.0.alpha.2

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 (219) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/plugins/marketplace.json +20 -0
  3. data/CHANGELOG.md +172 -0
  4. data/LICENSE +28 -0
  5. data/README.md +22 -15
  6. data/STYLE_GUIDE.md +425 -0
  7. data/app/assets/stylesheets/nitro_kit-tailwind-v4.css +38 -0
  8. data/app/assets/stylesheets/nitro_kit.css +7239 -0
  9. data/app/components/nitro_kit/accordion.rb +125 -65
  10. data/app/components/nitro_kit/alert.rb +80 -43
  11. data/app/components/nitro_kit/app_navigation.rb +307 -0
  12. data/app/components/nitro_kit/app_shell.rb +227 -0
  13. data/app/components/nitro_kit/appearance_bootstrap.rb +146 -0
  14. data/app/components/nitro_kit/appearance_picker.rb +156 -0
  15. data/app/components/nitro_kit/auth_shell.rb +34 -0
  16. data/app/components/nitro_kit/avatar.rb +85 -29
  17. data/app/components/nitro_kit/avatar_stack.rb +155 -9
  18. data/app/components/nitro_kit/badge.rb +43 -81
  19. data/app/components/nitro_kit/button.rb +180 -103
  20. data/app/components/nitro_kit/button_group.rb +63 -11
  21. data/app/components/nitro_kit/button_to.rb +69 -0
  22. data/app/components/nitro_kit/card.rb +42 -45
  23. data/app/components/nitro_kit/checkbox.rb +115 -62
  24. data/app/components/nitro_kit/checkbox_group.rb +131 -22
  25. data/app/components/nitro_kit/choice.rb +55 -0
  26. data/app/components/nitro_kit/combobox.rb +292 -105
  27. data/app/components/nitro_kit/command_palette.rb +412 -0
  28. data/app/components/nitro_kit/component.rb +322 -46
  29. data/app/components/nitro_kit/container.rb +32 -0
  30. data/app/components/nitro_kit/control_group.rb +45 -0
  31. data/app/components/nitro_kit/danger_zone.rb +78 -0
  32. data/app/components/nitro_kit/data_section.rb +106 -0
  33. data/app/components/nitro_kit/details_table.rb +229 -0
  34. data/app/components/nitro_kit/dialog.rb +257 -83
  35. data/app/components/nitro_kit/dropdown.rb +335 -122
  36. data/app/components/nitro_kit/dropzone.rb +326 -0
  37. data/app/components/nitro_kit/empty_state.rb +99 -0
  38. data/app/components/nitro_kit/field.rb +461 -236
  39. data/app/components/nitro_kit/field_group.rb +15 -8
  40. data/app/components/nitro_kit/fieldset.rb +37 -39
  41. data/app/components/nitro_kit/flex.rb +49 -0
  42. data/app/components/nitro_kit/form_builder.rb +313 -72
  43. data/app/components/nitro_kit/form_section.rb +79 -0
  44. data/app/components/nitro_kit/grid.rb +34 -0
  45. data/app/components/nitro_kit/icon.rb +54 -25
  46. data/app/components/nitro_kit/input.rb +97 -10
  47. data/app/components/nitro_kit/label.rb +22 -6
  48. data/app/components/nitro_kit/layout_options.rb +7 -0
  49. data/app/components/nitro_kit/page_header.rb +76 -0
  50. data/app/components/nitro_kit/pagination.rb +355 -69
  51. data/app/components/nitro_kit/pagination_bar.rb +90 -0
  52. data/app/components/nitro_kit/progressive_image.rb +181 -0
  53. data/app/components/nitro_kit/radio_button.rb +77 -55
  54. data/app/components/nitro_kit/radio_button_group.rb +107 -36
  55. data/app/components/nitro_kit/responsive_value.rb +94 -0
  56. data/app/components/nitro_kit/rich_text_area.rb +31 -0
  57. data/app/components/nitro_kit/select.rb +129 -64
  58. data/app/components/nitro_kit/settings_layout.rb +152 -0
  59. data/app/components/nitro_kit/sheet.rb +202 -0
  60. data/app/components/nitro_kit/stat_grid.rb +75 -0
  61. data/app/components/nitro_kit/switch.rb +108 -51
  62. data/app/components/nitro_kit/table.rb +237 -45
  63. data/app/components/nitro_kit/tabs.rb +168 -63
  64. data/app/components/nitro_kit/textarea.rb +81 -10
  65. data/app/components/nitro_kit/toast.rb +211 -80
  66. data/app/components/nitro_kit/toolbar.rb +75 -0
  67. data/app/components/nitro_kit/tooltip.rb +235 -34
  68. data/app/components/nitro_kit/typeset.rb +28 -0
  69. data/app/javascript/controllers/nk/app_shell_controller.js +224 -0
  70. data/app/javascript/controllers/nk/appearance_controller.js +73 -0
  71. data/app/javascript/controllers/nk/avatar_controller.js +15 -0
  72. data/app/javascript/controllers/nk/button_controller.js +56 -0
  73. data/app/javascript/controllers/nk/checkable_controller.js +41 -0
  74. data/app/javascript/controllers/nk/combobox_controller.js +314 -82
  75. data/app/javascript/controllers/nk/command_palette_controller.js +249 -0
  76. data/app/javascript/controllers/nk/dialog_controller.js +14 -10
  77. data/app/javascript/controllers/nk/dropdown_controller.js +106 -58
  78. data/app/javascript/controllers/nk/dropzone/direct_upload.js +61 -0
  79. data/app/javascript/controllers/nk/dropzone/file_rules.js +77 -0
  80. data/app/javascript/controllers/nk/dropzone/form_submit_lock.js +30 -0
  81. data/app/javascript/controllers/nk/dropzone_controller.js +482 -0
  82. data/app/javascript/controllers/nk/overlay_position.js +37 -0
  83. data/app/javascript/controllers/nk/progressive_image_controller.js +116 -0
  84. data/app/javascript/controllers/nk/tabs_controller.js +135 -23
  85. data/app/javascript/controllers/nk/toast_controller.js +112 -45
  86. data/app/javascript/controllers/nk/tooltip_controller.js +12 -47
  87. data/config/importmap.rb +2 -0
  88. data/config/locales/en.yml +98 -0
  89. data/docs/agent_guide.md +139 -0
  90. data/docs/agent_native_spec.md +422 -0
  91. data/docs/component_contracts.md +263 -0
  92. data/docs/customization.md +595 -0
  93. data/docs/hotwire.md +94 -0
  94. data/docs/initialization_prompt.md +36 -0
  95. data/docs/migration_1_to_2.md +318 -0
  96. data/docs/new_app_strategy.md +22 -0
  97. data/docs/patterns/application_foundation.md +136 -0
  98. data/docs/patterns/crud_resource.md +144 -0
  99. data/docs/patterns/destructive_action.md +105 -0
  100. data/docs/patterns/flash_and_toast.md +57 -0
  101. data/docs/patterns/inline_edit.md +59 -0
  102. data/docs/patterns/queryable_collection.md +187 -0
  103. data/docs/patterns/resource_form.md +126 -0
  104. data/docs/rails_conventions.md +95 -0
  105. data/docs/rails_integration.md +514 -0
  106. data/lib/generators/nitro_kit/install_generator.rb +48 -0
  107. data/lib/generators/nitro_kit/upgrade_smoke_tests_generator.rb +67 -0
  108. data/lib/nitro_kit/engine.rb +20 -0
  109. data/lib/nitro_kit/installation.rb +671 -0
  110. data/lib/nitro_kit/migration_inventory.rb +345 -0
  111. data/lib/nitro_kit/upgrade_smoke_test.rb +403 -0
  112. data/lib/nitro_kit/version.rb +1 -1
  113. data/lib/nitro_kit.rb +5 -42
  114. data/lib/rails/commands/nitro_kit/nitro_kit_command.rb +42 -0
  115. data/lib/tasks/nitro_kit_tasks.rake +79 -4
  116. data/plugins/nitro-kit/.codex-plugin/plugin.json +28 -0
  117. data/plugins/nitro-kit/skills/nitro-kit-hotwire/SKILL.md +67 -0
  118. data/plugins/nitro-kit/skills/nitro-kit-hotwire/agents/openai.yaml +4 -0
  119. data/plugins/nitro-kit/skills/nitro-kit-rails/SKILL.md +60 -0
  120. data/plugins/nitro-kit/skills/nitro-kit-rails/agents/openai.yaml +4 -0
  121. data/plugins/nitro-kit/skills/nitro-kit-ui/SKILL.md +62 -0
  122. data/plugins/nitro-kit/skills/nitro-kit-ui/agents/openai.yaml +4 -0
  123. data/src/stylesheets/nitro_kit/components/accordion.css +119 -0
  124. data/src/stylesheets/nitro_kit/components/alert.css +82 -0
  125. data/src/stylesheets/nitro_kit/components/app_navigation.css +156 -0
  126. data/src/stylesheets/nitro_kit/components/app_shell.css +605 -0
  127. data/src/stylesheets/nitro_kit/components/appearance_picker.css +117 -0
  128. data/src/stylesheets/nitro_kit/components/auth_shell.css +8 -0
  129. data/src/stylesheets/nitro_kit/components/avatar.css +81 -0
  130. data/src/stylesheets/nitro_kit/components/avatar_stack.css +80 -0
  131. data/src/stylesheets/nitro_kit/components/badge.css +76 -0
  132. data/src/stylesheets/nitro_kit/components/button.css +283 -0
  133. data/src/stylesheets/nitro_kit/components/button_group.css +53 -0
  134. data/src/stylesheets/nitro_kit/components/button_to.css +6 -0
  135. data/src/stylesheets/nitro_kit/components/card.css +70 -0
  136. data/src/stylesheets/nitro_kit/components/checkbox.css +160 -0
  137. data/src/stylesheets/nitro_kit/components/checkbox_group.css +61 -0
  138. data/src/stylesheets/nitro_kit/components/combobox.css +148 -0
  139. data/src/stylesheets/nitro_kit/components/command_palette.css +255 -0
  140. data/src/stylesheets/nitro_kit/components/container.css +25 -0
  141. data/src/stylesheets/nitro_kit/components/control_group.css +149 -0
  142. data/src/stylesheets/nitro_kit/components/danger_zone.css +54 -0
  143. data/src/stylesheets/nitro_kit/components/data_section.css +40 -0
  144. data/src/stylesheets/nitro_kit/components/details_table.css +53 -0
  145. data/src/stylesheets/nitro_kit/components/dialog.css +80 -0
  146. data/src/stylesheets/nitro_kit/components/dropdown.css +152 -0
  147. data/src/stylesheets/nitro_kit/components/dropzone.css +198 -0
  148. data/src/stylesheets/nitro_kit/components/empty_state.css +53 -0
  149. data/src/stylesheets/nitro_kit/components/field.css +79 -0
  150. data/src/stylesheets/nitro_kit/components/field_group.css +6 -0
  151. data/src/stylesheets/nitro_kit/components/fieldset.css +28 -0
  152. data/src/stylesheets/nitro_kit/components/flex.css +455 -0
  153. data/src/stylesheets/nitro_kit/components/form_section.css +71 -0
  154. data/src/stylesheets/nitro_kit/components/grid.css +308 -0
  155. data/src/stylesheets/nitro_kit/components/icon.css +26 -0
  156. data/src/stylesheets/nitro_kit/components/input.css +80 -0
  157. data/src/stylesheets/nitro_kit/components/label.css +9 -0
  158. data/src/stylesheets/nitro_kit/components/layout.css +440 -0
  159. data/src/stylesheets/nitro_kit/components/page_header.css +61 -0
  160. data/src/stylesheets/nitro_kit/components/pagination.css +119 -0
  161. data/src/stylesheets/nitro_kit/components/pagination_bar.css +41 -0
  162. data/src/stylesheets/nitro_kit/components/palette.css +113 -0
  163. data/src/stylesheets/nitro_kit/components/progressive_image.css +139 -0
  164. data/src/stylesheets/nitro_kit/components/radio_button.css +127 -0
  165. data/src/stylesheets/nitro_kit/components/radio_button_group.css +143 -0
  166. data/src/stylesheets/nitro_kit/components/rich_text_area.css +21 -0
  167. data/src/stylesheets/nitro_kit/components/select.css +70 -0
  168. data/src/stylesheets/nitro_kit/components/settings_layout.css +100 -0
  169. data/src/stylesheets/nitro_kit/components/sheet.css +100 -0
  170. data/src/stylesheets/nitro_kit/components/stat_grid.css +51 -0
  171. data/src/stylesheets/nitro_kit/components/switch.css +152 -0
  172. data/src/stylesheets/nitro_kit/components/table.css +116 -0
  173. data/src/stylesheets/nitro_kit/components/tabs.css +138 -0
  174. data/src/stylesheets/nitro_kit/components/textarea.css +39 -0
  175. data/src/stylesheets/nitro_kit/components/toast.css +131 -0
  176. data/src/stylesheets/nitro_kit/components/toolbar.css +78 -0
  177. data/src/stylesheets/nitro_kit/components/tooltip.css +124 -0
  178. data/src/stylesheets/nitro_kit/components/typeset.css +191 -0
  179. data/src/stylesheets/nitro_kit/layers.css +7 -0
  180. data/src/stylesheets/nitro_kit/reset.css +192 -0
  181. data/src/stylesheets/nitro_kit/tokens.css +198 -0
  182. metadata +182 -51
  183. data/Rakefile +0 -8
  184. data/app/assets/tailwind/application.css +0 -49
  185. data/app/components/nitro_kit/datepicker.rb +0 -9
  186. data/app/helpers/nitro_kit/accordion_helper.rb +0 -9
  187. data/app/helpers/nitro_kit/alert_helper.rb +0 -11
  188. data/app/helpers/nitro_kit/avatar_helper.rb +0 -13
  189. data/app/helpers/nitro_kit/badge_helper.rb +0 -13
  190. data/app/helpers/nitro_kit/button_group_helper.rb +0 -9
  191. data/app/helpers/nitro_kit/button_helper.rb +0 -49
  192. data/app/helpers/nitro_kit/card_helper.rb +0 -9
  193. data/app/helpers/nitro_kit/checkbox_helper.rb +0 -50
  194. data/app/helpers/nitro_kit/combobox_helper.rb +0 -9
  195. data/app/helpers/nitro_kit/datepicker_helper.rb +0 -9
  196. data/app/helpers/nitro_kit/dialog_helper.rb +0 -9
  197. data/app/helpers/nitro_kit/dropdown_helper.rb +0 -9
  198. data/app/helpers/nitro_kit/field_group_helper.rb +0 -9
  199. data/app/helpers/nitro_kit/field_helper.rb +0 -9
  200. data/app/helpers/nitro_kit/fieldset_helper.rb +0 -9
  201. data/app/helpers/nitro_kit/form_helper.rb +0 -13
  202. data/app/helpers/nitro_kit/icon_helper.rb +0 -9
  203. data/app/helpers/nitro_kit/input_helper.rb +0 -37
  204. data/app/helpers/nitro_kit/label_helper.rb +0 -19
  205. data/app/helpers/nitro_kit/pagination_helper.rb +0 -46
  206. data/app/helpers/nitro_kit/radio_button_helper.rb +0 -23
  207. data/app/helpers/nitro_kit/select_helper.rb +0 -24
  208. data/app/helpers/nitro_kit/switch_helper.rb +0 -9
  209. data/app/helpers/nitro_kit/table_helper.rb +0 -9
  210. data/app/helpers/nitro_kit/tabs_helper.rb +0 -9
  211. data/app/helpers/nitro_kit/textarea_helper.rb +0 -9
  212. data/app/helpers/nitro_kit/toast_helper.rb +0 -25
  213. data/app/helpers/nitro_kit/tooltip_helper.rb +0 -9
  214. data/app/javascript/controllers/nk/accordion_controller.js +0 -18
  215. data/app/javascript/controllers/nk/datepicker_controller.js +0 -5
  216. data/app/javascript/controllers/nk/switch_controller.js +0 -32
  217. data/lib/generators/nitro_kit/component_generator.rb +0 -84
  218. data/lib/nitro_kit/schema_builder.rb +0 -129
  219. data/lib/nitro_kit/variants.rb +0 -21
@@ -0,0 +1,105 @@
1
+ # Destructive action
2
+
3
+ Use a native Nitro Dialog when the user needs to review impact or type confirmation. The dialog only owns accessible disclosure and focus behavior; a real Rails form owns the destructive request.
4
+
5
+ ## Summary
6
+
7
+ - Use a native Nitro `Dialog` only when the user must review impact or type a
8
+ confirmation; a real Rails form owns the destructive request.
9
+ - Place the reviewed deletion on the resource's edit page, not on the
10
+ operational show page.
11
+ - The delete form targets `_top` so a successful redirect navigates the page
12
+ instead of resolving inside the surrounding frame.
13
+ - Authorize and load the record on the server. The confirmation UI is not a
14
+ security boundary.
15
+ - When the consequence fits in one sentence, use Turbo's native `turbo_confirm`
16
+ instead. Never stack both confirmation surfaces.
17
+
18
+ ## Reviewed deletion
19
+
20
+ Place this composition on the resource's edit page. A show page is the
21
+ operational home; it should not advertise permanent deletion on every visit.
22
+
23
+ ```ruby
24
+ module UI
25
+ class DeleteProject < Phlex::HTML
26
+ include Phlex::Rails::Helpers::DOMID
27
+ include Phlex::Rails::Helpers::FormWith
28
+ include Phlex::Rails::Helpers::Routes
29
+
30
+ def initialize(project)
31
+ @project = project
32
+ end
33
+
34
+ def view_template
35
+ render NitroKit::DangerZone.new(
36
+ title: "Delete project",
37
+ description: "This permanently removes the project and its activity.",
38
+ id: dom_id(project, :danger_zone)
39
+ ) do |zone|
40
+ zone.confirmation do
41
+ render NitroKit::Dialog.new(id: dom_id(project, :delete_dialog)) do |dialog|
42
+ dialog.trigger("Review deletion", variant: :destructive)
43
+ dialog.panel(
44
+ title: "Delete #{project.name}?",
45
+ description: "This action cannot be undone."
46
+ ) do
47
+ form_with(
48
+ model: project,
49
+ url: project_path(project),
50
+ method: :delete,
51
+ data: { turbo_frame: "_top" }
52
+ ) do
53
+ render NitroKit::Button.new(
54
+ "Delete project",
55
+ type: :submit,
56
+ variant: :destructive,
57
+ data: { turbo_submits_with: "Deleting…" }
58
+ )
59
+ end
60
+ dialog.close_button(label: "Cancel deletion")
61
+ end
62
+ end
63
+ end
64
+ zone.escape NitroKit::Button.new("Keep project", href: project_path(project))
65
+ end
66
+ end
67
+
68
+ private
69
+ attr_reader :project
70
+ end
71
+ end
72
+ ```
73
+
74
+ The top-level target makes a successful redirect navigate the page rather than trying to render the destination inside the surrounding settings frame.
75
+
76
+ ```ruby
77
+ def destroy
78
+ project = Current.account.projects.find(params[:id])
79
+ project.destroy!
80
+ redirect_to projects_path, status: :see_other, notice: "Project deleted"
81
+ end
82
+ ```
83
+
84
+ Authorize and load the record on the server even when the dialog is open. The confirmation UI is not a security boundary.
85
+
86
+ ## Compact confirmation
87
+
88
+ When the consequence fits in one sentence and needs no review UI, keep the ordinary request and use Turbo's native browser confirmation:
89
+
90
+ ```ruby
91
+ form_with(model: project, url: project_path(project), method: :delete) do
92
+ render NitroKit::Button.new(
93
+ "Delete",
94
+ type: :submit,
95
+ variant: :destructive,
96
+ data: { turbo_confirm: "Delete this project permanently?" }
97
+ )
98
+ end
99
+ ```
100
+
101
+ Do not stack `turbo_confirm` inside a Dialog. Choose one confirmation surface.
102
+
103
+ ## Tests
104
+
105
+ Request-test authorization, deletion, 303 redirect, and flash. System-test the dialog only when the reviewed flow matters: trigger opens it, Cancel closes and restores focus, and the destructive submit removes the record.
@@ -0,0 +1,57 @@
1
+ # Flash and toast
2
+
3
+ Rails flash is the server-side feedback contract. Render it once in the application layout with Nitro's toast adapter so redirects, Turbo visits, and full-page fallbacks all use the same path.
4
+
5
+ ## Summary
6
+
7
+ - Render `NitroKit::Toast::FlashMessages` once in the application layout; Rails
8
+ flash stays the single server-side feedback contract.
9
+ - `notice` maps to the default presentation, `alert` and `error` to error, and
10
+ `success`, `warning`, and `info` to their matching variants.
11
+ - Every toast item is Turbo-temporary so a cached page never replays old
12
+ feedback, while the region itself survives and stays addressable.
13
+ - Use `flash.now` when rendering the current request; a Turbo Stream that does
14
+ not redirect updates the same stable notification region.
15
+ - Do not introduce a client-side notification store for server outcomes.
16
+
17
+ ```ruby
18
+ class UI::ApplicationLayout < Phlex::HTML
19
+ include Phlex::Rails::Layout
20
+ include Phlex::Rails::Helpers::Flash
21
+
22
+ def view_template
23
+ doctype
24
+ html do
25
+ head do
26
+ stylesheet_link_tag("nitro_kit", data: { turbo_track: "reload" })
27
+ end
28
+ body do
29
+ render NitroKit::Toast::FlashMessages.new(flash: flash)
30
+ yield
31
+ end
32
+ end
33
+ end
34
+ end
35
+ ```
36
+
37
+ Controllers set ordinary flash while redirecting:
38
+
39
+ ```ruby
40
+ redirect_to projects_path, status: :see_other, notice: "Project created"
41
+ redirect_to billing_path, status: :see_other, alert: "Payment method was declined"
42
+ ```
43
+
44
+ `notice` maps to the default presentation, `alert` and `error` to error, and `success`, `warning`, and `info` to their matching variants. Every toast item is Turbo-temporary so a cached page does not replay old feedback, dismissible or not. The region itself survives, so `#{toast id}-list` stays addressable.
45
+
46
+ Use `flash.now` only when rendering in the current request:
47
+
48
+ ```ruby
49
+ flash.now[:alert] = "Import failed"
50
+ render UI::ImportForm.new(@import), status: :unprocessable_entity
51
+ ```
52
+
53
+ For a request-scoped Turbo Stream that does not redirect, update a stable notification region in the same stream response. Keep the HTML branch and flash fallback. Do not introduce a client-side notification store for server outcomes.
54
+
55
+ ## Tests
56
+
57
+ Controller tests assert the flash severity and message. One layout or integration test should prove the flash renders through `section[data-nk=toast]`. Nitro's own tests cover timers and dismissal behavior.
@@ -0,0 +1,59 @@
1
+ # Inline edit
2
+
3
+ Wrap one complete resource region in a stable Turbo Frame. Show, edit, validation failure, success, and Cancel all return that same frame ID.
4
+
5
+ ## Summary
6
+
7
+ - One complete resource region lives in a stable Turbo Frame; show, edit,
8
+ invalid, success, and Cancel all return that same frame ID.
9
+ - Invalid updates render the editing frame with 422; success redirects with 303
10
+ and Turbo extracts the matching read-only frame.
11
+ - The show response must contain the same `turbo_frame_tag(dom_id(record))` as
12
+ the edit response.
13
+ - Use a Turbo Stream only when the update also changes another region, such as
14
+ a page title or a summary count.
15
+
16
+ ```ruby
17
+ module UI
18
+ class ProjectPanel < Phlex::HTML
19
+ include Phlex::Rails::Helpers::DOMID
20
+ include Phlex::Rails::Helpers::TurboFrameTag
21
+
22
+ def initialize(project, editing: false)
23
+ @project = project
24
+ @editing = editing
25
+ end
26
+
27
+ def view_template
28
+ turbo_frame_tag(dom_id(project)) do
29
+ if editing
30
+ render UI::ProjectFormFields.new(project)
31
+ else
32
+ render NitroKit::Card.new do |card|
33
+ card.title(project.name)
34
+ card.body { project.description }
35
+ card.footer do
36
+ render NitroKit::Button.new("Edit", href: "/projects/#{project.id}/edit")
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ private
44
+ attr_reader :project, :editing
45
+ end
46
+ end
47
+ ```
48
+
49
+ The edit form submits normally within the frame. Its Cancel link targets the resource show action, which returns the read-only frame. Invalid updates render the editing frame with 422. A successful update redirects with 303 to the resource action; Turbo follows the redirect and extracts the matching read-only frame:
50
+
51
+ ```ruby
52
+ redirect_to @project, status: :see_other, notice: "Project updated"
53
+ ```
54
+
55
+ The show response must contain `turbo_frame_tag(dom_id(project))` just like the edit response. If success renders directly instead, return frame-shaped HTML rather than labeling a bare frame as a Turbo Stream. Use a stream only when the update also changes another region, such as a page title or summary count.
56
+
57
+ ## Tests
58
+
59
+ Assert every endpoint returns the same `turbo-frame` ID. Cover invalid values remaining visible at 422 and Cancel restoring the read-only view. Add a system test when focus or scroll preservation is important.
@@ -0,0 +1,187 @@
1
+ # Queryable collection
2
+
3
+ Use one GET-driven Turbo Frame for filters, sorting, results, and pagination. The URL is the state: the query object reads parameters, the server renders the complete region, and browser history remains useful.
4
+
5
+ ## Summary
6
+
7
+ - One GET-driven Turbo Frame with a stable ID owns filters, sorting, results,
8
+ and pagination; the URL is the state.
9
+ - The frame carries `data-turbo-action="advance"` so pagination is a history
10
+ step, while filter, reset, and sort links use `turbo_action: "replace"`.
11
+ - An application query object owns parameter allowlists, default ordering, and
12
+ page bounds. `NitroKit::Table` renders sortable headers but owns no query
13
+ policy, and Ransack is never a Nitro dependency.
14
+ - Every response contains the same frame, including empty results. Links that
15
+ leave the collection use `data: { turbo_frame: "_top" }`.
16
+ - No Stimulus controller is required. An optional autosubmit controller submits
17
+ the same GET form without becoming the source of truth.
18
+
19
+ ## Contract
20
+
21
+ - The frame has one stable ID.
22
+ - The frame promotes pagination with `data-turbo-action="advance"` so each page
23
+ remains a meaningful Back and Forward step.
24
+ - Filter forms use GET, target that frame, and replace the current history entry.
25
+ - A small filter set stays in one responsive row at wide widths and stacks on
26
+ narrow screens. Do not turn two fields and two actions into a full-page form.
27
+ - Sort and pagination links preserve the active query parameters. Sort links
28
+ replace the current history entry; pagination inherits the frame's `advance`.
29
+ - `NitroKit::Table` renders the sortable table contract but does not own query policy.
30
+ - The HTML response always contains the same frame, including empty results.
31
+ - No Stimulus controller is required for submit-based filters. An optional autosubmit controller may submit the same GET form without becoming the source of truth.
32
+
33
+ ## Controller
34
+
35
+ Keep parameter normalization and allowlists in an application query object. Ransack is one possible implementation, not a Nitro dependency.
36
+
37
+ ```ruby
38
+ class ProjectsController < ApplicationController
39
+ def index
40
+ @query = ProjectsQuery.new(Project.all, params: params.fetch(:q, {}), page: params[:page])
41
+ end
42
+ end
43
+ ```
44
+
45
+ The query object should expose `records`, `filters`, `current_sort`, `direction`, `sort_url(key)`, and pagination URLs. It is also the right place for Ransack allowlists, default ordering, and page bounds.
46
+
47
+ ## Phlex region
48
+
49
+ ```ruby
50
+ module UI
51
+ class ProjectsTable < Phlex::HTML
52
+ include Phlex::Rails::Helpers::FormWith
53
+ include Phlex::Rails::Helpers::TurboFrameTag
54
+
55
+ FRAME_ID = "projects-results"
56
+
57
+ def initialize(query)
58
+ @query = query
59
+ end
60
+
61
+ def view_template
62
+ turbo_frame_tag(FRAME_ID, data: { turbo_action: "advance" }) do
63
+ render NitroKit::Flex.new(dir: :col, gap: 6, align: :stretch) do
64
+ render_filters
65
+ render_results
66
+ render_pagination
67
+ end
68
+ end
69
+ end
70
+
71
+ private
72
+ attr_reader :query
73
+
74
+ def render_filters
75
+ form_with(
76
+ scope: :q,
77
+ url: "/projects",
78
+ method: :get,
79
+ builder: NitroKit::FormBuilder,
80
+ data: {
81
+ turbo_frame: FRAME_ID,
82
+ turbo_action: "replace"
83
+ }
84
+ ) do |form|
85
+ form.hidden_field(:s, value: query.filters["s"])
86
+ render NitroKit::Grid.new(cols: "1 md:3", gap: 3) do
87
+ form.field(:name_cont, as: :search, label: "Search", value: query.filters["name_cont"])
88
+ form.field(:status_eq, as: :select, label: "Status", options: Project.statuses.keys, include_blank: "All statuses", value: query.filters["status_eq"])
89
+ render NitroKit::Flex.new(dir: :row, gap: 2, align: :end, justify: :end) do
90
+ render NitroKit::Button.new(
91
+ "Reset",
92
+ href: "/projects",
93
+ data: {
94
+ turbo_frame: FRAME_ID,
95
+ turbo_action: "replace"
96
+ }
97
+ )
98
+ form.submit("Apply filters", data: { turbo_submits_with: "Filtering…" })
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ def render_results
105
+ render NitroKit::Table.new(
106
+ sort: query.current_sort,
107
+ direction: query.direction
108
+ ) do |table|
109
+ table.caption("Projects")
110
+ table.thead do
111
+ table.tr do
112
+ table.th(
113
+ sort: :name,
114
+ href: query.sort_url(:name),
115
+ sort_data: { turbo_action: "replace" }
116
+ )
117
+ table.th(
118
+ sort: :status,
119
+ href: query.sort_url(:status),
120
+ sort_data: { turbo_action: "replace" }
121
+ )
122
+ table.th(
123
+ "Updated",
124
+ sort: :updated_at,
125
+ href: query.sort_url(:updated_at),
126
+ align: :right,
127
+ sort_data: { turbo_action: "replace" }
128
+ )
129
+ table.th("Actions", align: :right)
130
+ end
131
+ end
132
+ table.tbody do
133
+ if query.records.any?
134
+ query.records.each { |project| render_row(table, project) }
135
+ else
136
+ table.tr { table.td("No projects match these filters.", html: { colspan: 4 }) }
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ def render_row(table, project)
143
+ table.tr do
144
+ table.th(project.name, scope: :row)
145
+ table.td { render NitroKit::Badge.new(project.status.humanize) }
146
+ table.td(project.updated_at.to_date.to_fs(:long), align: :right)
147
+ table.td(align: :right) do
148
+ render NitroKit::Button.new(
149
+ "View",
150
+ href: project_path(project),
151
+ data: { turbo_frame: "_top" }
152
+ )
153
+ end
154
+ end
155
+ end
156
+
157
+ def render_pagination
158
+ render NitroKit::PaginationBar.new do |bar|
159
+ bar.summary(query.summary)
160
+ bar.pagination(query.pagination)
161
+ end
162
+ end
163
+ end
164
+ end
165
+ ```
166
+
167
+ Sorting and pagination links rendered inside the frame naturally navigate it.
168
+ The frame's `advance` action makes pagination a browser-history step. Filters,
169
+ reset links, and sort links override that default with `turbo_action: "replace"`
170
+ so repeated query refinements do not fill history with disposable states. Use
171
+ `data: { turbo_frame: FRAME_ID }` when a query control sits outside the frame.
172
+ Reset with a plain collection URL so stale query parameters disappear.
173
+
174
+ Links that leave the collection, including View, Edit, and New, are not frame
175
+ navigations. Give them `data: { turbo_frame: "_top" }`, or place them outside
176
+ the results frame. Otherwise Turbo will look for the collection frame in the
177
+ destination response and replace the region with “Content missing.”
178
+
179
+ ## Tests
180
+
181
+ Request tests should prove that query parameters survive sort and page changes,
182
+ invalid sort keys fall back safely, and the response always contains
183
+ `turbo-frame#projects-results`. A system test should use real clicks, scope
184
+ result assertions to that frame, cover filter → sort → paginate → Back and
185
+ Forward, assert the address bar after each query transition, and open a row
186
+ action without a frame-missing error. Use Capybara's retrying assertions rather
187
+ than sleeps or arbitrary waits.
@@ -0,0 +1,126 @@
1
+ # Resource form
2
+
3
+ Use one model-backed component for both the initial form and validation errors. Rails owns field names and errors; Nitro owns form presentation; the response status tells Turbo whether to replace or follow a redirect.
4
+
5
+ ## Summary
6
+
7
+ - One model-backed Phlex component renders both the initial form and its
8
+ validation errors.
9
+ - Rails owns field names and errors, Nitro owns form presentation, and the
10
+ response status tells Turbo what to do: 422 re-renders the invalid object,
11
+ 303 redirects on success.
12
+ - In an application shell, render exactly one primary submit in the route
13
+ toolbar and associate it through the native `form:` attribute; do not also
14
+ call `form.submit` in the body.
15
+ - A genuinely standalone form keeps its submit inside the same `form.group` as
16
+ its visible fields.
17
+ - Parents own rhythm: stacked fields always sit inside `FieldGroup`
18
+ (`form.group` on the builder, or `render NitroKit::FieldGroup.new`). Fields
19
+ rendered as bare siblings of the `form` element stack with no gap — that is
20
+ an authoring mistake, not a layout the field can correct itself.
21
+ - Wrap the form in a Turbo Frame only when it is embedded in a larger screen
22
+ and needs an independent lifecycle.
23
+
24
+ ## Phlex form
25
+
26
+ ```ruby
27
+ module UI
28
+ class ProjectForm < Phlex::HTML
29
+ include Phlex::Rails::Helpers::FormWith
30
+
31
+ def initialize(
32
+ project,
33
+ form_id: ActionView::RecordIdentifier.dom_id(project, :form)
34
+ )
35
+ @project = project
36
+ @form_id = form_id
37
+ end
38
+
39
+ def view_template
40
+ render NitroKit::FormSection.new(
41
+ title: project.persisted? ? "Edit project" : "New project",
42
+ description: "Project details are visible to every workspace member."
43
+ ) do |section|
44
+ section.form do
45
+ form_with(
46
+ model: project,
47
+ builder: NitroKit::FormBuilder,
48
+ id: form_id
49
+ ) do |form|
50
+ form.group do
51
+ form.field(:name, required: true, autofocus: true)
52
+ form.field(
53
+ :status,
54
+ as: :select,
55
+ options: Project.statuses.keys.map do |value|
56
+ [ value.humanize, value ]
57
+ end
58
+ )
59
+ form.field(:description, as: :textarea)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ private
67
+ attr_reader :project, :form_id
68
+ end
69
+ end
70
+ ```
71
+
72
+ In an application shell, render exactly one primary submit in the route
73
+ toolbar and associate it with `form_id`:
74
+
75
+ ```ruby
76
+ Button(
77
+ project.persisted? ? "Save project" : "Create project",
78
+ type: :submit,
79
+ form: form_id,
80
+ variant: :primary,
81
+ data: { turbo_submits_with: "Saving…" }
82
+ )
83
+ ```
84
+
85
+ Do not also call `form.submit` in the form body. Use an in-form submit only
86
+ when the form is genuinely standalone and has no toolbar action; put that
87
+ submit inside the same `form.group` as its visible fields.
88
+
89
+ ## Controller
90
+
91
+ Render the same invalid object with 422. Redirect successful HTML submissions with 303.
92
+
93
+ ```ruby
94
+ class ProjectsController < ApplicationController
95
+ def create
96
+ @project = Current.account.projects.build(project_params)
97
+
98
+ if @project.save
99
+ redirect_to @project, status: :see_other, notice: "Project created"
100
+ else
101
+ render UI::ProjectForm.new(@project), status: :unprocessable_entity
102
+ end
103
+ end
104
+
105
+ def update
106
+ @project = Current.account.projects.find(params[:id])
107
+
108
+ if @project.update(project_params)
109
+ redirect_to @project, status: :see_other, notice: "Project updated"
110
+ else
111
+ render UI::ProjectForm.new(@project), status: :unprocessable_entity
112
+ end
113
+ end
114
+
115
+ private
116
+ def project_params
117
+ params.expect(project: [:name, :status, :description])
118
+ end
119
+ end
120
+ ```
121
+
122
+ The same response works with Turbo Drive and without it. Wrap this component in a stable Turbo Frame only when the form is embedded in a larger screen and should have an independent lifecycle; [inline edit](inline_edit.md) shows that boundary. Add a Turbo Stream branch only when success must update multiple regions instead of navigating.
123
+
124
+ ## Tests
125
+
126
+ Assert 303 plus the redirect and flash on success. Assert 422, the submitted value, and the model error on failure. For an embedded form, also assert the stable frame ID. Add a system test for focus or multi-region effects, not merely to restate controller behavior.
@@ -0,0 +1,95 @@
1
+ # The Nitro Kit Rails path
2
+
3
+ Nitro Kit 2 follows one conventional Rails path so application code, UI, and
4
+ agent output compose predictably. Treat this as the greenfield default. In an
5
+ existing application, preserve established conventions unless the task
6
+ explicitly includes changing them.
7
+
8
+ ## Model the domain with Rails
9
+
10
+ - Put business rules on domain models. Do not introduce a service, form,
11
+ command, or policy object until a real boundary requires one.
12
+ - Scope tenant-owned records through `Current.team` or `Current.account`.
13
+ Record `Current.user` as the actor or creator instead of using the user as
14
+ the tenancy boundary.
15
+ - For a new team-aware application, use `User`, `Team`, and `Membership` from
16
+ the first signup. Put roles on the membership and create the first user's
17
+ team plus owner membership in the same transaction. Read
18
+ `docs/patterns/application_foundation.md` before adding authentication,
19
+ invitations, team administration, or account settings.
20
+ - Represent meaningful lifecycle state with a record when the state has
21
+ identity, timing, provenance, or behavior. A published post has a
22
+ `Post::Publication`; it is not merely a `published` boolean.
23
+ - Name capability concerns with adjectives such as `Publishable` only after
24
+ more than one model shares the behavior. Keep the first implementation on
25
+ the model that owns it.
26
+
27
+ ## Route nouns, not commands
28
+
29
+ Map controllers to REST resources. When an action does not fit the seven
30
+ standard actions, look for the missing noun:
31
+
32
+ ```ruby
33
+ namespace :admin do
34
+ resources :posts do
35
+ resource :publication, only: %i[create destroy],
36
+ module: :posts
37
+ end
38
+ end
39
+ ```
40
+
41
+ `Admin::Posts::PublicationsController#create` publishes and `#destroy`
42
+ unpublishes. Keep controllers thin: load through the tenant boundary, invoke a
43
+ domain method, then render or redirect.
44
+
45
+ Use the authenticated admin `show` action as the operational detail or draft
46
+ preview unless the product truly has another independently addressable
47
+ representation. Public controllers must query only records visible to the
48
+ public.
49
+
50
+ ## Use ordinary Rails responses
51
+
52
+ - Use `form_with` and `NitroKit::FormBuilder` for model-backed forms.
53
+ - Redirect successful non-GET submissions with `status: :see_other`.
54
+ - Render the same invalid model with `status: :unprocessable_entity`.
55
+ - Keep an HTML response for every Turbo-enhanced flow.
56
+ - Keep filtering, sorting, and pagination in GET parameters.
57
+ - Set flash on the server and render it through
58
+ `NitroKit::Toast::FlashMessages`.
59
+
60
+ ## Compose CRUD as one page
61
+
62
+ For an authenticated admin or product area, prefer the hybrid `AppShell` with
63
+ a `Toolbar` in its topbar. The shell supplies responsive navigation disclosure;
64
+ the toolbar supplies the route's one `h1` and its basic actions. The application
65
+ owns one content gutter immediately inside the shell main region.
66
+
67
+ Do not repeat the route title in a `PageHeader`, section heading, card title,
68
+ and table caption. Use `PageHeader` inside an application shell only when the
69
+ page needs a genuinely content-led introduction beyond the toolbar title. A
70
+ single-table index renders the table directly. A form page begins with the
71
+ actual `FormSection`. A detail page begins with status or metadata. Use a Card
72
+ only for a bounded object that needs a surface, never as the automatic wrapper
73
+ for every region.
74
+
75
+ Read `docs/patterns/crud_resource.md` before building a complete resource.
76
+
77
+ ## Keep the browser thin
78
+
79
+ Render HTML on the server. Use Turbo Drive by default, Frames for one stable
80
+ region, request Streams for multi-target responses, broadcasts for other
81
+ sessions, and Stimulus only for browser-owned behavior. Do not create a JSON
82
+ API or client-side state store for an ordinary Rails screen.
83
+
84
+ ## Test the contract
85
+
86
+ Use Minitest and fixtures. Cover:
87
+
88
+ - tenant isolation and role differences;
89
+ - public visibility of drafts and published records;
90
+ - successful `303` redirects and invalid `422` renders;
91
+ - the noun resource that creates or removes lifecycle state;
92
+ - the HTML fallback before adding a system test;
93
+ - browser behavior with Capybara waiting assertions, never sleeps.
94
+
95
+ Prefer a small complete test over layers of factories, mocks, and helper DSLs.