senren-ui 0.1.5 → 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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +124 -2
  3. data/CONTRIBUTING.md +79 -9
  4. data/README.md +149 -14
  5. data/Rakefile +14 -1
  6. data/docs/components.md +222 -0
  7. data/docs/hot_reload.md +103 -0
  8. data/docs/performance_testing.md +38 -0
  9. data/lib/commands/senren/add/add_command.rb +35 -0
  10. data/lib/generators/senren/component/templates/controller.js.tt +7 -4
  11. data/lib/generators/senren/install/install_generator.rb +74 -0
  12. data/lib/generators/senren/install/templates/base_component.rb.tt +89 -8
  13. data/lib/generators/senren/install/templates/conventions.md.tt +26 -7
  14. data/lib/senren/rails/agent_rules_writer.rb +62 -19
  15. data/lib/senren/rails/asset_path_guard.rb +128 -0
  16. data/lib/senren/rails/base_component_patch.rb +64 -0
  17. data/lib/senren/rails/component_copier.rb +138 -18
  18. data/lib/senren/rails/component_installer.rb +73 -0
  19. data/lib/senren/rails/doctor.rb +7 -4
  20. data/lib/senren/rails/engine.rb +23 -0
  21. data/lib/senren/rails/host_paths.rb +11 -2
  22. data/lib/senren/rails/marker_block.rb +81 -0
  23. data/lib/senren/rails/registry.rb +73 -35
  24. data/lib/senren/rails/safe_write.rb +169 -0
  25. data/lib/senren/rails/skill_writer.rb +48 -12
  26. data/lib/senren/rails/version.rb +1 -1
  27. data/lib/senren/rails.rb +2 -1
  28. data/lib/senren-ui.rb +15 -0
  29. data/lib/tasks/senren.rake +53 -34
  30. data/registry/components.yml +45 -0
  31. data/registry/recipes.yml +12 -0
  32. data/templates/components/alert_dialog/alert_dialog_component.rb +1 -1
  33. data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
  34. data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
  35. data/templates/components/avatar/avatar_component.rb +8 -1
  36. data/templates/components/billing_plan_card/billing_plan_card_component.html.erb +1 -1
  37. data/templates/components/breadcrumb/breadcrumb_component.rb +2 -2
  38. data/templates/components/button/button_component.html.erb +1 -1
  39. data/templates/components/carousel/carousel_component.rb +1 -1
  40. data/templates/components/cart/cart_component.html.erb +61 -0
  41. data/templates/components/cart/cart_component.rb +71 -0
  42. data/templates/components/checkbox/checkbox_component.rb +1 -1
  43. data/templates/components/clipboard/clipboard_component.html.erb +1 -1
  44. data/templates/components/command/command_component.rb +2 -2
  45. data/templates/components/date_picker/date_picker_component.html.erb +1 -1
  46. data/templates/components/dialog/dialog_component.rb +1 -1
  47. data/templates/components/dropdown_menu/dropdown_menu_component.rb +10 -7
  48. data/templates/components/form/form_component.html.erb +8 -1
  49. data/templates/components/form/form_component.rb +12 -2
  50. data/templates/components/input/input_component.html.erb +1 -1
  51. data/templates/components/input/input_component.rb +19 -0
  52. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
  53. data/templates/components/label/label_component.html.erb +1 -2
  54. data/templates/components/label/label_component.rb +12 -2
  55. data/templates/components/link/link_component.html.erb +1 -1
  56. data/templates/components/native_select/native_select_component.html.erb +19 -5
  57. data/templates/components/native_select/native_select_component.rb +17 -5
  58. data/templates/components/pagination/pagination_component.rb +2 -1
  59. data/templates/components/product_card/product_card_component.html.erb +38 -0
  60. data/templates/components/product_card/product_card_component.rb +49 -0
  61. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
  62. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
  63. data/templates/components/separator/separator_component.rb +7 -0
  64. data/templates/components/sheet/sheet_component.rb +1 -1
  65. data/templates/components/sidebar/sidebar_component.rb +2 -2
  66. data/templates/components/switch/switch_component.html.erb +2 -2
  67. data/templates/components/tooltip/tooltip_component.rb +2 -2
  68. data/templates/components/top_nav/top_nav_component.rb +2 -2
  69. data/templates/components/typography/typography_component.rb +7 -0
  70. data/templates/controllers/accordion_controller.js +1 -1
  71. data/templates/controllers/alert_dialog_controller.js +31 -7
  72. data/templates/controllers/cart_controller.js +83 -0
  73. data/templates/controllers/clipboard_controller.js +12 -1
  74. data/templates/controllers/command_controller.js +3 -4
  75. data/templates/controllers/context_menu_controller.js +38 -11
  76. data/templates/controllers/data_table_controller.js +8 -3
  77. data/templates/controllers/dialog_controller.js +47 -21
  78. data/templates/controllers/dropdown_menu_controller.js +40 -27
  79. data/templates/controllers/hover_card_controller.js +8 -0
  80. data/templates/controllers/invite_member_dialog_controller.js +6 -0
  81. data/templates/controllers/masked_input_controller.js +8 -1
  82. data/templates/controllers/popover_controller.js +25 -10
  83. data/templates/controllers/rich_text_editor_lite_controller.js +175 -28
  84. data/templates/controllers/sheet_controller.js +41 -11
  85. metadata +20 -6
  86. data/lib/senren/rails/installer.rb +0 -84
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Senren
4
4
  module Rails
