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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +229 -1
  3. data/README.md +25 -10
  4. data/STYLE_GUIDE.md +47 -5
  5. data/app/assets/stylesheets/nitro_kit-tailwind-v4.css +3 -3
  6. data/app/assets/stylesheets/nitro_kit.css +2820 -1816
  7. data/app/components/nitro_kit/accordion.rb +2 -26
  8. data/app/components/nitro_kit/alert.rb +1 -11
  9. data/app/components/nitro_kit/app_navigation.rb +45 -11
  10. data/app/components/nitro_kit/app_shell.rb +1 -7
  11. data/app/components/nitro_kit/appearance_picker.rb +11 -12
  12. data/app/components/nitro_kit/avatar.rb +6 -1
  13. data/app/components/nitro_kit/avatar_stack.rb +3 -0
  14. data/app/components/nitro_kit/badge.rb +13 -4
  15. data/app/components/nitro_kit/button_group.rb +2 -2
  16. data/app/components/nitro_kit/checkbox.rb +7 -4
  17. data/app/components/nitro_kit/checkbox_group.rb +0 -25
  18. data/app/components/nitro_kit/combobox.rb +4 -33
  19. data/app/components/nitro_kit/command_palette.rb +42 -59
  20. data/app/components/nitro_kit/component.rb +57 -5
  21. data/app/components/nitro_kit/danger_zone.rb +5 -1
  22. data/app/components/nitro_kit/data_section.rb +28 -4
  23. data/app/components/nitro_kit/details_table.rb +19 -8
  24. data/app/components/nitro_kit/dialog.rb +23 -19
  25. data/app/components/nitro_kit/dropdown.rb +1 -6
  26. data/app/components/nitro_kit/dropzone.rb +4 -15
  27. data/app/components/nitro_kit/empty_state.rb +21 -2
  28. data/app/components/nitro_kit/field.rb +35 -11
  29. data/app/components/nitro_kit/form_builder.rb +1 -2
  30. data/app/components/nitro_kit/progressive_image.rb +2 -2
  31. data/app/components/nitro_kit/radio_button.rb +7 -4
  32. data/app/components/nitro_kit/radio_button_group.rb +0 -25
  33. data/app/components/nitro_kit/select.rb +4 -17
  34. data/app/components/nitro_kit/settings_layout.rb +17 -4
  35. data/app/components/nitro_kit/{form_section.rb → settings_section.rb} +18 -11
  36. data/app/components/nitro_kit/sheet.rb +29 -20
  37. data/app/components/nitro_kit/stat_grid.rb +18 -2
  38. data/app/components/nitro_kit/table.rb +22 -3
  39. data/app/components/nitro_kit/tabs.rb +2 -13
  40. data/app/components/nitro_kit/toast.rb +4 -2
  41. data/app/components/nitro_kit/toolbar.rb +2 -0
  42. data/app/components/nitro_kit/tooltip.rb +7 -10
  43. data/app/javascript/controllers/nk/app_shell_controller.js +3 -3
  44. data/app/javascript/controllers/nk/appearance_controller.js +6 -0
  45. data/app/javascript/controllers/nk/combobox_controller.js +51 -1
  46. data/app/javascript/controllers/nk/command_palette_controller.js +25 -11
  47. data/app/javascript/controllers/nk/dialog_controller.js +54 -0
  48. data/app/javascript/controllers/nk/dropdown_controller.js +45 -0
  49. data/app/javascript/controllers/nk/dropzone_controller.js +2 -2
  50. data/app/javascript/controllers/nk/progressive_image_controller.js +11 -7
  51. data/app/javascript/controllers/nk/tabs_controller.js +21 -2
  52. data/app/javascript/controllers/nk/tooltip_controller.js +9 -0
  53. data/config/locales/en.yml +4 -1
  54. data/docs/agent_guide.md +63 -123
  55. data/docs/agent_native_spec.md +72 -383
  56. data/docs/browser_support.md +82 -0
  57. data/docs/component_contracts.md +108 -82
  58. data/docs/customization.md +689 -91
  59. data/docs/hotwire.md +50 -76
  60. data/docs/initialization_prompt.md +26 -31
  61. data/docs/migration_1_to_2.md +88 -30
  62. data/docs/patterns/application_foundation.md +45 -119
  63. data/docs/patterns/crud_resource.md +44 -126
  64. data/docs/patterns/destructive_action.md +45 -77
  65. data/docs/patterns/flash_and_toast.md +18 -36
  66. data/docs/patterns/inline_edit.md +21 -44
  67. data/docs/patterns/queryable_collection.md +65 -161
  68. data/docs/patterns/resource_form.md +33 -81
  69. data/docs/rails_conventions.md +38 -75
  70. data/docs/rails_integration.md +96 -446
  71. data/lib/generators/nitro_kit/upgrade_smoke_tests_generator.rb +12 -0
  72. data/lib/nitro_kit/installation.rb +20 -7
  73. data/lib/nitro_kit/migration_inventory.rb +275 -8
  74. data/lib/nitro_kit/upgrade_smoke_test.rb +10 -12
  75. data/lib/nitro_kit/version.rb +1 -1
  76. data/lib/rails/commands/nitro_kit/nitro_kit_command.rb +10 -3
  77. data/plugins/nitro-kit/skills/nitro-kit-hotwire/SKILL.md +4 -0
  78. data/plugins/nitro-kit/skills/nitro-kit-rails/SKILL.md +13 -1
  79. data/plugins/nitro-kit/skills/nitro-kit-ui/SKILL.md +32 -15
  80. data/src/stylesheets/nitro_kit/components/accordion.css +44 -44
  81. data/src/stylesheets/nitro_kit/components/alert.css +27 -36
  82. data/src/stylesheets/nitro_kit/components/app_navigation.css +92 -20
  83. data/src/stylesheets/nitro_kit/components/app_shell.css +199 -196
  84. data/src/stylesheets/nitro_kit/components/appearance_picker.css +61 -39
  85. data/src/stylesheets/nitro_kit/components/auth_shell.css +2 -2
  86. data/src/stylesheets/nitro_kit/components/avatar.css +15 -11
  87. data/src/stylesheets/nitro_kit/components/avatar_stack.css +48 -29
  88. data/src/stylesheets/nitro_kit/components/badge.css +13 -9
  89. data/src/stylesheets/nitro_kit/components/button.css +121 -79
  90. data/src/stylesheets/nitro_kit/components/button_group.css +19 -14
  91. data/src/stylesheets/nitro_kit/components/card.css +12 -6
  92. data/src/stylesheets/nitro_kit/components/checkbox.css +54 -67
  93. data/src/stylesheets/nitro_kit/components/checkbox_group.css +16 -16
  94. data/src/stylesheets/nitro_kit/components/combobox.css +39 -29
  95. data/src/stylesheets/nitro_kit/components/command_palette.css +110 -51
  96. data/src/stylesheets/nitro_kit/components/container.css +6 -6
  97. data/src/stylesheets/nitro_kit/components/control_group.css +61 -67
  98. data/src/stylesheets/nitro_kit/components/danger_zone.css +8 -8
  99. data/src/stylesheets/nitro_kit/components/data_section.css +4 -4
  100. data/src/stylesheets/nitro_kit/components/details_table.css +17 -15
  101. data/src/stylesheets/nitro_kit/components/dialog.css +8 -5
  102. data/src/stylesheets/nitro_kit/components/dropdown.css +36 -30
  103. data/src/stylesheets/nitro_kit/components/dropzone.css +78 -35
  104. data/src/stylesheets/nitro_kit/components/empty_state.css +7 -7
  105. data/src/stylesheets/nitro_kit/components/field.css +32 -28
  106. data/src/stylesheets/nitro_kit/components/field_group.css +13 -0
  107. data/src/stylesheets/nitro_kit/components/fieldset.css +8 -2
  108. data/src/stylesheets/nitro_kit/components/flex.css +1 -1
  109. data/src/stylesheets/nitro_kit/components/grid.css +1 -1
  110. data/src/stylesheets/nitro_kit/components/icon.css +5 -5
  111. data/src/stylesheets/nitro_kit/components/input.css +42 -8
  112. data/src/stylesheets/nitro_kit/components/layout.css +165 -165
  113. data/src/stylesheets/nitro_kit/components/page_header.css +7 -7
  114. data/src/stylesheets/nitro_kit/components/pagination.css +58 -49
  115. data/src/stylesheets/nitro_kit/components/pagination_bar.css +9 -9
  116. data/src/stylesheets/nitro_kit/components/palette.css +145 -77
  117. data/src/stylesheets/nitro_kit/components/progressive_image.css +45 -55
  118. data/src/stylesheets/nitro_kit/components/radio_button.css +48 -46
  119. data/src/stylesheets/nitro_kit/components/radio_button_group.css +49 -49
  120. data/src/stylesheets/nitro_kit/components/rich_text_area.css +16 -1
  121. data/src/stylesheets/nitro_kit/components/select.css +24 -15
  122. data/src/stylesheets/nitro_kit/components/settings_layout.css +26 -23
  123. data/src/stylesheets/nitro_kit/components/settings_section.css +84 -0
  124. data/src/stylesheets/nitro_kit/components/sheet.css +30 -10
  125. data/src/stylesheets/nitro_kit/components/stat_grid.css +11 -10
  126. data/src/stylesheets/nitro_kit/components/switch.css +60 -62
  127. data/src/stylesheets/nitro_kit/components/table.css +43 -35
  128. data/src/stylesheets/nitro_kit/components/tabs.css +48 -40
  129. data/src/stylesheets/nitro_kit/components/textarea.css +20 -5
  130. data/src/stylesheets/nitro_kit/components/toast.css +11 -47
  131. data/src/stylesheets/nitro_kit/components/toolbar.css +16 -41
  132. data/src/stylesheets/nitro_kit/components/tooltip.css +38 -29
  133. data/src/stylesheets/nitro_kit/components/typeset.css +150 -4
  134. data/src/stylesheets/nitro_kit/reset.css +15 -15
  135. data/src/stylesheets/nitro_kit/tokens.css +501 -79
  136. metadata +4 -4
  137. data/docs/new_app_strategy.md +0 -22
  138. data/src/stylesheets/nitro_kit/components/form_section.css +0 -71
