kanso 0.1.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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +352 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/images/kanso/icons/check-circle.svg +3 -0
  6. data/app/assets/images/kanso/icons/chevron-down.svg +3 -0
  7. data/app/assets/images/kanso/icons/exclamation-circle.svg +3 -0
  8. data/app/assets/images/kanso/icons/exclamation-triangle.svg +3 -0
  9. data/app/assets/images/kanso/icons/information-circle.svg +3 -0
  10. data/app/assets/images/kanso/icons/question-circle.svg +3 -0
  11. data/app/assets/images/kanso/icons/x-circle.svg +3 -0
  12. data/app/assets/images/kanso/icons/x-mark.svg +3 -0
  13. data/app/assets/javascripts/kanso/controllers/dropdown_controller.js +52 -0
  14. data/app/assets/javascripts/kanso/controllers/form_controller.js +17 -0
  15. data/app/assets/javascripts/kanso/controllers/modal_controller.js +48 -0
  16. data/app/assets/javascripts/kanso/controllers/notification_controller.js +43 -0
  17. data/app/assets/javascripts/kanso/helpers/transition.js +49 -0
  18. data/app/components/kanso/button_component.rb +58 -0
  19. data/app/components/kanso/class_combinable.rb +25 -0
  20. data/app/components/kanso/dropdown_component.html.erb +19 -0
  21. data/app/components/kanso/dropdown_component.rb +10 -0
  22. data/app/components/kanso/form_field_component.html.erb +24 -0
  23. data/app/components/kanso/form_field_component.rb +51 -0
  24. data/app/components/kanso/form_field_skeleton_component.html.erb +7 -0
  25. data/app/components/kanso/form_field_skeleton_component.rb +11 -0
  26. data/app/components/kanso/icon_component.rb +30 -0
  27. data/app/components/kanso/modal_component.html.erb +53 -0
  28. data/app/components/kanso/modal_component.rb +49 -0
  29. data/app/components/kanso/notification_component.html.erb +25 -0
  30. data/app/components/kanso/notification_component.rb +58 -0
  31. data/app/controllers/kanso/application_controller.rb +4 -0
  32. data/app/helpers/kanso/application_helper.rb +4 -0
  33. data/config/importmap.rb +5 -0
  34. data/config/routes.rb +2 -0
  35. data/lib/generators/kanso/install/install_generator.rb +209 -0
  36. data/lib/kanso/engine.rb +11 -0
  37. data/lib/kanso/version.rb +3 -0
  38. data/lib/kanso.rb +6 -0
  39. data/lib/tasks/kanso_tasks.rake +4 -0
  40. metadata +192 -0
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kanso
4
+ class ButtonComponent < ViewComponent::Base
5
+ include Kanso::ClassCombinable
6
+
7
+ THEME_CLASSES = {
8
+ primary: "bg-indigo-500 text-white hover:bg-indigo-600 active:bg-indigo-700",
9
+ danger: "bg-red-600 text-white hover:bg-red-700 active:bg-red-800",
10
+ default: "bg-white text-indigo-600 border border-indigo-600 focus:ring-indigo-500/50 hover:bg-indigo-50 hover:border-indigo-700 active:bg-indigo-700 active:text-white"
11
+ }.freeze
12
+
13
+ def initialize(theme: :default, tag: :button, url: nil, method: nil, **options)
14
+ @theme = theme
15
+ @tag = tag
16
+ @url = url
17
+ @method = method
18
+ @options = options
19
+ end
20
+
21
+ def call
22
+ if @url
23
+ button_to(@url, method: @method, **html_options) do
24
+ content
25
+ end
26
+ else
27
+ content_tag(@tag, content, html_options)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def html_options
34
+ final_options = @options.dup
35
+ user_provided_class_value = final_options.delete(:class)
36
+ combined_class_string = combine_classes(classes, user_provided_class_value)
37
+ final_options.deep_merge(class: combined_class_string)
38
+ end
39
+
40
+ def classes
41
+ [ base_classes, theme_classes ].join(" ")
42
+ end
43
+
44
+ def base_classes
45
+ "inline-flex items-center justify-center gap-x-2 " +
46
+ "w-full sm:w-auto rounded-full font-semibold whitespace-nowrap select-none no-underline text-center px-5 py-2 " +
47
+ "shadow-md " +
48
+ "transform transition duration-200 " +
49
+ "hover:cursor-pointer " +
50
+ "active:translate-y-px active:shadow-none " +
51
+ "disabled:cursor-not-allowed disabled:opacity-50"
52
+ end
53
+
54
+ def theme_classes
55
+ THEME_CLASSES.fetch(@theme, THEME_CLASSES[:default])
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,25 @@
1
+ module Kanso
2
+ module ClassCombinable
3
+ extend ActiveSupport::Concern
4
+
5
+ def combine_classes(component_classes_string, user_classes_value)
6
+ component_classes_array = component_classes_string.split(" ")
7
+
8
+ user_classes_array = clean_and_split_classes(user_classes_value)
9
+
10
+ (component_classes_array + user_classes_array).compact.uniq.join(" ")
11
+ end
12
+
13
+ private
14
+
15
+ def clean_and_split_classes(value)
16
+ if value.nil?
17
+ []
18
+ elsif value.kind_of?(Array)
19
+ value
20
+ else
21
+ value.split(" ")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,19 @@
1
+ <div data-controller="kanso--dropdown" class="relative inline-block text-left">
2
+ <div data-action="click->kanso--dropdown#toggle">
3
+ <%= trigger %>
4
+ </div>
5
+
6
+ <div data-kanso--dropdown-target="panel"
7
+ class="hidden absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none transition ease-out duration-100"
8
+ role="menu"
9
+ aria-orientation="vertical"
10
+ tabindex="-1"
11
+ data-action="
12
+ keydown.esc@window->kanso--dropdown#close
13
+ click@window->kanso--dropdown#closeOutside
14
+ ">
15
+
16
+ <%= content %>
17
+
18
+ </div>
19
+ </div>
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kanso
4
+ class DropdownComponent < ViewComponent::Base
5
+ renders_one :trigger
6
+
7
+ def initialize
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,24 @@
1
+ <div data-controller="kanso--form" class="mb-4">
2
+ <%= form.label attribute, for: field_id, class: "block text-sm font-medium leading-6 text-slate-900" %>
3
+
4
+ <div class="relative mt-1">
5
+ <%= form.send(type, attribute, field_options) %>
6
+
7
+ <% if has_errors? %>
8
+ <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
9
+ <%= render Kanso::IconComponent.new(name: "exclamation-circle", class: "h-5 w-5 text-red-500") %>
10
+ </div>
11
+ <% end %>
12
+ </div>
13
+
14
+ <div id="<%= description_id %>" class="mt-2 text-sm min-h-[1.25rem]">
15
+ <% if has_errors? %>
16
+ <p data-kanso--form-target="errorMessage"
17
+ class="text-red-600 opacity-0 transition duration-300 ease-out -translate-y-2">
18
+ <%= form.object.errors.full_messages_for(attribute).to_sentence %>
19
+ </p>
20
+ <% elsif help_text.present? %>
21
+ <p class="text-slate-500"><%= help_text %></p>
22
+ <% end %>
23
+ </div>
24
+ </div>
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kanso
4
+ class FormFieldComponent < ViewComponent::Base
5
+ renders_one :help_text
6
+
7
+ attr_reader :form, :attribute, :type, :options
8
+
9
+ def initialize(form:, attribute:, type: :text_field, **options)
10
+ @form = form
11
+ @attribute = attribute
12
+ @type = type
13
+ @options = options
14
+ end
15
+
16
+ private
17
+
18
+ def has_errors?
19
+ form.object.errors.include?(attribute)
20
+ end
21
+
22
+ def field_id
23
+ helpers.dom_id(form.object, attribute)
24
+ end
25
+
26
+ def description_id
27
+ "#{field_id}_description"
28
+ end
29
+
30
+ def field_classes
31
+ base_classes = "block w-full rounded-md border-0 py-1.5 px-3 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6"
32
+
33
+ if has_errors?
34
+ error_classes = "text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500"
35
+ "#{base_classes} #{error_classes}"
36
+ else
37
+ normal_classes = "text-slate-900 ring-slate-300 placeholder:text-slate-400 focus:ring-blue-600"
38
+ "#{base_classes} #{normal_classes}"
39
+ end
40
+ end
41
+
42
+ def field_options
43
+ options.deep_merge(
44
+ id: field_id,
45
+ class: field_classes,
46
+ aria: { describedby: description_id },
47
+ data: { kanso__form_target: ("errorField" if has_errors?) }
48
+ )
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,7 @@
1
+ <% fields.times do %>
2
+ <div class="mb-4 animate-pulse">
3
+ <div class="mb-1 h-5 w-1/3 rounded bg-slate-200"></div>
4
+ <div class="h-10 w-full rounded-md bg-slate-200"></div>
5
+ <div class="mt-2 h-5 w-3/4 rounded bg-slate-200"></div>
6
+ </div>
7
+ <% end %>
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kanso
4
+ class FormFieldSkeletonComponent < ViewComponent::Base
5
+ attr_reader :fields
6
+
7
+ def initialize(fields: 1)
8
+ @fields = fields
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kanso
4
+ class IconComponent < ViewComponent::Base
5
+ attr_reader :name, :options
6
+
7
+ def initialize(name:, **options)
8
+ @name = name
9
+ @options = options
10
+ end
11
+
12
+ def call
13
+ attributes = tag.attributes(
14
+ options.deep_merge(data: { icon_name: name })
15
+ )
16
+
17
+ raw_svg = cached_svg_content
18
+ raw_svg.sub("<svg", "<svg #{attributes}").html_safe
19
+ end
20
+
21
+ private
22
+
23
+ def cached_svg_content
24
+ Rails.cache.fetch("kanso:icon:#{name}", expires_in: 1.day) do
25
+ file_path = Kanso::Engine.root.join("app/assets/images/kanso/icons/#{name}.svg")
26
+ File.read(file_path) if File.exist?(file_path)
27
+ end.to_s
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,53 @@
1
+ <div data-controller="kanso--modal">
2
+
3
+ <div data-action="click->kanso--modal#open">
4
+ <%= trigger %>
5
+ </div>
6
+
7
+ <div data-kanso--modal-target="container"
8
+ class="fixed inset-0 z-50 hidden"
9
+ data-action="keydown.esc@window->kanso--modal#close">
10
+
11
+ <div data-kanso--modal-target="backdrop"
12
+ data-action="mousedown->kanso--modal#backdropMousedown mouseup->kanso--modal#backdropMouseup"
13
+ class="fixed inset-0 flex w-full items-center justify-center bg-slate-900/70 p-4 transition-opacity duration-300 ease-in-out"
14
+ data-transition-enter-from="opacity-0"
15
+ data-transition-enter-to="opacity-100"
16
+ data-transition-leave-from="opacity-100"
17
+ data-transition-leave-to="opacity-0">
18
+
19
+ <div role="dialog"
20
+ aria-modal="true"
21
+ aria-labelledby="<%= header.title_id if header.present? %>"
22
+ data-kanso--modal-target="panel"
23
+ class="flex w-full <%= size_classes %> max-h-full transform flex-col rounded-lg bg-white text-left shadow-2xl transition-all duration-300 ease-in-out"
24
+ data-transition-enter-from="opacity-0 scale-95"
25
+ data-transition-enter-to="opacity-100 scale-100"
26
+ data-transition-leave-from="opacity-100 scale-100"
27
+ data-transition-leave-to="opacity-0 scale-95">
28
+
29
+ <% if header.present? %>
30
+ <div data-test-id="kanso--modal-header" class="flex flex-shrink-0 items-center justify-between rounded-t-lg border-b border-slate-200 p-4">
31
+ <h2 id="<%= header.title_id %>" class="text-lg font-semibold text-slate-900">
32
+ <%= header.title %>
33
+ </h2>
34
+ <button type="button" data-action="click->kanso--modal#close" class="-m-2 p-2 text-slate-400 hover:cursor-pointer hover:text-slate-600">
35
+ <span class="sr-only">Close</span>
36
+ <%= render Kanso::IconComponent.new(name: "x-mark", class: "h-6 w-6") %>
37
+ </button>
38
+ </div>
39
+ <% end %>
40
+
41
+ <div data-test-id="kanso--modal-body" class="overflow-y-auto p-4">
42
+ <%= content %>
43
+ </div>
44
+
45
+ <% if footer.present? %>
46
+ <div data-test-id="kanso--modal-footer" class="flex flex-shrink-0 flex-row-reverse items-center gap-x-2 rounded-b-lg border-t border-slate-200 bg-slate-50 p-4">
47
+ <%= footer %>
48
+ </div>
49
+ <% end %>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ </div>
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kanso
4
+ class ModalComponent < ViewComponent::Base
5
+ renders_one :trigger
6
+ renders_one :header, ->(title:) do
7
+ HeaderComponent.new(title: title)
8
+ end
9
+ renders_one :footer
10
+
11
+ attr_reader :size
12
+
13
+ def initialize(size: :lg)
14
+ @size = size
15
+ end
16
+
17
+ private
18
+
19
+ def size_classes
20
+ case size
21
+ when :sm
22
+ "max-w-sm"
23
+ when :md
24
+ "max-w-md"
25
+ when :lg
26
+ "max-w-lg"
27
+ when :xl
28
+ "max-w-xl"
29
+ when :xxl
30
+ "max-w-2xl"
31
+ else
32
+ "max-w-lg"
33
+ end
34
+ end
35
+
36
+ class HeaderComponent < ViewComponent::Base
37
+ attr_reader :title, :title_id
38
+
39
+ def initialize(title:)
40
+ @title = title
41
+ @title_id = "modal-title-#{SecureRandom.hex(4)}"
42
+ end
43
+
44
+ def call
45
+ content
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,25 @@
1
+ <div data-controller="kanso--notification"
2
+ class="flex w-full max-w-sm items-center rounded-lg bg-white p-4 shadow-lg ring-1 ring-black ring-opacity-5 opacity-0 transform translate-x-full transition-all duration-300 ease-out"
3
+ role="<%= theme_data.aria_role %>">
4
+ <div class="flex-shrink-0">
5
+ <div class="flex h-8 w-8 items-center justify-center rounded-full <%= theme_data.icon_bg_classes %>">
6
+ <%= render Kanso::IconComponent.new(name: theme_data.icon_name, class: "h-5 w-5 #{theme_data.icon_color_classes}") %>
7
+ </div>
8
+ </div>
9
+ <div class="ml-3 w-0 flex-1 pt-0.5">
10
+ <% if title.present? %>
11
+ <h3 class="text-sm font-medium <%= theme_data.title_text_classes %>"><%= title %></h3>
12
+ <p class="mt-1 text-sm <%= theme_data.body_text_classes %>"><%= message %></p>
13
+ <% else %>
14
+ <p class="text-sm font-medium <%= theme_data.title_text_classes %>"><%= message %></p>
15
+ <% end %>
16
+ </div>
17
+ <div class="ml-4 flex flex-shrink-0">
18
+ <button type="button"
19
+ data-action="click->kanso--notification#close"
20
+ class="inline-flex rounded-md bg-white text-gray-400 transition-colors hover:text-gray-500 hover:cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
21
+ <span class="sr-only">Close</span>
22
+ <%= render Kanso::IconComponent.new(name: "x-mark", class: "h-5 w-5") %>
23
+ </button>
24
+ </div>
25
+ </div>
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kanso
4
+ class NotificationComponent < ViewComponent::Base
5
+ Theme = Struct.new(
6
+ :icon_name,
7
+ :icon_bg_classes,
8
+ :icon_color_classes,
9
+ :title_text_classes,
10
+ :body_text_classes,
11
+ :aria_role,
12
+ keyword_init: true
13
+ )
14
+
15
+ THEMES = {
16
+ success: Theme.new(
17
+ icon_name: "check-circle",
18
+ icon_bg_classes: "bg-green-100",
19
+ icon_color_classes: "text-green-600",
20
+ title_text_classes: "text-green-800",
21
+ body_text_classes: "text-green-700",
22
+ aria_role: "status"
23
+ ),
24
+ error: Theme.new(
25
+ icon_name: "x-circle",
26
+ icon_bg_classes: "bg-red-100",
27
+ icon_color_classes: "text-red-600",
28
+ title_text_classes: "text-red-800",
29
+ body_text_classes: "text-red-700",
30
+ aria_role: "alert"
31
+ ),
32
+ warning: Theme.new(
33
+ icon_name: "exclamation-triangle",
34
+ icon_bg_classes: "bg-yellow-100",
35
+ icon_color_classes: "text-yellow-600",
36
+ title_text_classes: "text-yellow-800",
37
+ body_text_classes: "text-yellow-700",
38
+ aria_role: "alert"
39
+ ),
40
+ info: Theme.new(
41
+ icon_name: "information-circle",
42
+ icon_bg_classes: "bg-blue-100",
43
+ icon_color_classes: "text-blue-600",
44
+ title_text_classes: "text-blue-800",
45
+ body_text_classes: "text-blue-700",
46
+ aria_role: "status"
47
+ )
48
+ }.freeze
49
+
50
+ attr_reader :title, :message, :theme_data
51
+
52
+ def initialize(message:, title: nil, theme: :info)
53
+ @title = title
54
+ @message = message
55
+ @theme_data = THEMES[theme.to_sym] || THEMES[:info]
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,4 @@
1
+ module Kanso
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Kanso
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,5 @@
1
+ pin "controllers/kanso--modal_controller", to: "kanso/controllers/modal_controller.js"
2
+ pin "controllers/kanso--form_controller", to: "kanso/controllers/form_controller.js"
3
+ pin "controllers/kanso--notification_controller", to: "kanso/controllers/notification_controller.js"
4
+ pin "controllers/kanso--dropdown_controller", to: "kanso/controllers/dropdown_controller.js"
5
+ pin "transition", to: "kanso/helpers/transition.js"
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ Kanso::Engine.routes.draw do
2
+ end
@@ -0,0 +1,209 @@
1
+ # lib/generators/kanso/install/install_generator.rb
2
+
3
+ class Kanso::InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("templates", __dir__)
5
+
6
+ # Kanso Philosophy: Public methods are generator tasks.
7
+ # Helper methods must be explicitly declared to avoid being run by Thor.
8
+ no_commands do
9
+ # This method exists to make the generator testable.
10
+ # In a test environment, we can stub this to point to a temporary directory.
11
+ def application_root
12
+ Rails.root
13
+ end
14
+ end
15
+
16
+ def install
17
+ say "Kanso: Starting installation...", :cyan
18
+
19
+ # --- Prerequisite Checks based on observable facts ---
20
+ css_path = application_root.join("app/assets/tailwind/application.css")
21
+ root_config_path = application_root.join("tailwind.config.js")
22
+ legacy_config_path = application_root.join("config/tailwind.config.js")
23
+
24
+ unless File.exist?(css_path)
25
+ say "\nKanso Installation Blocked", :red
26
+ say "--------------------------", :red
27
+ say "Could not find the required Tailwind CSS entrypoint at:", :yellow
28
+ say "`app/assets/tailwind/application.css`", :bold
29
+ say "\nThis file is required for Kanso to be installed correctly.", :yellow
30
+ say "Please run `rails tailwindcss:install` to generate it, then re-run this generator.", :cyan
31
+ return
32
+ end
33
+
34
+ if File.exist?(legacy_config_path)
35
+ say "\nKanso Installation Blocked", :red
36
+ say "--------------------------", :red
37
+ say "A Tailwind configuration file was found at:", :yellow
38
+ say "`config/tailwind.config.js`", :bold
39
+ say "\nTo ensure a single source of truth, Kanso requires the configuration to be at the project root.", :yellow
40
+ say "Please migrate this file to `tailwind.config.js` at the root of your project, then re-run this generator.", :cyan
41
+ return
42
+ end
43
+
44
+ # --- Core Logic ---
45
+ configure_tailwind_config(root_config_path)
46
+ configure_application_css(css_path, root_config_path)
47
+
48
+ say "Kanso: Installation complete! ✨", :green
49
+ end
50
+
51
+ private
52
+
53
+ # --- Tailwind Configuration Logic ---
54
+
55
+ def configure_tailwind_config(path)
56
+ if File.exist?(path)
57
+ inject_into_tailwind_config(path)
58
+ else
59
+ create_tailwind_config(path)
60
+ end
61
+ end
62
+
63
+ def inject_into_tailwind_config(path)
64
+ file_content = File.read(path)
65
+
66
+ if file_content.include?("// Kanso: Managed")
67
+ say "Kanso: Tailwind configuration is already Kanso-aware. Skipping.", :yellow
68
+ return
69
+ end
70
+
71
+ if file_content.match?(/export\s+default/m)
72
+ say "Kanso: Your `tailwind.config.js` uses ESM syntax (`export default`).", :yellow
73
+ say " To prevent conflicts, Kanso will not modify this file.", :yellow
74
+ say " Please manually add the Kanso content path to your config:", :cyan
75
+ say " You'll need `path.join` and this helper function:", :bold
76
+ puts find_kanso_gem_path_function.indent(7)
77
+ puts " And add this to your `content` array:".indent(7)
78
+ puts " `path.join(kansoGemPath, \"app/components/**/*.{rb,html.erb}\")`".indent(7)
79
+ return
80
+ end
81
+
82
+ say "Kanso: Found existing `tailwind.config.js`. Safely appending Kanso configuration.", :cyan
83
+ append_to_file path, kanso_scriptlet
84
+ end
85
+
86
+ def create_tailwind_config(path)
87
+ say "Kanso: No `tailwind.config.js` found. Creating a new, Kanso-aware configuration.", :cyan
88
+ create_file path, <<~JS
89
+ // Kanso: Managed Tailwind Configuration
90
+ const path = require("path");
91
+ const { execSync } = require("child_process");
92
+
93
+ #{find_kanso_gem_path_function.strip.indent(2)}
94
+
95
+ const kansoGemPath = findKansoGemPath();
96
+
97
+ module.exports = {
98
+ content: [
99
+ "./app/helpers/**/*.rb",
100
+ "./app/javascript/**/*.js",
101
+ "./app/views/**/*.{html,html.erb,erb}",
102
+ path.join(kansoGemPath, "app/components/**/*.{rb,html.erb}")
103
+ ],
104
+ theme: {
105
+ extend: {},
106
+ },
107
+ plugins: [],
108
+ }
109
+ JS
110
+ end
111
+
112
+ # --- CSS Configuration Logic ---
113
+
114
+ def configure_application_css(css_path, config_path)
115
+ file_content = File.read(css_path)
116
+ inject_config_bridge(css_path, file_content, config_path)
117
+ inject_base_styles(css_path, file_content)
118
+ end
119
+
120
+ def inject_config_bridge(css_path, file_content, config_path)
121
+ if file_content.include?("@config")
122
+ say "Kanso: `@config` directive already present in CSS. Skipping.", :yellow
123
+ else
124
+ relative_config_path = config_path.relative_path_from(css_path.dirname).to_s
125
+ config_block = "/* Kanso: Tells Tailwind to use our smart JS config */\n@config \"#{relative_config_path}\";\n\n"
126
+ prepend_to_file css_path, config_block
127
+ say "Kanso: Injected `@config` bridge into `#{relative_path(css_path)}`.", :green
128
+ end
129
+ end
130
+
131
+ def inject_base_styles(css_path, file_content)
132
+ if file_content.include?("/* Kanso: Base Styles")
133
+ say "Kanso: Base styles already present. Skipping.", :yellow
134
+ else
135
+ anchor = /@import\s+["']tailwindcss["'];\s*(\r\n|\n)/
136
+ if file_content.match?(anchor)
137
+ insert_into_file css_path, "\n#{base_styles_block.strip}\n", after: anchor
138
+ say "Kanso: Injected base styles.", :green
139
+ else
140
+ say "Kanso: Could not find the `@import \"tailwindcss\";` directive.", :red
141
+ say " Please add the Kanso base styles block manually:", :yellow
142
+ puts base_styles_block
143
+ end
144
+ end
145
+ end
146
+
147
+ # --- Helper Methods & Content Blocks ---
148
+
149
+ def kanso_scriptlet
150
+ <<~JS
151
+
152
+ // --- Kanso Configuration (appended by `rails g kanso:install`) ---
153
+ // Kanso: Managed
154
+ try {
155
+ const kansoGemPath = require("child_process").execSync(`bundle exec ruby -e "puts Bundler.rubygems.find_name(%q{kanso}).first.full_gem_path"`).toString().trim();
156
+ if (kansoGemPath) {
157
+ const kansoContentPath = require("path").join(kansoGemPath, "app/components/**/*.{rb,html.erb}");
158
+ if (module.exports.content) {
159
+ if (!module.exports.content.includes(kansoContentPath)) {
160
+ module.exports.content.push(kansoContentPath);
161
+ }
162
+ } else {
163
+ module.exports.content = [kansoContentPath];
164
+ }
165
+ }
166
+ } catch (e) {
167
+ console.error("Kanso post-install configuration failed. Please add the Kanso content path to your tailwind.config.js manually.");
168
+ console.error(e);
169
+ }
170
+ // --- End Kanso Configuration ---
171
+ JS
172
+ end
173
+
174
+ def find_kanso_gem_path_function
175
+ <<~JS
176
+ function findKansoGemPath() {
177
+ try {
178
+ return execSync(
179
+ `bundle exec ruby -e "puts Bundler.rubygems.find_name(%q{kanso}).first.full_gem_path"`
180
+ ).toString().trim();
181
+ } catch (e) {
182
+ console.error("Could not find the 'kanso' gem via Bundler. Please ensure it's in your Gemfile.");
183
+ console.error(e.stderr.toString());
184
+ process.exit(1);
185
+ }
186
+ }
187
+ JS
188
+ end
189
+
190
+ def base_styles_block
191
+ <<~CSS
192
+ /* Kanso: Base Styles injected into the native 'base' cascade layer */
193
+ @layer base {
194
+ input:focus, textarea:focus, select:focus {
195
+ outline: none;
196
+ }
197
+ turbo-frame[busy] {
198
+ opacity: 0.5;
199
+ transition: opacity 0.3s ease-in-out;
200
+ }
201
+ }
202
+ /* End Kanso Base Styles */
203
+ CSS
204
+ end
205
+
206
+ def relative_path(path)
207
+ path.relative_path_from(Rails.root).to_s
208
+ end
209
+ end
@@ -0,0 +1,11 @@
1
+ require "view_component"
2
+
3
+ module Kanso
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Kanso
6
+
7
+ initializer "kanso.importmap", before: "importmap" do |app|
8
+ app.config.importmap.paths << Engine.root.join("config/importmap.rb")
9
+ end
10
+ end
11
+ end