shadcn-phlex 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 (113) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +195 -0
  3. data/app.css +20 -0
  4. data/css/shadcn-source.css +3 -0
  5. data/css/shadcn-tailwind.css +160 -0
  6. data/css/themes/mauve.css +62 -0
  7. data/css/themes/mist.css +62 -0
  8. data/css/themes/neutral.css +74 -0
  9. data/css/themes/olive.css +62 -0
  10. data/css/themes/stone.css +62 -0
  11. data/css/themes/taupe.css +62 -0
  12. data/css/themes/zinc.css +62 -0
  13. data/js/controllers/accordion_controller.js +135 -0
  14. data/js/controllers/checkbox_controller.js +52 -0
  15. data/js/controllers/collapsible_controller.js +85 -0
  16. data/js/controllers/combobox_controller.js +168 -0
  17. data/js/controllers/command_controller.js +171 -0
  18. data/js/controllers/context_menu_controller.js +132 -0
  19. data/js/controllers/dark_mode_controller.js +106 -0
  20. data/js/controllers/dialog_controller.js +205 -0
  21. data/js/controllers/drawer_controller.js +161 -0
  22. data/js/controllers/dropdown_menu_controller.js +189 -0
  23. data/js/controllers/hover_card_controller.js +85 -0
  24. data/js/controllers/index.js +89 -0
  25. data/js/controllers/menubar_controller.js +171 -0
  26. data/js/controllers/navigation_menu_controller.js +160 -0
  27. data/js/controllers/popover_controller.js +151 -0
  28. data/js/controllers/radio_group_controller.js +78 -0
  29. data/js/controllers/scroll_area_controller.js +117 -0
  30. data/js/controllers/select_controller.js +198 -0
  31. data/js/controllers/sheet_controller.js +130 -0
  32. data/js/controllers/slider_controller.js +142 -0
  33. data/js/controllers/switch_controller.js +40 -0
  34. data/js/controllers/tabs_controller.js +96 -0
  35. data/js/controllers/toast_controller.js +206 -0
  36. data/js/controllers/toggle_controller.js +30 -0
  37. data/js/controllers/toggle_group_controller.js +73 -0
  38. data/js/controllers/tooltip_controller.js +146 -0
  39. data/lib/generators/shadcn_phlex/component_generator.rb +79 -0
  40. data/lib/generators/shadcn_phlex/install_generator.rb +217 -0
  41. data/lib/shadcn/base.rb +27 -0
  42. data/lib/shadcn/engine.rb +24 -0
  43. data/lib/shadcn/kit.rb +1158 -0
  44. data/lib/shadcn/themes/accent_colors.rb +106 -0
  45. data/lib/shadcn/themes/base_colors.rb +313 -0
  46. data/lib/shadcn/ui/accordion.rb +135 -0
  47. data/lib/shadcn/ui/alert.rb +79 -0
  48. data/lib/shadcn/ui/alert_dialog.rb +220 -0
  49. data/lib/shadcn/ui/aspect_ratio.rb +35 -0
  50. data/lib/shadcn/ui/avatar.rb +134 -0
  51. data/lib/shadcn/ui/badge.rb +48 -0
  52. data/lib/shadcn/ui/breadcrumb.rb +180 -0
  53. data/lib/shadcn/ui/button.rb +63 -0
  54. data/lib/shadcn/ui/button_group.rb +58 -0
  55. data/lib/shadcn/ui/card.rb +133 -0
  56. data/lib/shadcn/ui/checkbox.rb +72 -0
  57. data/lib/shadcn/ui/collapsible.rb +76 -0
  58. data/lib/shadcn/ui/combobox.rb +229 -0
  59. data/lib/shadcn/ui/command.rb +256 -0
  60. data/lib/shadcn/ui/context_menu.rb +319 -0
  61. data/lib/shadcn/ui/dialog.rb +226 -0
  62. data/lib/shadcn/ui/direction.rb +23 -0
  63. data/lib/shadcn/ui/drawer.rb +217 -0
  64. data/lib/shadcn/ui/dropdown_menu.rb +384 -0
  65. data/lib/shadcn/ui/empty.rb +97 -0
  66. data/lib/shadcn/ui/field.rb +126 -0
  67. data/lib/shadcn/ui/hover_card.rb +75 -0
  68. data/lib/shadcn/ui/input.rb +36 -0
  69. data/lib/shadcn/ui/input_group.rb +32 -0
  70. data/lib/shadcn/ui/input_otp.rb +112 -0
  71. data/lib/shadcn/ui/item.rb +115 -0
  72. data/lib/shadcn/ui/kbd.rb +45 -0
  73. data/lib/shadcn/ui/label.rb +28 -0
  74. data/lib/shadcn/ui/menubar.rb +345 -0
  75. data/lib/shadcn/ui/native_select.rb +31 -0
  76. data/lib/shadcn/ui/navigation_menu.rb +238 -0
  77. data/lib/shadcn/ui/pagination.rb +224 -0
  78. data/lib/shadcn/ui/popover.rb +147 -0
  79. data/lib/shadcn/ui/progress.rb +40 -0
  80. data/lib/shadcn/ui/radio_group.rb +92 -0
  81. data/lib/shadcn/ui/resizable.rb +108 -0
  82. data/lib/shadcn/ui/scroll_area.rb +75 -0
  83. data/lib/shadcn/ui/select.rb +235 -0
  84. data/lib/shadcn/ui/separator.rb +36 -0
  85. data/lib/shadcn/ui/sheet.rb +231 -0
  86. data/lib/shadcn/ui/sidebar.rb +420 -0
  87. data/lib/shadcn/ui/skeleton.rb +23 -0
  88. data/lib/shadcn/ui/slider.rb +72 -0
  89. data/lib/shadcn/ui/sonner.rb +177 -0
  90. data/lib/shadcn/ui/spinner.rb +58 -0
  91. data/lib/shadcn/ui/switch.rb +75 -0
  92. data/lib/shadcn/ui/table.rb +154 -0
  93. data/lib/shadcn/ui/tabs.rb +154 -0
  94. data/lib/shadcn/ui/text_field.rb +146 -0
  95. data/lib/shadcn/ui/textarea.rb +32 -0
  96. data/lib/shadcn/ui/theme_toggle.rb +74 -0
  97. data/lib/shadcn/ui/toggle.rb +66 -0
  98. data/lib/shadcn/ui/toggle_group.rb +75 -0
  99. data/lib/shadcn/ui/tooltip.rb +78 -0
  100. data/lib/shadcn/ui/typography.rb +217 -0
  101. data/lib/shadcn/version.rb +5 -0
  102. data/lib/shadcn-phlex.rb +6 -0
  103. data/lib/shadcn.rb +80 -0
  104. data/package.json +14 -0
  105. data/skills/shadcn-phlex/SKILL.md +190 -0
  106. data/skills/shadcn-phlex/evals/evals.json +90 -0
  107. data/skills/shadcn-phlex/references/component-catalog.md +355 -0
  108. data/skills/shadcn-phlex/rules/composition.md +235 -0
  109. data/skills/shadcn-phlex/rules/forms.md +151 -0
  110. data/skills/shadcn-phlex/rules/helpers.md +54 -0
  111. data/skills/shadcn-phlex/rules/stimulus.md +61 -0
  112. data/skills/shadcn-phlex/rules/styling.md +177 -0
  113. metadata +209 -0