@@ -23,10 +23,18 @@ module NitroKit
23
23
  Each skill resolves the installed gem with `bundle show nitro_kit` and reads
24
24
  its version-matched documentation.
25
25
 
26
+ In a greenfield application, run `bin/rails generate phlex:install` and use
27
+ Phlex for the application layout, route views, and reusable UI. In an
28
+ established application, preserve its existing view architecture and
29
+ introduce Phlex and Nitro Kit only at the requested boundary unless an
30
+ application-wide migration is explicitly authorized.
31
+
26
32
  Do not use Nitro Kit 1.x APIs, `nk_*` helpers, copied Nitro components, or
27
- application-owned `controllers/nk`. Compose the installed Phlex Kit and keep
28
- routes, records, authorization, queries, DOM IDs, and server responses in the
29
- application.
33
+ application-owned `controllers/nk`. Include `NitroKit` once in the base Phlex
34
+ component and prefer capitalized Kit methods such as `Button(...)` and
35
+ `Card(...)`; use `.new` only when another API requires a component object.
36
+ Keep routes, records, authorization, queries, DOM IDs, and server responses
37
+ in the application.
30
38
 
31
39
  During migration, replace an existing form control only when Nitro Kit 2 has
32
40
  a genuine semantic and behavioral equivalent. Otherwise preserve the control
