senren-ui 0.1.4 → 0.1.6

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -2
  3. data/CONTRIBUTING.md +41 -8
  4. data/README.md +73 -11
  5. data/docs/components.md +222 -0
  6. data/docs/performance_testing.md +34 -0
  7. data/lib/commands/senren/add/add_command.rb +35 -0
  8. data/lib/generators/senren/install/install_generator.rb +4 -7
  9. data/lib/generators/senren/install/templates/base_component.rb.tt +39 -6
  10. data/lib/generators/senren/install/templates/conventions.md.tt +22 -8
  11. data/lib/senren/rails/agent_rules_writer.rb +175 -0
  12. data/lib/senren/rails/component_copier.rb +75 -6
  13. data/lib/senren/rails/component_installer.rb +47 -0
  14. data/lib/senren/rails/doctor.rb +26 -13
  15. data/lib/senren/rails/host_paths.rb +12 -3
  16. data/lib/senren/rails/installer.rb +4 -2
  17. data/lib/senren/rails/llms_writer.rb +5 -132
  18. data/lib/senren/rails/registry.rb +63 -31
  19. data/lib/senren/rails/skill_writer.rb +1 -1
  20. data/lib/senren/rails/version.rb +1 -1
  21. data/lib/senren/rails.rb +2 -0
  22. data/lib/tasks/senren.rake +26 -21
  23. data/templates/components/billing_plan_card/billing_plan_card_component.html.erb +1 -1
  24. data/templates/components/breadcrumb/breadcrumb_component.rb +2 -2
  25. data/templates/components/button/button_component.html.erb +1 -1
  26. data/templates/components/carousel/carousel_component.rb +1 -1
  27. data/templates/components/command/command_component.rb +1 -1
  28. data/templates/components/dropdown_menu/dropdown_menu_component.rb +10 -7
  29. data/templates/components/form/form_component.html.erb +8 -1
  30. data/templates/components/form/form_component.rb +3 -1
  31. data/templates/components/input/input_component.html.erb +1 -1
  32. data/templates/components/input/input_component.rb +19 -0
  33. data/templates/components/label/label_component.html.erb +1 -2
  34. data/templates/components/label/label_component.rb +12 -2
  35. data/templates/components/link/link_component.html.erb +1 -1
  36. data/templates/components/native_select/native_select_component.html.erb +19 -5
  37. data/templates/components/native_select/native_select_component.rb +17 -5
  38. data/templates/components/pagination/pagination_component.rb +2 -1
  39. data/templates/components/sidebar/sidebar_component.rb +2 -2
  40. data/templates/components/switch/switch_component.html.erb +2 -2
  41. data/templates/components/top_nav/top_nav_component.rb +2 -2
  42. data/templates/controllers/rich_text_editor_lite_controller.js +12 -2
  43. metadata +23 -4
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'yaml'
4
-
5
3
  module Senren
6
4
  module Rails
7
- # Generates public/llms.txt and public/llms-full.txt for AI discoverability.
8
- # The entire content of both files is owned by this writer; do not edit by hand.
5
+ # Backward-compatible wrapper around AgentRulesWriter.
6
+ #
7
+ # Legacy llms generation now maps to agent rules synchronization and no longer
8
+ # writes public llms files.
9
9
  class LlmsWriter
10
10
  attr_reader :registry, :paths
11
11
 
@@ -15,134 +15,7 @@ module Senren
15
15
  end
16
16
 
17
17
  def generate!
