nitro_kit 2.0.0.alpha.2 → 2.0.0.alpha.3

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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/README.md +23 -4
  4. data/STYLE_GUIDE.md +37 -5
  5. data/app/assets/stylesheets/nitro_kit.css +1714 -1358
  6. data/app/components/nitro_kit/accordion.rb +1 -7
  7. data/app/components/nitro_kit/app_navigation.rb +1 -2
  8. data/app/components/nitro_kit/app_shell.rb +1 -7
  9. data/app/components/nitro_kit/appearance_picker.rb +1 -11
  10. data/app/components/nitro_kit/avatar.rb +6 -1
  11. data/app/components/nitro_kit/avatar_stack.rb +3 -0
  12. data/app/components/nitro_kit/button_group.rb +2 -2
  13. data/app/components/nitro_kit/checkbox.rb +7 -4
  14. data/app/components/nitro_kit/checkbox_group.rb +0 -25
  15. data/app/components/nitro_kit/combobox.rb +3 -16
  16. data/app/components/nitro_kit/command_palette.rb +41 -59
  17. data/app/components/nitro_kit/component.rb +57 -5
  18. data/app/components/nitro_kit/danger_zone.rb +5 -1
  19. data/app/components/nitro_kit/data_section.rb +28 -4
  20. data/app/components/nitro_kit/details_table.rb +19 -8
  21. data/app/components/nitro_kit/dialog.rb +23 -19
  22. data/app/components/nitro_kit/dropdown.rb +1 -6
  23. data/app/components/nitro_kit/dropzone.rb +3 -14
  24. data/app/components/nitro_kit/empty_state.rb +21 -2
  25. data/app/components/nitro_kit/field.rb +35 -11
  26. data/app/components/nitro_kit/form_builder.rb +0 -1
  27. data/app/components/nitro_kit/progressive_image.rb +2 -2
  28. data/app/components/nitro_kit/radio_button.rb +7 -4
  29. data/app/components/nitro_kit/radio_button_group.rb +0 -25
  30. data/app/components/nitro_kit/select.rb +3 -3
  31. data/app/components/nitro_kit/settings_layout.rb +17 -4
  32. data/app/components/nitro_kit/{form_section.rb → settings_section.rb} +18 -11
  33. data/app/components/nitro_kit/sheet.rb +29 -20
  34. data/app/components/nitro_kit/stat_grid.rb +18 -2
  35. data/app/components/nitro_kit/table.rb +22 -3
  36. data/app/components/nitro_kit/tabs.rb +1 -13
  37. data/app/components/nitro_kit/toast.rb +4 -2
  38. data/app/components/nitro_kit/toolbar.rb +2 -0
  39. data/app/components/nitro_kit/tooltip.rb +7 -10
  40. data/app/javascript/controllers/nk/app_shell_controller.js +3 -3
  41. data/app/javascript/controllers/nk/appearance_controller.js +6 -0
  42. data/app/javascript/controllers/nk/combobox_controller.js +51 -1
  43. data/app/javascript/controllers/nk/command_palette_controller.js +21 -11
  44. data/app/javascript/controllers/nk/dialog_controller.js +54 -0
  45. data/app/javascript/controllers/nk/dropdown_controller.js +45 -0
  46. data/app/javascript/controllers/nk/dropzone_controller.js +2 -2
  47. data/app/javascript/controllers/nk/progressive_image_controller.js +11 -7
  48. data/app/javascript/controllers/nk/tabs_controller.js +8 -1
  49. data/app/javascript/controllers/nk/tooltip_controller.js +9 -0
  50. data/config/locales/en.yml +4 -1
  51. data/docs/agent_guide.md +31 -16
  52. data/docs/agent_native_spec.md +27 -10
  53. data/docs/browser_support.md +193 -0
  54. data/docs/component_contracts.md +88 -82
  55. data/docs/customization.md +27 -15
  56. data/docs/hotwire.md +15 -3
  57. data/docs/initialization_prompt.md +31 -6
  58. data/docs/migration_1_to_2.md +61 -19
  59. data/docs/patterns/application_foundation.md +6 -4
  60. data/docs/patterns/crud_resource.md +6 -3
  61. data/docs/patterns/destructive_action.md +25 -0
  62. data/docs/patterns/resource_form.md +1 -1
  63. data/docs/rails_conventions.md +4 -1
  64. data/docs/rails_integration.md +39 -11
  65. data/lib/generators/nitro_kit/upgrade_smoke_tests_generator.rb +12 -0
  66. data/lib/nitro_kit/installation.rb +9 -4
  67. data/lib/nitro_kit/migration_inventory.rb +268 -8
  68. data/lib/nitro_kit/upgrade_smoke_test.rb +10 -12
  69. data/lib/nitro_kit/version.rb +1 -1
  70. data/lib/rails/commands/nitro_kit/nitro_kit_command.rb +10 -3
  71. data/plugins/nitro-kit/skills/nitro-kit-hotwire/SKILL.md +4 -0
  72. data/plugins/nitro-kit/skills/nitro-kit-rails/SKILL.md +6 -1
  73. data/plugins/nitro-kit/skills/nitro-kit-ui/SKILL.md +18 -8
  74. data/src/stylesheets/nitro_kit/components/accordion.css +40 -40
  75. data/src/stylesheets/nitro_kit/components/alert.css +9 -9
  76. data/src/stylesheets/nitro_kit/components/app_navigation.css +19 -19
  77. data/src/stylesheets/nitro_kit/components/app_shell.css +196 -193
  78. data/src/stylesheets/nitro_kit/components/appearance_picker.css +41 -31
  79. data/src/stylesheets/nitro_kit/components/avatar.css +3 -3
  80. data/src/stylesheets/nitro_kit/components/avatar_stack.css +36 -19
  81. data/src/stylesheets/nitro_kit/components/button.css +83 -58
  82. data/src/stylesheets/nitro_kit/components/button_group.css +19 -14
  83. data/src/stylesheets/nitro_kit/components/card.css +11 -5
  84. data/src/stylesheets/nitro_kit/components/checkbox.css +40 -36
  85. data/src/stylesheets/nitro_kit/components/checkbox_group.css +13 -13
  86. data/src/stylesheets/nitro_kit/components/combobox.css +33 -23
  87. data/src/stylesheets/nitro_kit/components/command_palette.css +48 -47
  88. data/src/stylesheets/nitro_kit/components/control_group.css +52 -58
  89. data/src/stylesheets/nitro_kit/components/danger_zone.css +3 -3
  90. data/src/stylesheets/nitro_kit/components/data_section.css +1 -1
  91. data/src/stylesheets/nitro_kit/components/details_table.css +13 -13
  92. data/src/stylesheets/nitro_kit/components/dialog.css +6 -3
  93. data/src/stylesheets/nitro_kit/components/dropdown.css +29 -23
  94. data/src/stylesheets/nitro_kit/components/dropzone.css +71 -28
  95. data/src/stylesheets/nitro_kit/components/empty_state.css +6 -6
  96. data/src/stylesheets/nitro_kit/components/field.css +30 -26
  97. data/src/stylesheets/nitro_kit/components/field_group.css +13 -0
  98. data/src/stylesheets/nitro_kit/components/fieldset.css +6 -0
  99. data/src/stylesheets/nitro_kit/components/input.css +9 -2
  100. data/src/stylesheets/nitro_kit/components/layout.css +165 -165
  101. data/src/stylesheets/nitro_kit/components/page_header.css +3 -3
  102. data/src/stylesheets/nitro_kit/components/pagination.css +55 -46
  103. data/src/stylesheets/nitro_kit/components/pagination_bar.css +7 -7
  104. data/src/stylesheets/nitro_kit/components/palette.css +20 -20
  105. data/src/stylesheets/nitro_kit/components/progressive_image.css +44 -54
  106. data/src/stylesheets/nitro_kit/components/radio_button.css +36 -29
  107. data/src/stylesheets/nitro_kit/components/radio_button_group.css +43 -43
  108. data/src/stylesheets/nitro_kit/components/rich_text_area.css +15 -0
  109. data/src/stylesheets/nitro_kit/components/select.css +2 -9
  110. data/src/stylesheets/nitro_kit/components/settings_layout.css +21 -18
  111. data/src/stylesheets/nitro_kit/components/settings_section.css +84 -0
  112. data/src/stylesheets/nitro_kit/components/sheet.css +23 -3
  113. data/src/stylesheets/nitro_kit/components/stat_grid.css +10 -9
  114. data/src/stylesheets/nitro_kit/components/switch.css +37 -33
  115. data/src/stylesheets/nitro_kit/components/table.css +35 -31
  116. data/src/stylesheets/nitro_kit/components/tabs.css +43 -39
  117. data/src/stylesheets/nitro_kit/components/toast.css +20 -9
  118. data/src/stylesheets/nitro_kit/components/toolbar.css +14 -39
  119. data/src/stylesheets/nitro_kit/components/tooltip.css +34 -29
  120. data/src/stylesheets/nitro_kit/components/typeset.css +147 -1
  121. data/src/stylesheets/nitro_kit/reset.css +9 -9
  122. data/src/stylesheets/nitro_kit/tokens.css +8 -9
  123. metadata +4 -3
  124. data/src/stylesheets/nitro_kit/components/form_section.css +0 -71
