nitro_kit 2.0.0.alpha.2 → 2.0.0.alpha.4

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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +229 -1
  3. data/README.md +25 -10
  4. data/STYLE_GUIDE.md +47 -5
  5. data/app/assets/stylesheets/nitro_kit-tailwind-v4.css +3 -3
  6. data/app/assets/stylesheets/nitro_kit.css +2820 -1816
  7. data/app/components/nitro_kit/accordion.rb +2 -26
  8. data/app/components/nitro_kit/alert.rb +1 -11
  9. data/app/components/nitro_kit/app_navigation.rb +45 -11
  10. data/app/components/nitro_kit/app_shell.rb +1 -7
  11. data/app/components/nitro_kit/appearance_picker.rb +11 -12
  12. data/app/components/nitro_kit/avatar.rb +6 -1
  13. data/app/components/nitro_kit/avatar_stack.rb +3 -0
  14. data/app/components/nitro_kit/badge.rb +13 -4
  15. data/app/components/nitro_kit/button_group.rb +2 -2
  16. data/app/components/nitro_kit/checkbox.rb +7 -4
  17. data/app/components/nitro_kit/checkbox_group.rb +0 -25
  18. data/app/components/nitro_kit/combobox.rb +4 -33
  19. data/app/components/nitro_kit/command_palette.rb +42 -59
  20. data/app/components/nitro_kit/component.rb +57 -5
  21. data/app/components/nitro_kit/danger_zone.rb +5 -1
  22. data/app/components/nitro_kit/data_section.rb +28 -4
  23. data/app/components/nitro_kit/details_table.rb +19 -8
  24. data/app/components/nitro_kit/dialog.rb +23 -19
  25. data/app/components/nitro_kit/dropdown.rb +1 -6
  26. data/app/components/nitro_kit/dropzone.rb +4 -15
  27. data/app/components/nitro_kit/empty_state.rb +21 -2
  28. data/app/components/nitro_kit/field.rb +35 -11
  29. data/app/components/nitro_kit/form_builder.rb +1 -2
  30. data/app/components/nitro_kit/progressive_image.rb +2 -2
  31. data/app/components/nitro_kit/radio_button.rb +7 -4
  32. data/app/components/nitro_kit/radio_button_group.rb +0 -25
  33. data/app/components/nitro_kit/select.rb +4 -17
  34. data/app/components/nitro_kit/settings_layout.rb +17 -4
  35. data/app/components/nitro_kit/{form_section.rb → settings_section.rb} +18 -11
  36. data/app/components/nitro_kit/sheet.rb +29 -20
  37. data/app/components/nitro_kit/stat_grid.rb +18 -2
  38. data/app/components/nitro_kit/table.rb +22 -3
  39. data/app/components/nitro_kit/tabs.rb +2 -13
  40. data/app/components/nitro_kit/toast.rb +4 -2
  41. data/app/components/nitro_kit/toolbar.rb +2 -0
  42. data/app/components/nitro_kit/tooltip.rb +7 -10
  43. data/app/javascript/controllers/nk/app_shell_controller.js +3 -3
  44. data/app/javascript/controllers/nk/appearance_controller.js +6 -0
  45. data/app/javascript/controllers/nk/combobox_controller.js +51 -1
  46. data/app/javascript/controllers/nk/command_palette_controller.js +25 -11
  47. data/app/javascript/controllers/nk/dialog_controller.js +54 -0
  48. data/app/javascript/controllers/nk/dropdown_controller.js +45 -0
  49. data/app/javascript/controllers/nk/dropzone_controller.js +2 -2
  50. data/app/javascript/controllers/nk/progressive_image_controller.js +11 -7
  51. data/app/javascript/controllers/nk/tabs_controller.js +21 -2
  52. data/app/javascript/controllers/nk/tooltip_controller.js +9 -0
  53. data/config/locales/en.yml +4 -1
  54. data/docs/agent_guide.md +63 -123
  55. data/docs/agent_native_spec.md +72 -383
  56. data/docs/browser_support.md +82 -0
  57. data/docs/component_contracts.md +108 -82
  58. data/docs/customization.md +689 -91
  59. data/docs/hotwire.md +50 -76
  60. data/docs/initialization_prompt.md +26 -31
  61. data/docs/migration_1_to_2.md +88 -30
  62. data/docs/patterns/application_foundation.md +45 -119
  63. data/docs/patterns/crud_resource.md +44 -126
  64. data/docs/patterns/destructive_action.md +45 -77
  65. data/docs/patterns/flash_and_toast.md +18 -36
  66. data/docs/patterns/inline_edit.md +21 -44
  67. data/docs/patterns/queryable_collection.md +65 -161
  68. data/docs/patterns/resource_form.md +33 -81
  69. data/docs/rails_conventions.md +38 -75
  70. data/docs/rails_integration.md +96 -446
  71. data/lib/generators/nitro_kit/upgrade_smoke_tests_generator.rb +12 -0
  72. data/lib/nitro_kit/installation.rb +20 -7
  73. data/lib/nitro_kit/migration_inventory.rb +275 -8
  74. data/lib/nitro_kit/upgrade_smoke_test.rb +10 -12
  75. data/lib/nitro_kit/version.rb +1 -1
  76. data/lib/rails/commands/nitro_kit/nitro_kit_command.rb +10 -3
  77. data/plugins/nitro-kit/skills/nitro-kit-hotwire/SKILL.md +4 -0
  78. data/plugins/nitro-kit/skills/nitro-kit-rails/SKILL.md +13 -1
  79. data/plugins/nitro-kit/skills/nitro-kit-ui/SKILL.md +32 -15
  80. data/src/stylesheets/nitro_kit/components/accordion.css +44 -44
  81. data/src/stylesheets/nitro_kit/components/alert.css +27 -36
  82. data/src/stylesheets/nitro_kit/components/app_navigation.css +92 -20
  83. data/src/stylesheets/nitro_kit/components/app_shell.css +199 -196
  84. data/src/stylesheets/nitro_kit/components/appearance_picker.css +61 -39
  85. data/src/stylesheets/nitro_kit/components/auth_shell.css +2 -2
  86. data/src/stylesheets/nitro_kit/components/avatar.css +15 -11
  87. data/src/stylesheets/nitro_kit/components/avatar_stack.css +48 -29
  88. data/src/stylesheets/nitro_kit/components/badge.css +13 -9
  89. data/src/stylesheets/nitro_kit/components/button.css +121 -79
  90. data/src/stylesheets/nitro_kit/components/button_group.css +19 -14
  91. data/src/stylesheets/nitro_kit/components/card.css +12 -6
  92. data/src/stylesheets/nitro_kit/components/checkbox.css +54 -67
  93. data/src/stylesheets/nitro_kit/components/checkbox_group.css +16 -16
  94. data/src/stylesheets/nitro_kit/components/combobox.css +39 -29
  95. data/src/stylesheets/nitro_kit/components/command_palette.css +110 -51
  96. data/src/stylesheets/nitro_kit/components/container.css +6 -6
  97. data/src/stylesheets/nitro_kit/components/control_group.css +61 -67
  98. data/src/stylesheets/nitro_kit/components/danger_zone.css +8 -8
  99. data/src/stylesheets/nitro_kit/components/data_section.css +4 -4
  100. data/src/stylesheets/nitro_kit/components/details_table.css +17 -15
  101. data/src/stylesheets/nitro_kit/components/dialog.css +8 -5
  102. data/src/stylesheets/nitro_kit/components/dropdown.css +36 -30
  103. data/src/stylesheets/nitro_kit/components/dropzone.css +78 -35
  104. data/src/stylesheets/nitro_kit/components/empty_state.css +7 -7
  105. data/src/stylesheets/nitro_kit/components/field.css +32 -28
  106. data/src/stylesheets/nitro_kit/components/field_group.css +13 -0
  107. data/src/stylesheets/nitro_kit/components/fieldset.css +8 -2
  108. data/src/stylesheets/nitro_kit/components/flex.css +1 -1
  109. data/src/stylesheets/nitro_kit/components/grid.css +1 -1
  110. data/src/stylesheets/nitro_kit/components/icon.css +5 -5
  111. data/src/stylesheets/nitro_kit/components/input.css +42 -8
  112. data/src/stylesheets/nitro_kit/components/layout.css +165 -165
  113. data/src/stylesheets/nitro_kit/components/page_header.css +7 -7
  114. data/src/stylesheets/nitro_kit/components/pagination.css +58 -49
  115. data/src/stylesheets/nitro_kit/components/pagination_bar.css +9 -9
  116. data/src/stylesheets/nitro_kit/components/palette.css +145 -77
  117. data/src/stylesheets/nitro_kit/components/progressive_image.css +45 -55
  118. data/src/stylesheets/nitro_kit/components/radio_button.css +48 -46
  119. data/src/stylesheets/nitro_kit/components/radio_button_group.css +49 -49
  120. data/src/stylesheets/nitro_kit/components/rich_text_area.css +16 -1
  121. data/src/stylesheets/nitro_kit/components/select.css +24 -15
  122. data/src/stylesheets/nitro_kit/components/settings_layout.css +26 -23
  123. data/src/stylesheets/nitro_kit/components/settings_section.css +84 -0
  124. data/src/stylesheets/nitro_kit/components/sheet.css +30 -10
  125. data/src/stylesheets/nitro_kit/components/stat_grid.css +11 -10
  126. data/src/stylesheets/nitro_kit/components/switch.css +60 -62
  127. data/src/stylesheets/nitro_kit/components/table.css +43 -35
  128. data/src/stylesheets/nitro_kit/components/tabs.css +48 -40
  129. data/src/stylesheets/nitro_kit/components/textarea.css +20 -5
  130. data/src/stylesheets/nitro_kit/components/toast.css +11 -47
  131. data/src/stylesheets/nitro_kit/components/toolbar.css +16 -41
  132. data/src/stylesheets/nitro_kit/components/tooltip.css +38 -29
  133. data/src/stylesheets/nitro_kit/components/typeset.css +150 -4
  134. data/src/stylesheets/nitro_kit/reset.css +15 -15
  135. data/src/stylesheets/nitro_kit/tokens.css +501 -79
  136. metadata +4 -4
  137. data/docs/new_app_strategy.md +0 -22
  138. data/src/stylesheets/nitro_kit/components/form_section.css +0 -71