18
- paths.llms_short.parent.mkpath
19
- paths.llms_full.parent.mkpath
20
- atomic_write(paths.llms_short, render_short)
21
- atomic_write(paths.llms_full, render_full)
22
- [paths.llms_short, paths.llms_full]
23
- end
24
-
25
- private
26
-
27
- def installed
28
- path = paths.installed_components
29
- return [] unless path.exist?
30
-
31
- ledger = YAML.safe_load_file(path) || {}
32
- Array(ledger['installed']).filter_map { |e| registry.find(e['name']) }
33
- end
34
-
35
- def render_short
36
- names = installed.map(&:name).sort
37
- <<~TXT
38
- # Senren UI
39
-
40
- Generated by senren-ui. Do not edit by hand. Run
41
- `bin/rails senren:llms:generate` to regenerate.
42
-
43
- Senren UI is the local Rails UI system used by this application.
44
- Use `.senren/skill.md` as the primary AI Agent guide.
45
-
46
- ## Hard Rules
47
-
48
- - Use Senren components before writing custom HTML.
49
- - Use ViewComponent for reusable UI.
50
- - Use Turbo for server state.
51
- - Use Stimulus only for local behavior.
52
- - Do not introduce React, Vue, Alpine, or any external state framework.
53
- - Do not hard-code colors; use semantic Tailwind tokens.
54
-
55
- ## Important Files
56
-
57
- - `.senren/skill.md` - centralized AI agent guide
58
- - `.senren/registry.yml` - mirror of installable components
59
- - `.senren/installed_components.yml` - what is currently installed
60
- - `.senren/conventions.md` - Senren conventions for humans and agents
61
-
62
- ## Installed Components (#{names.size})
63
-
64
- #{names.map { |n| "- #{n}" }.join("\n")}
65
- TXT
66
- end
67
-
68
- def render_full
69
- comps = installed.sort_by(&:name)
70
-
71
- out = []
72
- out << '# Senren UI - Full Snapshot'
73
- out << ''
74
- out << 'Generated by senren-ui. Do not edit by hand. Run'
75
- out << '`bin/rails senren:llms:generate` to regenerate.'
76
- out << ''
77
- out << '## Hard Rules'
78
- out << ''
79
- out << '- Use Senren components before writing custom HTML.'
80
- out << '- Server-rendered HTML first; ViewComponent for reusable UI.'
81
- out << '- Hotwire (Turbo + Stimulus) is the only client runtime.'
82
- out << '- TailwindCSS with semantic tokens (`bg-background`, `text-foreground`, ...).'
83
- out << '- Do not introduce React, Vue, Alpine, or external state frameworks.'
84
- out << '- Components copied into `app/components/senren/` are owned by this app; edit them directly.'
85
- out << ''
86
- out << '## Installed Component Inventory'
87
- out << ''
88
-
89
- registry.groups.each do |group|
90
- group_comps = comps.select { |c| c.category == group['id'] }
91
- next if group_comps.empty?
92
-
93
- out << "### #{group['title']}"
94
- out << ''
95
- out << group['description'].to_s
96
- out << ''
97
- group_comps.each { |c| out << render_component(c) }
98
- end
99
-
100
- out << '## Recipes'
101
- out << ''
102
- registry.recipes.each do |id, recipe|
103
- out << "### #{id}"
104
- out << ''
105
- out << recipe['description'].to_s
106
- out << ''
107
- out << 'Components:'
108
- recipe['components'].each { |c| out << "- #{c}" }
109
- out << ''
110
- end
111
-
112
- out.join("\n")
113
- end
114
-
115
- def render_component(comp)
116
- ruby_class = "Senren::#{comp.name.split('_').map { |w| w[0].upcase + w[1..] }.join}Component"
117
- s = []
118
- s << "#### #{comp.name}#{' (stub)' if comp.stub?}"
119
- s << ''
120
- s << "Category: #{comp.category}. " \
121
- "Client: #{comp.client? ? "yes (#{comp.controller})" : 'no'}. " \
122
- "Variants: #{comp.variants.empty? ? 'none' : comp.variants.join(', ')}."
123
- s << ''
124
- s << "Use for: #{comp.use_for.join('; ')}." unless comp.use_for.empty?
125
- s << "Avoid: #{comp.avoid.join('; ')}." unless comp.avoid.empty?
126
- s << ''
127
- s << 'Rails usage:'
128
- s << ''
129
- s << '```erb'
130
- s << if comp.variants.any?
131
- "<%= render #{ruby_class}.new(variant: :#{comp.variants.first}) do %>"
132
- else
133
- "<%= render #{ruby_class}.new do %>"
134
- end
135
- s << ' ...'
136
- s << '<% end %>'
137
- s << '```'
138
- s << ''
139
- s.join("\n")
140
- end
141
-
142
- def atomic_write(path, content)
143
- tmp = "#{path}.tmp"
144
- File.write(tmp, content)
145
- File.rename(tmp, path)
18
+ AgentRulesWriter.new(registry: registry, paths: paths).sync!
146
19
  end
147
20
  end
148
21
  end
@@ -3,23 +3,16 @@ require 'yaml'
3
3
  module Senren
4
4
  module Rails
5
5
  # Loads, validates, and queries the Senren component registry.
6
- #
7
- # reg = Senren::Rails::Registry.load!
8
- # reg.find("button") # => Component struct
9
- # reg.dependencies("dialog") # => [<button>]
10
- # reg.group("forms") # => [<form>, <input>, ...]
11
6
  class Registry
12
7
  include Enumerable
13
8
 
14
9
  REQUIRED_KEYS = %w[category client can_have_client files depends_on pairs_with variants accessibility ai].freeze
10
+ OPTIONAL_KEYS = %w[controller stub].freeze
11
+ ALLOWED_KEYS = (REQUIRED_KEYS + OPTIONAL_KEYS).freeze
15
12
  VALID_CATEGORIES = %w[actions forms overlays navigation layout data saas rich].freeze
16
13
 
17
- Component = Struct.new(
18
- :name, :category, :client, :can_have_client, :controller, :stub,
19
- :files, :depends_on, :pairs_with, :variants, :accessibility,
20
- :use_for, :avoid,
21
- keyword_init: true
22
- ) do
14
+ Component = Struct.new(:name, :category, :client, :can_have_client, :controller, :stub, :files, :depends_on,
15
+ :pairs_with, :variants, :accessibility, :use_for, :avoid, keyword_init: true) do
23
16
  def stub? = stub == true
24
17
  def client? = client == true
25
18
 
@@ -43,6 +36,7 @@ module Senren
43
36
  end
44
37
 
45
38
  def initialize(components_yaml, groups_yaml, recipes_yaml)
39
+ @raw_components = (components_yaml || {}).fetch('components', {})
46
40
  @components = parse_components(components_yaml)
47
41
  @groups = (groups_yaml || {}).fetch('groups', [])
48
42
  @recipes = (recipes_yaml || {}).fetch('recipes', {})
@@ -57,21 +51,11 @@ module Senren
57
51
  "Known: #{@components.keys.sort.join(', ')}"
58
52
  end
59
53
 
60
- def all
61
- @components.values
62
- end
63
-
64
- def each(&)
65
- all.each(&)
66
- end
54
+ def all = @components.values
55
+ def each(&) = all.each(&)
56
+ alias find_each each
67
57
 
68
- def find_each(&)
69
- each(&)
70
- end
71
-
72
- def names
73
- @components.keys
74
- end
58
+ def names = @components.keys
75
59
 
76
60
  def group(category_id)
77
61
  @components.values.select { |c| c.category == category_id.to_s }
@@ -118,12 +102,48 @@ module Senren
118
102
 
119
103
  def validate_components(errors)
120
104
  @components.each do |name, comp|
121
- errors << "#{name}: invalid category #{comp.category.inspect}" unless VALID_CATEGORIES.include?(comp.category)
122
- comp.depends_on.each do |dep|
123
- errors << "#{name}: depends_on unknown component #{dep.inspect}" unless @components.key?(dep)
124
- end
125
- errors << "#{name}: client=true but can_have_client=false" if comp.client && !comp.can_have_client
126
- errors << "#{name}: client=true requires a controller identifier" if comp.client && comp.controller.nil?
105
+ validate_component(name, comp, errors)
106
+ end
107
+ end
108
+
109
+ def validate_component(name, comp, errors)
110
+ validate_component_keys(name, errors)
111
+ validate_component_category(name, comp, errors)
112
+ validate_component_dependencies(name, comp, errors)
113
+ validate_component_client_contract(name, comp, errors)
114
+ validate_component_file_paths(name, comp, errors)
115
+ end
116
+
117
+ def validate_component_keys(name, errors)
118
+ extra_keys = @raw_components.fetch(name).keys - ALLOWED_KEYS
119
+ errors << "#{name}: unknown keys #{extra_keys.sort.join(', ')}" if extra_keys.any?
120
+ end
121
+
122
+ def validate_component_category(name, comp, errors)
123
+ return if VALID_CATEGORIES.include?(comp.category)
124
+
125
+ errors << "#{name}: invalid category #{comp.category.inspect}"
126
+ end
127
+
128
+ def validate_component_dependencies(name, comp, errors)
129
+ comp.depends_on.each do |dep|
130
+ errors << "#{name}: depends_on unknown component #{dep.inspect}" unless @components.key?(dep)
131
+ end
132
+ end
133
+
134
+ def validate_component_client_contract(name, comp, errors)
135
+ errors << "#{name}: client=true but can_have_client=false" if comp.client && !comp.can_have_client
136
+ errors << "#{name}: client=true requires a controller identifier" if comp.client && comp.controller.nil?
137
+ return unless comp.client && !controller_file?(name, comp)
138
+
139
+ errors << "#{name}: client=true requires a Stimulus controller file"
140
+ end
141
+
142
+ def validate_component_file_paths(name, comp, errors)
143
+ comp.files.each do |path|
144
+ next if allowed_component_file?(name, path)
145
+
146
+ errors << "#{name}: invalid file path #{path.inspect}"
127
147
  end
128
148
  end
129
149
 
@@ -156,6 +176,18 @@ module Senren
156
176
  ).freeze