@@ -1,4 +1,5 @@
1
1
  require "pathname"
2
+ require "prism"
2
3
  require "ripper"
3
4
 
4
5
  module NitroKit
@@ -53,6 +54,180 @@ 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) && node.block
145
+
146
+ parameter = node.block.parameters&.parameters&.requireds&.first
147
+ return unless parameter.is_a?(Prism::RequiredParameterNode)
148
+ return if parameter.name == :table
149
+
150
+ visitor = TableCompoundVisitor.new(parameter.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
+ parameter = node.block.parameters&.parameters&.requireds&.first
170
+ return unless parameter.is_a?(Prism::RequiredParameterNode)
171
+
172
+ visitor = IconTriggerVisitor.new(parameter.name, component)
173
+ visitor.visit(node.block.body) if node.block.body
174
+ issues.concat(visitor.issues)
175
+ end
176
+
177
+ def table_constructor?(node)
178
+ node.is_a?(Prism::CallNode) && node.name == :new &&
179
+ node.receiver&.respond_to?(:full_name) && node.receiver.full_name == "NitroKit::Table"
180
+ end
181
+ end
182
+
183
+ class IconTriggerVisitor < ContractVisitor
184
+ attr_reader :issues
185
+
186
+ def initialize(receiver_name, component)
187
+ @receiver_name = receiver_name
188
+ @component = component
189
+ @issues = []
190
+ end
191
+
192
+ def visit_call_node(node)
193
+ if unnamed_icon_trigger?(node)
194
+ issues << RuntimeContractVisitor::Issue.new(
195
+ line: node.location.start_line,
196
+ guidance: "icon-only #{@component}#trigger requires label:, aria: { label: ... }, or aria: { labelledby: ... }"
197
+ )
198
+ end
199
+ super
200
+ end
201
+
202
+ private
203
+ def unnamed_icon_trigger?(node)
204
+ return false unless node.name == :trigger
205
+ return false unless node.receiver.is_a?(Prism::LocalVariableReadNode) && node.receiver.name == @receiver_name
206
+ return false unless keyword_names(node).intersect?(%i[icon icon_end])
207
+ return false if positional_arguments(node).any? { !_1.is_a?(Prism::NilNode) }
208
+ return false if node.block || dynamic_arguments?(node)
209
+ return false if keyword_names(node).intersect?(%i[label aria])
210
+
211
+ true
212
+ end
213
+ end
214
+
215
+ class TableCompoundVisitor < ContractVisitor
216
+ attr_reader :issues
217
+
218
+ def initialize(receiver_name)
219
+ @receiver_name = receiver_name
220
+ @issues = []
221
+ end
222
+
223
+ def visit_call_node(node)
224
+ if receiver_named?(node.receiver, @receiver_name)
225
+ issues.concat(table_keyword_issues(node))
226
+ end
227
+ super
228
+ end
229
+ end
230
+ private_constant :ContractVisitor, :RuntimeContractVisitor, :IconTriggerVisitor, :TableCompoundVisitor
56
231
 
57
232
  attr_reader :application_root
58
233
 
@@ -66,6 +241,8 @@ module NitroKit
66
241
  "Copied or shadow components" => copied_components,
67
242
  "Custom or legacy controllers" => controllers,
68
243
  "Vendored dependencies" => vendored_dependencies,
244
+ "Application-owned button treatments" => application_button_treatments,
245
+ "2.0 runtime contract errors" => runtime_contract_errors,
69
246
  "Known replacements" => replacement_summary,
70
247
  "Unresolved or application-owned items" => disposition_summary
71
248
  }