5
- VERSION = '0.1.5'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
data/lib/senren/rails.rb CHANGED
@@ -8,12 +8,13 @@ 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'
12
13
  autoload :AgentRulesWriter, 'senren/rails/agent_rules_writer'
13
14
  autoload :LlmsWriter, 'senren/rails/llms_writer'
14
- autoload :Installer, 'senren/rails/installer'
15
15
  autoload :Doctor, 'senren/rails/doctor'
16
16
  autoload :HostPaths, 'senren/rails/host_paths'
17
+ autoload :MarkerBlock, 'senren/rails/marker_block'
17
18
 
18
19
  def self.gem_root
19
20
  GEM_ROOT
data/lib/senren-ui.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The entry point Bundler reaches for by default.
4
+ #
5
+ # `gem "senren-ui"` with no `require:` option makes Bundler try "senren-ui",
6
+ # then its hyphen-to-slash fallback "senren/ui". Without this file both fail —
7
+ # and Bundler swallows the second LoadError, so the app boots with no engine,
8
+ # no rake tasks, and no AssetPathGuard, while the generator and `senren:add`
9
+ # keep working because they require "senren/rails" themselves.
10
+ #
11
+ # That combination is the worst possible failure mode: the install looks
12
+ # healthy and the production source-disclosure guard is simply absent. The
13
+ # README documents `require: "senren/rails"`, which is still correct; this makes
14
+ # forgetting it harmless rather than silent.
15
+ require 'senren/rails'
@@ -3,22 +3,19 @@
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|
8
- names = parse_names(args)
9
- options = parse_options
10
- abort 'Usage: bin/rails senren:add NAME [NAME...] [--client | --no-client]' if names.empty?
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|
9
+ names = SenrenRakeArgs.names(args)
10
+ options = SenrenRakeArgs.options
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::AgentRulesWriter.new(registry: registry, paths: paths).sync!
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
@@ -62,25 +59,47 @@ end
62
59
 
63
60
  # Helpers ---------------------------------------------------------------
64
61
 
65
- def parse_names(args)
66
- raw = []
67
- raw.concat(args.extras)
68
- raw << args[:names] if args[:names]
69
- raw.concat(ARGV.drop_while { |a| !a.start_with?('senren:') }.drop(1))
70
- raw
71
- .flatten
72
- .flat_map { |s| s.to_s.split(/[,\s]+/) }
73
- .reject { |s| s.empty? || s.start_with?('-') }
74
- .uniq
75
- end
62
+ # Namespaced deliberately. These were top-level `def`s, which Ruby defines as
63
+ # private methods on Object — so loading this rake file gave every class in the
64
+ # host app a `parse_options` and a `parse_names`. `parse_options` is a
65
+ # plausible name for an app to define itself.
66
+ module SenrenRakeArgs
67
+ module_function
76
68
 
