nitro_kit 0.9.0 → 2.0.0.alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/plugins/marketplace.json +20 -0
  3. data/CHANGELOG.md +172 -0
  4. data/LICENSE +28 -0
  5. data/README.md +22 -15
  6. data/STYLE_GUIDE.md +425 -0
  7. data/app/assets/stylesheets/nitro_kit-tailwind-v4.css +38 -0
  8. data/app/assets/stylesheets/nitro_kit.css +7239 -0
  9. data/app/components/nitro_kit/accordion.rb +125 -65
  10. data/app/components/nitro_kit/alert.rb +80 -43
  11. data/app/components/nitro_kit/app_navigation.rb +307 -0
  12. data/app/components/nitro_kit/app_shell.rb +227 -0
  13. data/app/components/nitro_kit/appearance_bootstrap.rb +146 -0
  14. data/app/components/nitro_kit/appearance_picker.rb +156 -0
  15. data/app/components/nitro_kit/auth_shell.rb +34 -0
  16. data/app/components/nitro_kit/avatar.rb +85 -29
  17. data/app/components/nitro_kit/avatar_stack.rb +155 -9
  18. data/app/components/nitro_kit/badge.rb +43 -81
  19. data/app/components/nitro_kit/button.rb +180 -103
  20. data/app/components/nitro_kit/button_group.rb +63 -11
  21. data/app/components/nitro_kit/button_to.rb +69 -0
  22. data/app/components/nitro_kit/card.rb +42 -45
  23. data/app/components/nitro_kit/checkbox.rb +115 -62
  24. data/app/components/nitro_kit/checkbox_group.rb +131 -22
  25. data/app/components/nitro_kit/choice.rb +55 -0
  26. data/app/components/nitro_kit/combobox.rb +292 -105
  27. data/app/components/nitro_kit/command_palette.rb +412 -0
  28. data/app/components/nitro_kit/component.rb +322 -46
  29. data/app/components/nitro_kit/container.rb +32 -0
  30. data/app/components/nitro_kit/control_group.rb +45 -0
  31. data/app/components/nitro_kit/danger_zone.rb +78 -0
  32. data/app/components/nitro_kit/data_section.rb +106 -0
  33. data/app/components/nitro_kit/details_table.rb +229 -0
  34. data/app/components/nitro_kit/dialog.rb +257 -83
  35. data/app/components/nitro_kit/dropdown.rb +335 -122
  36. data/app/components/nitro_kit/dropzone.rb +326 -0
  37. data/app/components/nitro_kit/empty_state.rb +99 -0
  38. data/app/components/nitro_kit/field.rb +461 -236
  39. data/app/components/nitro_kit/field_group.rb +15 -8
  40. data/app/components/nitro_kit/fieldset.rb +37 -39
  41. data/app/components/nitro_kit/flex.rb +49 -0
  42. data/app/components/nitro_kit/form_builder.rb +313 -72
  43. data/app/components/nitro_kit/form_section.rb +79 -0
  44. data/app/components/nitro_kit/grid.rb +34 -0
  45. data/app/components/nitro_kit/icon.rb +54 -25
  46. data/app/components/nitro_kit/input.rb +97 -10
  47. data/app/components/nitro_kit/label.rb +22 -6
  48. data/app/components/nitro_kit/layout_options.rb +7 -0
  49. data/app/components/nitro_kit/page_header.rb +76 -0
  50. data/app/components/nitro_kit/pagination.rb +355 -69
  51. data/app/components/nitro_kit/pagination_bar.rb +90 -0
  52. data/app/components/nitro_kit/progressive_image.rb +181 -0
  53. data/app/components/nitro_kit/radio_button.rb +77 -55
  54. data/app/components/nitro_kit/radio_button_group.rb +107 -36
  55. data/app/components/nitro_kit/responsive_value.rb +94 -0
  56. data/app/components/nitro_kit/rich_text_area.rb +31 -0
  57. data/app/components/nitro_kit/select.rb +129 -64
  58. data/app/components/nitro_kit/settings_layout.rb +152 -0
  59. data/app/components/nitro_kit/sheet.rb +202 -0
  60. data/app/components/nitro_kit/stat_grid.rb +75 -0
  61. data/app/components/nitro_kit/switch.rb +108 -51
  62. data/app/components/nitro_kit/table.rb +237 -45
  63. data/app/components/nitro_kit/tabs.rb +168 -63
  64. data/app/components/nitro_kit/textarea.rb +81 -10
  65. data/app/components/nitro_kit/toast.rb +211 -80
  66. data/app/components/nitro_kit/toolbar.rb +75 -0
  67. data/app/components/nitro_kit/tooltip.rb +235 -34
  68. data/app/components/nitro_kit/typeset.rb +28 -0
  69. data/app/javascript/controllers/nk/app_shell_controller.js +224 -0
  70. data/app/javascript/controllers/nk/appearance_controller.js +73 -0
  71. data/app/javascript/controllers/nk/avatar_controller.js +15 -0
  72. data/app/javascript/controllers/nk/button_controller.js +56 -0
  73. data/app/javascript/controllers/nk/checkable_controller.js +41 -0
  74. data/app/javascript/controllers/nk/combobox_controller.js +314 -82
  75. data/app/javascript/controllers/nk/command_palette_controller.js +249 -0
  76. data/app/javascript/controllers/nk/dialog_controller.js +14 -10
  77. data/app/javascript/controllers/nk/dropdown_controller.js +106 -58
  78. data/app/javascript/controllers/nk/dropzone/direct_upload.js +61 -0
  79. data/app/javascript/controllers/nk/dropzone/file_rules.js +77 -0
  80. data/app/javascript/controllers/nk/dropzone/form_submit_lock.js +30 -0
  81. data/app/javascript/controllers/nk/dropzone_controller.js +482 -0
  82. data/app/javascript/controllers/nk/overlay_position.js +37 -0
  83. data/app/javascript/controllers/nk/progressive_image_controller.js +116 -0
  84. data/app/javascript/controllers/nk/tabs_controller.js +135 -23
  85. data/app/javascript/controllers/nk/toast_controller.js +112 -45
  86. data/app/javascript/controllers/nk/tooltip_controller.js +12 -47
  87. data/config/importmap.rb +2 -0
  88. data/config/locales/en.yml +98 -0
  89. data/docs/agent_guide.md +139 -0
  90. data/docs/agent_native_spec.md +422 -0
  91. data/docs/component_contracts.md +263 -0
  92. data/docs/customization.md +595 -0
  93. data/docs/hotwire.md +94 -0
  94. data/docs/initialization_prompt.md +36 -0
  95. data/docs/migration_1_to_2.md +318 -0
  96. data/docs/new_app_strategy.md +22 -0
  97. data/docs/patterns/application_foundation.md +136 -0
  98. data/docs/patterns/crud_resource.md +144 -0
  99. data/docs/patterns/destructive_action.md +105 -0
  100. data/docs/patterns/flash_and_toast.md +57 -0
  101. data/docs/patterns/inline_edit.md +59 -0
  102. data/docs/patterns/queryable_collection.md +187 -0
  103. data/docs/patterns/resource_form.md +126 -0
  104. data/docs/rails_conventions.md +95 -0
  105. data/docs/rails_integration.md +514 -0
  106. data/lib/generators/nitro_kit/install_generator.rb +48 -0
  107. data/lib/generators/nitro_kit/upgrade_smoke_tests_generator.rb +67 -0
  108. data/lib/nitro_kit/engine.rb +20 -0
  109. data/lib/nitro_kit/installation.rb +671 -0
  110. data/lib/nitro_kit/migration_inventory.rb +345 -0
  111. data/lib/nitro_kit/upgrade_smoke_test.rb +403 -0
  112. data/lib/nitro_kit/version.rb +1 -1
  113. data/lib/nitro_kit.rb +5 -42
  114. data/lib/rails/commands/nitro_kit/nitro_kit_command.rb +42 -0
  115. data/lib/tasks/nitro_kit_tasks.rake +79 -4
  116. data/plugins/nitro-kit/.codex-plugin/plugin.json +28 -0
  117. data/plugins/nitro-kit/skills/nitro-kit-hotwire/SKILL.md +67 -0
  118. data/plugins/nitro-kit/skills/nitro-kit-hotwire/agents/openai.yaml +4 -0
  119. data/plugins/nitro-kit/skills/nitro-kit-rails/SKILL.md +60 -0
  120. data/plugins/nitro-kit/skills/nitro-kit-rails/agents/openai.yaml +4 -0
  121. data/plugins/nitro-kit/skills/nitro-kit-ui/SKILL.md +62 -0
  122. data/plugins/nitro-kit/skills/nitro-kit-ui/agents/openai.yaml +4 -0
  123. data/src/stylesheets/nitro_kit/components/accordion.css +119 -0
  124. data/src/stylesheets/nitro_kit/components/alert.css +82 -0
  125. data/src/stylesheets/nitro_kit/components/app_navigation.css +156 -0
  126. data/src/stylesheets/nitro_kit/components/app_shell.css +605 -0
  127. data/src/stylesheets/nitro_kit/components/appearance_picker.css +117 -0
  128. data/src/stylesheets/nitro_kit/components/auth_shell.css +8 -0
  129. data/src/stylesheets/nitro_kit/components/avatar.css +81 -0
  130. data/src/stylesheets/nitro_kit/components/avatar_stack.css +80 -0
  131. data/src/stylesheets/nitro_kit/components/badge.css +76 -0
  132. data/src/stylesheets/nitro_kit/components/button.css +283 -0
  133. data/src/stylesheets/nitro_kit/components/button_group.css +53 -0
  134. data/src/stylesheets/nitro_kit/components/button_to.css +6 -0
  135. data/src/stylesheets/nitro_kit/components/card.css +70 -0
  136. data/src/stylesheets/nitro_kit/components/checkbox.css +160 -0
  137. data/src/stylesheets/nitro_kit/components/checkbox_group.css +61 -0
  138. data/src/stylesheets/nitro_kit/components/combobox.css +148 -0
  139. data/src/stylesheets/nitro_kit/components/command_palette.css +255 -0
  140. data/src/stylesheets/nitro_kit/components/container.css +25 -0
  141. data/src/stylesheets/nitro_kit/components/control_group.css +149 -0
  142. data/src/stylesheets/nitro_kit/components/danger_zone.css +54 -0
  143. data/src/stylesheets/nitro_kit/components/data_section.css +40 -0
  144. data/src/stylesheets/nitro_kit/components/details_table.css +53 -0
  145. data/src/stylesheets/nitro_kit/components/dialog.css +80 -0
  146. data/src/stylesheets/nitro_kit/components/dropdown.css +152 -0
  147. data/src/stylesheets/nitro_kit/components/dropzone.css +198 -0
  148. data/src/stylesheets/nitro_kit/components/empty_state.css +53 -0
  149. data/src/stylesheets/nitro_kit/components/field.css +79 -0
  150. data/src/stylesheets/nitro_kit/components/field_group.css +6 -0
  151. data/src/stylesheets/nitro_kit/components/fieldset.css +28 -0
  152. data/src/stylesheets/nitro_kit/components/flex.css +455 -0
  153. data/src/stylesheets/nitro_kit/components/form_section.css +71 -0
  154. data/src/stylesheets/nitro_kit/components/grid.css +308 -0
  155. data/src/stylesheets/nitro_kit/components/icon.css +26 -0
  156. data/src/stylesheets/nitro_kit/components/input.css +80 -0
  157. data/src/stylesheets/nitro_kit/components/label.css +9 -0
  158. data/src/stylesheets/nitro_kit/components/layout.css +440 -0
  159. data/src/stylesheets/nitro_kit/components/page_header.css +61 -0
  160. data/src/stylesheets/nitro_kit/components/pagination.css +119 -0
  161. data/src/stylesheets/nitro_kit/components/pagination_bar.css +41 -0
  162. data/src/stylesheets/nitro_kit/components/palette.css +113 -0
  163. data/src/stylesheets/nitro_kit/components/progressive_image.css +139 -0
  164. data/src/stylesheets/nitro_kit/components/radio_button.css +127 -0
  165. data/src/stylesheets/nitro_kit/components/radio_button_group.css +143 -0
  166. data/src/stylesheets/nitro_kit/components/rich_text_area.css +21 -0
  167. data/src/stylesheets/nitro_kit/components/select.css +70 -0
  168. data/src/stylesheets/nitro_kit/components/settings_layout.css +100 -0
  169. data/src/stylesheets/nitro_kit/components/sheet.css +100 -0
  170. data/src/stylesheets/nitro_kit/components/stat_grid.css +51 -0
  171. data/src/stylesheets/nitro_kit/components/switch.css +152 -0
  172. data/src/stylesheets/nitro_kit/components/table.css +116 -0
  173. data/src/stylesheets/nitro_kit/components/tabs.css +138 -0
  174. data/src/stylesheets/nitro_kit/components/textarea.css +39 -0
  175. data/src/stylesheets/nitro_kit/components/toast.css +131 -0
  176. data/src/stylesheets/nitro_kit/components/toolbar.css +78 -0
  177. data/src/stylesheets/nitro_kit/components/tooltip.css +124 -0
  178. data/src/stylesheets/nitro_kit/components/typeset.css +191 -0
  179. data/src/stylesheets/nitro_kit/layers.css +7 -0
  180. data/src/stylesheets/nitro_kit/reset.css +192 -0
  181. data/src/stylesheets/nitro_kit/tokens.css +198 -0
  182. metadata +182 -51
  183. data/Rakefile +0 -8
  184. data/app/assets/tailwind/application.css +0 -49
  185. data/app/components/nitro_kit/datepicker.rb +0 -9
  186. data/app/helpers/nitro_kit/accordion_helper.rb +0 -9
  187. data/app/helpers/nitro_kit/alert_helper.rb +0 -11
  188. data/app/helpers/nitro_kit/avatar_helper.rb +0 -13
  189. data/app/helpers/nitro_kit/badge_helper.rb +0 -13
  190. data/app/helpers/nitro_kit/button_group_helper.rb +0 -9
  191. data/app/helpers/nitro_kit/button_helper.rb +0 -49
  192. data/app/helpers/nitro_kit/card_helper.rb +0 -9
  193. data/app/helpers/nitro_kit/checkbox_helper.rb +0 -50
  194. data/app/helpers/nitro_kit/combobox_helper.rb +0 -9
  195. data/app/helpers/nitro_kit/datepicker_helper.rb +0 -9
  196. data/app/helpers/nitro_kit/dialog_helper.rb +0 -9
  197. data/app/helpers/nitro_kit/dropdown_helper.rb +0 -9
  198. data/app/helpers/nitro_kit/field_group_helper.rb +0 -9
  199. data/app/helpers/nitro_kit/field_helper.rb +0 -9
  200. data/app/helpers/nitro_kit/fieldset_helper.rb +0 -9
  201. data/app/helpers/nitro_kit/form_helper.rb +0 -13
  202. data/app/helpers/nitro_kit/icon_helper.rb +0 -9
  203. data/app/helpers/nitro_kit/input_helper.rb +0 -37
  204. data/app/helpers/nitro_kit/label_helper.rb +0 -19
  205. data/app/helpers/nitro_kit/pagination_helper.rb +0 -46
  206. data/app/helpers/nitro_kit/radio_button_helper.rb +0 -23
  207. data/app/helpers/nitro_kit/select_helper.rb +0 -24
  208. data/app/helpers/nitro_kit/switch_helper.rb +0 -9
  209. data/app/helpers/nitro_kit/table_helper.rb +0 -9
  210. data/app/helpers/nitro_kit/tabs_helper.rb +0 -9
  211. data/app/helpers/nitro_kit/textarea_helper.rb +0 -9
  212. data/app/helpers/nitro_kit/toast_helper.rb +0 -25
  213. data/app/helpers/nitro_kit/tooltip_helper.rb +0 -9
  214. data/app/javascript/controllers/nk/accordion_controller.js +0 -18
  215. data/app/javascript/controllers/nk/datepicker_controller.js +0 -5
  216. data/app/javascript/controllers/nk/switch_controller.js +0 -32
  217. data/lib/generators/nitro_kit/component_generator.rb +0 -84
  218. data/lib/nitro_kit/schema_builder.rb +0 -129
  219. data/lib/nitro_kit/variants.rb +0 -21