@@ -129,19 +306,98 @@ module NitroKit
129
306
  VENDORED_MODULES.each do |name, guidance|
130
307
  findings << Finding.new(status: :unresolved, path: location(path, line_number), guidance:) if line.include?(name)
131
308
  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
309
  end
140
310
  end
141
311
 
142
312
  findings.uniq { [ _1.path, _1.guidance ] }
143
313
  end
144
314
 
315
+ def application_button_treatments
316
+ return [] unless application_stylesheets.any? { _1.read.match?(BUTTON_TREATMENT_DEFINITION) }
317
+
318
+ source_files.filter_map do |path|
319
+ lines = path.each_line.with_index(1).filter_map do |line, line_number|
320
+ next if line.lstrip.start_with?("#", "<%#")
321
+
322
+ line_number if line.match?(BUTTON_CLASS)
323
+ end
324
+ next if lines.empty?
325
+
326
+ Finding.new(
327
+ status: :application_owned,
328
+ path: "#{relative(path)}:#{lines.join(',')}",
329
+ guidance: "review the application-owned button treatment; migrate ordinary actions to NitroKit::Button and preserve specialized controls as semantic application HTML"
330
+ )
331
+ end
332
+ end
333
+
334
+ def runtime_contract_errors
335
+ source_files.flat_map do |path|
336
+ source = runtime_ruby_source(path)
337
+ next [] unless source
338
+
339
+ result = Prism.parse(source)
340
+ issues = if result.success?
341
+ runtime_contract_issues(result.value)
342
+ elsif path.extname == ".erb"
343
+ erb_runtime_contract_issues(path.read)
344
+ else
345
+ []
346
+ end
347
+ issues.map do |issue|
348
+ Finding.new(
349
+ status: :unresolved,
350
+ path: location(path, issue.line),
351
+ guidance: issue.guidance
352
+ )
353
+ end
354
+ end
355
+ end
356
+
357
+ def runtime_contract_issues(node, line_offset: 0)
358
+ visitor = RuntimeContractVisitor.new
359
+ visitor.visit(node)
360
+ visitor.issues.map { _1.with(line: _1.line + line_offset) }
361
+ end
362
+
363
+ def erb_runtime_contract_issues(source)
364
+ source.to_enum(:scan, ERB_TAG_PATTERN).flat_map do
365
+ match = Regexp.last_match
366
+ fragment = match[2].sub(/\bdo(?:\s*\|[^|]*\|)?\s*\z/, "")
367
+ result = Prism.parse(fragment)
368
+ next [] unless result.success?
369
+
370
+ line_offset = source[0...match.begin(2)].count("\n")
371
+ runtime_contract_issues(result.value, line_offset:)
372
+ end
373
+ end
374
+
375
+ def runtime_ruby_source(path)
376
+ case path.extname
377
+ when ".rb" then path.read
378
+ when ".erb" then erb_ruby_source(path.read)
379
+ end
380
+ end
381
+
382
+ def erb_ruby_source(source)
383
+ ruby = source.gsub(/[^\n]/, " ")
384
+ source.to_enum(:scan, ERB_TAG_PATTERN).each do
385
+ match = Regexp.last_match
386
+ next if match[1]&.include?("=") && !standalone_erb_tag?(source, match)
387
+
388
+ ruby[match.begin(2)...match.end(2)] = match[2]
389
+ end
390
+ ruby
391
+ end
392
+
393
+ def standalone_erb_tag?(source, match)
394
+ line_break = source.rindex("\n", match.begin(0) - 1)
395
+ line_start = line_break ? line_break + 1 : 0
396
+ line_end = source.index("\n", match.end(0)) || source.length
397
+
398
+ source[line_start...match.begin(0)].strip.empty? && source[match.end(0)...line_end].strip.empty?
399
+ end
400
+
145
401
  def replacement_summary