@@ -1,187 +1,91 @@
1
1
  # Queryable collection
2
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.
3
+ **Audience:** Coding agents and developers implementing filters, sorting, and
4
+ pagination with Turbo Frames.
4
5
 
5
6
  ## Summary
6
7
 
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.
8
+ - One GET-driven Turbo Frame owns filters, sorting, results, and pagination;
9
+ URL parameters are the state.
10
+ - An application query object owns allowlists, defaults, tenant scope, and page
11
+ bounds; `NitroKit::Table` owns no query policy.
12
+ - Pagination advances browser history; filters, reset, and sorting replace the
13
+ current history entry.
14
+ - Every response contains the same frame, including empty results; links that
15
+ leave the collection target `_top`.
16
+
17
+ ## Query contract
18
+
19
+ Build the query from a tenant-scoped relation:
36
20
 
37
21
  ```ruby
38
- class ProjectsController < ApplicationController
39
- def index
40
- @query = ProjectsQuery.new(Project.all, params: params.fetch(:q, {}), page: params[:page])
41
- end
22
+ def index
23
+ @query = ProjectsQuery.new(
24
+ Current.account.projects,
25
+ params: params.fetch(:q, {}),
26
+ page: params[:page]
27
+ )
42
28
  end
43
29
  ```
44
30
 
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.
31
+ The query object owns parameter allowlists, default ordering, page bounds, and
32
+ query URL generation. It may expose `records`, `filters`, `current_sort`,
33
+ `direction`, `sort_url(key)`, `pagination`, and `summary`. Ransack is one
34
+ possible implementation, not a Nitro dependency.
46
35
 