77
- def parse_options
78
- client_override =
79
- if ARGV.include?('--client')
80
- true
81
- elsif ARGV.include?('--no-client')
82
- false
83
- end
69
+ def names(args)
70
+ raw = []
71
+ raw.concat(args.extras)
72
+ raw << args[:names] if args[:names]
73
+ raw.concat(trailing_arguments)
74
+ Senren::Rails::ComponentInstaller.normalize_names(raw)
75
+ end
84
76
 
85
- { client_override: client_override, force: ARGV.include?('--force') }
77
+ def options
78
+ words = trailing_arguments
79
+ client_override = true if words.include?('--client')
80
+ client_override = false if words.include?('--no-client')
81
+
82
+ { client_override: client_override, force: words.include?('--force') }
83
+ end
84
+
85
+ # The words that belong to `senren:add`, and only those.
86
+ #
87
+ # This used to be `ARGV.drop_while { ... }.drop(1)` with no upper bound, so
88
+ # every later argument on the command line was swallowed: `rake
89
+ # 'senren:add[button]' db:seed` tried to install a component named "db:seed"
90
+ # and aborted the whole invocation, and a `--force` intended for a different
91
+ # task silently enabled overwriting here.
92
+ def trailing_arguments
93
+ ARGV
94
+ .drop_while { |arg| !arg.start_with?('senren:') }
95
+ .drop(1)
96
+ .take_while { |arg| !rake_target?(arg) }
97
+ end
98
+
99
+ # A namespaced task (`db:seed`) or an environment assignment (`RAILS_ENV=x`).
100
+ # Component names are matched by Registry::NAME_PATTERN and can contain
101
+ # neither character.
102
+ def rake_target?(arg)
103
+ arg.include?(':') || arg.include?('=')
104
+ end
86
105
  end
@@ -1051,3 +1051,48 @@ components:
1051
1051
  ai:
1052
1052
  use_for: [light formatting (bold/italic/links/lists) only]
1053
1053
  avoid: [tables, embeds, media - out of scope for v0.1]
1054
+
1055
+ product_card:
1056
+ category: saas
1057
+ client: false
1058
+ can_have_client: false
1059
+ files:
1060
+ - app/components/senren/product_card_component.rb
1061
+ - app/components/senren/product_card_component.html.erb
1062
+ depends_on: []
1063
+ pairs_with: [cart, badge, button, card]
1064
+ variants: [default, featured]
1065
+ accessibility:
1066
+ - Image alt text repeats the product title; decorative placeholder is aria-hidden.
1067
+ - Out of stock disables the submit button rather than hiding it.
1068
+ ai:
1069
+ use_for:
1070
+ - product listings
1071
+ - catalogue grids
1072
+ - storefront search results
1073
+ avoid:
1074
+ - carrying cart state; adding to a cart is a form submission the server owns
1075
+
1076
+ cart:
1077
+ category: saas
1078
+ client: true
1079
+ can_have_client: true
1080
+ controller: senren--cart
1081
+ files:
1082
+ - app/components/senren/cart_component.rb
1083
+ - app/components/senren/cart_component.html.erb
1084
+ - app/javascript/controllers/senren/cart_controller.js
1085
+ depends_on: []
1086
+ pairs_with: [product_card, button, separator, empty_state]
1087
+ variants: [default, flush]
1088
+ accessibility:
1089
+ - Quantity steppers are grouped with an accessible name per line.
1090
+ - Subtotal is text, not an image, so it is announced on change.
1091
+ ai:
1092
+ use_for:
1093
+ - cart summary panels
1094
+ - checkout side panels
1095
+ - order review before payment
1096
+ avoid:
1097
+ - persisting quantities; the controller is display-only and the server is authoritative
1098
+ - formatting currency in the component; pass price_cents and a formatted price
data/registry/recipes.yml CHANGED
@@ -77,3 +77,15 @@ recipes:
77
77
  - native_select