157
177
  end
158
178
  end
179
+
180
+ def controller_file?(name, comp)
181
+ comp.files.include?("app/javascript/controllers/senren/#{name}_controller.js")
182
+ end
183
+
184
+ def allowed_component_file?(name, path)
185
+ [
186
+ "app/components/senren/#{name}_component.rb",
187
+ "app/components/senren/#{name}_component.html.erb",
188
+ "app/javascript/controllers/senren/#{name}_controller.js"
189
+ ].include?(path)
190
+ end
159
191
  end
160
192
  end
161
193
  end
@@ -50,7 +50,7 @@ module Senren
50
50
 
51
51
  def render(installed_names)
52
52
  if installed_names.empty?
53
- return '_No Senren components installed yet. Run `bin/rails senren:add <name>...` to install components._'
53
+ return '_No Senren components installed yet. Run `bin/rails senren:add button` to install components._'
54
54
  end
55
55
 
56
56
  lines = []
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Senren
4
4
  module Rails
5
- VERSION = '0.1.4'
5
+ VERSION = '0.1.6'
6
6
  end
7
7
  end
data/lib/senren/rails.rb CHANGED
@@ -8,7 +8,9 @@ module Senren
8
8
 
9
9
  autoload :Registry, 'senren/rails/registry'
10
10
  autoload :ComponentCopier, 'senren/rails/component_copier'