47
- ## Phlex region
36
+ ## Frame composition
48
37
 
49
38
  ```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
39
+ FRAME_ID = "projects-results"
40
+
41
+ turbo_frame_tag(FRAME_ID, data: { turbo_action: "advance" }) do
42
+ form_with(
43
+ scope: :q,
44
+ url: projects_path,
45
+ method: :get,
46
+ builder: NitroKit::FormBuilder,
47
+ data: { turbo_frame: FRAME_ID, turbo_action: "replace" }
48
+ ) do |form|
49
+ form.group do
50
+ form.field(:name_cont, as: :search, label: "Search")
51
+ form.submit("Apply filters")
59
52
  end
53
+ end
60
54
 
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
55
+ render NitroKit::Table.new(
56
+ sort: query.current_sort,
57
+ direction: query.direction
58
+ ) do |table|
59
+ table.caption("Projects")
60
+ table.thead do
61
+ table.tr do
62
+ table.th(:name, sort: :name, href: query.sort_url(:name),
63
+ sort_data: { turbo_action: "replace" })
68
64
  end
69
65
  end
66
+ table.tbody do
67
+ query.records.each { |project| render_project_row(table, project) }
68
+ end
69
+ end
70
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
71
+ render NitroKit::PaginationBar.new do |bar|
72
+ bar.summary(query.summary)
73
+ bar.pagination(query.pagination)
163
74
  end
164
75
  end
165
76
  ```