146
402
  unresolved_findings.group_by(&:guidance).map do |guidance, findings|
147
403
  Finding.new(
@@ -173,7 +429,7 @@ module NitroKit
173
429
  end
174
430
 
175
431
  def all_findings
176
- @all_findings ||= (legacy_helpers + copied_components + controllers + vendored_dependencies)
432
+ @all_findings ||= (legacy_helpers + copied_components + controllers + vendored_dependencies + application_button_treatments + runtime_contract_errors)
177
433
  .uniq { [ _1.status, _1.path, _1.guidance ] }
178
434
  end
179
435
 
@@ -181,6 +437,10 @@ module NitroKit
181
437
  application_root.glob("app/**/*.{rb,erb,haml,slim}").select(&:file?)
182
438
  end
183
439
 
440
+ def application_stylesheets
441
+ application_root.glob("app/**/*.{css,scss,sass}").select(&:file?)
442
+ end
443
+
184
444
  def dependency_files
185
445
  files = %w[Gemfile config/importmap.rb package.json].filter_map do |name|
186
446
  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.3"
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,6 +21,8 @@ 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.
@@ -42,10 +44,13 @@ contract as a substitute for the installed API.
42
44
  Nitro Kit 1.x source as the fallback.
43
45
  - Redirect successful mutations with `303`; render invalid models with `422`.
44
46
  - Render HTML on the server and add Hotwire progressively.
47
+ - Set the document language on the root `html` element.
45
48
  - Test with Minitest and fixtures, including tenancy and unhappy paths.
46
49
  - In authenticated admin areas, default to a hybrid `AppShell` with the route's
47
50
  one `h1` and basic actions in its `Toolbar`. Keep one page gutter and avoid
48
- repeated headings or automatic Card wrappers.
51
+ repeated headings or automatic Card wrappers. At narrow widths, let trailing
52
+ actions stack below a Back affordance and title instead of clipping the title
53
+ or hiding persistent actions.
49
54
  - In a new team-aware application, create the first user's `Team` and owner
50
55
  `Membership` together. Put roles on memberships and scope product records
51
56
  through `Current.team`.
@@ -14,13 +14,18 @@ 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
 
@@ -30,15 +35,17 @@ If the gem is not installed, say that the skill requires Nitro Kit and follow th
30
35
  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
36
  3. Use `NitroKit::FormBuilder` explicitly with Rails `form_with` for model-backed forms.
32
37
  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.
38
+ 5. 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.
34
39
  6. Verify closed options and required compound declarations before rendering.
35
40
  7. For authenticated CRUD, prefer a hybrid `AppShell` with a `Toolbar` that
36
41
  owns the route's single `h1` and basic actions. The shell main region owns
37
42
  one content gutter. Do not repeat that heading in `PageHeader`, or wrap each
38
- table, form, and detail region in another Card.
43
+ table, form, and detail region in another Card. At narrow widths, preserve
44
+ the full title and persistent actions by stacking the trailing actions below
45
+ the title rather than clipping either region.
39
46
  8. For team administration and account settings, read
40
47
  `docs/patterns/application_foundation.md`. Put Settings after an
41
- `AppNavigation` spacer and use `SettingsLayout` with plain `FormSection`
48
+ `AppNavigation` spacer and use `SettingsLayout` with plain `SettingsSection`
42
49
  regions instead of a stack of Cards.
43
50
 
44
51
  ## Preserve the boundary
@@ -53,10 +60,13 @@ If the gem is not installed, say that the skill requires Nitro Kit and follow th
53
60
  - Never downgrade a specialized control to a generic Nitro control for visual
54
61
  consistency, and never retain copied Nitro Kit 1.x source as the fallback.
55
62
  - 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.
63
+ - 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.
64
+ - 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
65
  - Do not add application-specific behavior to Nitro-owned Stimulus controllers.
58
66
  - Do not recreate a Nitro component with raw HTML unless the installed catalog cannot express the semantics.
59
67
 
60
68
  ## Verify
61
69
 
62
70
  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.
71
+
72
+ 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.
@@ -13,10 +13,10 @@
13
13
  }