@@ -152,12 +160,17 @@ module NitroKit
152
160
  end
153
161
 
154
162
  def stimulus_loader_check
155
- path = application_root.join("app/javascript/controllers/index.js")
156
- ready = path.exist? && path.read.include?("eagerLoadControllersFrom")
163
+ javascript = application_root.glob("app/javascript/**/*.{js,ts}").select(&:file?).map(&:read)
164
+ loader = if javascript.any? { _1.include?("eagerLoadControllersFrom") }
165
+ "eager controller loading"
166
+ elsif javascript.any? { _1.match?(/\bapplication\.register\s*\(/) }
167
+ "explicit controller registration"
168
+ end
169
+
157
170
  Check.new(
158
- status: ready ? :pass : :warn,
171
+ status: loader ? :pass : :warn,
159
172
  label: "Stimulus loader",
160
- detail: ready ? "eager controller loading is configured" : "verify the application's controller registration"
173
+ detail: loader ? "#{loader} is configured" : "verify the application's controller registration"
161
174
  )
162
175
  end
163
176
 
@@ -1,4 +1,5 @@
1
1
  require "pathname"
2
+ require "prism"
2
3
  require "ripper"
3
4
 
4
5
  module NitroKit
@@ -53,6 +54,187 @@ module NitroKit
53
54
  "@floating-ui/utils" => "remove the 1.x Floating UI pin or package after copied controllers are removed",
54
55
  "@github/combobox-nav" => "remove the 1.x combobox package; Nitro Kit 2 owns combobox behavior"
55
56
  }.freeze