166
77
 
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.
78
+ Reset with the plain collection URL so stale parameters disappear. Sort and
79
+ filter controls use `turbo_action: "replace"`; pagination inherits the frame's
80
+ `advance`. Give View, Edit, and New links
81
+ `data: { turbo_frame: "_top" }` or place them outside the frame.
173
82
 
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.”
83
+ No Stimulus controller is required. Optional autosubmit may call the same GET
84
+ form's `requestSubmit`; the URL remains the source of truth.
178
85
 
179
86
  ## Tests
180
87
 
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.
88
+ Request-test parameter preservation, safe fallback for invalid sort keys, and
89
+ the stable frame in populated and empty responses. System-test filter → sort →
90
+ paginate → Back/Forward, address-bar changes, and a row link leaving the frame.
91
+ Use Capybara waiting assertions, not sleeps.
@@ -1,126 +1,78 @@
1
1
  # Resource form
2
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.
3
+ **Audience:** Coding agents and developers implementing model-backed create
4
+ and update forms.
4
5
 
5
6
  ## Summary
6
7
 
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.
8
+ - One model-backed Phlex component renders initial and invalid states.
9
+ - Rails owns names, values, and errors; Nitro owns presentation.
10
+ - Invalid mutations render the same model with `422`; success redirects with
11
+ `303`.
12
+ - Use one primary submit: the shell toolbar owns it, or a standalone form keeps
13
+ it inside `form.group`.
14
+ - Wrap the form in a Turbo Frame only when it needs an independent lifecycle.
23
15
 
24
- ## Phlex form
16
+ ## Form
25
17
 
26
18
  ```ruby
27
19
  module UI
28
20
  class ProjectForm < Phlex::HTML
29
21
  include Phlex::Rails::Helpers::FormWith
30
22
 
31
- def initialize(
32
- project,
33
- form_id: ActionView::RecordIdentifier.dom_id(project, :form)
34
- )
23
+ def initialize(project, form_id:)
35
24
  @project = project
36
25
  @form_id = form_id
37
26
  end
38
27
 
39
28
  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|
29
+ render NitroKit::SettingsSection.new(title: "Project details") do |section|
44
30
  section.form do
45
31
  form_with(
46
- model: project,
32
+ model: @project,
47
33
  builder: NitroKit::FormBuilder,
48
- id: form_id
34
+ id: @form_id
49
35
  ) do |form|
50
36
  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
- )
37
+ form.field(:name, required: true)
38
+ form.field(:status, as: :select, options: Project.statuses.keys)
59
39
  form.field(:description, as: :textarea)
60
40
  end
61
41
  end
62
42
  end
63
43
  end
64
44
  end
65
-
66
- private
67
- attr_reader :project, :form_id
68
45
  end
69
46
  end
70
47
  ```
71
48
 
72
- In an application shell, render exactly one primary submit in the route
73
- toolbar and associate it with `form_id`:
49
+ In an application shell, render one toolbar submit associated through the
50
+ native form ID:
74
51
 
75
52
  ```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
- )
53
+ Button("Save project", type: :submit, form: form_id, variant: :primary)
83
54
  ```
84
55
 
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
56
+ Do not also call `form.submit` in the body. A standalone form without a toolbar
57
+ keeps its submit in the same `form.group` as its visible fields.
90
58
 
91
- Render the same invalid object with 422. Redirect successful HTML submissions with 303.
59
+ ## Responses
92
60
 