11
+ autoload :ComponentInstaller, 'senren/rails/component_installer'
11
12
  autoload :SkillWriter, 'senren/rails/skill_writer'
13
+ autoload :AgentRulesWriter, 'senren/rails/agent_rules_writer'
12
14
  autoload :LlmsWriter, 'senren/rails/llms_writer'
13
15
  autoload :Installer, 'senren/rails/installer'
14
16
  autoload :Doctor, 'senren/rails/doctor'
@@ -3,40 +3,49 @@
3
3
  require 'senren/rails'
4
4
 
5
5
  namespace :senren do
6
- desc 'Install one or more Senren components: rake senren:add[button,dialog] or bin/rails senren:add button dialog'
7
- task :add, [:names] => :environment do |_t, args|
6
+ desc 'Install one or more Senren components. Preferred: bin/rails senren:add button dialog. ' \
7
+ "Legacy: bin/rails 'senren:add[button,dialog]'"
8
+ task :add, [:names] do |_t, args|
8
9
  names = parse_names(args)
9
10
  options = parse_options
10
- abort 'Usage: bin/rails senren:add NAME [NAME...] [--client | --no-client]' if names.empty?
11
11
 
12
- paths = Senren::Rails::HostPaths.new
13
- registry = Senren::Rails::Registry.load!
14
- copier = Senren::Rails::ComponentCopier.new(registry: registry, paths: paths)
15
-
16
- installed = copier.install(names, client_override: options[:client_override], force: options[:force])
17
-
18
- Senren::Rails::SkillWriter.new(registry: registry, paths: paths).sync!
19
- Senren::Rails::LlmsWriter.new(registry: registry, paths: paths).generate!
20
-
21
- puts "Installed: #{installed.join(', ')}"
12
+ Senren::Rails::ComponentInstaller.new.install(
13
+ names: names,
14
+ client_override: options[:client_override],
15
+ force: options[:force]
16
+ )
17
+ rescue ArgumentError => e
18
+ abort e.message
22
19
  end
