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,671 @@
1
+ require "fileutils"
2
+ require "open3"
3
+ require "pathname"
4
+ require "ripper"
5
+ require "nitro_kit/migration_inventory"
6
+
7
+ module NitroKit
8
+ class Installation
9
+ ROOT = Pathname.new(File.expand_path("../..", __dir__))
10
+ SKILLS = %w[nitro-kit-hotwire nitro-kit-rails nitro-kit-ui].freeze
11
+ SKILL_ROOTS = [ ".agents/skills", ".claude/skills" ].freeze
12
+ MANAGED_STYLESHEETS = %w[
13
+ lexxy nitro_kit-tailwind-v4 nitro_kit tailwind application
14
+ ].freeze
15
+ AGENTS_START = "<!-- nitro-kit:start -->"
16
+ AGENTS_END = "<!-- nitro-kit:end -->"
17
+ AGENTS_BLOCK = <<~MARKDOWN.freeze
18
+ #{AGENTS_START}
19
+ ## Nitro Kit 2
20
+
21
+ This application uses Nitro Kit 2.x. Before changing Rails structure,
22
+ Hotwire interactions, or UI, use the matching project-local Nitro Kit skill.
23
+ Each skill resolves the installed gem with `bundle show nitro_kit` and reads
24
+ its version-matched documentation.
25
+
26
+ 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.
30
+
31
+ During migration, replace an existing form control only when Nitro Kit 2 has
32
+ a genuine semantic and behavioral equivalent. Otherwise preserve the control
33
+ as application-owned Rails and semantic HTML. Never downgrade specialized
34
+ behavior or retain copied Nitro Kit 1.x source as the fallback.
35
+ #{AGENTS_END}
36
+ MARKDOWN
37
+
38
+ Check = Struct.new(:status, :label, :detail, keyword_init: true)
39
+ LayoutExpression = Struct.new(:range, :type, :assets, :dynamic, keyword_init: true)
40
+ LayoutAnalysis = Struct.new(:head_range, :stylesheets, :bootstraps, :errors, keyword_init: true)
41
+
42
+ attr_reader :application_root
43
+
44
+ def initialize(application_root)
45
+ @application_root = Pathname.new(application_root)
46
+ end
47
+
48
+ def install
49
+ changes = { "AGENTS.md" => write_agents }.merge(write_skills)
50
+ changes[relative(layout_path)] = write_layout if layout_path
51
+ changes
52
+ end
53
+
54
+ def write_agents
55
+ path = application_root.join("AGENTS.md")
56
+ current = path.exist? ? path.read : ""
57
+ updated = if current.include?(AGENTS_START)
58
+ current.sub(/#{Regexp.escape(AGENTS_START)}.*?#{Regexp.escape(AGENTS_END)}/m, AGENTS_BLOCK.rstrip)
59
+ else
60
+ [ current.rstrip, AGENTS_BLOCK.rstrip ].reject(&:empty?).join("\n\n")
61
+ end
62
+
63
+ write(path, "#{updated.rstrip}\n")
64
+ end
65
+
66
+ def write_skills
67
+ SKILL_ROOTS.product(SKILLS).to_h do |root, skill|
68
+ destination = application_root.join(root, skill, "SKILL.md")
69
+ source = skill_source(skill)
70
+ [ destination.relative_path_from(application_root).to_s, write(destination, source.read) ]
71
+ end
72
+ end
73
+
74
+ def prompt
75
+ ROOT.join("docs/initialization_prompt.md").read
76
+ end
77
+
78
+ def copy_prompt
79
+ command = clipboard_command
80
+ raise "No supported clipboard command found" unless command
81
+
82
+ IO.popen(command, "w") { _1.write(prompt) }
83
+ raise "Clipboard command failed" unless $?.success?
84
+
85
+ command.first
86
+ end
87
+
88
+ def checks
89
+ [
90
+ version_check,
91
+ agents_check,
92
+ skills_check,
93
+ hotwire_check,
94
+ stimulus_loader_check,
95
+ stylesheet_setup_check,
96
+ phlex_kit_check,
97
+ *migration_checks
98
+ ]
99
+ end
100
+
101
+ def healthy?
102
+ checks.none? { _1.status == :fail }
103
+ end
104
+
105
+ private
106
+ def write(path, contents)
107
+ return :unchanged if path.exist? && path.read == contents
108
+
109
+ FileUtils.mkdir_p(path.dirname)
110
+ path.write(contents)
111
+ :written
112
+ end
113
+
114
+ def skill_source(skill)
115
+ ROOT.join("plugins/nitro-kit/skills", skill, "SKILL.md")
116
+ end
117
+
118
+ def version_check
119
+ status = NitroKit::VERSION.start_with?("2.") ? :pass : :fail
120
+ Check.new(status:, label: "Nitro Kit version", detail: NitroKit::VERSION)
121
+ end
122
+
123
+ def agents_check
124
+ path = application_root.join("AGENTS.md")
125
+ installed = path.exist? && path.read.include?(AGENTS_BLOCK.rstrip)
126
+ Check.new(
127
+ status: installed ? :pass : :fail,
128
+ label: "AGENTS.md guidance",
129
+ detail: installed ? "managed Nitro Kit 2 block is current" : "run the Nitro Kit install generator"
130
+ )
131
+ end
132
+
133
+ def skills_check
134
+ missing = SKILL_ROOTS.product(SKILLS).filter_map do |root, skill|
135
+ path = application_root.join(root, skill, "SKILL.md")
136
+ path.relative_path_from(application_root).to_s unless path.exist? && path.read == skill_source(skill).read
137
+ end
138
+ Check.new(
139
+ status: missing.empty? ? :pass : :fail,
140
+ label: "Project-local skills",
141
+ detail: missing.empty? ? "Codex and Claude skill routers are current" : "missing or stale: #{missing.join(', ')}"
142
+ )
143
+ end
144
+
145
+ def hotwire_check
146
+ missing = %w[turbo-rails stimulus-rails].reject { Gem.loaded_specs.key?(_1) }
147
+ Check.new(
148
+ status: missing.empty? ? :pass : :fail,
149
+ label: "Hotwire dependencies",
150
+ detail: missing.empty? ? "Turbo and Stimulus are available" : "missing: #{missing.join(', ')}"
151
+ )
152
+ end
153
+
154
+ def stimulus_loader_check
155
+ path = application_root.join("app/javascript/controllers/index.js")
156
+ ready = path.exist? && path.read.include?("eagerLoadControllersFrom")
157
+ Check.new(
158
+ status: ready ? :pass : :warn,
159
+ label: "Stimulus loader",
160
+ detail: ready ? "eager controller loading is configured" : "verify the application's controller registration"
161
+ )
162
+ end
163
+
164
+ def stylesheet_setup_check
165
+ return Check.new(
166
+ status: :fail,
167
+ label: "Stylesheet and appearance setup",
168
+ detail: "missing application layout; add AppearanceBootstrap before the ordered stylesheet entries"
169
+ ) unless layout_path
170
+
171
+ contents = layout_path.read
172
+ analysis = analyze_layout(contents)
173
+ expected = expected_stylesheets(analysis)
174
+ errors = []
175
+ bootstrap_count = analysis.bootstraps.size
176
+
177
+ if analysis.errors.any?
178
+ return Check.new(
179
+ status: :fail,
180
+ label: "Stylesheet and appearance setup",
181
+ detail: "#{relative(layout_path)}: installer left the layout unchanged — #{analysis.errors.join('; ')}; manually ensure AppearanceBootstrap → #{expected.join(' → ')} without discarding existing options"
182
+ )
183
+ end
184
+
185
+ errors << "add one NitroKit::AppearanceBootstrap before every stylesheet" if bootstrap_count.zero?
186
+ errors << "remove duplicate NitroKit::AppearanceBootstrap entries" if bootstrap_count > 1
187
+
188
+ assets = managed_stylesheet_assets(analysis)
189
+ expected.each do |stylesheet|
190
+ count = assets.count(stylesheet)
191
+ errors << "add stylesheet #{stylesheet.inspect}" if count.zero?
192
+ errors << "remove duplicate stylesheet #{stylesheet.inspect}" if count > 1
193
+ end
194
+
195
+ if (edit_error = layout_edit_error(analysis, expected))
196
+ errors << "installer left the layout unchanged: #{edit_error}"
197
+ end
198
+
199
+ if assets.include?("nitro_kit-tailwind-v4") && !expected.include?("tailwind")
200
+ errors << "remove nitro_kit-tailwind-v4 or load compiled Tailwind after nitro_kit"
201
+ end
202
+
203
+ bootstrap_position = analysis.bootstraps.first&.range&.begin
204
+ first_stylesheet_position = analysis.stylesheets.first&.range&.begin
205
+ if errors.empty? && (bootstrap_position >= first_stylesheet_position || assets != expected)
206
+ errors << "reorder to AppearanceBootstrap → #{expected.join(' → ')}"
207
+ end
208
+
209
+ Check.new(
210
+ status: errors.empty? ? :pass : :fail,
211
+ label: "Stylesheet and appearance setup",
212
+ detail: errors.empty? ? "#{relative(layout_path)}: AppearanceBootstrap → #{expected.join(' → ')}" : "#{relative(layout_path)}: #{errors.join('; ')}"
213
+ )
214
+ end
215
+
216
+ def phlex_kit_check
217
+ ready = application_root.glob("app/components/**/*.rb").any? { _1.read.match?(/include\s+(?:\(?\s*)?NitroKit\b/) }
218
+ Check.new(
219
+ status: ready ? :pass : :warn,
220
+ label: "Phlex Kit base",
221
+ detail: ready ? "an application component includes NitroKit" : "include NitroKit once in the application base component"
222
+ )
223
+ end
224
+
225
+ def migration_checks
226
+ MigrationInventory.new(application_root).categories.map do |label, findings|
227
+ Check.new(
228
+ status: findings.empty? ? :pass : :warn,
229
+ label: "Migration: #{label}",
230
+ detail: findings.empty? ? "migrated: none found" : findings.map do |finding|
231
+ "#{finding.status.to_s.tr('_', '-')}: #{finding.path} — #{finding.guidance}"
232
+ end.join("\n")
233
+ )
234
+ end
235
+ end
236
+
237
+ def layout_path
238
+ layout_paths.first
239
+ end
240
+
241
+ def layout_paths
242
+ [
243
+ application_root.join("app/components/ui/application_layout.rb"),
244
+ application_root.join("app/components/application_layout.rb"),
245
+ application_root.join("app/views/layouts/application.html.erb"),
246
+ *application_root.glob("app/components/**/application_layout.rb")
247
+ ].uniq.select(&:file?)
248
+ end
249
+
250
+ def write_layout
251
+ write(layout_path, configured_layout(layout_path.read))
252
+ end
253
+
254
+ def configured_layout(contents)
255
+ analysis = analyze_layout(contents)
256
+ expected = expected_stylesheets(analysis)
257
+ edit_error = layout_edit_error(analysis, expected)
258
+ return contents if edit_error
259
+
260
+ missing = expected - managed_stylesheet_assets(analysis)
261
+ return contents if missing.empty? && analysis.bootstraps.one?
262
+
263
+ lines = contents.lines
264
+ insertions = Hash.new { |hash, index| hash[index] = [] }
265
+ indent = layout_indent(lines, analysis)
266
+
267
+ if analysis.bootstraps.empty?
268
+ bootstrap_index = analysis.stylesheets.first&.range&.begin || analysis.head_range.begin + 1
269
+ insertions[bootstrap_index] << bootstrap_markup(indent)
270
+ end
271
+
272
+ missing.group_by { insertion_slot(analysis, expected, _1) }.each do |slot, assets|
273
+ index = stylesheet_insertion_index(analysis, slot)
274
+ assets.sort_by { expected.index(_1) }.each do |asset|
275
+ insertions[index] << stylesheet_markup(indent, asset)
276
+ end
277
+ end
278
+
279
+ (0..lines.length).each_with_object(String.new) do |index, updated|
280
+ insertions[index].each { updated << _1 }
281
+ updated << lines[index] if index < lines.length
282
+ end
283
+ end
284
+
285
+ def analyze_layout(contents)
286
+ errors = []
287
+ errors << "multiple application layouts found: #{layout_paths.map { relative(_1) }.join(', ')}" if layout_paths.many?
288
+ lines = contents.lines
289
+ head_range, boundary_errors = if layout_path.extname == ".rb"
290
+ phlex_head_range(contents)
291
+ else
292
+ erb_head_range(contents)
293
+ end
294
+ errors.concat(boundary_errors)
295
+
296
+ all_expressions = layout_path.extname == ".rb" ? phlex_layout_expressions(contents) : erb_layout_expressions(contents)
297
+ expressions = if head_range
298
+ all_expressions.select do |expression|
299
+ inside = head_range.cover?(expression.range.begin) && head_range.cover?(expression.range.end)
300
+ overlaps = head_range.cover?(expression.range.begin) || head_range.cover?(expression.range.end)
301
+ errors << "#{expression.type} call on line #{expression.range.begin + 1} crosses the document head boundary" if overlaps && !inside
302
+ inside
303
+ end
304
+ else
305
+ []
306
+ end
307
+ outside_expressions = all_expressions - expressions
308
+ outside_expressions.each do |expression|
309
+ next if head_range && (head_range.cover?(expression.range.begin) || head_range.cover?(expression.range.end))
310
+
311
+ errors << "#{expression.type} call on line #{expression.range.begin + 1} is outside the document head"
312
+ end
313
+
314
+ stylesheets = expressions.select { _1.type == :stylesheet }
315
+ bootstraps = expressions.select { _1.type == :bootstrap }
316
+ target_indent = head_range ? lines[head_range.begin][/^\s*/].to_s.length + 2 : nil
317
+
318
+ expressions.each do |expression|
319
+ actual_indent = lines[expression.range.begin][/^\s*/].to_s.length
320
+ errors << "#{expression.type} call on line #{expression.range.begin + 1} is conditional or nested" unless actual_indent == target_indent
321
+ end
322
+ stylesheets.each do |expression|
323
+ errors << "stylesheet call on line #{expression.range.begin + 1} has dynamic assets" if expression.dynamic
324
+ if (expression.assets & MANAGED_STYLESHEETS).any? && (expression.assets - MANAGED_STYLESHEETS).any?
325
+ errors << "stylesheet call on line #{expression.range.begin + 1} combines custom and canonical assets"
326
+ end
327
+ end
328
+
329
+ head_contents = head_range ? lines[head_range].join : ""
330
+ if layout_path.extname == ".erb" && head_contents.match?(/<%(?![=%#])/)
331
+ errors << "head contains conditional or executable ERB"
332
+ end
333
+ if head_contents.match?(/<link\b[^>]*\brel=["']stylesheet["']/i)
334
+ errors << "head contains a custom stylesheet link"
335
+ end
336
+
337
+ LayoutAnalysis.new(head_range: head_range || (0..0), stylesheets:, bootstraps:, errors: errors.uniq)
338
+ end
339
+
340
+ def erb_head_range(contents)
341
+ lines = contents.lines
342
+ starts = lines.each_index.select { lines[_1].match?(/^\s*<head(?:\s[^>]*)?>\s*$/i) }
343
+ finishes = lines.each_index.select { lines[_1].match?(/^\s*<\/head>\s*$/i) }
344
+ errors = []
345
+ errors << "expected one conventional <head> opening tag on its own line" unless starts.one?
346
+ errors << "expected one conventional </head> closing tag on its own line" unless finishes.one?
347
+ return [ nil, errors ] unless starts.one? && finishes.one?
348
+
349
+ errors << "the </head> closing tag must follow the <head> opening tag" unless starts.first < finishes.first
350
+ [ starts.first..finishes.first, errors ]
351
+ end
352
+
353
+ def phlex_head_range(contents)
354
+ syntax = Ripper.sexp(contents)
355
+ return [ nil, [ "could not parse the Phlex application layout" ] ] unless syntax
356
+
357
+ html_blocks = ruby_block_calls(syntax, "html")
358
+ head_blocks = ruby_block_calls(syntax, "head")
359
+ errors = []
360
+ errors << "expected one conventional html block in the Phlex application layout" unless html_blocks.one?
361
+ errors << "expected one conventional head block in the Phlex application layout" unless head_blocks.one?
362
+ return [ nil, errors ] unless html_blocks.one? && head_blocks.one?
363
+
364
+ html_statements = ruby_block_statements(html_blocks.first)
365
+ direct_heads = html_statements.select { ruby_block_call_name(_1) == "head" }
366
+ unless direct_heads.one? && direct_heads.first.equal?(head_blocks.first)
367
+ return [ nil, [ "the Phlex head block must be owned directly by the conventional html block" ] ]
368
+ end
369
+
370
+ position = ruby_node_position(head_blocks.first, "head")
371
+ return [ nil, [ "could not determine the Phlex head block boundary" ] ] unless position
372
+
373
+ start = position.first - 1
374
+ indent = lines_indent(contents.lines[start])
375
+ unless contents.lines[start].match?(/^#{Regexp.escape(indent)}head(?:\(\s*\))?\s+do\s*$/)
376
+ return [ nil, [ "the Phlex head block must use a conventional `head do` line" ] ]
377
+ end
378
+
379
+ head_statement_index = html_statements.index { _1.equal?(head_blocks.first) }
380
+ next_statement_position = html_statements[(head_statement_index + 1)..].filter_map { ruby_first_position(_1) }.min
381
+ upper_bound = if next_statement_position
382
+ next_statement_position.first - 2
383
+ else
384
+ html_position = ruby_node_position(html_blocks.first, "html")
385
+ html_indent = lines_indent(contents.lines[html_position.first - 1])
386
+ contents.lines.each_index.find do |index|
387
+ index > start && contents.lines[index].match?(/^#{Regexp.escape(html_indent)}end\s*$/)
388
+ end
389
+ end
390
+ return [ nil, [ "could not determine the closing `end` for the Phlex html block" ] ] unless upper_bound
391
+
392
+ finishes = contents.lines.each_index.select do |index|
393
+ index > start && index <= upper_bound && contents.lines[index].match?(/^#{Regexp.escape(indent)}end\s*$/)
394
+ end
395
+ unless finishes.one?
396
+ return [ nil, [ "could not uniquely determine the closing `end` for the Phlex head block" ] ]
397
+ end
398
+
399
+ [ start..finishes.first, errors ]
400
+ end
401
+
402
+ def phlex_layout_expressions(contents)
403
+ lines = contents.lines
404
+ Ripper.lex(contents).filter_map do |(position, event, token, _state)|
405
+ next unless event == :on_ident && token == "stylesheet_link_tag"
406
+
407
+ start = position.first - 1
408
+ range = ruby_expression_range(lines, start)
409
+ source = lines[range].join
410
+ assets = static_stylesheet_assets(source)
411
+ LayoutExpression.new(range:, type: :stylesheet, assets: assets || [], dynamic: assets.nil?)
412
+ end + lines.each_index.filter_map do |index|
413
+ range = ruby_expression_range(lines, index)
414
+ source = lines[range].join
415
+ next unless source.lstrip.start_with?("render") && bootstrap_expression?(source)
416
+
417
+ LayoutExpression.new(range:, type: :bootstrap, assets: [], dynamic: false)
418
+ end.uniq { [ _1.type, _1.range ] }.sort_by { _1.range.begin }
419
+ end
420
+
421
+ def erb_layout_expressions(contents)
422
+ contents.to_enum(:scan, /<%=(.*?)%>/m).filter_map do
423
+ match = Regexp.last_match
424
+ start = contents[0...match.begin(0)].count("\n")
425
+ finish = contents[0...match.end(0)].count("\n")
426
+ source = match[1]
427
+ if ruby_identifier?(source, "stylesheet_link_tag")
428
+ assets = static_stylesheet_assets(source)
429
+ LayoutExpression.new(range: start..finish, type: :stylesheet, assets: assets || [], dynamic: assets.nil?)
430
+ elsif bootstrap_expression?(source)
431
+ LayoutExpression.new(range: start..finish, type: :bootstrap, assets: [], dynamic: false)
432
+ end
433
+ end.sort_by { _1.range.begin }
434
+ end
435
+
436
+ def ruby_expression_range(lines, start)
437
+ (start...lines.length).each do |finish|
438
+ return start..finish if Ripper.sexp(lines[start..finish].join)
439
+ end
440
+ start..start
441
+ end
442
+
443
+ def static_stylesheet_assets(source)
444
+ arguments = invocation_arguments(Ripper.sexp(source), "stylesheet_link_tag")
445
+ return unless arguments
446
+
447
+ positional = arguments.take_while { !%i[bare_assoc_hash hash].include?(_1&.first) }
448
+ return unless arguments.drop(positional.length).all? { %i[bare_assoc_hash hash].include?(_1&.first) }
449
+
450
+ positional.map { string_literal(_1) }.tap { return if _1.any?(&:nil?) }
451
+ end
452
+
453
+ def invocation_arguments(node, name)
454
+ return unless node.is_a?(Array)
455
+
456
+ arguments = direct_invocation_arguments(node, name)
457
+ return arguments if arguments
458
+
459
+ node.filter_map { invocation_arguments(_1, name) if _1.is_a?(Array) }.first
460
+ end
461
+
462
+ def invocation_argument_lists(node, name)
463
+ return [] unless node.is_a?(Array)
464
+
465
+ arguments = direct_invocation_arguments(node, name)
466
+ nested = node.flat_map { invocation_argument_lists(_1, name) if _1.is_a?(Array) }.compact
467
+ arguments ? [ arguments, *nested ] : nested
468
+ end
469
+
470
+ def direct_invocation_arguments(node, name)
471
+ case node.first
472
+ when :method_add_arg
473
+ argument_list(node[2]) if callable_name(node[1]) == name
474
+ when :command
475
+ argument_list(node[2]) if node.dig(1, 1) == name
476
+ end
477
+ end
478
+
479
+ def callable_name(node)
480
+ node.dig(1, 1) if node&.first == :fcall
481
+ end
482
+
483
+ def ruby_block_calls(node, name)
484
+ return [] unless node.is_a?(Array)
485
+
486
+ calls = ruby_block_call_name(node) == name ? [ node ] : []
487
+ calls + node.flat_map { ruby_block_calls(_1, name) if _1.is_a?(Array) }.compact
488
+ end
489
+
490
+ def ruby_block_call_name(node)
491
+ return unless node&.first == :method_add_block
492
+
493
+ call = node[1]
494
+ case call&.first
495
+ when :method_add_arg
496
+ callable_name(call[1])
497
+ when :command
498
+ call.dig(1, 1)
499
+ end
500
+ end
501
+
502
+ def ruby_block_statements(node)
503
+ block = node[2]
504
+ block&.first == :do_block && block.dig(2, 1) || []
505
+ end
506
+
507
+ def ruby_node_position(node, name)
508
+ return unless node.is_a?(Array)
509
+ return node[2] if node.first == :@ident && node[1] == name
510
+
511
+ node.filter_map { ruby_node_position(_1, name) if _1.is_a?(Array) }.first
512
+ end
513
+
514
+ def ruby_first_position(node)
515
+ return unless node.is_a?(Array)
516
+ return node[2] if node.first.to_s.start_with?("@") && node[2].is_a?(Array)
517
+
518
+ node.filter_map { ruby_first_position(_1) if _1.is_a?(Array) }.min
519
+ end
520
+
521
+ def lines_indent(line)
522
+ line[/^\s*/].to_s
523
+ end
524
+
525
+ def argument_list(node)
526
+ node = node[1] if node&.first == :arg_paren
527
+ node&.first == :args_add_block ? node[1] : nil
528
+ end
529
+
530
+ def string_literal(node)
531
+ return unless node&.first == :string_literal
532
+ return "" if node[1]&.first == :string_content && node[1].length == 1
533
+ return unless node[1]&.first == :string_content && node[1][1..].all? { _1.first == :@tstring_content }
534
+
535
+ node[1][1..].map { _1[1] }.join
536
+ end
537
+
538
+ def bootstrap_expression?(source)
539
+ tokens = Ripper.lex(source).reject { %i[on_sp on_nl on_ignored_nl].include?(_1[1]) }.map { _1[2] }
540
+ tokens.each_cons(3).any? { _1 == [ "NitroKit", "::", "AppearanceBootstrap" ] }
541
+ end
542
+
543
+ def ruby_identifier?(source, identifier)
544
+ Ripper.lex(source).any? { _1[1] == :on_ident && _1[2] == identifier }
545
+ end
546
+
547
+ def expected_stylesheets(analysis)
548
+ assets = managed_stylesheet_assets(analysis) - [ "nitro_kit-tailwind-v4" ]
549
+ assets << "lexxy" if dependency?("lexxy")
550
+ assets << "tailwind" if tailwind?(assets)
551
+ assets << "nitro_kit"
552
+ assets << "application" if assets.include?("application") || application_stylesheet_asset?
553
+ assets << "nitro_kit-tailwind-v4" if assets.include?("tailwind")
554
+ ordered_stylesheets(assets.uniq)
555
+ end
556
+
557
+ def ordered_stylesheets(assets)
558
+ vendor = assets - %w[nitro_kit-tailwind-v4 nitro_kit tailwind application]
559
+ [
560
+ *vendor,
561
+ *(%w[nitro_kit-tailwind-v4] & assets),
562
+ *(%w[nitro_kit] & assets),
563
+ *(%w[tailwind] & assets),
564
+ *(%w[application] & assets)
565
+ ]
566
+ end
567
+
568
+ def layout_edit_error(analysis, expected)
569
+ return analysis.errors.join("; ") if analysis.errors.any?
570
+ return "duplicate AppearanceBootstrap calls require manual review" if analysis.bootstraps.many?
571
+
572
+ first_stylesheet = analysis.stylesheets.first
573
+ if analysis.bootstraps.one? && first_stylesheet && analysis.bootstraps.first.range.begin > first_stylesheet.range.begin
574
+ return "AppearanceBootstrap is after a stylesheet; move the existing call without changing its options"
575
+ end
576
+
577
+ existing = managed_stylesheet_assets(analysis)
578
+ return "duplicate canonical stylesheet assets require manual review" if existing.uniq != existing
579
+ unless existing == expected.select { existing.include?(_1) }
580
+ return "existing canonical stylesheets are misordered; reorder their intact calls manually"
581
+ end
582
+
583
+ missing = expected - existing
584
+ if missing.any? { insertion_slot(analysis, expected, _1).nil? }
585
+ "a combined stylesheet call spans a required insertion point; split it without changing its options, then rerun the installer"
586
+ end
587
+ end
588
+
589
+ def insertion_slot(analysis, expected, asset)
590
+ rank = expected.index(asset)
591
+ managed = analysis.stylesheets.select { (_1.assets & MANAGED_STYLESHEETS).any? }
592
+ lower = managed.each_index.filter_map do |index|
593
+ index + 1 if managed[index].assets.any? { expected.index(_1).to_i < rank }
594
+ end.max || 0
595
+ upper = managed.each_index.filter_map do |index|
596
+ index if managed[index].assets.any? { expected.index(_1).to_i > rank }
597
+ end.min || managed.length
598
+ lower if lower <= upper
599
+ end
600
+
601
+ def stylesheet_insertion_index(analysis, slot)
602
+ managed = analysis.stylesheets.select { (_1.assets & MANAGED_STYLESHEETS).any? }
603
+ if managed.empty?
604
+ anchor = analysis.stylesheets.last || analysis.bootstraps.first
605
+ anchor ? anchor.range.end + 1 : analysis.head_range.begin + 1
606
+ elsif slot < managed.length
607
+ managed[slot].range.begin
608
+ else
609
+ managed.last.range.end + 1
610
+ end
611
+ end
612
+
613
+ def layout_indent(lines, analysis)
614
+ expression = (analysis.stylesheets + analysis.bootstraps).min_by { _1.range.begin }
615
+ expression ? lines[expression.range.begin][/^\s*/] : "#{lines[analysis.head_range.begin][/^\s*/]} "
616
+ end
617
+
618
+ def bootstrap_markup(indent)
619
+ if layout_path.extname == ".rb"
620
+ "#{indent}render NitroKit::AppearanceBootstrap.new\n"
621
+ else
622
+ "#{indent}<%= render NitroKit::AppearanceBootstrap.new %>\n"
623
+ end
624
+ end
625
+
626
+ def stylesheet_markup(indent, asset)
627
+ if layout_path.extname == ".rb"
628
+ "#{indent}stylesheet_link_tag(#{asset.inspect}, data: { turbo_track: \"reload\" })\n"
629
+ else
630
+ "#{indent}<%= stylesheet_link_tag #{asset.inspect}, \"data-turbo-track\": \"reload\" %>\n"
631
+ end
632
+ end
633
+
634
+ def managed_stylesheet_assets(analysis)
635
+ analysis.stylesheets.flat_map(&:assets).select { MANAGED_STYLESHEETS.include?(_1) }
636
+ end
637
+
638
+ def tailwind?(assets)
639
+ assets.include?("tailwind") || dependency?("tailwindcss-rails") || application_root.join("app/assets/tailwind/application.css").file?
640
+ end
641
+
642
+ def application_stylesheet_asset?
643
+ application_root.glob("app/assets/stylesheets/application.*").any?(&:file?) ||
644
+ application_root.join("app/assets/builds/application.css").file?
645
+ end
646
+
647
+ def dependency?(name)
648
+ gemfile = application_root.join("Gemfile")
649
+ return false unless gemfile.file?
650
+
651
+ syntax = Ripper.sexp(gemfile.read)
652
+ syntax && invocation_argument_lists(syntax, "gem").any? { string_literal(_1.first) == name }
653
+ end
654
+
655
+ def relative(path)
656
+ path.relative_path_from(application_root).to_s
657
+ end
658
+
659
+ def clipboard_command
660
+ [ [ "pbcopy" ], [ "wl-copy" ], [ "xclip", "-selection", "clipboard" ], [ "clip" ] ].find do |command|
661
+ executable?(command.first)
662
+ end
663
+ end
664
+
665
+ def executable?(name)
666
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
667
+ File.executable?(File.join(directory, name))
668
+ end
669
+ end
670
+ end
671
+ end