57
+ BUTTON_TREATMENT_DEFINITION = /@utility\s+btn\b|\.btn(?:\s|[,{.:])/.freeze
58
+ BUTTON_CLASS = /(?<![a-z0-9_-])btn(?:-[a-z0-9_-]+)?(?![a-z0-9_-])/i.freeze
59
+ ERB_TAG_PATTERN = /<%(?!%|#)(==|[-=])?(.*?)(?:-)?%>/m
60
+ TABLE_COMPOUND_KEYWORDS = {
61
+ caption: %i[html aria data desperately_need_a_class],
62
+ thead: %i[html aria data desperately_need_a_class],
63
+ tbody: %i[html aria data desperately_need_a_class],
64
+ tr: %i[html aria data desperately_need_a_class],
65
+ th: %i[align scope sort href sort_data html aria data desperately_need_a_class],
66
+ td: %i[align html aria data desperately_need_a_class]
67
+ }.freeze
68
+
69
+ class ContractVisitor < Prism::Visitor
70
+ private
71
+ def positional_arguments(node)
72
+ Array(node.arguments&.arguments).reject { _1.is_a?(Prism::KeywordHashNode) }
73
+ end
74
+
75
+ def keyword_names(node)
76
+ Array(node.arguments&.arguments).grep(Prism::KeywordHashNode).flat_map(&:elements).filter_map do |element|
77
+ element.key.unescaped.to_sym if element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode)
78
+ end
79
+ end
80
+
81
+ def dynamic_arguments?(node)
82
+ Array(node.arguments&.arguments).any? do |argument|
83
+ argument.is_a?(Prism::SplatNode) || argument.is_a?(Prism::BlockArgumentNode) ||
84
+ (argument.is_a?(Prism::KeywordHashNode) && argument.elements.any? { _1.is_a?(Prism::AssocSplatNode) })
85
+ end
86
+ end
87
+
88
+ def table_keyword_issues(node)
89
+ allowed = TABLE_COMPOUND_KEYWORDS[node.name]
90
+ return [] unless allowed
91
+
92
+ (keyword_names(node) - allowed).map do |keyword|
93
+ guidance = if keyword == :class
94
+ "Table##{node.name} does not accept class: directly; move it to desperately_need_a_class:"
95
+ elsif keyword == :style
96
+ "Table##{node.name} does not allow style overrides; preserve this as application-owned HTML"
97
+ else
98
+ "Table##{node.name} does not accept #{keyword}: directly; move it to html: { #{keyword}: ... }"
99
+ end
100
+ RuntimeContractVisitor::Issue.new(line: node.location.start_line, guidance:)
101
+ end
102
+ end
103
+
104
+ def receiver_named?(receiver, name)
105
+ receiver.is_a?(Prism::LocalVariableReadNode) && receiver.name == name ||
106
+ receiver.is_a?(Prism::CallNode) && receiver.receiver.nil? && receiver.name == name && receiver.arguments.nil?
107
+ end
108
+ end
109
+
110
+ class RuntimeContractVisitor < ContractVisitor
111
+ Issue = Data.define(:line, :guidance)
112
+
113
+ attr_reader :issues
114
+
115
+ def initialize
116
+ @issues = []
117
+ end
118
+
119
+ def visit_call_node(node)
120
+ inspect_button(node)
121
+ inspect_table(node)
122
+ inspect_table_local(node)
123
+ inspect_icon_triggers(node)
124
+ super
125
+ end
126
+
127
+ private
128
+ def inspect_button(node)
129
+ return unless node.name == :new && node.receiver&.respond_to?(:full_name)
130
+ return unless node.receiver.full_name == "NitroKit::Button"
131
+ return unless keyword_names(node).intersect?(%i[icon icon_end])
132
+ return if positional_arguments(node).any? { !_1.is_a?(Prism::NilNode) }
133
+ return if node.block || dynamic_arguments?(node)
134
+ return if keyword_names(node).intersect?(%i[label aria])
135
+
136
+ issues << Issue.new(
137
+ line: node.location.start_line,
138
+ guidance: "icon-only NitroKit::Button requires label:, aria: { label: ... }, or aria: { labelledby: ... }"
139
+ )
140
+ end
141
+
142
+ def inspect_table(node)
143
+ constructor = positional_arguments(node).first
144
+ return unless node.name == :render && table_constructor?(constructor)
145
+
146
+ receiver_name = required_block_parameter_name(node)
147
+ return unless receiver_name
148
+ return if receiver_name == :table
149
+
150
+ visitor = TableCompoundVisitor.new(receiver_name)
151
+ visitor.visit(node.block.body) if node.block.body
152
+ issues.concat(visitor.issues)
153
+ end
154
+
155
+ def inspect_table_local(node)
156
+ return unless receiver_named?(node.receiver, :table)
157
+
158
+ issues.concat(table_keyword_issues(node))
159
+ end
160
+
161
+ def inspect_icon_triggers(node)
162
+ constructor = positional_arguments(node).first
163
+ return unless node.name == :render && node.block && constructor.is_a?(Prism::CallNode)
164
+ return unless constructor.name == :new && constructor.receiver&.respond_to?(:full_name)
165
+
166
+ component = constructor.receiver.full_name.delete_prefix("NitroKit::")
167
+ return unless %w[Dropdown Sheet].include?(component)
168
+
169
+ receiver_name = required_block_parameter_name(node)
170
+ return unless receiver_name
171
+
172
+ visitor = IconTriggerVisitor.new(receiver_name, component)
173
+ visitor.visit(node.block.body) if node.block.body
174
+ issues.concat(visitor.issues)
175
+ end
176
+
177
+ def required_block_parameter_name(node)
178
+ return unless node.block.is_a?(Prism::BlockNode)
179
+
180
+ parameter = node.block.parameters&.parameters&.requireds&.first
181
+ parameter.name if parameter.is_a?(Prism::RequiredParameterNode)
182
+ end
183
+
184
+ def table_constructor?(node)
185
+ node.is_a?(Prism::CallNode) && node.name == :new &&
186
+ node.receiver&.respond_to?(:full_name) && node.receiver.full_name == "NitroKit::Table"
187
+ end
188
+ end
189
+
190
+ class IconTriggerVisitor < ContractVisitor
191
+ attr_reader :issues
192
+
193
+ def initialize(receiver_name, component)
194
+ @receiver_name = receiver_name
195
+ @component = component
196
+ @issues = []
197
+ end
198
+
199
+ def visit_call_node(node)
200
+ if unnamed_icon_trigger?(node)
201
+ issues << RuntimeContractVisitor::Issue.new(
202
+ line: node.location.start_line,
203
+ guidance: "icon-only #{@component}#trigger requires label:, aria: { label: ... }, or aria: { labelledby: ... }"
204
+ )
205
+ end
206
+ super
207
+ end
208
+
209
+ private
210
+ def unnamed_icon_trigger?(node)
211
+ return false unless node.name == :trigger
212
+ return false unless node.receiver.is_a?(Prism::LocalVariableReadNode) && node.receiver.name == @receiver_name
213
+ return false unless keyword_names(node).intersect?(%i[icon icon_end])
214
+ return false if positional_arguments(node).any? { !_1.is_a?(Prism::NilNode) }
215
+ return false if node.block || dynamic_arguments?(node)
216
+ return false if keyword_names(node).intersect?(%i[label aria])
217
+
218
+ true
219
+ end
220
+ end
221
+
222
+ class TableCompoundVisitor < ContractVisitor
223
+ attr_reader :issues
224
+
225
+ def initialize(receiver_name)
226
+ @receiver_name = receiver_name
227
+ @issues = []
228
+ end
229
+
230
+ def visit_call_node(node)
231
+ if receiver_named?(node.receiver, @receiver_name)
232
+ issues.concat(table_keyword_issues(node))
233
+ end
234
+ super
235
+ end
236
+ end
237
+ private_constant :ContractVisitor, :RuntimeContractVisitor, :IconTriggerVisitor, :TableCompoundVisitor
56
238
 
57
239
  attr_reader :application_root
58
240
 
@@ -66,6 +248,8 @@ module NitroKit
66
248
  "Copied or shadow components" => copied_components,
67
249
  "Custom or legacy controllers" => controllers,
68
250
  "Vendored dependencies" => vendored_dependencies,
251
+ "Application-owned button treatments" => application_button_treatments,
252
+ "2.0 runtime contract errors" => runtime_contract_errors,
69
253
  "Known replacements" => replacement_summary,
70
254
  "Unresolved or application-owned items" => disposition_summary
71
255
  }