78
78
  - button
79
79
  - alert
80
+
81
+ storefront:
82
+ description: Product listing with a cart panel; the server owns the cart, the widget owns the UI.
83
+ components:
84
+ - app_shell
85
+ - top_nav
86
+ - page_header
87
+ - product_card
88
+ - cart
89
+ - badge
90
+ - button
91
+ - empty_state
@@ -13,7 +13,7 @@ module Senren
13
13
 
14
14
  def initialize(variant: :default, id: nil, class_name: nil, **html)
15
15
  super(variant: variant, size: :md, class_name: class_name, **html)
16
- @dom_id = id || "senren-alert-dialog-#{SecureRandom.hex(3)}"
16
+ @dom_id = id || senren_dom_id
17
17
  end
18
18
 
19
19
  attr_reader :dom_id
@@ -4,7 +4,7 @@
4
4
  <% else %>
5
5
  <label class="block text-sm font-medium text-[hsl(var(--senren-foreground))]"><%= label %></label>
6
6
  <div class="flex flex-col gap-2 sm:flex-row">
7
- <input type="password" readonly value="<%= value %>" data-senren--api-key-field-target="input" class="h-10 min-w-0 flex-1 rounded-(--senren-radius) border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-muted)/0.35)] px-3 font-mono text-sm text-[hsl(var(--senren-foreground))] outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
7
+ <input type="password" readonly aria-label="<%= label %>" value="<%= value %>" data-senren--api-key-field-target="input" class="h-10 min-w-0 flex-1 rounded-(--senren-radius) border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-muted)/0.35)] px-3 font-mono text-sm text-[hsl(var(--senren-foreground))] outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
8
8
  <button type="button" data-senren--api-key-field-target="revealButton" data-action="click->senren--api-key-field#toggle" class="inline-flex h-10 cursor-pointer items-center justify-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] px-3 text-sm font-medium hover:bg-[hsl(var(--senren-accent))]"><%= reveal_label %></button>
9
9
  <button type="button" data-action="click->senren--api-key-field#copy" class="inline-flex h-10 cursor-pointer items-center justify-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-3 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90"><%= copy_label %></button>
10
10
  </div>
@@ -10,5 +10,12 @@ module Senren
10
10
  }.freeze
11
11
 
12
12
  SIZES = { md: '' }.freeze
13
+
14
+ # BaseComponent defaults to `variant: :default`, which this component does
15
+ # not define, so `.new` with no variant raised instead of rendering. The
16
+ # default is :square (the neutral ratio).
17
+ def initialize(variant: :square, **args)
18
+ super
19
+ end
13
20
  end
14
21
  end
@@ -17,7 +17,14 @@ module Senren
17
17
  @fallback = fallback.presence || initials.presence || initials_from_alt
18
18
  end
19
19
 
20
- attr_reader :src, :alt, :fallback
20
+ attr_reader :alt, :fallback
21
+
22
+ # Avatar sources are user-controlled by nature — a profile URL is the
23
+ # canonical example — and this one reaches image_tag. Rails' asset_path
24
+ # incidentally neutralises `javascript:`, so this is not XSS, but an
25
+ # off-origin `//tracker.example/x.gif` is a beacon that fires for every
26
+ # viewer of the page. safe_media_url is the media-specific policy.
27
+ def src = @src && safe_media_url(@src)
21
28
 
22
29
  private
23
30
 
@@ -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
  }