23
20
 
24
21
  namespace :skill do
25
- desc 'Rebuild .senren/skill.md from the registry and installed_components ledger.'
22
+ desc 'Rebuild .senren/skill.md and refresh agent instruction adapters.'
26
23
  task sync: :environment do
27
24
  paths = Senren::Rails::HostPaths.new
28
25
  registry = Senren::Rails::Registry.load!
29
26
  file = Senren::Rails::SkillWriter.new(registry: registry, paths: paths).sync!
30
27
  puts "Wrote #{file}"
28
+ Senren::Rails::AgentRulesWriter.new(registry: registry, paths: paths).sync!
29
+ end
30
+ end
31
+
32
+ namespace :agents do
33
+ desc 'Regenerate .senren/agent-rules.md and adapter instruction files.'
34
+ task sync: :environment do
35
+ paths = Senren::Rails::HostPaths.new
36
+ registry = Senren::Rails::Registry.load!
37
+ files = Senren::Rails::AgentRulesWriter.new(registry: registry, paths: paths).sync!
38
+ files.each { |f| puts "Wrote #{f}" }
31
39
  end
32
40
  end
33
41
 
34
42
  namespace :llms do
35
- desc 'Regenerate public/llms.txt and public/llms-full.txt.'
43
+ desc 'Deprecated alias for senren:agents:sync.'
36
44
  task generate: :environment do
45
+ puts 'senren:llms:generate is deprecated. Running senren:agents:sync instead.'
37
46
  paths = Senren::Rails::HostPaths.new
38
47
  registry = Senren::Rails::Registry.load!
39
- files = Senren::Rails::LlmsWriter.new(registry: registry, paths: paths).generate!
48
+ files = Senren::Rails::AgentRulesWriter.new(registry: registry, paths: paths).sync!
40
49
  files.each { |f| puts "Wrote #{f}" }
41
50
  end
42
51
  end
@@ -55,11 +64,7 @@ def parse_names(args)
55
64
  raw.concat(args.extras)
56
65
  raw << args[:names] if args[:names]
57
66
  raw.concat(ARGV.drop_while { |a| !a.start_with?('senren:') }.drop(1))
58
- raw
59
- .flatten
60
- .flat_map { |s| s.to_s.split(/[,\s]+/) }
61
- .reject { |s| s.empty? || s.start_with?('-') }
62
- .uniq
67
+ Senren::Rails::ComponentInstaller.normalize_names(raw)
63
68
  end
64
69
 
65
70
  def parse_options
@@ -22,7 +22,7 @@
22
22
  </ul>
23
23
  <% end %>
24
24
  <% if cta_label.present? %>