93
61
  ```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
62
+ if @project.update(project_params)
63
+ redirect_to @project, status: :see_other, notice: "Project updated"
64
+ else
65
+ render UI::ProjectForm.new(@project, form_id:),
66
+ status: :unprocessable_entity
119
67
  end
120
68
  ```
121
69
 
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.
70
+ Wrap the component in a stable frame only when embedded in a larger screen.
71
+ Use a Turbo Stream only when success updates multiple regions. See
72
+ [Inline edit](inline_edit.md).
123
73
 
124
74
  ## Tests
125
75
 
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.
76
+ Assert redirect and flash at `303`; submitted values and model errors at `422`;
77
+ and the stable frame ID when embedded. Add a system test for focus or
78
+ multi-region behavior, not to repeat controller coverage.
@@ -1,95 +1,58 @@
1
- # The Nitro Kit Rails path
1
+ # Rails conventions for Nitro Kit applications
2
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.
3
+ **Audience:** Coding agents and developers building Rails application features
4
+ with Nitro Kit. These are greenfield defaults; preserve established application
5
+ conventions unless the task explicitly changes them.
7
6
 
8
- ## Model the domain with Rails
7
+ ## Domain and tenancy
9
8
 
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.
9
+ - Put business rules on domain models. Add service, form, command, or policy
10
+ objects only when a real boundary requires one.
11
+ - Scope tenant-owned records through `Current.team` or `Current.account`. Use
12
+ `Current.user` as the actor, not the tenancy boundary.
13
+ - For team-aware products, model `User`, `Team`, and `Membership`; roles belong
14
+ to memberships. See [Application foundation](patterns/application_foundation.md).
15
+ - Model lifecycle state as a record when it has identity, timing, provenance,
16
+ or behavior.
26
17
 
27
- ## Route nouns, not commands
18
+ ## Routes and responses
28
19
 
29
- Map controllers to REST resources. When an action does not fit the seven
30
- standard actions, look for the missing noun:
20
+ Use REST resources. When an action does not fit the seven standard actions,
21
+ find the missing noun:
31
22
 
32
23
  ```ruby
33
- namespace :admin do
34
- resources :posts do
35
- resource :publication, only: %i[create destroy],
36
- module: :posts
37
- end
24
+ resources :posts do
25
+ resource :publication, only: %i[create destroy], module: :posts
38
26
  end
39
27
  ```
40
28
 
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.
29
+ Keep controllers thin: load through the tenant boundary, invoke the model, and
30
+ render or redirect.
44
31
 
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`.
32
+ - Use `form_with(..., builder: NitroKit::FormBuilder)` for model-backed forms.
33
+ - Redirect successful mutations with `status: :see_other`.
54
34
  - Render the same invalid model with `status: :unprocessable_entity`.
55
- - Keep an HTML response for every Turbo-enhanced flow.
35
+ - Keep an HTML response for Turbo-enhanced flows.
56
36
  - Keep filtering, sorting, and pagination in GET parameters.
57
- - Set flash on the server and render it through
37
+ - Set Rails flash on the server and render it once through
58
38
  `NitroKit::Toast::FlashMessages`.
59
39
 
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
40
+ See [Browser support](browser_support.md) for native month and week input
41
+ limitations, and [Hotwire](hotwire.md) for transport choices.
78
42
 
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.
43
+ ## Page composition
83
44
 
84
- ## Test the contract
45
+ Use one route title, normally the shell toolbar's `h1`. Add another heading
46
+ only for a genuinely separate region. Do not repeat the title across a toolbar,
47
+ PageHeader, section, Card, and table caption.
85
48
 
86
- Use Minitest and fixtures. Cover:
49
+ Use Card for a bounded object that needs a surface, not as a default wrapper.
50
+ Use one application-owned content gutter inside the shell main region. See
51
+ [Complete product resource](patterns/crud_resource.md) for CRUD composition.
87
52
 
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.
53
+ ## Tests
94
54
 
95
- Prefer a small complete test over layers of factories, mocks, and helper DSLs.
55
+ Use Minitest and fixtures. Cover tenant isolation, authorization differences,
56
+ lifecycle resources, public visibility, `303` success, `422` validation, and
57
+ the HTML fallback. Add browser tests for behavior that request tests cannot
58
+ prove, using Capybara waiting assertions instead of sleeps.