nitro_kit 0.9.0 → 2.0.0.alpha.1

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 (220) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/plugins/marketplace.json +20 -0
  3. data/CHANGELOG.md +151 -0
  4. data/LICENSE +28 -0
  5. data/README.md +21 -16
  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 +7225 -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 +128 -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 +104 -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/implementation_plan.md +279 -0
  95. data/docs/initialization_prompt.md +36 -0
  96. data/docs/migration_1_to_2.md +318 -0
  97. data/docs/new_app_strategy.md +24 -0
  98. data/docs/patterns/application_foundation.md +136 -0
  99. data/docs/patterns/crud_resource.md +144 -0
  100. data/docs/patterns/destructive_action.md +105 -0
  101. data/docs/patterns/flash_and_toast.md +57 -0
  102. data/docs/patterns/inline_edit.md +59 -0
  103. data/docs/patterns/queryable_collection.md +187 -0
  104. data/docs/patterns/resource_form.md +126 -0
  105. data/docs/rails_conventions.md +95 -0
  106. data/docs/rails_integration.md +514 -0
  107. data/lib/generators/nitro_kit/install_generator.rb +48 -0
  108. data/lib/generators/nitro_kit/upgrade_smoke_tests_generator.rb +67 -0
  109. data/lib/nitro_kit/engine.rb +20 -0
  110. data/lib/nitro_kit/installation.rb +671 -0
  111. data/lib/nitro_kit/migration_inventory.rb +345 -0
  112. data/lib/nitro_kit/upgrade_smoke_test.rb +403 -0
  113. data/lib/nitro_kit/version.rb +1 -1
  114. data/lib/nitro_kit.rb +5 -42
  115. data/lib/rails/commands/nitro_kit/nitro_kit_command.rb +42 -0
  116. data/lib/tasks/nitro_kit_tasks.rake +79 -4
  117. data/plugins/nitro-kit/.codex-plugin/plugin.json +28 -0
  118. data/plugins/nitro-kit/skills/nitro-kit-hotwire/SKILL.md +67 -0
  119. data/plugins/nitro-kit/skills/nitro-kit-hotwire/agents/openai.yaml +4 -0
  120. data/plugins/nitro-kit/skills/nitro-kit-rails/SKILL.md +60 -0
  121. data/plugins/nitro-kit/skills/nitro-kit-rails/agents/openai.yaml +4 -0
  122. data/plugins/nitro-kit/skills/nitro-kit-ui/SKILL.md +62 -0
  123. data/plugins/nitro-kit/skills/nitro-kit-ui/agents/openai.yaml +4 -0
  124. data/src/stylesheets/nitro_kit/components/accordion.css +119 -0
  125. data/src/stylesheets/nitro_kit/components/alert.css +82 -0
  126. data/src/stylesheets/nitro_kit/components/app_navigation.css +156 -0
  127. data/src/stylesheets/nitro_kit/components/app_shell.css +605 -0
  128. data/src/stylesheets/nitro_kit/components/appearance_picker.css +117 -0
  129. data/src/stylesheets/nitro_kit/components/auth_shell.css +8 -0
  130. data/src/stylesheets/nitro_kit/components/avatar.css +81 -0
  131. data/src/stylesheets/nitro_kit/components/avatar_stack.css +80 -0
  132. data/src/stylesheets/nitro_kit/components/badge.css +76 -0
  133. data/src/stylesheets/nitro_kit/components/button.css +283 -0
  134. data/src/stylesheets/nitro_kit/components/button_group.css +53 -0
  135. data/src/stylesheets/nitro_kit/components/button_to.css +6 -0
  136. data/src/stylesheets/nitro_kit/components/card.css +70 -0
  137. data/src/stylesheets/nitro_kit/components/checkbox.css +160 -0
  138. data/src/stylesheets/nitro_kit/components/checkbox_group.css +61 -0
  139. data/src/stylesheets/nitro_kit/components/combobox.css +148 -0
  140. data/src/stylesheets/nitro_kit/components/command_palette.css +255 -0
  141. data/src/stylesheets/nitro_kit/components/container.css +25 -0
  142. data/src/stylesheets/nitro_kit/components/control_group.css +149 -0
  143. data/src/stylesheets/nitro_kit/components/danger_zone.css +54 -0
  144. data/src/stylesheets/nitro_kit/components/data_section.css +40 -0
  145. data/src/stylesheets/nitro_kit/components/details_table.css +53 -0
  146. data/src/stylesheets/nitro_kit/components/dialog.css +80 -0
  147. data/src/stylesheets/nitro_kit/components/dropdown.css +152 -0
  148. data/src/stylesheets/nitro_kit/components/dropzone.css +189 -0
  149. data/src/stylesheets/nitro_kit/components/empty_state.css +53 -0
  150. data/src/stylesheets/nitro_kit/components/field.css +79 -0
  151. data/src/stylesheets/nitro_kit/components/field_group.css +6 -0
  152. data/src/stylesheets/nitro_kit/components/fieldset.css +28 -0
  153. data/src/stylesheets/nitro_kit/components/flex.css +455 -0
  154. data/src/stylesheets/nitro_kit/components/form_section.css +71 -0
  155. data/src/stylesheets/nitro_kit/components/grid.css +308 -0
  156. data/src/stylesheets/nitro_kit/components/icon.css +26 -0
  157. data/src/stylesheets/nitro_kit/components/input.css +80 -0
  158. data/src/stylesheets/nitro_kit/components/label.css +9 -0
  159. data/src/stylesheets/nitro_kit/components/layout.css +440 -0
  160. data/src/stylesheets/nitro_kit/components/page_header.css +62 -0
  161. data/src/stylesheets/nitro_kit/components/pagination.css +119 -0
  162. data/src/stylesheets/nitro_kit/components/pagination_bar.css +41 -0
  163. data/src/stylesheets/nitro_kit/components/palette.css +113 -0
  164. data/src/stylesheets/nitro_kit/components/progressive_image.css +139 -0
  165. data/src/stylesheets/nitro_kit/components/radio_button.css +127 -0
  166. data/src/stylesheets/nitro_kit/components/radio_button_group.css +143 -0
  167. data/src/stylesheets/nitro_kit/components/rich_text_area.css +21 -0
  168. data/src/stylesheets/nitro_kit/components/select.css +70 -0
  169. data/src/stylesheets/nitro_kit/components/settings_layout.css +100 -0
  170. data/src/stylesheets/nitro_kit/components/sheet.css +100 -0
  171. data/src/stylesheets/nitro_kit/components/stat_grid.css +51 -0
  172. data/src/stylesheets/nitro_kit/components/switch.css +152 -0
  173. data/src/stylesheets/nitro_kit/components/table.css +110 -0
  174. data/src/stylesheets/nitro_kit/components/tabs.css +138 -0
  175. data/src/stylesheets/nitro_kit/components/textarea.css +39 -0
  176. data/src/stylesheets/nitro_kit/components/toast.css +131 -0
  177. data/src/stylesheets/nitro_kit/components/toolbar.css +78 -0
  178. data/src/stylesheets/nitro_kit/components/tooltip.css +124 -0
  179. data/src/stylesheets/nitro_kit/components/typeset.css +191 -0
  180. data/src/stylesheets/nitro_kit/layers.css +7 -0
  181. data/src/stylesheets/nitro_kit/reset.css +192 -0
  182. data/src/stylesheets/nitro_kit/tokens.css +198 -0
  183. metadata +183 -51
  184. data/Rakefile +0 -8
  185. data/app/assets/tailwind/application.css +0 -49
  186. data/app/components/nitro_kit/datepicker.rb +0 -9
  187. data/app/helpers/nitro_kit/accordion_helper.rb +0 -9
  188. data/app/helpers/nitro_kit/alert_helper.rb +0 -11
  189. data/app/helpers/nitro_kit/avatar_helper.rb +0 -13
  190. data/app/helpers/nitro_kit/badge_helper.rb +0 -13
  191. data/app/helpers/nitro_kit/button_group_helper.rb +0 -9
  192. data/app/helpers/nitro_kit/button_helper.rb +0 -49
  193. data/app/helpers/nitro_kit/card_helper.rb +0 -9
  194. data/app/helpers/nitro_kit/checkbox_helper.rb +0 -50
  195. data/app/helpers/nitro_kit/combobox_helper.rb +0 -9
  196. data/app/helpers/nitro_kit/datepicker_helper.rb +0 -9
  197. data/app/helpers/nitro_kit/dialog_helper.rb +0 -9
  198. data/app/helpers/nitro_kit/dropdown_helper.rb +0 -9
  199. data/app/helpers/nitro_kit/field_group_helper.rb +0 -9
  200. data/app/helpers/nitro_kit/field_helper.rb +0 -9
  201. data/app/helpers/nitro_kit/fieldset_helper.rb +0 -9
  202. data/app/helpers/nitro_kit/form_helper.rb +0 -13
  203. data/app/helpers/nitro_kit/icon_helper.rb +0 -9
  204. data/app/helpers/nitro_kit/input_helper.rb +0 -37
  205. data/app/helpers/nitro_kit/label_helper.rb +0 -19
  206. data/app/helpers/nitro_kit/pagination_helper.rb +0 -46
  207. data/app/helpers/nitro_kit/radio_button_helper.rb +0 -23
  208. data/app/helpers/nitro_kit/select_helper.rb +0 -24
  209. data/app/helpers/nitro_kit/switch_helper.rb +0 -9
  210. data/app/helpers/nitro_kit/table_helper.rb +0 -9
  211. data/app/helpers/nitro_kit/tabs_helper.rb +0 -9
  212. data/app/helpers/nitro_kit/textarea_helper.rb +0 -9
  213. data/app/helpers/nitro_kit/toast_helper.rb +0 -25
  214. data/app/helpers/nitro_kit/tooltip_helper.rb +0 -9
  215. data/app/javascript/controllers/nk/accordion_controller.js +0 -18
  216. data/app/javascript/controllers/nk/datepicker_controller.js +0 -5
  217. data/app/javascript/controllers/nk/switch_controller.js +0 -32
  218. data/lib/generators/nitro_kit/component_generator.rb +0 -84
  219. data/lib/nitro_kit/schema_builder.rb +0 -129
  220. data/lib/nitro_kit/variants.rb +0 -21
@@ -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.