seams 0.1.0 → 0.2.0

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 (222) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -12
  3. data/README.md +235 -82
  4. data/lib/generators/seams/accounts/accounts_generator.rb +31 -3
  5. data/lib/generators/seams/accounts/templates/app/controllers/memberships_controller.rb.tt +100 -0
  6. data/lib/generators/seams/accounts/templates/app/views/memberships/index.html.erb.tt +52 -0
  7. data/lib/generators/seams/accounts/templates/config/routes.rb.tt +10 -4
  8. data/lib/generators/seams/accounts/templates/lib/concerns/authorization.rb.tt +35 -2
  9. data/lib/generators/seams/accounts/templates/lib/engine.rb.tt +13 -0
  10. data/lib/generators/seams/accounts/templates/spec/runtime/authorization_spec.rb.tt +102 -0
  11. data/lib/generators/seams/accounts/templates/spec/runtime/memberships_flow_spec.rb.tt +145 -0
  12. data/lib/generators/seams/admin/admin_generator.rb +51 -4
  13. data/lib/generators/seams/admin/templates/README.md.tt +33 -3
  14. data/lib/generators/seams/admin/templates/app/controllers/admin/application_controller.rb.tt +156 -8
  15. data/lib/generators/seams/admin/templates/app/dashboards/admin/account_dashboard.rb.tt +9 -1
  16. data/lib/generators/seams/admin/templates/app/dashboards/admin/accounts_membership_dashboard.rb.tt +9 -1
  17. data/lib/generators/seams/admin/templates/app/dashboards/admin/identity_dashboard.rb.tt +6 -0
  18. data/lib/generators/seams/admin/templates/app/dashboards/admin/invitation_dashboard.rb.tt +7 -1
  19. data/lib/generators/seams/admin/templates/app/dashboards/admin/invoice_dashboard.rb.tt +6 -0
  20. data/lib/generators/seams/admin/templates/app/dashboards/admin/lifetime_pass_dashboard.rb.tt +6 -0
  21. data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_dashboard.rb.tt +6 -0
  22. data/lib/generators/seams/admin/templates/app/dashboards/admin/notification_preference_dashboard.rb.tt +6 -0
  23. data/lib/generators/seams/admin/templates/app/dashboards/admin/plan_dashboard.rb.tt +6 -0
  24. data/lib/generators/seams/admin/templates/app/dashboards/admin/subscription_dashboard.rb.tt +6 -0
  25. data/lib/generators/seams/admin/templates/app/dashboards/admin/team_dashboard.rb.tt +12 -2
  26. data/lib/generators/seams/admin/templates/app/dashboards/admin/teams_membership_dashboard.rb.tt +7 -1
  27. data/lib/generators/seams/admin/templates/app/fields/admin/fields/belongs_to.rb.tt +12 -0
  28. data/lib/generators/seams/admin/templates/app/fields/admin/fields/dashboard_option.rb.tt +22 -0
  29. data/lib/generators/seams/admin/templates/app/fields/admin/fields/has_many.rb.tt +12 -0
  30. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/account_policy.rb.tt +4 -0
  31. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/accounts_membership_policy.rb.tt +6 -0
  32. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/application_policy.rb.tt +51 -10
  33. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/identity_policy.rb.tt +4 -0
  34. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invitation_policy.rb.tt +6 -0
  35. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/invoice_policy.rb.tt +6 -0
  36. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/lifetime_pass_policy.rb.tt +6 -0
  37. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_policy.rb.tt +6 -0
  38. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/notification_preference_policy.rb.tt +6 -0
  39. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/plan_policy.rb.tt +7 -0
  40. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/subscription_policy.rb.tt +6 -0
  41. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/team_policy.rb.tt +6 -0
  42. data/lib/generators/seams/admin/templates/app/policies/admin/tenant/teams_membership_policy.rb.tt +6 -0
  43. data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_index_header.html.erb.tt +57 -0
  44. data/lib/generators/seams/admin/templates/app/views/seams/admin/application/_navigation.html.erb.tt +14 -0
  45. data/lib/generators/seams/admin/templates/config/routes.rb.tt +24 -14
  46. data/lib/generators/seams/admin/templates/lib/concerns/authenticator.rb.tt +3 -0
  47. data/lib/generators/seams/admin/templates/lib/configuration.rb.tt +3 -1
  48. data/lib/generators/seams/admin/templates/spec/runtime/admin_boot_spec.rb.tt +4 -2
  49. data/lib/generators/seams/auth/add_oauth_provider/add_oauth_provider_generator.rb +1 -2
  50. data/lib/generators/seams/auth/auth_generator.rb +1 -2
  51. data/lib/generators/seams/auth/templates/app/controllers/oauth/callbacks_controller.rb.tt +3 -0
  52. data/lib/generators/seams/auth/templates/app/controllers/password_resets_controller.rb.tt +4 -0
  53. data/lib/generators/seams/auth/templates/app/controllers/registrations_controller.rb.tt +5 -0
  54. data/lib/generators/seams/auth/templates/app/controllers/sessions_controller.rb.tt +5 -0
  55. data/lib/generators/seams/auth/templates/db/migrate/create_auth_oauth_providers.rb.tt +5 -1
  56. data/lib/generators/seams/auth/templates/lib/concerns/authentication.rb.tt +12 -1
  57. data/lib/generators/seams/auth/templates/lib/engine.rb.tt +27 -0
  58. data/lib/generators/seams/billing/billing_generator.rb +4 -3
  59. data/lib/generators/seams/billing/templates/README.md.tt +26 -0
  60. data/lib/generators/seams/billing/templates/app/controllers/invoices_controller.rb.tt +2 -0
  61. data/lib/generators/seams/billing/templates/app/controllers/subscriptions_controller.rb.tt +2 -0
  62. data/lib/generators/seams/billing/templates/app/services/invoices/sync_service.rb.tt +8 -7
  63. data/lib/generators/seams/billing/templates/app/services/stripe_service.rb.tt +9 -7
  64. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/invoice_handler_base.rb.tt +18 -3
  65. data/lib/generators/seams/billing/templates/app/services/webhooks/handlers/subscription_handler_base.rb.tt +7 -21
  66. data/lib/generators/seams/billing/templates/lib/engine.rb.tt +15 -0
  67. data/lib/generators/seams/billing/templates/lib/gateways/stripe.rb.tt +10 -10
  68. data/lib/generators/seams/billing/templates/lib/stripe/client.rb.tt +40 -14
  69. data/lib/generators/seams/billing/templates/lib/stripe/payload.rb.tt +90 -0
  70. data/lib/generators/seams/billing/templates/lib/stripe/webhook_signature.rb.tt +11 -1
  71. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/charge_refunded.json.tt +1 -0
  72. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/checkout_session_completed.json.tt +1 -0
  73. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_created.json.tt +15 -4
  74. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_deleted.json.tt +7 -1
  75. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_trial_will_end.json.tt +18 -1
  76. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/customer_subscription_updated.json.tt +26 -5
  77. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_created.json.tt +16 -2
  78. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_finalized.json.tt +16 -2
  79. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_paid.json.tt +17 -2
  80. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_payment_failed.json.tt +16 -2
  81. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/invoice_voided.json.tt +16 -2
  82. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_payment_failed.json.tt +1 -0
  83. data/lib/generators/seams/billing/templates/spec/fixtures/stripe/payment_intent_succeeded.json.tt +1 -0
  84. data/lib/generators/seams/billing/templates/spec/gateways/stripe_spec.rb.tt +47 -1
  85. data/lib/generators/seams/billing/templates/spec/runtime/webhook_handlers_spec.rb.tt +39 -1
  86. data/lib/generators/seams/design/design_generator.rb +647 -0
  87. data/lib/generators/seams/design/templates/README.md.tt +129 -0
  88. data/lib/generators/seams/design/templates/app/assets/tailwind/_tokens.css +322 -0
  89. data/lib/generators/seams/design/templates/app/assets/tailwind/themes/_quire.css +42 -0
  90. data/lib/generators/seams/design/templates/app/controllers/design/dashboard_controller.rb.tt +30 -0
  91. data/lib/generators/seams/design/templates/app/controllers/design/guide_controller.rb.tt +28 -0
  92. data/lib/generators/seams/design/templates/app/form_builders/design/form_builder.rb.tt +90 -0
  93. data/lib/generators/seams/design/templates/app/helpers/design/ui_helper.rb.tt +37 -0
  94. data/lib/generators/seams/design/templates/app/views/design/dashboard/index.html.erb.tt +38 -0
  95. data/lib/generators/seams/design/templates/app/views/design/guide/index.html.erb.tt +19 -0
  96. data/lib/generators/seams/design/templates/app/views/layouts/application.html.erb.tt +61 -0
  97. data/lib/generators/seams/design/templates/app/views/layouts/design/guide.html.erb.tt +21 -0
  98. data/lib/generators/seams/design/templates/app/views/ui/_banner.html.erb.tt +10 -0
  99. data/lib/generators/seams/design/templates/app/views/ui/_breadcrumb.html.erb.tt +8 -0
  100. data/lib/generators/seams/design/templates/app/views/ui/_build_row.html.erb.tt +14 -0
  101. data/lib/generators/seams/design/templates/app/views/ui/_button.html.erb.tt +4 -0
  102. data/lib/generators/seams/design/templates/app/views/ui/_card.html.erb.tt +2 -0
  103. data/lib/generators/seams/design/templates/app/views/ui/_chapter_row.html.erb.tt +11 -0
  104. data/lib/generators/seams/design/templates/app/views/ui/_checkbox.html.erb.tt +12 -0
  105. data/lib/generators/seams/design/templates/app/views/ui/_counter.html.erb.tt +2 -0
  106. data/lib/generators/seams/design/templates/app/views/ui/_data_table.html.erb.tt +25 -0
  107. data/lib/generators/seams/design/templates/app/views/ui/_dialog.html.erb.tt +14 -0
  108. data/lib/generators/seams/design/templates/app/views/ui/_diff.html.erb.tt +12 -0
  109. data/lib/generators/seams/design/templates/app/views/ui/_drawer.html.erb.tt +7 -0
  110. data/lib/generators/seams/design/templates/app/views/ui/_empty.html.erb.tt +5 -0
  111. data/lib/generators/seams/design/templates/app/views/ui/_field.html.erb.tt +14 -0
  112. data/lib/generators/seams/design/templates/app/views/ui/_icon.html.erb.tt +2 -0
  113. data/lib/generators/seams/design/templates/app/views/ui/_icon_sprite.html.erb.tt +22 -0
  114. data/lib/generators/seams/design/templates/app/views/ui/_input_group.html.erb.tt +11 -0
  115. data/lib/generators/seams/design/templates/app/views/ui/_kbd.html.erb.tt +4 -0
  116. data/lib/generators/seams/design/templates/app/views/ui/_menu.html.erb.tt +18 -0
  117. data/lib/generators/seams/design/templates/app/views/ui/_meter.html.erb.tt +15 -0
  118. data/lib/generators/seams/design/templates/app/views/ui/_note.html.erb.tt +2 -0
  119. data/lib/generators/seams/design/templates/app/views/ui/_outline.html.erb.tt +9 -0
  120. data/lib/generators/seams/design/templates/app/views/ui/_pagination.html.erb.tt +12 -0
  121. data/lib/generators/seams/design/templates/app/views/ui/_panel.html.erb.tt +2 -0
  122. data/lib/generators/seams/design/templates/app/views/ui/_popover.html.erb.tt +2 -0
  123. data/lib/generators/seams/design/templates/app/views/ui/_radio.html.erb.tt +5 -0
  124. data/lib/generators/seams/design/templates/app/views/ui/_savestate.html.erb.tt +5 -0
  125. data/lib/generators/seams/design/templates/app/views/ui/_segmented.html.erb.tt +7 -0
  126. data/lib/generators/seams/design/templates/app/views/ui/_stepper.html.erb.tt +12 -0
  127. data/lib/generators/seams/design/templates/app/views/ui/_switch.html.erb.tt +6 -0
  128. data/lib/generators/seams/design/templates/app/views/ui/_tag.html.erb.tt +2 -0
  129. data/lib/generators/seams/design/templates/app/views/ui/_toast.html.erb.tt +12 -0
  130. data/lib/generators/seams/design/templates/app/views/ui/_toolbar.html.erb.tt +15 -0
  131. data/lib/generators/seams/design/templates/app/views/ui/previews/_banner.html.erb.tt +8 -0
  132. data/lib/generators/seams/design/templates/app/views/ui/previews/_breadcrumb.html.erb.tt +5 -0
  133. data/lib/generators/seams/design/templates/app/views/ui/previews/_build_row.html.erb.tt +6 -0
  134. data/lib/generators/seams/design/templates/app/views/ui/previews/_button.html.erb.tt +9 -0
  135. data/lib/generators/seams/design/templates/app/views/ui/previews/_card.html.erb.tt +12 -0
  136. data/lib/generators/seams/design/templates/app/views/ui/previews/_chapter_row.html.erb.tt +6 -0
  137. data/lib/generators/seams/design/templates/app/views/ui/previews/_checkbox.html.erb.tt +6 -0
  138. data/lib/generators/seams/design/templates/app/views/ui/previews/_counter.html.erb.tt +6 -0
  139. data/lib/generators/seams/design/templates/app/views/ui/previews/_data_table.html.erb.tt +14 -0
  140. data/lib/generators/seams/design/templates/app/views/ui/previews/_dialog.html.erb.tt +8 -0
  141. data/lib/generators/seams/design/templates/app/views/ui/previews/_diff.html.erb.tt +10 -0
  142. data/lib/generators/seams/design/templates/app/views/ui/previews/_drawer.html.erb.tt +5 -0
  143. data/lib/generators/seams/design/templates/app/views/ui/previews/_empty.html.erb.tt +5 -0
  144. data/lib/generators/seams/design/templates/app/views/ui/previews/_field.html.erb.tt +5 -0
  145. data/lib/generators/seams/design/templates/app/views/ui/previews/_input_group.html.erb.tt +5 -0
  146. data/lib/generators/seams/design/templates/app/views/ui/previews/_kbd.html.erb.tt +5 -0
  147. data/lib/generators/seams/design/templates/app/views/ui/previews/_menu.html.erb.tt +9 -0
  148. data/lib/generators/seams/design/templates/app/views/ui/previews/_meter.html.erb.tt +6 -0
  149. data/lib/generators/seams/design/templates/app/views/ui/previews/_note.html.erb.tt +2 -0
  150. data/lib/generators/seams/design/templates/app/views/ui/previews/_outline.html.erb.tt +8 -0
  151. data/lib/generators/seams/design/templates/app/views/ui/previews/_pagination.html.erb.tt +5 -0
  152. data/lib/generators/seams/design/templates/app/views/ui/previews/_panel.html.erb.tt +5 -0
  153. data/lib/generators/seams/design/templates/app/views/ui/previews/_popover.html.erb.tt +2 -0
  154. data/lib/generators/seams/design/templates/app/views/ui/previews/_radio.html.erb.tt +6 -0
  155. data/lib/generators/seams/design/templates/app/views/ui/previews/_savestate.html.erb.tt +4 -0
  156. data/lib/generators/seams/design/templates/app/views/ui/previews/_segmented.html.erb.tt +15 -0
  157. data/lib/generators/seams/design/templates/app/views/ui/previews/_stepper.html.erb.tt +6 -0
  158. data/lib/generators/seams/design/templates/app/views/ui/previews/_switch.html.erb.tt +5 -0
  159. data/lib/generators/seams/design/templates/app/views/ui/previews/_tag.html.erb.tt +8 -0
  160. data/lib/generators/seams/design/templates/app/views/ui/previews/_toast.html.erb.tt +6 -0
  161. data/lib/generators/seams/design/templates/app/views/ui/previews/_toolbar.html.erb.tt +11 -0
  162. data/lib/generators/seams/design/templates/lib/design/components.rb.tt +22 -0
  163. data/lib/generators/seams/design/templates/lib/design.rb.tt +18 -0
  164. data/lib/generators/seams/design/templates/lib/engine.rb.tt +38 -0
  165. data/lib/generators/seams/design/templates/lib/generators/design/component/component_generator.rb.tt +52 -0
  166. data/lib/generators/seams/design/templates/lib/generators/design/component/templates/component.html.erb.tt +5 -0
  167. data/lib/generators/seams/design/templates/lib/generators/design/component/templates/preview.html.erb.tt +3 -0
  168. data/lib/generators/seams/design/templates/spec/runtime/design_boot_spec.rb.tt +79 -0
  169. data/lib/generators/seams/design/templates/spec/runtime/form_builder_spec.rb.tt +97 -0
  170. data/lib/generators/seams/design/templates/spec/runtime/guide_spec.rb.tt +40 -0
  171. data/lib/generators/seams/design/templates/spec/runtime/ui_components_spec.rb.tt +70 -0
  172. data/lib/generators/seams/engine/engine_generator.rb +6 -0
  173. data/lib/generators/seams/engine/templates/Gemfile.tt +1 -1
  174. data/lib/generators/seams/engine/templates/app/application_controller.rb.tt +6 -0
  175. data/lib/generators/seams/engine/templates/rubocop.yml.tt +9 -0
  176. data/lib/generators/seams/install/install_generator.rb +72 -6
  177. data/lib/generators/seams/install/templates/Dockerfile.tt +2 -2
  178. data/lib/generators/seams/install/templates/bin_seams.tt +7 -4
  179. data/lib/generators/seams/install/templates/ci.yml.tt +18 -8
  180. data/lib/generators/seams/install/templates/deploy.yml.tt +4 -2
  181. data/lib/generators/seams/install/templates/doc/ARCHITECTURE.md.tt +1 -1
  182. data/lib/generators/seams/install/templates/docker-entrypoint.tt +4 -2
  183. data/lib/generators/seams/install/templates/herb.yml.tt +10 -0
  184. data/lib/generators/seams/install/templates/lefthook.yml.tt +25 -0
  185. data/lib/generators/seams/install/templates/ruby-version.tt +1 -1
  186. data/lib/generators/seams/install/templates/seams.rake.tt +13 -1
  187. data/lib/generators/seams/install/templates/strong_migrations.rb.tt +26 -0
  188. data/lib/generators/seams/notifications/notifications_generator.rb +3 -2
  189. data/lib/generators/seams/notifications/templates/app/controllers/preferences_controller.rb.tt +5 -13
  190. data/lib/generators/seams/notifications/templates/lib/engine.rb.tt +12 -0
  191. data/lib/generators/seams/notifications/templates/lib/notifications.rb.tt +1 -0
  192. data/lib/generators/seams/notifications/templates/lib/preferences.rb.tt +36 -0
  193. data/lib/generators/seams/permissions/permissions_generator.rb +97 -0
  194. data/lib/generators/seams/permissions/templates/config/initializers/seams_permissions.rb.tt +35 -0
  195. data/lib/generators/seams/teams/templates/app/controllers/invitations_controller.rb.tt +27 -11
  196. data/lib/generators/seams/teams/templates/app/controllers/teams_controller.rb.tt +5 -13
  197. data/lib/generators/seams/teams/templates/lib/concerns/authorization.rb.tt +2 -4
  198. data/lib/generators/seams/teams/templates/lib/engine.rb.tt +13 -0
  199. data/lib/seams/cli/list.rb +15 -0
  200. data/lib/seams/cli/quality.rb +46 -5
  201. data/lib/seams/cli/resolve.rb +9 -5
  202. data/lib/seams/cli/test_changed.rb +5 -1
  203. data/lib/seams/cli.rb +20 -0
  204. data/lib/seams/configuration.rb +24 -1
  205. data/lib/seams/cops/no_cross_engine_dependency.rb +39 -7
  206. data/lib/seams/cops/no_cross_engine_model_access.rb +79 -1
  207. data/lib/seams/event_registry.rb +19 -0
  208. data/lib/seams/events/adapter.rb +9 -0
  209. data/lib/seams/events/adapters/active_support.rb +4 -0
  210. data/lib/seams/events/publisher.rb +29 -0
  211. data/lib/seams/events.rb +1 -0
  212. data/lib/seams/generators/dummy_app_writer.rb +21 -0
  213. data/lib/seams/generators/follow_up_generator.rb +1 -2
  214. data/lib/seams/generators/host_injector.rb +9 -2
  215. data/lib/seams/generators/splicer.rb +2 -2
  216. data/lib/seams/observability/adapter.rb +22 -0
  217. data/lib/seams/observability.rb +6 -0
  218. data/lib/seams/permission_registry.rb +66 -0
  219. data/lib/seams/permissions.rb +120 -0
  220. data/lib/seams/version.rb +1 -1
  221. data/lib/seams.rb +7 -0
  222. metadata +107 -3