@@ -129,19 +313,98 @@ module NitroKit
129
313
  VENDORED_MODULES.each do |name, guidance|
130
314
  findings << Finding.new(status: :unresolved, path: location(path, line_number), guidance:) if line.include?(name)
131
315
  end
132
- if line.match?(/\btailwind_merge\b/)
133
- findings << Finding.new(
134
- status: :unresolved,
135
- path: location(path, line_number),
136
- guidance: "remove tailwind_merge; Nitro Kit 2 ships static CSS and does not use it"
137
- )
138
- end
139
316
  end
140
317
  end
141
318
 
142
319
  findings.uniq { [ _1.path, _1.guidance ] }
143
320
  end
144
321
 
322
+ def application_button_treatments
323
+ return [] unless application_stylesheets.any? { _1.read.match?(BUTTON_TREATMENT_DEFINITION) }
324
+
325
+ source_files.filter_map do |path|
326
+ lines = path.each_line.with_index(1).filter_map do |line, line_number|
327
+ next if line.lstrip.start_with?("#", "<%#")
328
+
329
+ line_number if line.match?(BUTTON_CLASS)
330
+ end
331
+ next if lines.empty?
332
+
333
+ Finding.new(
334
+ status: :application_owned,
335
+ path: "#{relative(path)}:#{lines.join(',')}",
336
+ guidance: "review the application-owned button treatment; migrate ordinary actions to NitroKit::Button and preserve specialized controls as semantic application HTML"
337
+ )
338
+ end
339
+ end
340
+
341
+ def runtime_contract_errors
342
+ source_files.flat_map do |path|
343
+ source = runtime_ruby_source(path)
344
+ next [] unless source
345
+
346
+ result = Prism.parse(source)
347
+ issues = if result.success?
348
+ runtime_contract_issues(result.value)
349
+ elsif path.extname == ".erb"
350
+ erb_runtime_contract_issues(path.read)
351
+ else
352
+ []
353
+ end
354
+ issues.map do |issue|
355
+ Finding.new(
356
+ status: :unresolved,
357
+ path: location(path, issue.line),
358
+ guidance: issue.guidance
359
+ )
360
+ end
361
+ end
362
+ end
363
+
364
+ def runtime_contract_issues(node, line_offset: 0)
365
+ visitor = RuntimeContractVisitor.new
366
+ visitor.visit(node)
367
+ visitor.issues.map { _1.with(line: _1.line + line_offset) }
368
+ end
369
+
370
+ def erb_runtime_contract_issues(source)
371
+ source.to_enum(:scan, ERB_TAG_PATTERN).flat_map do
372
+ match = Regexp.last_match
373
+ fragment = match[2].sub(/\bdo(?:\s*\|[^|]*\|)?\s*\z/, "")
374
+ result = Prism.parse(fragment)
375
+ next [] unless result.success?
376
+
377
+ line_offset = source[0...match.begin(2)].count("\n")
378
+ runtime_contract_issues(result.value, line_offset:)
379
+ end
380
+ end
381
+
382
+ def runtime_ruby_source(path)
383
+ case path.extname
384
+ when ".rb" then path.read
385
+ when ".erb" then erb_ruby_source(path.read)
386
+ end
387
+ end
388
+
389
+ def erb_ruby_source(source)
390
+ ruby = source.gsub(/[^\n]/, " ")
391
+ source.to_enum(:scan, ERB_TAG_PATTERN).each do
392
+ match = Regexp.last_match
393
+ next if match[1]&.include?("=") && !standalone_erb_tag?(source, match)
394
+
395
+ ruby[match.begin(2)...match.end(2)] = match[2]
396
+ end
397
+ ruby
398
+ end
399
+
400
+ def standalone_erb_tag?(source, match)
401
+ line_break = source.rindex("\n", match.begin(0) - 1)
402
+ line_start = line_break ? line_break + 1 : 0
403
+ line_end = source.index("\n", match.end(0)) || source.length
404
+
405
+ source[line_start...match.begin(0)].strip.empty? && source[match.end(0)...line_end].strip.empty?
406
+ end
407
+
145
408
  def replacement_summary