@@ -0,0 +1,61 @@
1
+ <%= tag.section(**root_attrs("flex flex-col rounded-(--senren-radius) border bg-[hsl(var(--senren-card))] shadow-sm", id: dom_id, "aria-label": title, data: { controller: "senren--cart", "senren--cart-currency-value": currency, "senren--cart-subtotal-cents-value": subtotal_cents })) do %>
2
+ <header class="flex items-center justify-between border-b border-[hsl(var(--senren-border))] px-4 py-3">
3
+ <h2 class="text-sm font-semibold text-[hsl(var(--senren-foreground))]"><%= title %></h2>
4
+ <span class="text-sm text-[hsl(var(--senren-muted-foreground))]" data-senren--cart-target="count"><%= total_quantity %></span>
5
+ </header>
6
+
7
+ <% if empty? %>
8
+ <p class="px-4 py-8 text-center text-sm text-[hsl(var(--senren-muted-foreground))]"><%= empty_text %></p>
9
+ <% else %>
10
+ <ul class="divide-y divide-[hsl(var(--senren-border))]">
11
+ <% items.each do |item| %>
12
+ <li class="flex items-center gap-3 px-4 py-3"
13
+ data-senren--cart-target="line"
14
+ data-line-id="<%= item[:id] %>"
15
+ data-price-cents="<%= item[:price_cents] %>">
16
+ <% if (image = safe_media_url(item[:image_url])) %>
17
+ <img src="<%= image %>" alt="" loading="lazy" class="h-12 w-12 shrink-0 rounded-(--senren-radius) object-cover">
18
+ <% else %>
19
+ <div class="h-12 w-12 shrink-0 rounded-(--senren-radius) bg-[hsl(var(--senren-muted)/0.5)]" aria-hidden="true"></div>
20
+ <% end %>
21
+
22
+ <div class="min-w-0 flex-1">
23
+ <p class="truncate text-sm font-medium text-[hsl(var(--senren-foreground))]"><%= item[:name] %></p>
24
+ <p class="text-sm text-[hsl(var(--senren-muted-foreground))]" data-senren--cart-target="lineTotal"><%= line_total(item) %></p>
25
+ </div>
26
+
27
+ <div class="flex items-center gap-1" role="group" aria-label="<%= item[:name] %> quantity">
28
+ <button type="button"
29
+ data-action="click->senren--cart#decrement"
30
+ aria-label="Decrease <%= item[:name] %> quantity"
31
+ class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] text-sm hover:bg-[hsl(var(--senren-accent))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]">&minus;</button>
32
+
33
+ <output class="w-8 text-center text-sm tabular-nums text-[hsl(var(--senren-foreground))]"
34
+ data-senren--cart-target="quantity"><%= item[:quantity] %></output>
35
+
36
+ <button type="button"
37
+ data-action="click->senren--cart#increment"
38
+ aria-label="Increase <%= item[:name] %> quantity"
39
+ class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] text-sm hover:bg-[hsl(var(--senren-accent))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]">+</button>
40
+ </div>
41
+
42
+ <button type="button"
43
+ data-action="click->senren--cart#remove"
44
+ aria-label="Remove <%= item[:name] %>"
45
+ class="inline-flex h-8 cursor-pointer items-center rounded-(--senren-radius) px-2 text-sm text-[hsl(var(--senren-muted-foreground))] hover:bg-[hsl(var(--senren-accent))] hover:text-[hsl(var(--senren-destructive))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]">Remove</button>
46
+ </li>
47
+ <% end %>
48
+ </ul>
49
+
50
+ <footer class="flex items-center justify-between gap-3 border-t border-[hsl(var(--senren-border))] px-4 py-3">
51
+ <div class="text-sm text-[hsl(var(--senren-muted-foreground))]">
52
+ Subtotal
53
+ <span class="ml-1 font-semibold text-[hsl(var(--senren-foreground))]" data-senren--cart-target="subtotal"><%= subtotal %></span>
54
+ </div>
55
+
56
+ <% if safe_checkout_url %>
57
+ <a href="<%= safe_checkout_url %>" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]"><%= checkout_label %></a>
58
+ <% end %>
59
+ </footer>
60
+ <% end %>
61
+ <% end %>
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ # A shopping cart: line items with quantity steppers, a live subtotal, and
5
+ # removal.
6
+ #
7
+ # The server owns the cart. This component owns the widget. The Stimulus
8
+ # controller only does what does not round-trip pleasantly — stepping a
9
+ # quantity and recomputing the displayed subtotal while the user clicks — and
10
+ # announces the result with senren--cart:changed so a host app can update a
11
+ # header badge without reaching inside.
12
+ #
13
+ # Money arrives twice, and on purpose: `price` already formatted for display,
14
+ # and `price_cents` as an integer for arithmetic. Formatting is locale- and
15
+ # money-library-specific and belongs to the host app. Doing arithmetic on a
16
+ # formatted string is how currency bugs start.
17
+ class CartComponent < BaseComponent
18
+ VARIANTS = {
19
+ default: 'border-[hsl(var(--senren-border))]',
20
+ flush: 'border-transparent shadow-none'
21
+ }.freeze
22
+
23
+ SIZES = { md: '' }.freeze
24
+
25
+ def initialize(items: [], currency: '$', title: 'Cart', empty_text: 'Your cart is empty.',
26
+ checkout_url: nil, checkout_label: 'Checkout', variant: :default,
27
+ id: nil, class_name: nil, **html)
28
+ super(variant: variant, size: :md, class_name: class_name, **html)
29
+ @items = Array(items).map { |item| normalize_item(item) }
30
+ @currency = currency
31
+ @title = title
32
+ @empty_text = empty_text
33
+ @checkout_url = checkout_url
34
+ @checkout_label = checkout_label
35
+ @dom_id = id || senren_dom_id(title)
36
+ end
37
+
38
+ attr_reader :items, :currency, :title, :empty_text, :checkout_label, :dom_id
39
+
40
+ def empty? = items.empty?
41
+ def total_quantity = items.sum { |item| item[:quantity] }
42
+ def subtotal_cents = items.sum { |item| item[:price_cents] * item[:quantity] }
43
+ def subtotal = format_cents(subtotal_cents)
44
+ def line_total(item) = format_cents(item[:price_cents] * item[:quantity])
45
+ def safe_checkout_url = @checkout_url && safe_url(@checkout_url)
46
+
47
+ # Kept public so the same rendering is available to a host app updating a
48
+ # line through a Turbo Stream.
49
+ # Kernel.format explicitly: ViewComponent::Base defines its own `format`
50
+ # (the current template format, taking no arguments), which shadows
51
+ # Kernel#format inside a component. On Rails 8.x this happened to resolve;
52
+ # on 7.1 it raised "wrong number of arguments (given 2, expected 0)" as soon
53
+ # as the call ran inside a tag block. Caught by the version matrix.
54
+ def format_cents(cents)
55
+ "#{currency}#{Kernel.format('%.2f', cents.to_i / 100.0)}"
56
+ end
57
+
58
+ private
59
+
60
+ def normalize_item(item)
61
+ {
62
+ id: item[:id] || item['id'],
63
+ name: item[:name] || item['name'],
64
+ price_cents: (item[:price_cents] || item['price_cents']).to_i,
65
+ quantity: [(item[:quantity] || item['quantity'] || 1).to_i, 1].max,
66
+ image_url: item[:image_url] || item['image_url'],
67
+ remove_url: item[:remove_url] || item['remove_url']
68
+ }
69
+ end
70
+ end
71
+ end
@@ -10,7 +10,7 @@ module Senren
10
10
  @name = name