25
- <%= link_to cta_label, cta_href || "#", class: "mt-6 inline-flex h-10 w-full items-center justify-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90" %>
25
+ <%= link_to cta_label, safe_url(cta_href), class: "mt-6 inline-flex h-10 w-full items-center justify-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90" %>
26
26
  <% end %>
27
27
  <% end %>
28
28
  <% end %>
@@ -19,10 +19,10 @@ module Senren
19
19
  def normalize_items(items)
20
20
  Array(items).map do |item|
21
21
  if item.is_a?(Hash)
22
- { label: item.fetch(:label) { item.fetch('label') }, href: item[:href] || item['href'] }
22
+ { label: item.fetch(:label) { item.fetch('label') }, href: safe_url(item[:href] || item['href'], fallback: nil) }
23
23
  else
24
24
  label, href = item
25
- { label: label, href: href }
25
+ { label: label, href: safe_url(href, fallback: nil) }
26
26
  end
27
27
  end
28
28
  end
@@ -1,6 +1,6 @@
1
1
  <% base = "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-(--senren-radius) font-medium transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] disabled:opacity-50 disabled:pointer-events-none disabled:cursor-not-allowed" %>
2
2
  <% if as == :a %>
3
- <%= tag.a content, **root_attrs(base, href: href) %>
3
+ <%= tag.a content, **root_attrs(base, href: safe_url(href)) %>
4
4
  <% else %>
5
5
  <%= tag.button content, **root_attrs(base, type: type) %>
6
6
  <% end %>
@@ -21,7 +21,7 @@ module Senren
21
21
  {
22
22
  title: slide[:title] || slide['title'],
23
23
  description: slide[:description] || slide['description'],
24
- image_url: slide[:image_url] || slide['image_url'],
24
+ image_url: safe_media_url(slide[:image_url] || slide['image_url']),
25
25
  alt: slide[:alt] || slide['alt'],
26
26
  badge: slide[:badge] || slide['badge']
27
27
  }
@@ -29,7 +29,7 @@ module Senren
29
29
  id: data[:id] || data['id'] || "#{dom_id}-option-#{index}",
30
30
  label: label,
31
31
  description: description,
32
- href: data[:href] || data['href'],
32
+ href: safe_url(data[:href] || data['href'], fallback: nil),
33
33
  keywords: [label, description, keywords].flatten.compact.join(' ')
34
34
  }
35
35
  end
@@ -12,23 +12,26 @@ module Senren
12
12
  super(variant: :default, size: :md, class_name: class_name, **html)
13
13
  end
14
14
 
15
- class ItemTag < ViewComponent::Base
16
- def initialize(href: nil, method: nil, destructive: false, **opts)
15
+ class ItemTag < BaseComponent
16
+ VARIANTS = { default: '' }.freeze
17
+ SIZES = { md: '' }.freeze
18
+ ITEM_ACTION = 'click->senren--dropdown-menu#close keydown->senren--dropdown-menu#onItemKey'
19
+
20
+ def initialize(href: nil, method: nil, destructive: false, class_name: nil, **)
21
+ super(variant: :default, size: :md, class_name: class_name, **)
17
22
  @href = href
18
23
  @method = method
19
24
  @destructive = destructive
20
- @opts = opts
21
25
  end
22
26
 
23
27
  def call
24
28
  klass = 'block w-full text-left px-3 py-2 text-sm rounded-sm hover:bg-[hsl(var(--senren-accent))] focus:bg-[hsl(var(--senren-accent))] outline-none cursor-pointer'
29
+ klass += " #{class_name}" if class_name.present?
25
30
  klass += ' text-[hsl(var(--senren-destructive))]' if @destructive
26
31
  if @href
27
- link_to(content, @href, role: 'menuitem', method: @method, class: klass,
28
- data: { action: 'click->senren--dropdown-menu#close keydown->senren--dropdown-menu#onItemKey' })
32
+ link_to(content, safe_url(@href), role: 'menuitem', method: @method, class: klass, data: { action: ITEM_ACTION }, **html_attrs)
29
33
  else