146
409
  unresolved_findings.group_by(&:guidance).map do |guidance, findings|
147
410
  Finding.new(
@@ -173,7 +436,7 @@ module NitroKit
173
436
  end
174
437
 
175
438
  def all_findings
176
- @all_findings ||= (legacy_helpers + copied_components + controllers + vendored_dependencies)
439
+ @all_findings ||= (legacy_helpers + copied_components + controllers + vendored_dependencies + application_button_treatments + runtime_contract_errors)
177
440
  .uniq { [ _1.status, _1.path, _1.guidance ] }
178
441
  end
179
442
 
@@ -181,6 +444,10 @@ module NitroKit
181
444
  application_root.glob("app/**/*.{rb,erb,haml,slim}").select(&:file?)
182
445
  end
183
446
 
447
+ def application_stylesheets
448
+ application_root.glob("app/**/*.{css,scss,sass}").select(&:file?)
449
+ end
450
+
184
451
  def dependency_files
185
452
  files = %w[Gemfile config/importmap.rb package.json].filter_map do |name|
186
453
  path = application_root.join(name)
@@ -171,34 +171,32 @@ module NitroKit
171
171
 
172
172
  private
173
173
  def install_routes!
174
- collisions = %w[GET PATCH].select { |method| route_matches?(method) }
174
+ collisions = %w[GET PATCH].select { |method| exact_route_matches?(method) }
175
175
  if collisions.any?
176
176
  raise "Nitro Kit upgrade smoke route #{PATH} collides with host #{collisions.join("/")} routing"
177
177
  end
178
178
 
179
- Rails.application.routes.append(&ROUTE_BLOCK)
179
+ Rails.application.routes.prepend(&ROUTE_BLOCK)
180
180
  Rails.application.reload_routes!
181
181
  end
182
182
 
183
183
  def uninstall_routes!
184
- append_blocks.delete(ROUTE_BLOCK)
184
+ prepend_blocks.delete(ROUTE_BLOCK)
185
185
  Rails.application.reload_routes!
186
186
  end
187
187
 
188
- def route_matches?(method)
189
- request = ActionDispatch::Request.new(
190
- Rack::MockRequest.env_for(PATH, method:)
191
- )
192
- Rails.application.routes.router.recognize(request) { return true }
193
- false
188
+ def exact_route_matches?(method)
189
+ Rails.application.routes.routes.any? do |route|
190
+ route.verb.to_s.match?(method) && route.path.spec.to_s.split("(", 2).first == PATH
191
+ end
194
192
  end
195
193
 
196
194
  def route_block_installed?
197
- append_blocks.include?(ROUTE_BLOCK)
195
+ prepend_blocks.include?(ROUTE_BLOCK)
198
196
  end
199
197
 
200
- def append_blocks
201
- Rails.application.routes.instance_variable_get(:@append)
198
+ def prepend_blocks
199
+ Rails.application.routes.instance_variable_get(:@prepend)
202
200
  end
203
201
 
204
202
  def ensure_test_environment!
@@ -1,3 +1,3 @@
1
1
  module NitroKit
2
- VERSION = "2.0.0.alpha.2"
2
+ VERSION = "2.0.0.alpha.4"
3
3
  end
@@ -1,14 +1,21 @@
1
+ require "json"
1
2
  require "rails/command"
2
3
 
3
4
  module Rails
4
5
  module Command
5
6
  class NitroKitCommand < Base
6
7
  desc "doctor", "Verify the Nitro Kit 2 application and agent integration"
8
+ option :format, type: :string, default: "text", enum: %w[text json],
9
+ desc: "Output human-readable text or structured JSON"
7
10
  def doctor
8
11
  checks = installation.checks
9
- checks.each do |check|
10
- color = { pass: :green, warn: :yellow, fail: :red }.fetch(check.status)
11
- say_status(check.status.to_s.upcase, "#{check.label}: #{check.detail}", color)
12
+ if options[:format] == "json"
13
+ say JSON.pretty_generate(checks.map { { status: _1.status, label: _1.label, detail: _1.detail } })
14
+ else
15
+ checks.each do |check|
16
+ color = { pass: :green, warn: :yellow, fail: :red }.fetch(check.status)
17
+ say_status(check.status.to_s.upcase, "#{check.label}: #{check.detail}", color)
18
+ end
12
19
  end
13
20
 
14
21
  exit 1 if checks.any? { _1.status == :fail }
@@ -22,6 +22,8 @@ Make the server response and stable DOM boundary the interaction API. Add Stimul
22
22
  - application-wide confirmation and settings forms:
23
23
  `application_foundation.md`
24
24
  6. Inspect the installed Nitro component contract used by the recipe. Never assume a recipe from another version still matches.
25
+ 7. Read `NITRO_KIT_ROOT/docs/browser_support.md` for the canonical
26
+ full/reduced/unavailable no-JavaScript classification.
25
27
 
26
28
  Do not proceed with a remembered Nitro Kit 1.x API. Do not copy or recreate
27
29
  the installed gem's `nk--*` controllers under `app/javascript/controllers/nk`.
@@ -41,6 +43,8 @@ Keep frames around complete resource or collection regions, not individual butto
41
43
  - Redirect successful non-GET form submissions with `303 See Other`.
42
44
  - Render invalid HTML and Turbo submissions with `422 Unprocessable Entity` and the same invalid model instance.
43
45
  - Preserve an HTML response branch as the progressive fallback.
46
+ - Do not describe that HTML branch as a JavaScript-free interaction when its
47
+ control still depends on Turbo or a closed overlay.
44
48
  - Let GET query parameters be the source of truth for filtering, sorting, and pagination.
45
49
  - Use native Nitro Dialog behavior for reviewed destructive actions. Use `data: { turbo_confirm: ... }` for compact confirmations that do not need a dialog.
46
50
  - Render flash through `NitroKit::Toast::FlashMessages`; the application owns setting the flash.
@@ -21,12 +21,21 @@ established application outside the requested scope.
21
21
  7. For authentication, teams, application navigation, or settings, read
22
22
  `NITRO_KIT_ROOT/docs/patterns/application_foundation.md` completely.
23
23
  8. Read the matching Hotwire recipe before implementing an interaction.
24
+ 9. Read `NITRO_KIT_ROOT/docs/browser_support.md` before claiming an
25
+ interaction works without JavaScript.
24
26
 
25
27
  Never use a Nitro Kit 1.x helper, copied component, controller, or Tailwind
26
28
  contract as a substitute for the installed API.
27
29
 
28
30
  ## Follow the application grammar
29
31
 
32
+ - In a greenfield application, run `bin/rails generate phlex:install` and use
33
+ Phlex for the application layout, route-level views, and reusable UI. Render
34
+ `Views::*` objects from controllers instead of creating ERB wrappers around
35
+ Phlex. In an established application, preserve its view architecture and
36
+ introduce Phlex only at the requested boundary unless an application-wide
37
+ migration is explicitly authorized. Judge this from existing view code, not
38
+ the Rails version or apparent age.
30
39
  - Scope tenant data through `Current.team` or `Current.account`; record
31
40
  `Current.user` as the actor.
32
41
  - Put domain behavior on the model before introducing another abstraction.
@@ -42,10 +51,13 @@ contract as a substitute for the installed API.
42
51
  Nitro Kit 1.x source as the fallback.
43
52
  - Redirect successful mutations with `303`; render invalid models with `422`.
44
53
  - Render HTML on the server and add Hotwire progressively.
54
+ - Set the document language on the root `html` element.
45
55
  - Test with Minitest and fixtures, including tenancy and unhappy paths.
46
56
  - In authenticated admin areas, default to a hybrid `AppShell` with the route's
47
57
  one `h1` and basic actions in its `Toolbar`. Keep one page gutter and avoid
48
- repeated headings or automatic Card wrappers.
58
+ repeated headings or automatic Card wrappers. At narrow widths, let trailing
59
+ actions stack below a Back affordance and title instead of clipping the title
60
+ or hiding persistent actions.
49
61
  - In a new team-aware application, create the first user's `Team` and owner
50
62
  `Membership` together. Put roles on memberships and scope product records
51
63
  through `Current.team`.
@@ -14,31 +14,45 @@ Use the documentation shipped with the application's installed gem as the source
14
14
  3. Run `bundle show nitro_kit` and treat its output as `NITRO_KIT_ROOT`.
15
15
  4. Read `NITRO_KIT_ROOT/docs/agent_guide.md` completely.
16
16
  5. Read the relevant sections of `NITRO_KIT_ROOT/docs/component_contracts.md`. Read `customization.md` only for themes, tokens, or application composition.
17
- 6. Inspect the installed component source when constructor or compound-slot details remain unclear. Never guess a component API from memory.
17
+ 6. For any interactive component or no-JavaScript claim, read the canonical
18
+ classifications in `NITRO_KIT_ROOT/docs/browser_support.md`.
19
+ 7. Inspect the installed component source when constructor or compound-slot details remain unclear. Never guess a component API from memory.
18
20
 
19
21
  For a Nitro Kit 1.x migration, read
20
22
  `NITRO_KIT_ROOT/docs/migration_1_to_2.md` before editing. Inventory product
21
- flows and behavior first. If the Nitro Kit MCP catalog is available, search it
22
- by workflow rather than old component name, then select high-level
23
- compositions before replacing atoms.
23
+ flows, behavior, application-owned button classes and Rails button helpers,
24
+ joined controls, and the existing semantic color, focus, radius, density, and
25
+ typography tokens first. Capture representative wide and narrow screenshots.
26
+ If the Nitro Kit MCP catalog is available, search it by workflow rather than
27
+ old component name, then select high-level compositions before replacing
28
+ atoms.
24
29
 
25
30
  If the gem is not installed, say that the skill requires Nitro Kit and follow the application's requested installation scope. Do not substitute APIs from an older Nitro Kit release.
26
31
 
27
32
  ## Build the interface
28
33
 
29
- 1. Reuse the highest-level Nitro block that matches the page region, then compose components inside it.
30
- 2. Include `NitroKit` once in the application's base Phlex component and use capitalized Kit methods such as `Button(...)` and `Card(...)`. Use `.new` only when another API needs a component object. Keep product-specific components under the application's namespace, commonly `UI::*`.
31
- 3. Use `NitroKit::FormBuilder` explicitly with Rails `form_with` for model-backed forms.
32
- 4. Keep routes, authorization, records, query policy, DOM IDs, Turbo boundaries, and response semantics in the application.
33
- 5. Use documented `--nk-*` properties for theming and Nitro layout primitives for layout.
34
- 6. Verify closed options and required compound declarations before rendering.
35
- 7. For authenticated CRUD, prefer a hybrid `AppShell` with a `Toolbar` that
34
+ 1. In a greenfield application, run `bin/rails generate phlex:install` and use
35
+ Phlex for the application layout, route-level `Views::*`, and reusable UI.
36
+ Do not add ERB wrappers whose only purpose is to render Phlex. In an
37
+ established application with meaningful view conventions, preserve them
38
+ and introduce Phlex and Nitro Kit only at the requested boundary unless an
39
+ application-wide migration is explicitly authorized. Determine this from
40
+ the existing view architecture, not the Rails version or apparent age.
41
+ 2. Reuse the highest-level Nitro block that matches the page region, then compose components inside it.
42
+ 3. Include `NitroKit` once in the application's base Phlex component and use capitalized Kit methods such as `Button(...)` and `Card(...)`. Use `.new` only when another API needs a component object. Keep product-specific components under the application's namespace, commonly `UI::*`.
43
+ 4. Use `NitroKit::FormBuilder` explicitly with Rails `form_with` for model-backed forms.
44
+ 5. Keep routes, authorization, records, query policy, DOM IDs, Turbo boundaries, and response semantics in the application.
45
+ 6. Translate the application's semantic theme into documented `--nk-*` properties instead of choosing similar raw palette values. Use `--nk-button-radius` when Button shape intentionally differs from inputs and surfaces.
46
+ 7. Verify closed options and required compound declarations before rendering.
47
+ 8. For authenticated CRUD, prefer a hybrid `AppShell` with a `Toolbar` that
36
48
  owns the route's single `h1` and basic actions. The shell main region owns
37
49
  one content gutter. Do not repeat that heading in `PageHeader`, or wrap each
38
- table, form, and detail region in another Card.
39
- 8. For team administration and account settings, read
50
+ table, form, and detail region in another Card. At narrow widths, preserve
51
+ the full title and persistent actions by stacking the trailing actions below
52
+ the title rather than clipping either region.
53
+ 9. For team administration and account settings, read
40
54
  `docs/patterns/application_foundation.md`. Put Settings after an
41
- `AppNavigation` spacer and use `SettingsLayout` with plain `FormSection`
55
+ `AppNavigation` spacer and use `SettingsLayout` with plain `SettingsSection`
42
56
  regions instead of a stack of Cards.
43
57
 
44
58
  ## Preserve the boundary
@@ -53,10 +67,13 @@ If the gem is not installed, say that the skill requires Nitro Kit and follow th
53
67
  - Never downgrade a specialized control to a generic Nitro control for visual
54
68
  consistency, and never retain copied Nitro Kit 1.x source as the fallback.
55
69
  - Do not introduce `nk_*` helpers, a general ERB bridge, or generated variant helpers.
56
- - Do not pass `class:` or `style:`. Prefer component options, composition, or theme tokens. Use `desperately_need_a_class:` only for a genuine external integration boundary.
70
+ - Pass native attributes through each component method's documented `html:`, `aria:`, or `data:` boundary; for example, use `table.tr(html: { id: dom_id(record) })`, not `table.tr(id: ...)`. Do not pass `class:` or `style:`. Prefer component options, composition, wrappers, or theme tokens. Use `desperately_need_a_class:` only for a named external integration boundary that requires a class hook; it accepts Rails-style strings, symbols, nested arrays, and conditional hashes without manual joining.
71
+ - Give every icon-only Button, Dropdown trigger, and Sheet trigger an explicit `label:` or ARIA label. For custom `form.field` blocks, render an explicit field label instead of relying on an unused implicit model translation.
57
72
  - Do not add application-specific behavior to Nitro-owned Stimulus controllers.
58
73
  - Do not recreate a Nitro component with raw HTML unless the installed catalog cannot express the semantics.
59
74
 
60
75
  ## Verify
61
76
 
62
77
  Run the smallest relevant application tests. For component rendering, assert semantic elements and owned `data-nk` or slot attributes rather than private implementation helpers. Exercise invalid and empty states when the UI accepts user input or collections.
78
+
79
+ For a migration, Doctor is an inventory, not visual proof. Run representative form and component rendering with `ActiveModel::Translation.raise_on_missing_translations` enabled when the application uses strict i18n. Compare the same representative flows in a browser at wide and narrow widths, exercise keyboard focus, and inspect computed styles for missing application classes, stacked Button content, broken compound corners, double focus rings, clipping, and theme drift. Re-audit rendered native buttons, Rails button helpers, and application-owned button classes before declaring the conversion complete. Search the whole application for `desperately_need_a_class:` and review every result, aiming for zero. Move layout and visual treatment to application-owned wrappers, remove generic class forwarding, accept incidental Nitro defaults, and keep unmatched product UI application-owned; retain only documented external-integration hooks.