11
11
  @value = value
12
12
  @checked = checked
13
- @id = id || "#{name.to_s.parameterize}-#{SecureRandom.hex(2)}"
13
+ @id = id || senren_dom_id(name, value)
14
14
  @label = label
15
15
  end
16
16
 
@@ -1,5 +1,5 @@
1
1
  <div <%= tag.attributes(**root_attrs("inline-flex items-center gap-2", data: { controller: "senren--clipboard", "senren--clipboard-copied-label-value": copied_label })) %>>
2
- <input type="text" readonly value="<%= value %>" data-senren--clipboard-target="source" class="h-10 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-muted)/0.4)] px-3 font-mono text-sm text-[hsl(var(--senren-foreground))]">
2
+ <input type="text" readonly aria-label="<%= label %>" value="<%= value %>" data-senren--clipboard-target="source" class="h-10 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-muted)/0.4)] px-3 font-mono text-sm text-[hsl(var(--senren-foreground))]">
3
3
  <button type="button" data-senren--clipboard-target="button" data-action="click->senren--clipboard#copy" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-3 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90">
4
4
  <%= label %>
5
5
  </button>
@@ -11,7 +11,7 @@ module Senren
11
11
  @placeholder = placeholder
12
12
  @label = label
13
13
  @empty_text = empty_text