14
14
 
15
15
  :where(
16
- [data-nk="accordion"]
17
- > [data-slot="accordion-item"]
18
- > [data-slot="accordion-trigger"]
19
- ) {
16
+ [data-nk="accordion"]
17
+ > [data-slot="accordion-item"]
18
+ > [data-slot="accordion-trigger"]
19
+ ) {
20
20
  display: flex;
21
21
  width: 100%;
22
22
  min-height: 2.75rem;
@@ -35,28 +35,28 @@
35
35
  }
36
36
 
37
37
  :where(
38
- [data-nk="accordion"]
39
- > [data-slot="accordion-item"]
40
- > [data-slot="accordion-trigger"]
41
- )::-webkit-details-marker {
38
+ [data-nk="accordion"]
39
+ > [data-slot="accordion-item"]
40
+ > [data-slot="accordion-trigger"]
41
+ )::-webkit-details-marker {
42
42
  display: none;
43
43
  }
44
44
 
45
45
  :where(
46
- [data-nk="accordion"]
47
- > [data-slot="accordion-item"]
48
- > [data-slot="accordion-trigger"]
49
- > [data-slot="accordion-label"]
50
- ) {
46
+ [data-nk="accordion"]
47
+ > [data-slot="accordion-item"]
48
+ > [data-slot="accordion-trigger"]
49
+ > [data-slot="accordion-label"]
50
+ ) {
51
51
  flex: 1;
52
52
  }