@@ -0,0 +1,647 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "rails/generators"
5
+ require "seams"
6
+ require "generators/seams/engine/engine_generator"
7
+ require "seams/generators/host_injector"
8
+ require "seams/generators/eject_aware"
9
+
10
+ module Seams
11
+ module Generators
12
+ # Generates the canonical Design engine on top of the generic engine
13
+ # scaffold — Phase 1 (this unit): the engine skeleton, the non-isolated
14
+ # wiring that makes `ui_*` helpers + `ui/` partials visible host-wide, the
15
+ # Tailwind v4 token injection, the FormBuilder default, and the icon sprite
16
+ # render.
17
+ #
18
+ # The design engine is DELIBERATELY NOT `isolate_namespace`d (D4 in
19
+ # proposals/design_system_engine.md). The whole value is that a component
20
+ # renders anywhere in the host and in every other engine's views without
21
+ # ceremony, which requires the partials and the helper to live in the host's
22
+ # view paths and `ActionController::Base`. The base EngineGenerator produces
23
+ # an isolated engine, so this generator overwrites lib/design/engine.rb with
24
+ # the non-isolated form and removes the single-namespace leftovers the base
25
+ # scaffold ships.
26
+ #
27
+ # Naming (D1): the engine, CLI verb, folder and Ruby namespace are `design`
28
+ # (`Design::`); the VIEW + HELPER surface is `ui` — partials at
29
+ # app/views/ui/_<name>.html.erb, previews at app/views/ui/previews/, and
30
+ # auto-derived helpers named `ui_<name>`.
31
+ #
32
+ # Later sub-issues import this skeleton: #17 (tokens/theme), #18 (helpers +
33
+ # gallery + tests), #19 (FormBuilder + form components), #20 (the
34
+ # design:component generator), and the Phase 2/3 component + shell units.
35
+ #
36
+ # Run with: bin/seams design (or bin/rails generate seams:design)
37
+ #
38
+ # Like the admin generator, this is a long-but-flat orchestration class: each
39
+ # public method is one small, single-purpose generate step, so the length is
40
+ # inherent to the number of files the engine ships, not tangled logic.
41
+ # rubocop:disable-next Metrics/ClassLength
42
+ class DesignGenerator < Rails::Generators::Base
43
+ include Seams::Generators::HostInjector
44
+ include Seams::Generators::EjectAware
45
+
46
+ source_root File.expand_path("templates", __dir__)
47
+
48
+ # Opt-in app shell (#26). Off by default — the design system generates the
49
+ # component library only. With --shell, the generator additionally writes a
50
+ # default application layout (header, nav, flash, footer, all built from
51
+ # ui_* components) and a starter signed-in dashboard + route into the host,
52
+ # so the host boots looking like a product. This is the building block the
53
+ # future `seams new` orchestrator passes.
54
+ class_option :shell, type: :boolean, default: false,
55
+ desc: "Also generate a default application layout + starter dashboard"
56
+
57
+ ENGINE_NAME = "design"
58
+
59
+ def create_base_engine
60
+ # The base EngineGenerator raises if engines/design/ already exists.
61
+ # Skip it on a re-run so a second `bin/seams design` is a no-op on the
62
+ # engine and simply re-applies the (idempotent) host wiring below.
63
+ if File.directory?(engine_path(""))
64
+ say " exist engines/design (kept — re-applying host wiring only)", :blue
65
+ return
66
+ end
67
+
68
+ EngineGenerator.start([ENGINE_NAME], destination_root: destination_root)
69
+ end
70
+
71
+ # The base EngineGenerator emits an ISOLATED engine. The design engine is
72
+ # non-isolated by design (D4), so overwrite lib/design/engine.rb with the
73
+ # non-isolated form that auto-wires the helper into ActionController::Base.
74
+ # engine.rb stays framework-managed (NOT eject-aware), like every other
75
+ # canonical generator's engine.rb.
76
+ def overwrite_engine_entry_point
77
+ template "lib/engine.rb.tt", engine_path("lib/design/engine.rb"), force: true
78
+ template "lib/design.rb.tt", engine_path("lib/design.rb"), force: true
79
+ end
80
+
81
+ # The base scaffold ships an isolated engine's ApplicationController and
82
+ # ApplicationRecord under app/controllers/design/ and app/models/design/.
83
+ # A view-layer engine needs neither — its only controller is the dev-only
84
+ # guide (created below) and it has no models — so remove the leftovers.
85
+ def remove_isolated_leftovers
86
+ # config/routes.rb is KEPT (an empty `Design::Engine.routes.draw do end`)
87
+ # so the engine stays mountable in the dummy app + host; the dev-only
88
+ # guide route (#18) is drawn into it later.
89
+ %w[
90
+ app/controllers/design/application_controller.rb
91
+ app/models/design/application_record.rb
92
+ spec/design_spec.rb
93
+ ].each do |relative|
94
+ full = engine_path(relative)
95
+ next unless File.exist?(full)
96
+
97
+ FileUtils.rm(full)
98
+ say " remove #{relative} (isolated-engine leftover)", :red
99
+ end
100
+
101
+ %w[app/controllers/design app/models/design].each do |relative|
102
+ full = engine_path(relative)
103
+ Dir.rmdir(full) if File.directory?(full) && Dir.empty?(full)
104
+ end
105
+ end
106
+
107
+ # The auto-wire registry: Design.component_names derives the public
108
+ # component list from the preview partials, and resets on reload so a new
109
+ # component appears without a server restart. Ported from quire-saas's
110
+ # lib/compositor.rb (compositor -> design, compositor/previews -> ui/previews).
111
+ def create_auto_wire
112
+ template "lib/design/components.rb.tt", engine_path("lib/design/components.rb")
113
+ end
114
+
115
+ # The host-wide helper module. ui_icon is the one hand-written helper;
116
+ # define_component_helpers! auto-derives ui_<name> for every preview.
117
+ # Ported from quire-saas's app/helpers/compositor_helper.rb.
118
+ def create_helper
119
+ template "app/helpers/design/ui_helper.rb.tt",
120
+ engine_path("app/helpers/design/ui_helper.rb")
121
+ end
122
+
123
+ # The default form builder. Subclasses the standard Rails builder and only
124
+ # ADDS ui_* methods, so it is safe as the app-wide default. Ported from
125
+ # quire-saas's app/form_builders/compositor/form_builder.rb (compositor_*
126
+ # -> ui_*, the field partial path compositor/field -> ui/field). #19
127
+ # fleshes out the textarea/select/submit helpers + the ui/field partial.
128
+ def create_form_builder
129
+ template "app/form_builders/design/form_builder.rb.tt",
130
+ engine_path("app/form_builders/design/form_builder.rb")
131
+ end
132
+
133
+ # The form-input component set (#19): the building blocks Design::FormBuilder
134
+ # and hand-written forms render. Ported faithfully from quire-saas's
135
+ # compositor (compositor_* -> ui_*, quire copy neutralised in the previews):
136
+ #
137
+ # - field the label/input/hint/error wrapper with baked-in
138
+ # aria-invalid + aria-describedby wiring (what
139
+ # f.ui_text_field renders);
140
+ # - checkbox an accessible labelled checkbox with an optional hint;
141
+ # - radio a labelled radio (grouped by name in a fieldset);
142
+ # - switch a role="switch" toggle;
143
+ # - input_group a text input with an optional prefix/suffix affix.
144
+ #
145
+ # Each ships with a companion preview, which is what makes it "public": the
146
+ # auto-wire derives ui_<name> from the preview and the gallery lists it.
147
+ # Eject-aware so a host can own a component without losing it on regenerate.
148
+ def create_form_components
149
+ %w[field checkbox radio switch input_group].each do |name|
150
+ template_unless_ejected "app/views/ui/_#{name}.html.erb.tt",
151
+ engine_path("app/views/ui/_#{name}.html.erb")
152
+ template_unless_ejected "app/views/ui/previews/_#{name}.html.erb.tt",
153
+ engine_path("app/views/ui/previews/_#{name}.html.erb")
154
+ end
155
+ end
156
+
157
+ # The icon sprite + icon partials — the minimum view surface the skeleton
158
+ # needs so `render "ui/icon_sprite"` (wired into the host layout below) and
159
+ # ui_icon resolve. #25 ships the full primitive + icon set; these two are
160
+ # the load-bearing pair the host layout references on first boot.
161
+ def create_icon_partials
162
+ template_unless_ejected "app/views/ui/_icon.html.erb.tt",
163
+ engine_path("app/views/ui/_icon.html.erb")
164
+ template_unless_ejected "app/views/ui/_icon_sprite.html.erb.tt",
165
+ engine_path("app/views/ui/_icon_sprite.html.erb")
166
+ end
167
+
168
+ # The seed component set — enough for the gallery + the contract/render
169
+ # tests to have something real to render. Ported faithfully from
170
+ # quire-saas's compositor (compositor_* -> ui_*, quire copy neutralised):
171
+ # _button (takes a content block + variant/size) and _tag (a required
172
+ # `label:` strict local — the contract test relies on it being required).
173
+ # Each ships with a companion preview, which is what makes it "public":
174
+ # the auto-wire derives ui_<name> from the preview, and the gallery lists
175
+ # it. Eject-aware so a host can own a component without losing it on
176
+ # regenerate. #21+ ship the full component set.
177
+ def create_seed_components
178
+ %w[button tag].each do |name|
179
+ template_unless_ejected "app/views/ui/_#{name}.html.erb.tt",
180
+ engine_path("app/views/ui/_#{name}.html.erb")
181
+ template_unless_ejected "app/views/ui/previews/_#{name}.html.erb.tt",
182
+ engine_path("app/views/ui/previews/_#{name}.html.erb")
183
+ end
184
+ end
185
+
186
+ # The Navigation component set (Phase 2, GROUPKEY = nav). Ported faithfully
187
+ # from quire-saas's compositor (compositor_* -> ui_*, quire copy
188
+ # neutralised in the previews), each carrying its baked-in navigation
189
+ # accessibility roles/aria:
190
+ #
191
+ # - breadcrumb a nav[aria-label=Breadcrumb] trail with aria-current=page;
192
+ # - pagination a nav[aria-label=Pagination] with per-page aria-current;
193
+ # - menu a role=menu list of role=menuitem links/buttons;
194
+ # - segmented a role=group of aria-pressed toggle buttons;
195
+ # - stepper an ordered list with aria-current=step + done ticks;
196
+ # - toolbar a role=toolbar of labelled icon/text buttons;
197
+ # - outline a nav[aria-label=Outline] heading tree with aria-current.
198
+ #
199
+ # Each ships with a companion preview, which is what makes it "public": the
200
+ # auto-wire derives ui_<name> from the preview and the gallery lists it.
201
+ # Eject-aware so a host can own a component without losing it on regenerate.
202
+ def create_nav_components
203
+ %w[breadcrumb pagination menu segmented stepper toolbar outline].each do |name|
204
+ template_unless_ejected "app/views/ui/_#{name}.html.erb.tt",
205
+ engine_path("app/views/ui/_#{name}.html.erb")
206
+ template_unless_ejected "app/views/ui/previews/_#{name}.html.erb.tt",
207
+ engine_path("app/views/ui/previews/_#{name}.html.erb")
208
+ end
209
+ end
210
+
211
+ # The Overlays component set (Phase 2, GROUPKEY = overlays). Ported
212
+ # faithfully from quire-saas's compositor (compositor_* -> ui_*,
213
+ # compositor-dialog controller -> ui-dialog, quire copy neutralised in the
214
+ # previews), each carrying its baked-in overlay accessibility:
215
+ #
216
+ # - dialog a native <dialog aria-labelledby> with a labelled close
217
+ # button, driven by a ui-dialog Stimulus controller the host
218
+ # supplies (data-controller / data-action wiring baked in);
219
+ # - drawer an <aside aria-label> side-panel landmark;
220
+ # - popover a role=note annotation bubble;
221
+ # - savestate a role=status live region (saved / saving / unsaved).
222
+ #
223
+ # Each ships with a companion preview, which is what makes it "public": the
224
+ # auto-wire derives ui_<name> from the preview and the gallery lists it.
225
+ # Eject-aware so a host can own a component without losing it on regenerate.
226
+ def create_overlays_components
227
+ %w[dialog drawer popover savestate].each do |name|
228
+ template_unless_ejected "app/views/ui/_#{name}.html.erb.tt",
229
+ engine_path("app/views/ui/_#{name}.html.erb")
230
+ template_unless_ejected "app/views/ui/previews/_#{name}.html.erb.tt",
231
+ engine_path("app/views/ui/previews/_#{name}.html.erb")
232
+ end
233
+ end
234
+
235
+ # The Primitives & icons component set (Phase 2, GROUPKEY = primitives).
236
+ # The icon + icon_sprite primitives ship from create_icon_partials above;
237
+ # this set adds the remaining low-level building blocks, ported faithfully
238
+ # from quire-saas's compositor (compositor_* -> ui_*, quire copy
239
+ # neutralised in the previews), each carrying its baked-in accessibility:
240
+ #
241
+ # - panel a plain raised content surface (a content-block wrapper);
242
+ # - diff a per-line add/del/ctx list whose +/- signs are aria-labelled
243
+ # "added"/"removed" so the glyph alone is not load-bearing;
244
+ # - empty an empty-state with a required title + content-block body.
245
+ #
246
+ # Each ships with a companion preview, which is what makes it "public": the
247
+ # auto-wire derives ui_<name> from the preview and the gallery lists it.
248
+ # Eject-aware so a host can own a component without losing it on regenerate.
249
+ def create_primitive_components
250
+ %w[panel diff empty].each do |name|
251
+ template_unless_ejected "app/views/ui/_#{name}.html.erb.tt",
252
+ engine_path("app/views/ui/_#{name}.html.erb")
253
+ template_unless_ejected "app/views/ui/previews/_#{name}.html.erb.tt",
254
+ engine_path("app/views/ui/previews/_#{name}.html.erb")
255
+ end
256
+ end
257
+
258
+ # The Actions & status component set (Phase 2, GROUPKEY = actions). Ported
259
+ # faithfully from quire-saas's compositor (compositor_* -> ui_*, quire copy
260
+ # neutralised in the previews), each carrying its baked-in accessibility:
261
+ #
262
+ # - banner a page-level role=region announcement with a tone variant;
263
+ # - toast a role=status transient notification;
264
+ # - note an inline annotation span.
265
+ #
266
+ # Each ships with a companion preview, which is what makes it "public": the
267
+ # auto-wire derives ui_<name> from the preview and the gallery lists it.
268
+ # Eject-aware so a host can own a component without losing it on regenerate.
269
+ def create_actions_components
270
+ %w[banner toast note].each do |name|
271
+ template_unless_ejected "app/views/ui/_#{name}.html.erb.tt",
272
+ engine_path("app/views/ui/_#{name}.html.erb")
273
+ template_unless_ejected "app/views/ui/previews/_#{name}.html.erb.tt",
274
+ engine_path("app/views/ui/previews/_#{name}.html.erb")
275
+ end
276
+ end
277
+
278
+ # The Data display component set (Phase 2, GROUPKEY = data). Ported
279
+ # faithfully from quire-saas's compositor (compositor_* -> ui_*, quire copy
280
+ # neutralised in the previews), each carrying its baked-in accessibility:
281
+ #
282
+ # - card a titled content surface;
283
+ # - data_table a <table> with a caption + scoped headers;
284
+ # - chapter_row a manuscript chapter list row;
285
+ # - build_row an export/build status list row;
286
+ # - counter a labelled numeric stat;
287
+ # - meter a <meter>-backed progress indicator;
288
+ # - kbd a <kbd> keyboard-shortcut glyph.
289
+ #
290
+ # Each ships with a companion preview, which is what makes it "public": the
291
+ # auto-wire derives ui_<name> from the preview and the gallery lists it.
292
+ # Eject-aware so a host can own a component without losing it on regenerate.
293
+ def create_data_components
294
+ %w[card data_table chapter_row build_row counter meter kbd].each do |name|
295
+ template_unless_ejected "app/views/ui/_#{name}.html.erb.tt",
296
+ engine_path("app/views/ui/_#{name}.html.erb")
297
+ template_unless_ejected "app/views/ui/previews/_#{name}.html.erb.tt",
298
+ engine_path("app/views/ui/previews/_#{name}.html.erb")
299
+ end
300
+ end
301
+
302
+ # The living gallery (dev/test only). The controller renders every
303
+ # component from its preview so the docs cannot drift; the route is guarded
304
+ # to Rails.env.local? both in the controller (404 in production) and at the
305
+ # host routes (drawn inside an `if Rails.env.local?` block). Ported from
306
+ # quire-saas's compositor guide. Eject-aware so a host can restyle the
307
+ # gallery chrome.
308
+ def create_guide
309
+ template "app/controllers/design/guide_controller.rb.tt",
310
+ engine_path("app/controllers/design/guide_controller.rb")
311
+ template_unless_ejected "app/views/layouts/design/guide.html.erb.tt",
312
+ engine_path("app/views/layouts/design/guide.html.erb")
313
+ template_unless_ejected "app/views/design/guide/index.html.erb.tt",
314
+ engine_path("app/views/design/guide/index.html.erb")
315
+ end
316
+
317
+ # The design:component generator (#20): ships INSIDE the generated engine
318
+ # at engines/design/lib/generators/design/component/, so a host can run
319
+ # `rails g design:component <name>` (Rails auto-discovers it on the engine's
320
+ # lib path; no registration needed). Ported from quire-saas's Compositor.
321
+ #
322
+ # copy_file (NOT template): the generator's own .tt templates are ERB run by
323
+ # `rails g design:component`, so they must reach the engine VERBATIM. The
324
+ # meta-generator's ERB would un-escape their `<%%` markers and nest the
325
+ # `<%= file_name %>` placeholders inside a real tag — a parse error.
326
+ def create_component_generator
327
+ copy_file "lib/generators/design/component/component_generator.rb.tt",
328
+ engine_path("lib/generators/design/component/component_generator.rb")
329
+ copy_file "lib/generators/design/component/templates/component.html.erb.tt",
330
+ engine_path("lib/generators/design/component/templates/component.html.erb.tt")
331
+ copy_file "lib/generators/design/component/templates/preview.html.erb.tt",
332
+ engine_path("lib/generators/design/component/templates/preview.html.erb.tt")
333
+ end
334
+
335
+ def create_runtime_spec
336
+ template "spec/runtime/design_boot_spec.rb.tt",
337
+ engine_path("spec/runtime/design_boot_spec.rb")
338
+ template "spec/runtime/ui_components_spec.rb.tt",
339
+ engine_path("spec/runtime/ui_components_spec.rb")
340
+ template "spec/runtime/form_builder_spec.rb.tt",
341
+ engine_path("spec/runtime/form_builder_spec.rb")
342
+ template "spec/runtime/guide_spec.rb.tt",
343
+ engine_path("spec/runtime/guide_spec.rb")
344
+ end
345
+
346
+ def overwrite_readme
347
+ template "README.md.tt", engine_path("README.md"), force: true
348
+ end
349
+
350
+ # The opt-in app shell (#26), generated ONLY with --shell. Without the
351
+ # flag none of these files appear and the host keeps its rails-new layout.
352
+ #
353
+ # - app/views/layouts/application.html.erb — the HOST's default layout,
354
+ # overwritten (force) with one built entirely from ui_* components
355
+ # (header, nav, flash banners, footer). Eject-aware so a host that has
356
+ # already customised it on a later run keeps its version.
357
+ # - the starter signed-in dashboard controller + view, shipped INTO the
358
+ # engine (Design::DashboardController subclasses the host's
359
+ # ApplicationController), with an empty-state listing the engines the
360
+ # host could add. The route + root are drawn in wire_into_host.
361
+ def create_shell
362
+ return unless shell?
363
+
364
+ say " shell generating the opt-in app shell (--shell)", :green
365
+ create_shell_layout
366
+ template "app/controllers/design/dashboard_controller.rb.tt",
367
+ engine_path("app/controllers/design/dashboard_controller.rb")
368
+ template_unless_ejected "app/views/design/dashboard/index.html.erb.tt",
369
+ engine_path("app/views/design/dashboard/index.html.erb")
370
+ end
371
+
372
+ # Ship the example "quire" theme (#27) into the host as a token overlay the
373
+ # host can opt into. It is NOT applied by default (the neutral theme owns
374
+ # the default, per the proposal) — it sits alongside application.css as the
375
+ # worked proof that retheming is a token override: add one
376
+ # `@import "themes/quire";` line and the whole app reskins. The theming
377
+ # guide (doc/design-system/DESIGN_SYSTEM_THEMING.md) documents it. Eject-aware.
378
+ def create_example_theme
379
+ template_unless_ejected "app/assets/tailwind/themes/_quire.css",
380
+ host_path("app/assets/tailwind/themes/_quire.css")
381
+ end
382
+
383
+ # --- Host wiring ----------------------------------------------------------
384
+
385
+ def wire_into_host
386
+ # Tailwind v4 is a hard dependency (D2): the @theme token layer the
387
+ # engine ships is Tailwind-native. Inject the gem and write the token
388
+ # block into the host's application.css.
389
+ host_inject_gem("tailwindcss-rails", "~> 4.0")
390
+ inject_theme_into_host_css
391
+ set_host_default_form_builder
392
+ render_sprite_in_host_layout
393
+ draw_guide_route_in_host
394
+ draw_dashboard_route_in_host if shell?
395
+ end
396
+
397
+ def report_summary
398
+ say report_summary_text, :green
399
+ end
400
+
401
+ private
402
+
403
+ # Draw the dev/test-only living-gallery route into the HOST's routes. The
404
+ # design engine is non-isolated, so its Design::GuideController lives on the
405
+ # host's controller path and a plain host route reaches it — matching how
406
+ # quire-saas exposes /compositor/guide. The route is wrapped in an
407
+ # `if Rails.env.local?` guard so it does not exist in production at all
408
+ # (defence in depth with the controller's own guard_available? 404).
409
+ # Idempotent: skips if the route is already drawn.
410
+ def draw_guide_route_in_host
411
+ routes = host_path("config/routes.rb")
412
+ unless File.exist?(routes)
413
+ return host_skip("config/routes.rb not found — add the guide route " \
414
+ '(get "design/guide" => "design/guide#index") yourself')
415
+ end
416
+
417
+ return if File.read(routes).include?('"design/guide#index"')
418
+
419
+ say " inject config/routes.rb (design/guide — dev/test only)", :green
420
+ inject_into_file routes, after: routes_draw_anchor do
421
+ <<-RUBY
422
+ # The seams design living gallery — dev/test only. Renders every ui_*
423
+ # component from its preview so the docs cannot drift. Guarded here AND in
424
+ # the controller so it never reaches production.
425
+ if Rails.env.local?
426
+ get "design/guide" => "design/guide#index", as: :design_guide
427
+ end
428
+ RUBY
429
+ end
430
+ end
431
+
432
+ def engine_path(relative)
433
+ File.join(destination_root, "engines", ENGINE_NAME, relative)
434
+ end
435
+
436
+ def shell?
437
+ options[:shell]
438
+ end
439
+
440
+ # A human app name for the shell layout + dashboard copy, derived from the
441
+ # host's config/application.rb module (the `rails new` app name), falling
442
+ # back to a sensible default. Pure cosmetics — the host owns these files.
443
+ def app_name
444
+ @app_name ||= begin
445
+ application_rb = host_path("config/application.rb")
446
+ name = File.read(application_rb)[/module\s+([A-Z]\w+)/, 1] if File.exist?(application_rb)
447
+ (name || "App").gsub(/([a-z])([A-Z])/, '\1 \2')
448
+ end
449
+ end
450
+
451
+ # Write the HOST's application layout from the shell template. force: true
452
+ # because `rails new` already shipped a default application.html.erb we are
453
+ # deliberately replacing; eject-aware so a host that has stamped the eject
454
+ # header (to own its layout) keeps its version on a later regenerate.
455
+ def create_shell_layout
456
+ template_unless_ejected "app/views/layouts/application.html.erb.tt",
457
+ host_path("app/views/layouts/application.html.erb"),
458
+ force: true
459
+ end
460
+
461
+ # Draw the starter dashboard route into the HOST routes and point root at
462
+ # it, so a --shell host boots straight to the styled dashboard. The design
463
+ # engine is non-isolated, so Design::DashboardController lives on the host
464
+ # controller path and a plain host route reaches it. Idempotent: skips if
465
+ # the dashboard route is already drawn.
466
+ def draw_dashboard_route_in_host
467
+ routes = host_path("config/routes.rb")
468
+ unless File.exist?(routes)
469
+ return host_skip("config/routes.rb not found — add the dashboard route " \
470
+ '(root "design/dashboard#index") yourself')
471
+ end
472
+
473
+ return if File.read(routes).include?('"design/dashboard#index"')
474
+
475
+ say " inject config/routes.rb (starter dashboard + root)", :green
476
+ # Only add a root route if the host has none yet — never clobber a
477
+ # host-defined root.
478
+ root_line = File.read(routes).match?(/^\s*root\s/) ? "" : %( root "design/dashboard#index"\n)
479
+ block = <<~RUBY
480
+ # The seams design starter dashboard (--shell). A styled, signed-in home the
481
+ # host boots to; replace Design::DashboardController with your real home page.
482
+ get "dashboard" => "design/dashboard#index", as: :dashboard
483
+ RUBY
484
+ block = block.gsub(/^/, " ") + root_line
485
+ inject_into_file routes, after: routes_draw_anchor do
486
+ block
487
+ end
488
+ end
489
+
490
+ # Write the neutral @theme token layer into the host's Tailwind entrypoint.
491
+ # This is the SINGLE SOURCE every ui_* component reads (#17): the full,
492
+ # WCAG-AA-audited neutral default — the @theme palette/type tokens, the
493
+ # `:root` alias layer (type scale, spacing, radius, shadow, motion, layout,
494
+ # z-index, breakpoints) and the base focus/selection/skip-link rules. The
495
+ # block lives in templates/app/assets/tailwind/_tokens.css so it stays
496
+ # readable and diffable; the generator appends it verbatim.
497
+ #
498
+ # Also adds an `@source` line so Tailwind scans the engine's component
499
+ # partials and builds the utility classes they emit. If the host has no
500
+ # application.css yet (no tailwindcss-rails installed at generate time),
501
+ # create one with the `@import "tailwindcss"` line so the first boot has a
502
+ # working stylesheet. Idempotent — skips if the token marker is present.
503
+ def inject_theme_into_host_css
504
+ css_path = host_path("app/assets/tailwind/application.css")
505
+
506
+ unless File.exist?(css_path)
507
+ FileUtils.mkdir_p(File.dirname(css_path))
508
+ create_file css_path, host_css_preamble
509
+ end
510
+
511
+ # Ensure Tailwind scans the engine's ui/ partials even when the host
512
+ # already had its own application.css (e.g. after tailwindcss:install).
513
+ unless File.read(css_path).include?(ENGINE_SOURCE_GLOB)
514
+ append_to_file css_path, <<~CSS
515
+
516
+ /* Scan the seams design engine so the classes its ui/ partials emit are built. */
517
+ @source "#{ENGINE_SOURCE_GLOB}";
518
+ CSS
519
+ end
520
+
521
+ return if File.read(css_path).include?(THEME_MARKER)
522
+
523
+ say " inject app/assets/tailwind/application.css (@theme tokens)", :green
524
+ append_to_file css_path, "\n#{neutral_theme_block}"
525
+ end
526
+
527
+ ENGINE_SOURCE_GLOB = "../../../engines/design/app/views"
528
+ private_constant :ENGINE_SOURCE_GLOB
529
+
530
+ THEME_MARKER = "seams:design tokens"
531
+ private_constant :THEME_MARKER
532
+
533
+ # The Tailwind entrypoint we create when the host has none yet: the import
534
+ # plus an @source line so Tailwind scans the engine's ui/ partials and
535
+ # builds the utility classes they emit.
536
+ def host_css_preamble
537
+ <<~CSS
538
+ @import "tailwindcss";
539
+
540
+ /* Scan the seams design engine so the classes its ui/ partials emit are built. */
541
+ @source "#{ENGINE_SOURCE_GLOB}";
542
+ CSS
543
+ end
544
+
545
+ # The full neutral default token layer (#17), read verbatim from the
546
+ # template so the large CSS stays readable and reviewable in one place.
547
+ def neutral_theme_block
548
+ File.read(File.expand_path("templates/app/assets/tailwind/_tokens.css", __dir__))
549
+ end
550
+
551
+ # Make Design::FormBuilder the host's default form builder so every
552
+ # `form_with` / `form_for` gets the f.ui_* field methods without passing
553
+ # `builder:`. Injected into config/application.rb inside the Application
554
+ # class body. Idempotent.
555
+ def set_host_default_form_builder
556
+ application_rb = host_path("config/application.rb")
557
+ unless File.exist?(application_rb)
558
+ return host_skip("config/application.rb not found — set " \
559
+ "config.action_view.default_form_builder = \"Design::FormBuilder\" yourself")
560
+ end
561
+
562
+ contents = File.read(application_rb)
563
+ return if contents.include?("default_form_builder")
564
+
565
+ say " inject config/application.rb (default_form_builder = Design::FormBuilder)", :green
566
+ inject_into_class application_rb, "Application", <<~RUBY
567
+ # The Design engine's FormBuilder only ADDS ui_* field helpers; the
568
+ # standard f.text_field / f.select / f.submit are untouched, so it is
569
+ # safe as the app-wide default. Set as a string so the constant is
570
+ # resolved lazily, after the engine has loaded.
571
+ config.action_view.default_form_builder = "Design::FormBuilder"
572
+ RUBY
573
+ end
574
+
575
+ # Render the icon sprite once near the top of <body> in the host layout so
576
+ # the ui_* components can reference icons by fragment without an external
577
+ # request. Injected immediately after the opening <body> tag. Skips if the
578
+ # host layout is missing or already renders the sprite.
579
+ def render_sprite_in_host_layout
580
+ layout = host_path("app/views/layouts/application.html.erb")
581
+ unless File.exist?(layout)
582
+ return host_skip("app/views/layouts/application.html.erb not found — " \
583
+ 'add `<%= render "ui/icon_sprite" %>` near the top of <body> yourself')
584
+ end
585
+
586
+ contents = File.read(layout)
587
+ return if contents.include?('render "ui/icon_sprite"')
588
+
589
+ body_anchor = /<body[^>]*>\n/
590
+ unless contents.match?(body_anchor)
591
+ return host_skip("app/views/layouts/application.html.erb has no <body> tag — " \
592
+ 'add `<%= render "ui/icon_sprite" %>` near the top of <body> yourself')
593
+ end
594
+
595
+ say " inject app/views/layouts/application.html.erb (render \"ui/icon_sprite\")", :green
596
+ inject_into_file layout, after: body_anchor do
597
+ %( <%# seams design — icon sprite for ui_* components %>\n) +
598
+ %( <%= render "ui/icon_sprite" %>\n)
599
+ end
600
+ end
601
+
602
+ def shell_summary_note
603
+ return "" unless shell?
604
+
605
+ <<~SHELL
606
+
607
+ App shell (--shell): a default app/views/layouts/application.html.erb
608
+ and a starter dashboard at root (and /dashboard) were generated.
609
+ Boot the host and you land on the styled dashboard.
610
+ SHELL
611
+ end
612
+
613
+ def report_summary_text
614
+ <<~TXT
615
+
616
+ Design engine generated at engines/design/
617
+ #{shell_summary_note}
618
+
619
+ Next steps:
620
+ 1. bundle install
621
+ (picks up tailwindcss-rails, injected into the host Gemfile)
622
+
623
+ 2. bin/rails tailwindcss:install (if Tailwind isn't set up yet)
624
+ then build it: bin/rails tailwindcss:build
625
+
626
+ 3. Use the components anywhere in the host or another engine's views:
627
+ <%= ui_button(variant: :primary) { "Save" } %>
628
+ <%= form_with model: @record do |f| %>
629
+ <%= f.ui_text_field :title, label: "Title" %>
630
+ <% end %>
631
+
632
+ The engine is non-isolated: ui_* helpers and ui/ partials resolve
633
+ everywhere, and Design::FormBuilder is the host default form builder.
634
+
635
+ Retheme by overriding the @theme tokens in
636
+ app/assets/tailwind/application.css. The example "quire" theme ships at
637
+ app/assets/tailwind/themes/_quire.css — apply it with one line:
638
+ `@import "themes/quire";` (see doc/design-system/DESIGN_SYSTEM_THEMING.md).
639
+
640
+ Run the engine specs:
641
+ bin/rails seams:test[design]
642
+
643
+ TXT
644
+ end
645
+ end
646
+ end
647
+ end