14
- @dom_id = id || "senren-command-#{SecureRandom.hex(3)}"
14
+ @dom_id = id || senren_dom_id(label)
15
15
  @items = normalize_items(items)
16
16
  end
17
17
 
@@ -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
@@ -1,5 +1,5 @@
1
1
  <div <%= tag.attributes(**root_attrs("flex w-full items-center gap-2", data: { controller: "senren--date-picker" })) %>>
2
- <input type="date" id="<%= id %>" name="<%= name %>" value="<%= value %>" placeholder="<%= placeholder %>" data-senren--date-picker-target="input" class="hover:cursor-pointerh-10 w-full rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] px-3 text-sm text-[hsl(var(--senren-foreground))] outline-none transition-colors focus:ring-2 focus:ring-[hsl(var(--senren-ring))] <%= self.class::VARIANTS[variant] %>">
2
+ <input type="date" id="<%= id %>" name="<%= name %>" value="<%= value %>" placeholder="<%= placeholder %>" data-senren--date-picker-target="input" class="h-10 w-full max-w-56 cursor-pointer rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] px-3 text-sm text-[hsl(var(--senren-foreground))] outline-none transition-colors focus:ring-2 focus:ring-[hsl(var(--senren-ring))] [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-60 [&::-webkit-calendar-picker-indicator]:hover:opacity-100 <%= self.class::VARIANTS[variant] %>">
3
3
  <button type="button" data-action="click->senren--date-picker#today" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] px-3 text-sm font-medium hover:bg-[hsl(var(--senren-accent))]">Today</button>
4
4
  <button type="button" data-action="click->senren--date-picker#clear" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) px-3 text-sm text-[hsl(var(--senren-muted-foreground))] hover:bg-[hsl(var(--senren-accent))]">Clear</button>
5
5
  </div>
@@ -14,7 +14,7 @@ module Senren
14
14
  def initialize(open: false, id: nil, class_name: nil, **html)
15
15
  super(variant: :default, size: :md, class_name: class_name, **html)
16
16
  @open = open
17
- @dom_id = id || "senren-dialog-#{SecureRandom.hex(3)}"
17
+ @dom_id = id || senren_dom_id
18
18
  end
19
19
 
20
20
  attr_reader :open, :dom_id
@@ -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
@@ -13,6 +15,14 @@ module Senren
13
15
  @multipart = multipart
14
16
  end
15
17
 
16
- attr_reader :model, :url, :method, :multipart
18
+ attr_reader :model, :method, :multipart
19
+
20
+ # This one reaches form_with's `action`, which makes it the highest-value
21
+ # URL sink in the library: `//evil.example` is a protocol-relative absolute
22
+ # URL, so a form built from user-controlled input POSTs every field —
23
+ # including the CSRF token — off-origin. Same policy as every other href in
24
+ # the library; it was simply missed because the security test enumerated
25
+ # known components rather than asserting a property over all of them.
26
+ def url = @url && safe_url(@url)
17
27
  end
18
28
  end
@@ -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
@@ -18,7 +18,7 @@ module Senren
18
18
  @role_name = role_name
19
19
  @roles = Array(roles)
20
20
  @button_label = button_label
21
- @dom_id = id || "senren-invite-member-#{SecureRandom.hex(3)}"
21
+ @dom_id = id || senren_dom_id(title)
22
22
  end
23
23
 
24
24
  attr_reader :title, :description, :email_name, :role_name, :roles, :button_label, :dom_id
@@ -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 %>