53
53
 
54
54
  :where(
55
- [data-nk="accordion"]
56
- > [data-slot="accordion-item"]
57
- > [data-slot="accordion-trigger"]
58
- > [data-slot="accordion-icon"]
59
- ) {
55
+ [data-nk="accordion"]
56
+ > [data-slot="accordion-item"]
57
+ > [data-slot="accordion-trigger"]
58
+ > [data-slot="accordion-icon"]
59
+ ) {
60
60
  flex: none;
61
61
  width: 1rem;
62
62
  height: 1rem;
@@ -67,19 +67,19 @@
67
67
  }
68
68
 
69
69
  :where(
70
- [data-nk="accordion"]
71
- > [data-slot="accordion-item"][open]
72
- > [data-slot="accordion-trigger"]
73
- > [data-slot="accordion-icon"]
74
- ) {
70
+ [data-nk="accordion"]
71
+ > [data-slot="accordion-item"][open]
72
+ > [data-slot="accordion-trigger"]
73
+ > [data-slot="accordion-icon"]
74
+ ) {
75
75
  transform: rotate(180deg);
76
76
  }
77
77
 
78
78
  :where(
79
- [data-nk="accordion"]
80
- > [data-slot="accordion-item"]
81
- > [data-slot="accordion-content"]
82
- ) {
79
+ [data-nk="accordion"]
80
+ > [data-slot="accordion-item"]
81
+ > [data-slot="accordion-content"]
82
+ ) {
83
83
  padding-block-end: calc(var(--nk-space) * 4);
84
84
  line-height: var(--nk-leading-normal);
85
85
  color: var(--nk-color-muted-foreground);
@@ -89,30 +89,30 @@
89
89
  @layer nitro-kit.state {
90
90
  @media (hover: hover) {
91
91
  :where(
92
- [data-nk="accordion"]
93
- > [data-slot="accordion-item"]
94
- > [data-slot="accordion-trigger"]:hover
95
- ) {
92
+ [data-nk="accordion"]
93
+ > [data-slot="accordion-item"]
94
+ > [data-slot="accordion-trigger"]:hover
95
+ ) {
96
96
  color: var(--nk-color-primary);
97
97
  }
98
98
  }
99
99
 
100
100
  :where(
101
- [data-nk="accordion"]
102
- > [data-slot="accordion-item"]
103
- > [data-slot="accordion-trigger"]:focus-visible
104
- ) {
101
+ [data-nk="accordion"]
102
+ > [data-slot="accordion-item"]
103
+ > [data-slot="accordion-trigger"]:focus-visible
104
+ ) {
105
105
  outline: var(--nk-focus-width) solid var(--nk-color-focus);
106
106
  outline-offset: var(--nk-focus-offset);
107
107
  }
108
108
 
109
109
  @media (prefers-reduced-motion: reduce) {
110
110
  :where(
111
- [data-nk="accordion"]
112
- > [data-slot="accordion-item"]
113
- > [data-slot="accordion-trigger"]
114
- > [data-slot="accordion-icon"]
115
- ) {
111
+ [data-nk="accordion"]
112
+ > [data-slot="accordion-item"]
113
+ > [data-slot="accordion-trigger"]
114
+ > [data-slot="accordion-icon"]
115
+ ) {
116
116
  transition-duration: 0.01ms;
117
117
  }
118
118
  }
@@ -7,7 +7,7 @@
7
7
 
8
8
  position: relative;
9
9
  display: grid;
10
- width: 100%;
10
+ inline-size: 100%;
11
11
  gap: calc(var(--nk-space) * 2);
12
12
  padding: calc(var(--nk-space) * 4);
13
13
  font-size: var(--nk-text-sm);
@@ -32,22 +32,22 @@
32
32
 
33
33
  :where([data-nk="alert"] > [data-slot="alert-icon"]) {
34
34
  grid-row: 1 / -1;
35
- width: 1.25rem;
36
- height: 1.25rem;
35
+ inline-size: 1.25rem;
36
+ block-size: 1.25rem;
37
37
  margin-block-start: 0.0625rem;
38
38
  }
39
39
 
40
40
  :where(
41
- [data-nk="alert"]:has(> [data-slot="alert-icon"])
42
- > [data-slot="alert-title"],
43
- [data-nk="alert"]:has(> [data-slot="alert-icon"])
44
- > [data-slot="alert-description"]
45
- ) {
41
+ [data-nk="alert"]:has(> [data-slot="alert-icon"])
42
+ > [data-slot="alert-title"],
43
+ [data-nk="alert"]:has(> [data-slot="alert-icon"])
44
+ > [data-slot="alert-description"]
45
+ ) {
46
46
  grid-column: 2;
47
47
  }
48
48
 
49
49
  :where([data-nk="alert"] > [data-slot="alert-description"]) {
50
- max-width: var(--nk-content-lg);
50
+ max-inline-size: var(--nk-content-lg);
51
51
  color: var(--_nk-alert-foreground);
52
52
  text-wrap: pretty;
53
53
  }