@@ -0,0 +1,595 @@
1
+ # Customizing Nitro Kit
2
+
3
+ Nitro Kit owns component Ruby, markup, behavior, and default CSS. Applications customize the system by overriding the public `--nk-*` custom properties, composing components into application UI, and occasionally creating a narrow subclass. Applications do not copy or edit Nitro components.
4
+
5
+ ## Stylesheet order
6
+
7
+ Load browser styles in this order:
8
+
9
+ 1. Optional third-party base styles, such as Lexxy.
10
+ 2. The optional `nitro_kit-tailwind-v4` adapter.
11
+ 3. The generated `nitro_kit` distribution stylesheet.
12
+ 4. The application's compiled Tailwind CSS, when present.
13
+ 5. Application styles, including Nitro token overrides.
14
+
15
+ `NitroKit::AppearanceBootstrap` precedes every entry in this list. The install
16
+ generator owns this ordering and can safely be rerun. For example, an
17
+ application with Lexxy and no Tailwind has exactly three stylesheet entries:
18
+
19
+ ```erb
20
+ <%= stylesheet_link_tag "lexxy", "nitro_kit", "application", "data-turbo-track": "reload" %>
21
+ ```
22
+
23
+ A Rails application without Tailwind can use:
24
+
25
+ ```erb
26
+ <%= stylesheet_link_tag "nitro_kit", "application", "data-turbo-track": "reload" %>
27
+ ```
28
+
29
+ A Tailwind CSS v4 application can use:
30
+
31
+ ```erb
32
+ <%= stylesheet_link_tag \
33
+ "nitro_kit-tailwind-v4", \
34
+ "nitro_kit", \
35
+ "tailwind", \
36
+ "application", \
37
+ "data-turbo-track": "reload" %>
38
+ ```
39
+
40
+ Keep overrides unlayered in application CSS and load them after Nitro Kit. Nitro's selectors use `:where()` inside named cascade layers, so an ordinary application rule can override a token without selector escalation or `!important`.
41
+
42
+ Do not edit `app/assets/stylesheets/nitro_kit.css` in the gem or a bundled copy of it. That file is generated from `src/stylesheets/nitro_kit/` and is replaced on upgrade. Variables named `--_nk-*` are private component mechanics and may change without notice. Only the `--nk-*` variables listed below are the customization contract.
43
+
44
+ ## Global overrides
45
+
46
+ Shared tokens can be changed once on the document root:
47
+
48
+ ```css
49
+ :root {
50
+ --nk-font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
51
+ --nk-content-lg: 52rem;
52
+ }
53
+ ```
54
+
55
+ Color tokens need a light value, a no-JavaScript system fallback, and a dark value. Keep the selectors in this order:
56
+
57
+ ```css
58
+ :root,
59
+ [data-theme="light"] {
60
+ --nk-color-primary: oklch(0.55 0.2 260);
61
+ --nk-color-primary-foreground: oklch(0.985 0 0);
62
+ --nk-color-focus: oklch(0.55 0.2 260);
63
+ }
64
+
65
+ @media (prefers-color-scheme: dark) {
66
+ :root:not([data-theme]) {
67
+ --nk-color-primary: oklch(0.72 0.16 260);
68
+ --nk-color-primary-foreground: oklch(0.15 0.02 260);
69
+ --nk-color-focus: oklch(0.72 0.16 260);
70
+ }
71
+ }
72
+
73
+ [data-theme="dark"] {
74
+ --nk-color-primary: oklch(0.72 0.16 260);
75
+ --nk-color-primary-foreground: oklch(0.15 0.02 260);
76
+ --nk-color-focus: oklch(0.72 0.16 260);
77
+ }
78
+ ```
79
+
80
+ The media query matters when JavaScript or the appearance bootstrap is unavailable. When the runtime is active, `data-theme` is always the resolved `light` or `dark` appearance. `system` is a stored preference in `data-theme-preference`, not a third palette and never a `data-theme="system"` selector.
81
+
82
+ Raised default Buttons have their own tokens, so their dark treatment can change without recoloring cards, dialogs, menus, or data-entry controls:
83
+
84
+ ```css
85
+ @media (prefers-color-scheme: dark) {
86
+ :root:not([data-theme]) {
87
+ --nk-button-default-background: oklch(0.3 0.01 286);
88
+ }
89
+ }
90
+
91
+ [data-theme="dark"] {
92
+ --nk-button-default-background: oklch(0.3 0.01 286);
93
+ }
94
+ ```
95
+
96
+ ## Scoped overrides
97
+
98
+ Wrap a product area in an application-owned attribute when only that subtree should change:
99
+
100
+ ```ruby
101
+ class BillingArea < Phlex::HTML
102
+ def view_template
103
+ div(data: { app_theme: "billing" }) do
104
+ render Billing::Overview.new
105
+ end
106
+ end
107
+ end
108
+ ```
109
+
110
+ Then scope shared and appearance-specific values. This example follows the document appearance set by `AppearanceBootstrap` while retaining the no-JavaScript system fallback.
111
+
112
+ ```css
113
+ [data-app-theme="billing"] {
114
+ --nk-font-sans: ui-monospace, SFMono-Regular, Menlo, monospace;
115
+ --nk-content-md: 36rem;
116
+ }
117
+
118
+ :root [data-app-theme="billing"],
119
+ [data-theme="light"] [data-app-theme="billing"] {
120
+ --nk-color-primary: oklch(0.49 0.17 155);
121
+ --nk-color-primary-foreground: white;
122
+ }
123
+
124
+ @media (prefers-color-scheme: dark) {
125
+ :root:not([data-theme]) [data-app-theme="billing"] {
126
+ --nk-color-primary: oklch(0.72 0.16 155);
127
+ --nk-color-primary-foreground: oklch(0.15 0.02 155);
128
+ }
129
+ }
130
+
131
+ [data-theme="dark"] [data-app-theme="billing"] {
132
+ --nk-color-primary: oklch(0.72 0.16 155);
133
+ --nk-color-primary-foreground: oklch(0.15 0.02 155);
134
+ }
135
+ ```
136
+
137
+ This changes Nitro descendants through inheritance without adding classes to them or reaching into `data-slot` markup.
138
+
139
+ ## Appearance setup
140
+
141
+ Render `NitroKit::AppearanceBootstrap` in the document `head` before every stylesheet link. It resolves a validated `light`, `dark`, or `system` preference before CSS-visible paint. Render zero, one, or many pickers in the body; they all use the same document runtime.
142
+
143
+ ```ruby
144
+ class ApplicationLayout < Phlex::HTML
145
+ include Phlex::Rails::Layout
146
+ include Phlex::Rails::Helpers::ContentSecurityPolicyNonce
147
+
148
+ def view_template
149
+ doctype
150
+
151
+ html(lang: "en") do
152
+ head do
153
+ render NitroKit::AppearanceBootstrap.new(
154
+ default: :system,
155
+ nonce: content_security_policy_nonce
156
+ )
157
+ stylesheet_link_tag("nitro_kit", data: { turbo_track: "reload" })
158
+ stylesheet_link_tag("application", data: { turbo_track: "reload" })
159
+ end
160
+
161
+ body do
162
+ render NitroKit::AppearancePicker.new(
163
+ id: "application-appearance",
164
+ label: "Appearance"
165
+ )
166
+ yield
167
+ end
168
+ end
169
+ end
170
+ end
171
+ ```
172
+
173
+ The runtime persists the preference in `localStorage` under `nitro-kit-appearance`, follows live operating-system changes in system mode, and synchronizes other tabs. Storage failure leaves in-document selection working and falls back to `default:`. Nitro Kit does not synchronize the preference to an application user record.
174
+
175
+ For a nonce-based content security policy, pass Rails' `content_security_policy_nonce` as above and allow that nonce in `script-src`. For a hash-based policy, allow the exact fixed script body:
176
+
177
+ ```text
178
+ script-src 'self' 'sha256-Vcime4euWSeYtHSfjYjqz/XhRyzMcLpn6Ip2LlaHleY='
179
+ ```
180
+
181
+ The value is also available as `NitroKit::AppearanceBootstrap::CSP_HASH`. The default preference is stored in a data attribute, so changing `default:` does not change the hash. Recheck the constant when upgrading because an intentional runtime change produces a new hash.
182
+
183
+ ## Choosing a theme
184
+
185
+ Treat related tokens as a system:
186
+
187
+ - Accent changes usually set `--nk-color-primary`, `--nk-color-primary-foreground`, and `--nk-color-focus` for both appearances. The default hover value is derived automatically; set `--nk-color-primary-hover` only when the derived color is unsuitable.
188
+ - Neutral changes should coordinate canvas, surface, elevated, foreground, muted, border, and neutral-content pairs for both appearances.
189
+ - Default Button changes use the `--nk-button-default-*` tokens. They are separate from `--nk-color-surface` so a raised neutral action can change without recoloring inputs, cards, dialogs, and menus.
190
+ - Radius changes should move `--nk-radius-xs` through `--nk-radius-xl` together. Leave `--nk-radius-full` alone unless pills and circular controls should stop being fully rounded.
191
+ - Density changes should coordinate `--nk-space` with all five control-height tokens. Changing one component's internal gap is not a public theme contract.
192
+ - Font changes normally set `--nk-font-sans`; set `--nk-font-mono`, text sizes, line heights, or weights only when the whole type system calls for it.
193
+
194
+ Check foreground pairs and focus indicators for contrast in both appearances. The semantic names describe use, not a fixed hue: `danger` can be a project-appropriate destructive color, but it should remain recognizably destructive everywhere it appears.
195
+
196
+ ## Theme customizer
197
+
198
+ The interactive theme customizer lives on the documentation site at
199
+ [nitrokit.dev/customize](https://nitrokit.dev/customize). Pick an accent, neutral, radius, density, font, and application shell, watch a complete workspace update, then copy deterministic CSS containing only changed public tokens plus a copyable `AppShell` composition for the selected layout.
200
+
201
+ Paste the CSS into an application-owned stylesheet such as `app/assets/stylesheets/nitro_theme.css`, then load that stylesheet after Nitro Kit and any compiled Tailwind CSS:
202
+
203
+ ```erb
204
+ <%= stylesheet_link_tag \
205
+ "nitro_kit", \
206
+ "application", \
207
+ "nitro_theme", \
208
+ "data-turbo-track": "reload" %>
209
+ ```
210
+
211
+ The customizer copies text to the clipboard. It does not download files, write into an application, or generate a component implementation.
212
+
213
+ ## Application composition
214
+
215
+ Composition is the default extension mechanism. Put product policy, routes, copy, and domain objects in application components while Nitro owns the visual components:
216
+
217
+ Use an application-owned base beside Nitro Kit. Including `NitroKit` once makes
218
+ capitalized Kit methods available to descendants; the merge helper below is
219
+ ordinary application code and does not call Nitro private APIs:
220
+
221
+ ```ruby
222
+ class ApplicationComponent < Phlex::HTML
223
+ include NitroKit
224
+
225
+ private
226
+
227
+ def merge_attributes(defaults = {}, html: {}, data: {}, aria: {})
228
+ defaults = canonical_attributes(defaults, "defaults")
229
+ html = canonical_attributes(html, "HTML")
230
+ validate_html_boundaries!(html)
231
+
232
+ default_data = canonical_attributes(defaults.delete(:data) || {}, "default data", prefix: "data")
233
+ default_aria = canonical_attributes(defaults.delete(:aria) || {}, "default ARIA", prefix: "aria")
234
+ data = canonical_attributes(data, "data", prefix: "data")
235
+ aria = canonical_attributes(aria, "ARIA", prefix: "aria")
236
+ classes = merged_classes(defaults.delete(:class), html.delete(:class))
237
+
238
+ defaults.merge(html).tap do |attributes|
239
+ attributes[:class] = classes if classes
240
+ attributes[:data] = default_data.merge(data) if default_data.any? || data.any?
241
+ attributes[:aria] = default_aria.merge(aria) if default_aria.any? || aria.any?
242
+ end
243
+ end
244
+
245
+ def canonical_attributes(value, name, prefix: nil)
246
+ raise ArgumentError, "#{name} must be a Hash" unless value.is_a?(Hash)
247
+
248
+ value.each_with_object({}) do |(key, item), normalized|
249
+ unless key.is_a?(String) || key.is_a?(Symbol)
250
+ raise ArgumentError, "#{name} attribute keys must be Strings or Symbols"
251
+ end
252
+
253
+ key = key.to_s.downcase.tr("_", "-").to_sym
254
+ emitted_name = [ prefix, key ].compact.join("-")
255
+ raise ArgumentError, "Duplicate #{name} attribute #{emitted_name}" if normalized.key?(key)
256
+
257
+ normalized[key] = item
258
+ end
259
+ end
260
+
261
+ def validate_html_boundaries!(html)
262
+ html.each_key do |key|
263
+ boundary = %w[data aria].find do |name|
264
+ key == name.to_sym || key.to_s.start_with?("#{name}-")
265
+ end
266
+ next unless boundary
267
+
268
+ raise ArgumentError, "Pass #{key} through #{boundary}:, not html:"
269
+ end
270
+ end
271
+
272
+ def merged_classes(*values)
273
+ tokens = values.compact.flat_map do |value|
274
+ raise ArgumentError, "class values must be Strings" unless value.is_a?(String)
275
+
276
+ value.split
277
+ end
278
+ tokens = tokens.reverse.uniq.reverse
279
+ tokens.join(" ") if tokens.any?
280
+ end
281
+ end
282
+ ```
283
+
284
+ The precedence is explicit:
285
+
286
+ 1. Caller `html:` values replace same-key defaults.
287
+ 2. Caller `data:` and `aria:` values replace same-key nested defaults.
288
+ 3. Classes merge instead of replacing. Default tokens come first; caller
289
+ tokens come last; a duplicate survives once at its caller position.
290
+
291
+ Class attribute order does not override the CSS cascade; application
292
+ stylesheet source order still decides conflicts between class rules. The
293
+ helper canonicalizes keys to their lowercase, hyphenated HTML spelling before
294
+ merging, rejects nested or flattened `data-*`/`aria-*` attributes inside
295
+ `html:`, and never mutates the defaults. String, symbol, underscore, and dash
296
+ aliases therefore emit once; caller values win over defaults, while duplicate
297
+ aliases within one bag raise an error naming the emitted attribute.
298
+
299
+ A small reusable application component can then provide its own class-based
300
+ root while composing Nitro through the public Kit method:
301
+
302
+ ```ruby
303
+ module RailsIntegration
304
+ class StatusPill < ApplicationComponent
305
+ STATUSES = %i[received reviewed].freeze
306
+
307
+ def initialize(status, html: {}, data: {}, aria: {})
308
+ @status = status.respond_to?(:to_sym) ? status.to_sym : status
309
+ raise ArgumentError, "Unknown status #{status.inspect}" unless STATUSES.include?(@status)
310
+
311
+ @attributes = merge_attributes(
312
+ {
313
+ class: "status-pill status-pill--quiet",
314
+ title: "Submission status",
315
+ data: { application_component: "status-pill", state: @status },
316
+ aria: { live: "polite" }
317
+ },
318
+ html:,
319
+ data:,
320
+ aria:
321
+ )
322
+ end
323
+
324
+ def view_template
325
+ span(**attributes) do
326
+ Badge(status.to_s.humanize, color: :success, size: :sm)
327
+ end
328
+ end
329
+
330
+ private
331
+
332
+ attr_reader :attributes, :status
333
+ end
334
+ end
335
+ ```
336
+
337
+ For example, `html: { class: "receipt-state status-pill--quiet" }, data:
338
+ { state: "reviewed" }, aria: { live: "assertive" }` renders the classes as
339
+ `status-pill receipt-state status-pill--quiet` and lets the caller replace the
340
+ default state and live mode. The dummy application's
341
+ `ApplicationComponent`, `RailsIntegration::StatusPill`, and focused component
342
+ test execute this exact reference implementation.
343
+
344
+ Product components that do not need application classes remain smaller:
345
+
346
+ ```ruby
347
+ module UI
348
+ class UpgradeNotice < Phlex::HTML
349
+ def view_template
350
+ render NitroKit::Alert.new(variant: :warning) do |alert|
351
+ alert.title("Plan limit reached")
352
+ alert.description("Upgrade to invite another teammate.")
353
+ end
354
+ end
355
+ end
356
+ end
357
+ ```
358
+
359
+ A narrow subclass is acceptable when it fixes a small, stable application vocabulary and delegates everything to a public constructor:
360
+
361
+ ```ruby
362
+ module UI
363
+ class SaveButton < NitroKit::Button
364
+ def initialize(text = "Save", disabled: false, data: {})
365
+ super(text, variant: :primary, type: :submit, disabled:, data:)
366
+ end
367
+ end
368
+ end
369
+ ```
370
+
371
+ Do not override Nitro rendering methods, private helpers, internal `Data` records, `data-slot` structure, or Stimulus state. Those are implementation details. If a subclass needs those surfaces, compose a new application component instead.
372
+
373
+ ## Application shells
374
+
375
+ `AppShell` owns responsive application chrome. The caller owns one navigation tree, current-route policy, brand, account actions, and page content:
376
+
377
+ ```ruby
378
+ module Workspace
379
+ class Layout < Phlex::HTML
380
+ include Phlex::Rails::Helpers::Routes
381
+
382
+ def initialize(page:)
383
+ @page = page
384
+ end
385
+
386
+ def view_template
387
+ render NitroKit::AppShell.new(id: "workspace", layout: :sidebar) do |shell|
388
+ shell.brand { strong { "Northstar" } }
389
+
390
+ shell.navigation do
391
+ render NitroKit::AppNavigation.new(label: "Primary navigation") do |navigation|
392
+ navigation.body do
393
+ navigation.section(label: "Workspace") do
394
+ navigation.item("Overview", href: root_path, icon: :house, current: true)
395
+ navigation.item("Projects", href: projects_path, icon: :folder, badge: 12)
396
+ end
397
+ navigation.spacer
398
+ navigation.item("Settings", href: settings_path, icon: :settings)
399
+ end
400
+ end
401
+ end
402
+
403
+ shell.topbar do
404
+ render NitroKit::Button.new("New project", href: new_project_path, variant: :primary)
405
+ end
406
+
407
+ shell.main { render @page }
408
+ end
409
+ end
410
+ end
411
+ end
412
+ ```
413
+
414
+ Change only `layout:` to `:topbar` or `:hybrid`; the same `brand`, `navigation`, `topbar`, and `main` declarations remain valid. Nitro owns the responsive breakpoint, narrow drawer, focus management, sticky regions, and one reflowed navigation DOM tree. Do not clone navigation for mobile or add route registries to the shell.
415
+
416
+ The gallery has complete executable examples for [sidebar](/gallery/compositions/application-sidebar), [topbar](/gallery/compositions/application-topbar), and [hybrid](/gallery/compositions/application-hybrid) applications. Each route contains multiple populated, empty, loading, long-content, missing-content, or error combinations.
417
+
418
+ ## Rails forms and Hotwire
419
+
420
+ Customization does not change Rails ownership. Keep using Rails helpers directly from Phlex and select the Nitro builder explicitly:
421
+
422
+ ```ruby
423
+ class ProfileForm < Phlex::HTML
424
+ include Phlex::Rails::Helpers::FormWith
425
+
426
+ def initialize(profile)
427
+ @profile = profile
428
+ end
429
+
430
+ def view_template
431
+ form_with(model: @profile, builder: NitroKit::FormBuilder) do |form|
432
+ form.group do
433
+ form.field(:name, required: true)
434
+ form.field(:timezone, as: :select, options: timezone_choices)
435
+ form.submit("Save profile")
436
+ end
437
+ end
438
+ end
439
+ end
440
+ ```
441
+
442
+ Nitro Kit's engine contributes its controller pins automatically when `importmap-rails` is present. The application still owns Stimulus and its normal loader:
443
+
444
+ ```js
445
+ // app/javascript/controllers/index.js
446
+ import { application } from "controllers/application";
447
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading";
448
+
449
+ eagerLoadControllersFrom("controllers", application);
450
+ ```
451
+
452
+ This registers Nitro's `controllers/nk/*` modules together with application controllers. Without importmap, Ruby and CSS still work, but a bundler-based application must expose and register those Stimulus modules itself. Nitro Kit ships no third-party JavaScript runtime, and Nitro Kit 2.0 has no JavaScript-package entrypoint.
453
+
454
+ ## Optional Tailwind CSS v4 adapter
455
+
456
+ Nitro Kit does not require Tailwind, Tailwind configuration, or Tailwind Preflight — it ships its own global preflight in the `nitro-kit.reset` cascade layer, which unlayered application CSS always overrides. The optional `nitro_kit-tailwind-v4.css` asset only establishes compatible cascade-layer order and maps Nitro tokens to common Tailwind v4 theme variables, including background, foreground, primary, destructive, radii, shadows, fonts, spacing, and transition defaults.
457
+
458
+ Tailwind remains compiled and configured by the application. An application can add further aliases in its Tailwind CSS source with the v4 CSS-first API:
459
+
460
+ ```css
461
+ @import "tailwindcss";
462
+
463
+ @theme inline {
464
+ --color-brand: var(--nk-color-primary);
465
+ --color-brand-foreground: var(--nk-color-primary-foreground);
466
+ --font-product: var(--nk-font-sans);
467
+ }
468
+ ```
469
+
470
+ Use `@theme inline` when a Tailwind theme variable references another custom property so generated utilities resolve the live Nitro value. The adapter does not make Tailwind a Nitro runtime dependency, configure source detection, generate utility classes, or permit Tailwind classes inside Nitro component APIs.
471
+
472
+ ## Public token reference
473
+
474
+ The following 84 variables are the complete public token set. Theme-independent tokens are declared on `:root`. Appearance tokens have light, dark, and system-fallback values. Derived tokens have defaults expressed in terms of other public tokens and remain overrideable.
475
+
476
+ ### Typography
477
+
478
+ | Token | Role |
479
+ | --------------------------- | ---------------------------------- |
480
+ | `--nk-font-sans` | Default UI font family. |
481
+ | `--nk-font-mono` | Monospace font family. |
482
+ | `--nk-text-xs` | Extra-small text size. |
483
+ | `--nk-text-sm` | Small text size. |
484
+ | `--nk-text-base` | Base text size. |
485
+ | `--nk-text-lg` | Large text size. |
486
+ | `--nk-text-xl` | Extra-large text size. |
487
+ | `--nk-text-2xl` | Largest shipped display text size. |
488
+ | `--nk-leading-tight` | Tight line-height ratio. |
489
+ | `--nk-leading-normal` | Default line-height ratio. |
490
+ | `--nk-leading-relaxed` | Relaxed line-height ratio. |
491
+ | `--nk-font-weight-normal` | Normal text weight. |
492
+ | `--nk-font-weight-medium` | Medium text weight. |
493
+ | `--nk-font-weight-semibold` | Semibold text weight. |
494
+ | `--nk-font-weight-bold` | Bold text weight. |
495
+ | `--nk-typeset-font-body` | Typeset body font family. |
496
+ | `--nk-typeset-font-heading` | Typeset heading font family. |
497
+ | `--nk-typeset-font-mono` | Typeset code font family. |
498
+ | `--nk-typeset-size` | Typeset base text size. |
499
+ | `--nk-typeset-leading` | Typeset body line-height ratio. |
500
+ | `--nk-typeset-flow` | Typeset vertical rhythm unit. |
501
+
502
+ ### Spacing and dimensions
503
+
504
+ | Token | Role |
505
+ | ------------------------ | --------------------------------------------------------------- |
506
+ | `--nk-space` | Base spacing unit multiplied throughout components and layouts. |
507
+ | `--nk-control-height-xs` | Extra-small control height. |
508
+ | `--nk-control-height-sm` | Small control height. |
509
+ | `--nk-control-height-md` | Default control height. |
510
+ | `--nk-control-height-lg` | Large control height. |
511
+ | `--nk-control-height-xl` | Extra-large control height. |
512
+ | `--nk-content-sm` | Small Container maximum width. |
513
+ | `--nk-content-md` | Medium Container maximum width. |
514
+ | `--nk-content-lg` | Large Container maximum width. |
515
+ | `--nk-content-xl` | Extra-large Container maximum width. |
516
+
517
+ ### Shape, borders, and focus
518
+
519
+ | Token | Role |
520
+ | ------------------- | ----------------------------------- |
521
+ | `--nk-radius-xs` | Extra-small corner radius. |
522
+ | `--nk-radius-sm` | Small corner radius. |
523
+ | `--nk-radius-md` | Default control corner radius. |
524
+ | `--nk-radius-lg` | Large surface corner radius. |
525
+ | `--nk-radius-xl` | Extra-large overlay corner radius. |
526
+ | `--nk-radius-full` | Fully rounded pills and circles. |
527
+ | `--nk-border-width` | Default border and separator width. |
528
+ | `--nk-focus-width` | Focus-ring width. |
529
+ | `--nk-focus-offset` | Focus-ring offset. |
530
+
531
+ ### Elevation and motion
532
+
533
+ | Token | Role |
534
+ | ---------------------- | ------------------------------------- |
535
+ | `--nk-shadow-sm` | Low surface elevation. |
536
+ | `--nk-shadow-md` | Medium floating elevation. |
537
+ | `--nk-shadow-lg` | High overlay elevation. |
538
+ | `--nk-duration-fast` | Fast interaction duration. |
539
+ | `--nk-duration-normal` | Default interaction duration. |
540
+ | `--nk-duration-slow` | Deliberate overlay or image duration. |
541
+ | `--nk-ease` | Default transition timing function. |
542
+
543
+ ### Semantic colors
544
+
545
+ | Token | Role |
546
+ | ------------------------------- | ---------------------------------------------------------------------- |
547
+ | `--nk-color-canvas` | Page canvas and overscroll; light zinc uses a zinc-25-like near-white. |
548
+ | `--nk-color-surface` | Default component surface. |
549
+ | `--nk-color-surface-hover` | Derived interactive surface hover. |
550
+ | `--nk-color-elevated` | Raised or inset-neutral surface. |
551
+ | `--nk-color-foreground` | Primary text and icon color. |
552
+ | `--nk-color-muted` | Quiet fill. |
553
+ | `--nk-color-muted-foreground` | Secondary text and icon color. |
554
+ | `--nk-color-border` | Borders and separators. |
555
+ | `--nk-color-focus` | Focus indicator. |
556
+ | `--nk-color-primary` | Primary action and selected-state fill. |
557
+ | `--nk-color-primary-hover` | Derived primary interaction hover. |
558
+ | `--nk-color-primary-foreground` | Content placed on primary fill. |
559
+ | `--nk-color-neutral` | Neutral status fill or marker. |
560
+ | `--nk-color-neutral-content` | Strong neutral status content. |
561
+ | `--nk-color-info` | Informational status fill or marker. |
562
+ | `--nk-color-info-content` | Informational status content. |
563
+ | `--nk-color-success` | Successful status fill or marker. |
564
+ | `--nk-color-success-content` | Successful status content. |
565
+ | `--nk-color-warning` | Warning status fill or marker. |
566
+ | `--nk-color-warning-content` | Warning status content. |
567
+ | `--nk-color-danger` | Destructive action and error fill. |
568
+ | `--nk-color-danger-hover` | Derived destructive interaction hover. |
569
+ | `--nk-color-danger-foreground` | Content placed on destructive fill. |
570
+ | `--nk-color-danger-content` | Error and destructive status content. |
571
+ | `--nk-color-overlay` | Modal and drawer backdrop. |
572
+
573
+ ### Default button colors
574
+
575
+ | Token | Role |
576
+ | -------------------------------------- | --------------------------------------- |
577
+ | `--nk-button-default-background` | Raised default-action fill. |
578
+ | `--nk-button-default-hover-background` | Raised default-action interaction fill. |
579
+ | `--nk-button-default-foreground` | Content on a raised default action. |
580
+ | `--nk-button-default-border` | Border around a raised default action. |
581
+
582
+ The native file-input selector uses the same treatment. Ordinary inputs, selects, textareas, unchecked controls, cards, dialogs, and menus continue to use the general surface tokens.
583
+
584
+ ### Application shell
585
+
586
+ | Token | Role |
587
+ | ------------------------------------------ | --------------------------------------------------------------------- |
588
+ | `--nk-app-shell-sidebar-width` | Desktop sidebar width. |
589
+ | `--nk-app-shell-topbar-height` | Desktop and compact-header height. |
590
+ | `--nk-app-shell-background` | Shell canvas; derived from `--nk-color-canvas`. |
591
+ | `--nk-app-shell-sidebar-background` | Sidebar surface; derived from `--nk-color-surface`. |
592
+ | `--nk-app-shell-sidebar-foreground` | Sidebar content; derived from `--nk-color-foreground`. |
593
+ | `--nk-app-shell-sidebar-accent` | Current and hovered navigation fill; derived from `--nk-color-muted`. |
594
+ | `--nk-app-shell-sidebar-accent-foreground` | Content on the sidebar accent. |
595
+ | `--nk-app-shell-border` | Shell chrome separators; derived from `--nk-color-border`. |
data/docs/hotwire.md ADDED
@@ -0,0 +1,94 @@
1
+ # Hotwire with Nitro Kit
2
+
3
+ Nitro Kit owns component markup, CSS, and its focused progressive controllers.
4
+ Rails owns records, routes, authorization, queries, DOM identity, and server
5
+ responses. Hotwire transports server-rendered HTML between those boundaries.
6
+
7
+ ## Choose the smallest interaction
8
+
9
+ 1. Use ordinary links and forms under Turbo Drive.
10
+ 2. Use a Turbo Frame for one independently navigable or replaceable region.
11
+ 3. Return request-scoped Turbo Streams when one action changes multiple
12
+ regions.
13
+ 4. Broadcast only when another session needs the update.
14
+ 5. Add application Stimulus only for browser-owned behavior the preceding
15
+ layers cannot express.
16
+
17
+ Do not copy Nitro Kit controllers into the application. Do not add files under
18
+ `app/javascript/controllers/nk`; consume the `nk--*` controllers packaged by
19
+ the installed gem.
20
+
21
+ ## Response matrix
22
+
23
+ | Request | Success | Invalid or denied |
24
+ | -------------------- | --------------------------------------------- | ------------------------------------------------------------------- |
25
+ | HTML form mutation | Redirect with `303 See Other` | Render HTML with `422` |
26
+ | Frame form mutation | Redirect or render the same frame ID | Render the same frame ID with `422` |
27
+ | Stream form mutation | Return only when multiple targets must change | Render the invalid form target with `422` and keep an HTML fallback |
28
+ | GET query | Render from URL parameters | Render a useful empty or error state |
29
+
30
+ Use `dom_id` or one named constant for a frame. The show, edit, invalid,
31
+ success, and cancel responses must preserve that identifier.
32
+
33
+ ## Forms and application Stimulus
34
+
35
+ Let Turbo submit real Rails forms. Use `data-turbo-submits-with` to opt a Button
36
+ into submission feedback without replacing its visible label. The Button dims
37
+ immediately; `submission_indicator: :spinner` additionally reveals an indicator
38
+ after 1 second and lets the Button expand to fit it. Use `data-turbo-confirm` for
39
+ compact destructive confirmation.
40
+ Reviewed destructive flows may compose `DangerZone` and `Dialog`, but the
41
+ dialog must still submit a real Rails form.
42
+
43
+ Keep application controllers small, declarative, and disposable. For a
44
+ self-submitting control, the complete controller can be:
45
+
46
+ ```js
47
+ import { Controller } from "@hotwired/stimulus";
48
+
49
+ export default class extends Controller {
50
+ submit() {
51
+ this.element.requestSubmit();
52
+ }
53
+ }
54
+ ```
55
+
56
+ Attach `data-action="change->auto-submit#submit"` to the form so change events
57
+ bubble to that one controller root. Keep a submit button inside `noscript` as
58
+ the HTML fallback. Use `data-action` instead of manually registering DOM listeners. If a
59
+ controller owns a listener, observer, timer, object URL, or third-party
60
+ instance, release it in `disconnect`.
61
+
62
+ ## Morphing and cache lifecycle
63
+
64
+ Default to morphable server-rendered HTML. Use `data-turbo-permanent` only for
65
+ a genuinely stateful island, always with a stable unique `id`. Update content
66
+ inside a permanent element deliberately instead of making broad page regions
67
+ permanent.
68
+
69
+ Clean ephemeral UI before Turbo caches the page. Nitro Kit controllers own
70
+ their own cache and reconnect behavior; application controllers must do the
71
+ same for application-owned state.
72
+
73
+ ## Frame escape and recovery
74
+
75
+ An authentication redirect or error response inside a frame must not strand
76
+ the user behind a missing-frame error. Use `_top` when navigation must leave
77
+ the frame. Keep authentication and authorization handling capable of returning
78
+ a full-page response, and handle `turbo:frame-missing` only when the
79
+ application has a deliberate recovery policy.
80
+
81
+ Give lazy frames meaningful loading content. A failed frame should leave an
82
+ understandable state and a path to retry or continue without JavaScript.
83
+
84
+ ## Verify behavior
85
+
86
+ - Request-test `303`, `422`, HTML fallback, and stable frame IDs.
87
+ - On mutation responses, assert submitted content inside the response's
88
+ matching frame, not merely somewhere in the response body.
89
+ - System-test focus, dialogs, frame navigation, and multi-target changes.
90
+ - Use Capybara assertions that wait for the DOM; never use `sleep`.
91
+ - Test navigation, morphing, and reconnection without duplicating controller
92
+ roots or listeners.
93
+
94
+ Read the matching recipe under `docs/patterns/` for complete compositions.