30
- tag.button(content, type: 'button', role: 'menuitem', class: klass,
31
- data: { action: 'click->senren--dropdown-menu#close keydown->senren--dropdown-menu#onItemKey' }, **@opts)
34
+ tag.button(content, type: 'button', role: 'menuitem', class: klass, data: { action: ITEM_ACTION }, **html_attrs)
32
35
  end
33
36
  end
34
37
  end
@@ -1,3 +1,10 @@
1
- <%= form_with(model: model, url: url, method: method, multipart: multipart, **root_attrs("space-y-4")) do |f| %>
1
+ <%
2
+ form_opts = {}
3
+ form_opts[:model] = model if model
4
+ form_opts[:url] = url if url
5
+ form_opts[:method] = method if method
6
+ form_opts[:multipart] = multipart if multipart
7
+ %>
8
+ <%= form_with(**form_opts, **root_attrs("space-y-4")) do |f| %>
2
9
  <%= content || capture(f, &Proc.new) %>
3
10
  <% end %>
@@ -5,7 +5,9 @@ module Senren
5
5
  VARIANTS = { default: '' }.freeze
6
6
  SIZES = { md: '' }.freeze
7
7
 
8
- def initialize(model: nil, url: nil, method: :post, multipart: false, class_name: nil, **html)
8
+ # method: defaults to nil so Rails can infer PATCH for persisted models.
9
+ # Pass method: :post / :patch / :delete explicitly only when needed.
10
+ def initialize(model: nil, url: nil, method: nil, multipart: false, class_name: nil, **html)
9
11
  super(variant: :default, size: :md, class_name: class_name, **html)
10
12
  @model = model
11
13
  @url = url
@@ -1 +1 @@
1
- <%= tag.input(**root_attrs("flex w-full rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] text-[hsl(var(--senren-foreground))] file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-[hsl(var(--senren-muted-foreground))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50", id: id, name: name, type: type, value: value, placeholder: placeholder, "aria-invalid": variant == :error)) %>
1
+ <%= tag.input(**root_attrs(input_classes, id: id, name: name, type: type, value: value, placeholder: placeholder, "aria-invalid": variant == :error)) %>
@@ -13,6 +13,17 @@ module Senren
13
13
  lg: 'h-12 text-base px-4'
14
14
  }.freeze
15
15
 
16
+ # Base classes shared by all input types.
17
+ # NOTE: `flex` is intentionally omitted — it breaks browser-native
18
+ # date/datetime-local/time picker UI on some engines.
19
+ BASE_CLASSES = 'w-full rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] text-[hsl(var(--senren-foreground))] placeholder:text-[hsl(var(--senren-muted-foreground))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50'
20
+
21
+ # File-type inputs get a styled button instead of unstyled browser defaults.
22
+ FILE_CLASSES = 'file:mr-3 file:h-full file:cursor-pointer file:border-0 file:border-r file:border-solid file:border-[hsl(var(--senren-border))] file:bg-[hsl(var(--senren-muted))] file:px-3 file:text-sm file:font-medium file:text-[hsl(var(--senren-foreground))]'
23
+
24
+ # Non-file inputs get standard font styling for the placeholder.
25
+ TEXT_FILE_CLASSES = 'file:border-0 file:bg-transparent file:text-sm file:font-medium'
26
+
16
27
  def initialize(name:, type: 'text', value: nil, placeholder: nil, id: nil, variant: :default, size: :md,
17
28
  class_name: nil, **html)
18
29
  super(variant: variant, size: size, class_name: class_name, **html)
@@ -24,5 +35,13 @@ module Senren
24
35
  end
25
36
 
26
37
  attr_reader :name, :type, :value, :placeholder, :id