@@ -0,0 +1,73 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replicates Radix ToggleGroup behavior
4
+ // Supports single (default) and multiple selection
5
+ export default class extends Controller {
6
+ static targets = ["item"]
7
+ static values = {
8
+ type: { type: String, default: "single" }, // "single" or "multiple"
9
+ value: { type: Array, default: [] },
10
+ disabled: { type: Boolean, default: false },
11
+ }
12
+
13
+ connect() {
14
+ this._syncState()
15
+ }
16
+
17
+ toggle(event) {
18
+ if (this.disabledValue) return
19
+ const item = event.currentTarget
20
+ const value = item.dataset.value
21
+ if (!value) return
22
+
23
+ if (this.typeValue === "single") {
24
+ // Toggle off if already selected, otherwise select
25
+ if (this.valueValue.includes(value)) {
26
+ this.valueValue = []
27
+ } else {
28
+ this.valueValue = [value]
29
+ }
30
+ } else {
31
+ // Toggle in/out of array
32
+ if (this.valueValue.includes(value)) {
33
+ this.valueValue = this.valueValue.filter((v) => v !== value)
34
+ } else {
35
+ this.valueValue = [...this.valueValue, value]
36
+ }
37
+ }
38
+
39
+ this.dispatch("change", { detail: { value: this.valueValue } })
40
+ }
41
+
42
+ keydown(event) {
43
+ const items = this.itemTargets
44
+ const current = items.indexOf(event.currentTarget)
45
+ if (current === -1) return
46
+
47
+ let nextIndex
48
+ switch (event.key) {
49
+ case "ArrowRight":
50
+ case "ArrowDown":
51
+ event.preventDefault()
52
+ nextIndex = (current + 1) % items.length
53
+ items[nextIndex].focus()
54
+ break
55
+ case "ArrowLeft":
56
+ case "ArrowUp":
57
+ event.preventDefault()
58
+ nextIndex = (current - 1 + items.length) % items.length
59
+ items[nextIndex].focus()
60
+ break
61
+ }
62
+ }
63
+
64
+ valueValueChanged() { this._syncState() }
65
+
66
+ _syncState() {
67
+ this.itemTargets.forEach((item) => {
68
+ const isPressed = this.valueValue.includes(item.dataset.value)
69
+ item.dataset.state = isPressed ? "on" : "off"
70
+ item.setAttribute("aria-pressed", String(isPressed))
71
+ })
72
+ }
73
+ }
@@ -0,0 +1,146 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Replicates Radix Tooltip behavior
4
+ // Show on hover/focus with delay, hide on leave/blur, positioning
5
+ export default class extends Controller {
6
+ static targets = ["trigger", "content"]
7
+ static values = {
8
+ open: { type: Boolean, default: false },
9
+ side: { type: String, default: "top" },
10
+ sideOffset: { type: Number, default: 4 },
11
+ delayDuration: { type: Number, default: 200 },
12
+ skipDelayDuration: { type: Number, default: 300 },
13
+ }
14
+
15
+ connect() {
16
+ this._showTimeout = null
17
+ this._hideTimeout = null
18
+ this._hideTimeouts = []
19
+ this._syncState()
20
+ }
21
+
22
+ disconnect() {
23
+ clearTimeout(this._showTimeout)
24
+ clearTimeout(this._hideTimeout)
25
+ this._hideTimeouts.forEach(id => clearTimeout(id))
26
+ this._hideTimeouts = []
27
+ }
28
+
29
+ mouseEnter() {
30
+ clearTimeout(this._hideTimeout)
31
+ this._showTimeout = setTimeout(() => { this.openValue = true }, this.delayDurationValue)
32
+ }
33
+
34
+ mouseLeave() {
35
+ clearTimeout(this._showTimeout)
36
+ this._hideTimeout = setTimeout(() => { this.openValue = false }, 100)
37
+ }
38
+
39
+ focusIn() {
40
+ clearTimeout(this._hideTimeout)
41
+ this.openValue = true
42
+ }
43
+
44
+ focusOut() {
45
+ this.openValue = false
46
+ }
47
+
48
+ contentMouseEnter() {
49
+ clearTimeout(this._hideTimeout)
50
+ }
51
+
52
+ contentMouseLeave() {
53
+ this._hideTimeout = setTimeout(() => { this.openValue = false }, 100)
54
+ }
55
+
56
+ openValueChanged() { this._syncState() }
57
+
58
+ _syncState() {
59
+ if (!this._hideTimeouts) return
60
+ const state = this.openValue ? "open" : "closed"
61
+ this.element.dataset.state = state
62
+
63
+ this.contentTargets.forEach((el) => {
64
+ el.dataset.state = state
65
+ if (this.openValue) {
66
+ el.hidden = false
67
+ el.setAttribute("role", "tooltip")
68
+ this._position(el)
69
+ } else {
70
+ this._hideAfterAnimation(el)
71
+ }
72
+ })
73
+
74
+ // Link trigger aria-describedby
75
+ if (this.hasTriggerTarget && this.hasContentTarget) {
76
+ if (this.openValue) {
77
+ const id = this._ensureId(this.contentTarget, "tooltip")
78
+ this.triggerTarget.setAttribute("aria-describedby", id)
79
+ } else {
80
+ this.triggerTarget.removeAttribute("aria-describedby")
81
+ }
82
+ }
83
+ }
84
+
85
+ _position(content) {
86
+ const trigger = this.hasTriggerTarget ? this.triggerTarget : null
87
+ if (!trigger) return
88
+
89
+ const triggerRect = trigger.getBoundingClientRect()
90
+ const offset = this.sideOffsetValue
91
+
92
+ content.style.position = "fixed"
93
+ content.style.zIndex = "50"
94
+ content.style.pointerEvents = "auto"
95
+
96
+ // Measure
97
+ const contentRect = content.getBoundingClientRect()
98
+ let top, left
99
+
100
+ switch (this.sideValue) {
101
+ case "top":
102
+ top = triggerRect.top - contentRect.height - offset
103
+ left = triggerRect.left + (triggerRect.width - contentRect.width) / 2
104
+ content.dataset.side = "top"
105
+ break
106
+ case "bottom":
107
+ top = triggerRect.bottom + offset
108
+ left = triggerRect.left + (triggerRect.width - contentRect.width) / 2
109
+ content.dataset.side = "bottom"
110
+ break
111
+ case "left":
112
+ top = triggerRect.top + (triggerRect.height - contentRect.height) / 2
113
+ left = triggerRect.left - contentRect.width - offset
114
+ content.dataset.side = "left"
115
+ break
116
+ case "right":
117
+ top = triggerRect.top + (triggerRect.height - contentRect.height) / 2
118
+ left = triggerRect.right + offset
119
+ content.dataset.side = "right"
120
+ break
121
+ }
122
+
123
+ // Flip if overflowing
124
+ if (this.sideValue === "top" && top < 0) {
125
+ top = triggerRect.bottom + offset
126
+ content.dataset.side = "bottom"
127
+ } else if (this.sideValue === "bottom" && top + contentRect.height > window.innerHeight) {
128
+ top = triggerRect.top - contentRect.height - offset
129
+ content.dataset.side = "top"
130
+ }
131
+
132
+ left = Math.max(8, Math.min(left, window.innerWidth - contentRect.width - 8))
133
+
134
+ content.style.top = `${top}px`
135
+ content.style.left = `${left}px`
136
+ }
137
+
138
+ _hideAfterAnimation(el) {
139
+ this._hideTimeouts.push(setTimeout(() => { if (el.dataset.state === "closed") el.hidden = true }, 150))
140
+ }
141
+
142
+ _ensureId(el, prefix) {
143
+ if (!el.id) el.id = `${prefix}-${Math.random().toString(36).slice(2, 9)}`
144
+ return el.id
145
+ }
146
+ }
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module ShadcnPhlex
6
+ class ComponentGenerator < Rails::Generators::Base
7
+ desc "Copy a shadcn component into your app"
8
+ argument :name, type: :string, desc: "Component name (e.g., button, card, dialog)"
9
+
10
+ COMPONENTS = %w[
11
+ accordion alert alert_dialog aspect_ratio avatar badge breadcrumb
12
+ button button_group card checkbox collapsible context_menu dialog
13
+ direction drawer dropdown_menu empty field hover_card input
14
+ input_group item kbd label menubar native_select navigation_menu
15
+ pagination popover progress radio_group scroll_area select
16
+ separator sheet skeleton slider sonner spinner switch table tabs
17
+ textarea toggle toggle_group tooltip typography
18
+ ].freeze
19
+
20
+ # Components that need a Stimulus controller
21
+ INTERACTIVE = %w[
22
+ accordion checkbox collapsible combobox command context_menu dialog
23
+ drawer dropdown_menu hover_card menubar navigation_menu popover
24
+ radio_group scroll_area select sheet slider switch tabs toast
25
+ toggle toggle_group tooltip
26
+ ].freeze
27
+
28
+ def validate_component
29
+ unless COMPONENTS.include?(normalized_name) || normalized_name == "all"
30
+ say_status :error, "Unknown component: #{name}. Available: #{COMPONENTS.join(', ')}", :red
31
+ raise SystemExit
32
+ end
33
+ end
34
+
35
+ def copy_component
36
+ if normalized_name == "all"
37
+ COMPONENTS.each { |c| copy_single_component(c) }
38
+ else
39
+ copy_single_component(normalized_name)
40
+ end
41
+ end
42
+
43
+ def copy_stimulus_controller
44
+ if normalized_name == "all"
45
+ INTERACTIVE.each { |c| copy_single_controller(c) }
46
+ elsif INTERACTIVE.include?(normalized_name)
47
+ copy_single_controller(normalized_name)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def normalized_name
54
+ @normalized_name ||= name.underscore.tr("-", "_")
55
+ end
56
+
57
+ def copy_single_component(component_name)
58
+ source = gem_root.join("lib/shadcn/ui/#{component_name}.rb")
59
+ if File.exist?(source)
60
+ dest = "app/components/shadcn/ui/#{component_name}.rb"
61
+ copy_file source, dest
62
+ say_status :create, dest, :green
63
+ end
64
+ end
65
+
66
+ def copy_single_controller(component_name)
67
+ source = gem_root.join("js/controllers/#{component_name}_controller.js")
68
+ if File.exist?(source)
69
+ dest = "app/javascript/controllers/shadcn/#{component_name}_controller.js"
70
+ copy_file source, dest
71
+ say_status :create, dest, :green
72
+ end
73
+ end
74
+
75
+ def gem_root
76
+ Pathname.new(File.expand_path("../../..", __dir__))
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module ShadcnPhlex
6
+ class InstallGenerator < Rails::Generators::Base
7
+ source_root File.expand_path("install/templates", __dir__)
8
+ desc "Install shadcn-phlex: CSS, Stimulus controllers, and base config"
9
+
10
+ class_option :base_color, type: :string, default: "neutral",
11
+ desc: "Base color theme (neutral, stone, zinc, mauve, olive, mist, taupe)"
12
+ class_option :accent_color, type: :string, default: nil,
13
+ desc: "Accent color (blue, red, green, violet, orange, amber, etc.)"
14
+ class_option :radius, type: :string, default: "0.625rem",
15
+ desc: "Border radius base value"
16
+
17
+ def add_gems
18
+ gem "phlex-rails", "~> 2.1" unless gem_installed?("phlex-rails")
19
+ end
20
+
21
+ def add_npm_packages
22
+ say_status :run, "Adding tw-animate-css", :green
23
+ if File.exist?("package.json")
24
+ run "npm install tw-animate-css"
25
+ elsif File.exist?("yarn.lock")
26
+ run "yarn add tw-animate-css"
27
+ end
28
+ end
29
+
30
+ def configure_autoload
31
+ application_rb = "config/application.rb"
32
+ return unless File.exist?(application_rb)
33
+
34
+ content = File.read(application_rb)
35
+ return if content.include?("app/views")
36
+
37
+ inject_into_file application_rb,
38
+ after: /config\.autoload_lib.*\n/ do
39
+ <<-RUBY
40
+ # Autoload Phlex views
41
+ config.autoload_paths << Rails.root.join("app/views")
42
+
43
+ RUBY
44
+ end
45
+ say_status :inject, "config.autoload_paths << app/views", :green
46
+ end
47
+
48
+ def create_application_view
49
+ path = "app/views/application_view.rb"
50
+ return if File.exist?(path)
51
+
52
+ create_file path, <<~RUBY
53
+ # frozen_string_literal: true
54
+
55
+ class ApplicationView < Phlex::HTML
56
+ include Shadcn::Kit
57
+ include Phlex::Rails::Helpers::Routes
58
+ include Phlex::Rails::Helpers::StyleSheetLinkTag
59
+ include Phlex::Rails::Helpers::JavaScriptIncludeTag
60
+
61
+ # Dark mode blocking script — prevents flash of light mode
62
+ DARK_MODE_SCRIPT = '(function(){try{var t=localStorage.getItem("theme");if(t==="dark"||(t!=="light"&&window.matchMedia("(prefers-color-scheme:dark)").matches)){document.documentElement.classList.add("dark")}}catch(e){}})();'
63
+
64
+ def around_template(&block)
65
+ doctype
66
+ html(lang: "en") do
67
+ head do
68
+ meta(charset: "utf-8")
69
+ meta(name: "viewport", content: "width=device-width,initial-scale=1")
70
+ title { page_title }
71
+ script { raw(Phlex::HTML::SafeValue.new(DARK_MODE_SCRIPT)) }
72
+ stylesheet_link_tag("application")
73
+ javascript_include_tag("application", defer: "defer")
74
+ end
75
+ body(class: "min-h-screen bg-background text-foreground antialiased", data_controller: "shadcn--dark-mode") do
76
+ yield
77
+ end
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def page_title
84
+ "My App"
85
+ end
86
+ end
87
+ RUBY
88
+ end
89
+
90
+ def copy_tailwind_config
91
+ source = gem_css("shadcn-tailwind.css")
92
+ create_file "app/assets/stylesheets/shadcn-tailwind.css", File.read(source)
93
+ end
94
+
95
+ def create_theme
96
+ base_color = options[:base_color].to_sym
97
+ accent_color = options[:accent_color]&.to_sym
98
+ radius = options[:radius]
99
+
100
+ require "shadcn/themes/base_colors"
101
+ require "shadcn/themes/accent_colors"
102
+
103
+ theme_css = Shadcn::Themes.generate_css(
104
+ base_color: base_color,
105
+ accent_color: accent_color,
106
+ radius: radius
107
+ )
108
+
109
+ create_file "app/assets/stylesheets/shadcn-theme.css", <<~CSS
110
+ /*
111
+ * shadcn theme: #{base_color}#{accent_color ? " + #{accent_color}" : ""}
112
+ * To change themes, replace this file with CSS from ui.shadcn.com/themes
113
+ */
114
+ #{theme_css}
115
+ CSS
116
+ end
117
+
118
+ def create_css_entrypoint
119
+ gem_path = Gem.loaded_specs["shadcn-phlex"]&.full_gem_path
120
+
121
+ create_file "app/assets/stylesheets/shadcn.css", <<~CSS
122
+ @import "tailwindcss";
123
+ @import "tw-animate-css";
124
+ @import "./shadcn-tailwind.css";
125
+ @import "./shadcn-theme.css";
126
+
127
+ /* Ensure Tailwind scans component files for class names */
128
+ @source "../../app/components";
129
+ @source "../../app/views";
130
+ #{gem_path ? "@source \"#{gem_path}/lib/**/*.rb\";" : "/* @source \"path/to/shadcn-phlex/lib/**/*.rb\"; */"}
131
+ CSS
132
+ end
133
+
134
+ def copy_stimulus_controllers
135
+ target = "app/javascript/controllers/shadcn"
136
+
137
+ if File.directory?(gem_js_dir) && !File.directory?(target)
138
+ js_files = Dir[File.join(gem_js_dir, "*.js")]
139
+ js_files.each do |file|
140
+ create_file File.join(target, File.basename(file)), File.read(file)
141
+ end
142
+ say_status :copied, "#{js_files.count} Stimulus controllers to #{target}/", :green
143
+ end
144
+ end
145
+
146
+ def create_stimulus_registration
147
+ index_path = "app/javascript/controllers/index.js"
148
+ return unless File.exist?(index_path)
149
+
150
+ content = File.read(index_path)
151
+ return if content.include?("shadcn")
152
+
153
+ append_to_file index_path, <<~JS
154
+
155
+ // shadcn-phlex Stimulus controllers
156
+ import { registerShadcnControllers } from "./shadcn/index"
157
+ registerShadcnControllers(application)
158
+ JS
159
+ say_status :inject, "shadcn controllers into #{index_path}", :green
160
+ end
161
+
162
+ def print_setup_complete
163
+ say_status :info, "Setup complete!", :green
164
+ say <<~MSG
165
+
166
+ shadcn-phlex is ready. Here's what was set up:
167
+ - app/views/application_view.rb (base view with Kit, dark mode, asset tags)
168
+ - app/assets/stylesheets/shadcn.css (Tailwind + theme)
169
+ - app/javascript/controllers/shadcn/ (Stimulus controllers)
170
+ - config/application.rb (autoload app/views)
171
+
172
+ Start using components:
173
+ class MyView < ApplicationView
174
+ def view_template
175
+ ui_button { "Hello" }
176
+ end
177
+ end
178
+
179
+ To change your theme:
180
+ 1. Go to ui.shadcn.com/themes
181
+ 2. Copy the CSS
182
+ 3. Paste into app/assets/stylesheets/shadcn-theme.css
183
+
184
+ For AI/LLM support:
185
+ npx skills add shadcn-phlex/shadcn-phlex
186
+
187
+ MSG
188
+ end
189
+
190
+ private
191
+
192
+ def gem_installed?(name)
193
+ File.read("Gemfile").include?(name)
194
+ rescue Errno::ENOENT
195
+ false
196
+ end
197
+
198
+ def gem_root
199
+ @gem_root ||= begin
200
+ spec = Gem.loaded_specs["shadcn-phlex"]
201
+ if spec
202
+ spec.full_gem_path
203
+ else
204
+ File.expand_path("../../../..", __dir__)
205
+ end
206
+ end
207
+ end
208
+
209
+ def gem_css(filename)
210
+ File.join(gem_root, "css", filename)
211
+ end
212
+
213
+ def gem_js_dir
214
+ File.join(gem_root, "js", "controllers")
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shadcn
4
+ class Base < Phlex::HTML
5
+ TAILWIND_MERGER = TailwindMerge::Merger.new
6
+
7
+ private
8
+
9
+ # Equivalent to shadcn's cn() utility - merges Tailwind classes intelligently
10
+ def cn(*classes)
11
+ merged = classes.flatten.compact.join(" ")
12
+ TAILWIND_MERGER.merge(merged)
13
+ end
14
+
15
+ # Merge user-provided attrs with defaults, intelligently merging classes
16
+ def merge_attrs(defaults, overrides)
17
+ result = defaults.merge(overrides) do |key, default_val, override_val|
18
+ if key == :class
19
+ cn(default_val, override_val)
20
+ else
21
+ override_val
22
+ end
23
+ end
24
+ result
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shadcn
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Shadcn
6
+
7
+ initializer "shadcn.assets" do |app|
8
+ # Add JS controllers to asset pipeline
9
+ if app.config.respond_to?(:assets)
10
+ app.config.assets.paths << root.join("js")
11
+ end
12
+ end
13
+
14
+ initializer "shadcn.importmap", before: "importmap" do |app|
15
+ if app.config.respond_to?(:importmap)
16
+ # Pin all Stimulus controllers
17
+ app.config.importmap.pin_all_from(
18
+ root.join("js/controllers"),
19
+ under: "shadcn/controllers"
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end