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,136 @@
1
+ # A durable application foundation
2
+
3
+ Start a small authenticated application with the same records and page grammar
4
+ it will need after the second person joins. The first-user case should be the
5
+ smallest instance of the team model, not a separate personal mode that must be
6
+ replaced later.
7
+
8
+ ## Summary
9
+
10
+ - Model `User`, `Team`, and `Membership` from the first signup; role belongs to
11
+ `Membership`, and every team-owned record loads through `Current.team`.
12
+ - One hybrid `AppShell` frames the authenticated product: `AppNavigation` owns
13
+ brand and destinations, the shell `Toolbar` owns route titles and persistent
14
+ basic actions.
15
+ - One wrapper immediately inside `shell.main` owns responsive page padding for
16
+ every route; the shell owns viewport height and scrolling.
17
+ - Infrequent account destinations go after `navigation.spacer`; settings
18
+ compose with `SettingsLayout` and plain `FormSection` regions.
19
+ - When destination count warrants search, compose one `CommandPalette` in the
20
+ shell and render only routes the current membership may visit.
21
+ - Centralize cross-cutting feedback in one flash-driven toast region rendered
22
+ by the layout.
23
+
24
+ ## Use memberships from the first user
25
+
26
+ Use `User`, `Team`, and `Membership` even when signup creates exactly one of
27
+ each. Put the role on `Membership`, not `User`, because authority belongs to a
28
+ person's relationship with a team. Create the first team and owner membership
29
+ in the same transaction as signup.
30
+
31
+ Set `Current.user`, `Current.membership`, and `Current.team` from the session.
32
+ Load every team-owned record through `Current.team`; use `Current.user` for
33
+ authorship and audit fields. Start with the smallest role vocabulary the
34
+ product needs, usually owner, administrator, and member. Protect the last owner
35
+ in the domain model rather than only hiding a button.
36
+
37
+ Invitations should belong to a team, record the inviter and intended role,
38
+ expire, match the invited email, and be consumed when accepted. An existing
39
+ user accepts into a new membership; a new user completes signup and then uses
40
+ the same acceptance path.
41
+
42
+ ## Compose one authenticated frame
43
+
44
+ Use one hybrid `AppShell` for the authenticated product. Keep the brand and
45
+ primary destinations in `AppNavigation`; place route titles and persistent
46
+ basic actions in the shell `Toolbar`. One wrapper immediately inside
47
+ `shell.main` owns responsive page padding for every route. The topbar and
48
+ sidebar header should use the shell's shared height and border tokens rather
49
+ than independent padding guesses.
50
+
51
+ Put infrequent account navigation after `navigation.spacer`, near the account
52
+ controls at the bottom of the sidebar:
53
+
54
+ ```ruby
55
+ shell.navigation do
56
+ AppNavigation(label: "Workspace navigation") do |navigation|
57
+ navigation.body do
58
+ navigation.item("Inventory", href: assets_path, icon: :archive)
59
+ navigation.item("Team", href: team_path, icon: :users)
60
+ navigation.spacer
61
+ navigation.item("Settings", href: settings_profile_path, icon: :settings)
62
+ end
63
+ end
64
+ end
65
+ ```
66
+
67
+ Application code owns destinations and current-route policy. Nitro owns shell
68
+ layout, mobile disclosure, focus management, and navigation semantics.
69
+ For larger products, place one `CommandPalette` in the shell. Its native links
70
+ remain the navigation authority while Command-K or Control-K adds fast
71
+ filtering. Render the same authorized destination set the user can reach in
72
+ ordinary navigation; do not use the palette to bypass route policy.
73
+ When the destination set is too large or dynamic to render eagerly, pass
74
+ `search_url:` and return `CommandPalette::Results` from that endpoint. Keep the
75
+ same authorization scope on the initial links and every remote query.
76
+ Let the shell own viewport height and scrolling; do not add `min-height: 100vh`
77
+ to its main region or page wrapper. Put brand and destination icons through the
78
+ navigation slots so they share the same left alignment.
79
+
80
+ Team is an administration surface, not merely a roster. Include pending
81
+ invitations and the complete invite, role-change, removal, and revoke paths,
82
+ with last-owner protection in the model and authorization on every mutation.
83
+
84
+ Authentication is a standalone form surface. Inside `AuthShell`, use Rails
85
+ `form_with` with `NitroKit::FormBuilder` and put the visible fields, submit
86
+ control, and related recovery link in one `form.group`. The group owns their
87
+ vertical rhythm; `AuthShell` owns only the page container and spacing between
88
+ its major regions.
89
+
90
+ ## Keep settings plain
91
+
92
+ Use `SettingsLayout` inside the normal shell main region. Its navigation lists
93
+ stable subsections such as Profile, Notifications, Appearance, and Password;
94
+ its content renders the selected form. Use `FormSection` for genuinely distinct
95
+ form regions and ordinary whitespace or dividers between them.
96
+
97
+ Render subsection destinations as links and mark the active link with
98
+ `aria-current="page"`. They navigate between routes; Buttons and ButtonGroup
99
+ would incorrectly present them as in-page actions. Small preferences may
100
+ submit on change through a tiny application Stimulus controller that calls the
101
+ form's native `requestSubmit`. Keep a submit control in `noscript` so the form
102
+ still works without JavaScript.
103
+
104
+ The route still has one `h1` in the shell toolbar. Do not repeat “Settings” in
105
+ the page body, wrap each subsection in a Card, or give every form its own outer
106
+ padding. A toolbar Save button can submit the selected form with the native
107
+ `form:` attribute, so the action stays in the same place at narrow and wide
108
+ widths without JavaScript.
109
+
110
+ ## Centralize cross-cutting feedback
111
+
112
+ Render `Toast::FlashMessages` once in the application layout. Keep using
113
+ ordinary Rails flash and `303 See Other` redirects from controllers.
114
+
115
+ Compact destructive actions should continue to declare
116
+ `data: { turbo_confirm: "…" }` and use Turbo's native browser confirmation.
117
+ When the user needs branded review UI or more context than one sentence,
118
+ compose a dedicated native Nitro `Dialog` at the action's call site. The
119
+ browser's top layer keeps that inline dialog clear of ancestor clipping and
120
+ stacking contexts. Put record deletion on the edit route rather than adding a
121
+ danger surface to every operational show page.
122
+
123
+ ## Baseline acceptance path
124
+
125
+ Before polishing empty-state illustration or dashboard summaries, verify:
126
+
127
+ - signup or sign-in selects the current membership and team;
128
+ - another team cannot load the current team's records;
129
+ - owner, administrator, and member policy differs where intended;
130
+ - populated, empty, invalid, narrow, and destructive states work;
131
+ - settings forms preserve validation and use one content gutter;
132
+ - successful mutations redirect with `303` and invalid forms render with
133
+ `422`;
134
+ - confirmation has both cancel and confirm coverage;
135
+ - the shell, headings, tables, and forms remain usable without custom request
136
+ JavaScript.
@@ -0,0 +1,144 @@
1
+ # A complete product resource
2
+
3
+ Build CRUD as one coherent product surface, not independent generated screens.
4
+ Before writing the views, name the resource, tenant boundary, actor, lifecycle,
5
+ visibility rules, and states. Implement index, form, detail, destructive action,
6
+ and their tests together.
7
+
8
+ ## Summary
9
+
10
+ - Build index, form, detail, destructive action, and their tests as one
11
+ surface; name the resource, tenant boundary, actor, lifecycle, and states
12
+ before writing views.
13
+ - `AppShell(layout: :hybrid)` frames the admin area, and a `Toolbar` in
14
+ `shell.topbar` carries the route's single `h1` plus its basic actions.
15
+ - Spend hierarchy once: one route, one `h1`, an `h2` only for a genuinely
16
+ separate region, and no resource name repeated across toolbar, `PageHeader`,
17
+ `DataSection`, Card, and caption.
18
+ - Child routes add one compact icon Back link before the title instead of a
19
+ trailing Cancel action.
20
+ - One responsive padding rule on the main wrapper owns the page gutter;
21
+ data-heavy hybrid CRUD content uses the full available width.
22
+
23
+ ## Use the hybrid application frame
24
+
25
+ An authenticated admin area defaults to `AppShell(layout: :hybrid)`. Nitro Kit
26
+ owns the desktop sidebar, mobile menu button, navigation dialog, focus behavior,
27
+ and responsive transition. The application provides destinations and current
28
+ state.
29
+
30
+ Put a `Toolbar` in `shell.topbar`. Its leading region contains the route's one
31
+ `h1`; its trailing region contains basic actions such as New, Edit, Cancel,
32
+ Save, Publish, or View. A Button outside a form can submit it through the
33
+ native `form:` attribute. This keeps the same action hierarchy on narrow and
34
+ wide screens without custom JavaScript.
35
+
36
+ ```ruby
37
+ AppShell(id: "admin", layout: :hybrid) do |shell|
38
+ shell.navigation { render admin_navigation }
39
+ shell.topbar do
40
+ Toolbar do |toolbar|
41
+ toolbar.leading { h1 { page_title } }
42
+ toolbar.trailing do
43
+ Button(
44
+ "Save",
45
+ type: :submit,
46
+ form: dom_id(@post, :form),
47
+ variant: :primary
48
+ )
49
+ end
50
+ end
51
+ end
52
+ shell.main do
53
+ div(data: { ui: "admin-main" }) do
54
+ render page
55
+ end
56
+ end
57
+ end
58
+ ```
59
+
60
+ Child routes add one compact Back link before the title. Prefer an icon-only
61
+ Button with an explicit label such as `aria: { label: "Back to projects" }`.
62
+ Do not repeat that navigation as a trailing Cancel action.
63
+
64
+ The application stylesheet gives `admin-main` one responsive padding rule.
65
+ Child pages do not add another outer gutter. Keep data-heavy hybrid CRUD
66
+ content full width; constrain only a specific content-led region whose measure
67
+ benefits from it.
68
+
69
+ Do not add viewport height or another outer padding rule to `admin-main`; the
70
+ shell owns viewport geometry and the wrapper owns the one page gutter. Use the
71
+ same shell and gutter on team administration and settings routes.
72
+ Place a bottom-anchored Settings destination after `AppNavigation#spacer`, then
73
+ compose settings subsections with `SettingsLayout` and plain `FormSection`
74
+ regions. Settings destinations are links with `aria-current`, not action
75
+ Buttons. Read `application_foundation.md` for the complete application frame.
76
+
77
+ ## Spend hierarchy once
78
+
79
+ - One route, one `h1`, normally in the shell toolbar.
80
+ - Add an `h2` only for a genuinely separate region.
81
+ - Do not repeat “Posts” in the toolbar, `PageHeader`, `DataSection`, Card, and
82
+ visible table caption.
83
+ - Use `PageHeader` for a content-led introduction, not as mandatory CRUD
84
+ ceremony under an existing toolbar.
85
+ - Prefer whitespace and dividers. Use Card only for a bounded object that
86
+ benefits from its own surface. Never default to Card inside Card.
87
+
88
+ An index begins with a short introduction only if it adds useful context, then
89
+ renders its table and pagination directly. Use `DataSection` when a page has
90
+ multiple independently named datasets. At zero records, replace the data region
91
+ with one intentional `EmptyState`; keep the primary New action in the toolbar.
92
+
93
+ A new or edit page begins with `FormSection`. Use one form component for new,
94
+ edit, and invalid renders. Put the primary submit in the toolbar by setting the
95
+ button's `form:` to the form's stable DOM ID. Invalid submissions render the
96
+ same model and form with `422 Unprocessable Entity`. The toolbar owns that
97
+ action: do not render a second Save or Create submit inside the form body.
98
+
99
+ A detail page begins with status or stable metadata, then the resource itself.
100
+ Keep status inside that normal details flow instead of detaching it into a
101
+ second side panel.
102
+ Use the authenticated `show` route as the operational detail or draft preview.
103
+ Put lifecycle forms in the page and associate their toolbar buttons with
104
+ `form:`. Put destructive confirmation in one separate `DangerZone` on edit,
105
+ with a safe escape back to the record. Do not make every show page end in a
106
+ large deletion surface.
107
+
108
+ ## Model and route the lifecycle
109
+
110
+ Scope every lookup through `Current.team` or `Current.account`. Record
111
+ `Current.user` as author, creator, or publisher. If a state has provenance,
112
+ timing, or behavior, model it as a record and expose it as a noun resource:
113
+
114
+ ```ruby
115
+ namespace :admin do
116
+ resources :posts do
117
+ resource :publication,
118
+ only: %i[create destroy],
119
+ module: :posts
120
+ end
121
+ end
122
+ ```
123
+
124
+ The main controller keeps the seven REST actions. Successful mutations redirect
125
+ with `303 See Other`. Publication create and destroy invoke domain methods and
126
+ redirect. Public controllers query published records only. Ordinary Rails forms
127
+ and Turbo Drive are the default; do not add fetch code for CRUD submissions.
128
+
129
+ ## Ship the acceptance path
130
+
131
+ Request tests cover tenant isolation, public visibility, successful `303`
132
+ redirects, invalid `422` renders, pagination, and lifecycle resources. Rendering
133
+ assertions should also protect the high-level composition: hybrid AppShell,
134
+ one `h1`, navigation, toolbar action, actual form association, table or empty
135
+ state, and destructive confirmation. Add one browser test for the meaningful
136
+ end-to-end path, using Capybara waiting assertions instead of sleeps.
137
+
138
+ Assert that each primary action has one visible control. A toolbar-associated
139
+ form submit plus an identical body submit is duplication, even when both invoke
140
+ the same valid form.
141
+
142
+ Before finishing, inspect a populated index, empty index, invalid form, narrow
143
+ form, draft detail, published detail, and edit-owned destructive dialog. Remove any extra
144
+ heading, surface, wrapper, or page gutter that does not communicate information.
@@ -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.