38
+
39
+ def input_classes
40
+ file_type? ? "#{BASE_CLASSES} #{FILE_CLASSES}" : "#{BASE_CLASSES} #{TEXT_FILE_CLASSES}"
41
+ end
42
+
43
+ def file_type?
44
+ type.to_s == 'file'
45
+ end
27
46
  end
28
47
  end
@@ -1,4 +1,3 @@
1
1
  <%= tag.label(**root_attrs("text-sm font-medium leading-none text-[hsl(var(--senren-foreground))] peer-disabled:cursor-not-allowed peer-disabled:opacity-70", for: for_field)) do %>
2
- <%= content %>
3
- <% if variant == :required %><span class="text-[hsl(var(--senren-destructive))]" aria-hidden="true"> *</span><% end %>
2
+ <%= label_text %><% if variant == :required %><span class="text-[hsl(var(--senren-destructive))]" aria-hidden="true"> *</span><% end %>
4
3
  <% end %>
@@ -9,11 +9,21 @@ module Senren
9
9
 
10
10
  SIZES = { md: '' }.freeze
11
11
 
12
- def initialize(for_field:, variant: :default, class_name: nil, **html)
12
+ # Accept optional text: param as fallback for inline block syntax.
13
+ # This ensures both patterns work reliably:
14
+ # render(Senren::LabelComponent.new(for_field: "name", text: "Name"))
15
+ # render(Senren::LabelComponent.new(for_field: "name")) { "Name" }
16
+ def initialize(for_field:, text: nil, variant: :default, class_name: nil, **html)
13
17
  super(variant: variant, size: :md, class_name: class_name, **html)
14
18
  @for_field = for_field
19
+ @text = text
15
20
  end
16
21
 
17
- attr_reader :for_field
22
+ attr_reader :for_field, :text
23
+
24
+ # Resolve label text: content block > text param > empty string
25
+ def label_text
26
+ content.presence || @text || ''
27
+ end
18
28
  end
19
29
  end
@@ -1 +1 @@
1
- <%= tag.a content, **root_attrs("inline-flex items-center gap-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] rounded-sm", href: href, **external_attrs) %>
1
+ <%= tag.a content, **root_attrs("inline-flex items-center gap-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] rounded-sm", href: safe_url(href), **external_attrs) %>
@@ -1,5 +1,6 @@
1
- <%= tag.div(**wrapper_attrs) do %>
2
- <%= tag.select(**select_attrs) do %>
1
+ <% if native_arrow? %>
2
+ <%# Native mode: no wrapper div, no SVG arrow — let the browser render its own. %>
3
+ <%= tag.select(**root_select_attrs) do %>
3
4
  <% if prompt %>
4
5
  <option value=""><%= prompt %></option>
5
6
  <% end %>
@@ -8,7 +9,20 @@
8
9
  <option value="<%= value %>" <%= "selected" if selected.to_s == value.to_s %>><%= label %></option>
9
10
  <% end %>
10
11
  <% end %>
11
- <svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[hsl(var(--senren-muted-foreground))] transition-transform duration-150 group-focus-within:rotate-180">
12
- <path d="m5 8 5 5 5-5"></path>
13
- </svg>
12
+ <% else %>
13
+ <%# Custom arrow mode: wrapper div + positioned SVG. %>
14
+ <%= tag.div(class: "group relative w-full", data: { senren_component: senren_component_name }) do %>
15
+ <%= tag.select(**select_attrs) do %>
16
+ <% if prompt %>
17
+ <option value=""><%= prompt %></option>
18
+ <% end %>
19
+ <% options.each do |opt| %>
20
+ <% value, label = Array === opt ? opt : [opt, opt] %>
21
+ <option value="<%= value %>" <%= "selected" if selected.to_s == value.to_s %>><%= label %></option>
22
+ <% end %>
23
+ <% end %>
24
+ <svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[hsl(var(--senren-muted-foreground))] transition-transform duration-150 group-focus-within:rotate-180">
25
+ <path d="m5 8 5 5 5-5"></path>
26
+ </svg>
27
+ <% end %>
14
28
  <% end %>