senren-ui 0.1.6 → 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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -0
  3. data/CONTRIBUTING.md +40 -3
  4. data/README.md +110 -33
  5. data/Rakefile +14 -1
  6. data/docs/components.md +10 -10
  7. data/docs/hot_reload.md +103 -0
  8. data/docs/performance_testing.md +7 -3
  9. data/lib/generators/senren/component/templates/controller.js.tt +7 -4
  10. data/lib/generators/senren/install/install_generator.rb +74 -0
  11. data/lib/generators/senren/install/templates/base_component.rb.tt +52 -4
  12. data/lib/generators/senren/install/templates/conventions.md.tt +16 -1
  13. data/lib/senren/rails/agent_rules_writer.rb +62 -19
  14. data/lib/senren/rails/asset_path_guard.rb +128 -0
  15. data/lib/senren/rails/base_component_patch.rb +64 -0
  16. data/lib/senren/rails/component_copier.rb +101 -50
  17. data/lib/senren/rails/component_installer.rb +26 -0
  18. data/lib/senren/rails/doctor.rb +7 -4
  19. data/lib/senren/rails/engine.rb +23 -0
  20. data/lib/senren/rails/host_paths.rb +11 -2
  21. data/lib/senren/rails/marker_block.rb +81 -0
  22. data/lib/senren/rails/registry.rb +10 -4
  23. data/lib/senren/rails/safe_write.rb +169 -0
  24. data/lib/senren/rails/skill_writer.rb +47 -11
  25. data/lib/senren/rails/version.rb +1 -1
  26. data/lib/senren/rails.rb +1 -1
  27. data/lib/senren-ui.rb +15 -0
  28. data/lib/tasks/senren.rake +43 -17
  29. data/registry/components.yml +45 -0
  30. data/registry/recipes.yml +12 -0
  31. data/templates/components/alert_dialog/alert_dialog_component.rb +1 -1
  32. data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
  33. data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
  34. data/templates/components/avatar/avatar_component.rb +8 -1
  35. data/templates/components/cart/cart_component.html.erb +61 -0
  36. data/templates/components/cart/cart_component.rb +71 -0
  37. data/templates/components/checkbox/checkbox_component.rb +1 -1
  38. data/templates/components/clipboard/clipboard_component.html.erb +1 -1
  39. data/templates/components/command/command_component.rb +1 -1
  40. data/templates/components/date_picker/date_picker_component.html.erb +1 -1
  41. data/templates/components/dialog/dialog_component.rb +1 -1
  42. data/templates/components/form/form_component.rb +9 -1
  43. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
  44. data/templates/components/product_card/product_card_component.html.erb +38 -0
  45. data/templates/components/product_card/product_card_component.rb +49 -0
  46. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
  47. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
  48. data/templates/components/separator/separator_component.rb +7 -0
  49. data/templates/components/sheet/sheet_component.rb +1 -1
  50. data/templates/components/tooltip/tooltip_component.rb +2 -2
  51. data/templates/components/typography/typography_component.rb +7 -0
  52. data/templates/controllers/accordion_controller.js +1 -1
  53. data/templates/controllers/alert_dialog_controller.js +31 -7
  54. data/templates/controllers/cart_controller.js +83 -0
  55. data/templates/controllers/clipboard_controller.js +12 -1
  56. data/templates/controllers/command_controller.js +3 -4
  57. data/templates/controllers/context_menu_controller.js +38 -11
  58. data/templates/controllers/data_table_controller.js +8 -3
  59. data/templates/controllers/dialog_controller.js +47 -21
  60. data/templates/controllers/dropdown_menu_controller.js +40 -27
  61. data/templates/controllers/hover_card_controller.js +8 -0
  62. data/templates/controllers/invite_member_dialog_controller.js +6 -0
  63. data/templates/controllers/masked_input_controller.js +8 -1
  64. data/templates/controllers/popover_controller.js +25 -10
  65. data/templates/controllers/rich_text_editor_lite_controller.js +165 -28
  66. data/templates/controllers/sheet_controller.js +41 -11
  67. metadata +13 -17
  68. data/lib/senren/rails/installer.rb +0 -85
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'yaml'
4
+ require 'senren/rails/marker_block'
5
+ require 'senren/rails/safe_write'
4
6
 
5
7
  module Senren
6
8
  module Rails
@@ -30,16 +32,23 @@ module Senren
30
32
  installed = installed_names
31
33
  body = render(installed)
32
34
 
33
- paths.senren_dir.mkpath
35
+ SafeWrite.mkdir_p!(paths.senren_dir, paths.root, 'skill.md')
34
36
  existing = paths.skill_file.exist? ? paths.skill_file.read : default_outer_template
35
37
 
36
38
  new_content = inject(existing, body)
37
- paths.skill_file.write(new_content)
39
+ atomic_write(paths.skill_file, new_content)
38
40
  paths.skill_file
39
41
  end
40
42
 
41
43
  private
42
44
 
45
+ # Routed through SafeWrite rather than hand-rolled. assert_inside! plus
46
+ # rename was already safe for an existing symlink, but not for a dangling
47
+ # one, and two other writers proved that hand-rolled writes drift.
48
+ def atomic_write(path, content)
49
+ SafeWrite.write!(path, content, paths.root, path.to_s)
50
+ end
51
+
43
52
  def installed_names
44
53
  path = paths.installed_components
45
54
  return [] unless path.exist?
@@ -110,21 +119,51 @@ module Senren
110
119
  end
111
120
  end
112
121
 
122
+ # Describes what is installed, not what the registry offers.
123
+ #
124
+ # This rendered the registry default, so after `senren:add select
125
+ # --no-client` the skill file told agents to use
126
+ # `senren--select` and named a controller file that is not on disk. The
127
+ # copier computes the truth and writes it to the ledger — with a comment
128
+ # saying "never record client behavior in the ledger that was not
129
+ # installed" — and nothing read it back. The gem's flagship feature was
130
+ # wrong for precisely the installs where the flag was exercised.
113
131
  def client_summary(comp)
114
132
  return 'none' unless comp.client?
133
+ return 'none (installed without its Stimulus controller)' unless installed_with_client?(comp.name)
115
134
 
116
135
  path = "app/javascript/controllers/senren/#{comp.name}_controller.js"
117
136
  "Stimulus `#{comp.controller}` (`#{path}`)"
118
137
  end
119
138
 
139
+ # nil when the ledger predates this field, in which case the registry
140
+ # default is the best available answer.
141
+ def installed_with_client?(name)
142
+ entry = ledger_entries.find { |e| e['name'] == name }
143
+ return true if entry.nil? || !entry.key?('client')
144
+
145
+ entry['client']
146
+ end
147
+
148
+ def ledger_entries
149
+ @ledger_entries ||= begin
150
+ path = paths.installed_components
151
+ path.exist? ? Array((YAML.safe_load_file(path) || {})['installed']) : []
152
+ end
153
+ end
154
+
120
155
  def format_list(items)
121
156
  return '_none_' if items.empty?
122
157
 
123
158
  items.map { |i| "`#{i}`" }.join(', ')
124
159
  end
125
160
 
161
+ # NAME_PATTERN accepts consecutive underscores, and "foo__bar".split("_")
162
+ # yields an empty segment, so w[0] was nil and this raised NoMethodError —
163
+ # after the files were copied and the ledger written, leaving a
164
+ # half-completed install. Empty segments are dropped instead.
126
165
  def humanize(name)
127
- name.split('_').map { |w| w[0].upcase + w[1..] }.join
166
+ name.split('_').reject(&:empty?).map { |w| w[0].upcase + w[1..] }.join
128
167
  end
129
168
 
130
169
  def ruby_class_for(name)
@@ -132,13 +171,10 @@ module Senren
132
171
  end
133
172
 
134
173
  def inject(existing, generated_body)
135
- if existing.include?(START_MARKER) && existing.include?(END_MARKER)
136
- before = existing.split(START_MARKER, 2).first
137
- after = existing.split(END_MARKER, 2).last
138
- "#{before}#{START_MARKER}\n\n#{generated_body}\n\n#{END_MARKER}#{after}"
139
- else
140
- "#{existing.rstrip}\n\n#{START_MARKER}\n\n#{generated_body}\n\n#{END_MARKER}\n"
141
- end
174
+ MarkerBlock.inject(
175
+ existing, generated_body,
176
+ start_marker: START_MARKER, end_marker: END_MARKER, label: paths.skill_file.to_s
177
+ )
142
178
  end
143
179
 
144
180
  def default_outer_template
@@ -151,7 +187,7 @@ module Senren
151
187
 
152
188
  - Use Senren components before writing custom HTML.
153
189
  - Use ViewComponent for reusable UI; Turbo for server state; Stimulus for local behavior only.
154
- - Do not introduce React, Vue, Alpine, or any external state framework.
190
+ - Keep interactivity in Stimulus rather than a client-side framework.
155
191
  - Do not hard-code colors; use semantic Tailwind tokens like `bg-background`, `text-foreground`, `bg-primary`.
156
192
 
157
193
  ## Installed Components
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Senren
4
4
  module Rails
5
- VERSION = '0.1.6'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
data/lib/senren/rails.rb CHANGED
@@ -12,9 +12,9 @@ module Senren
12
12
  autoload :SkillWriter, 'senren/rails/skill_writer'
13
13
  autoload :AgentRulesWriter, 'senren/rails/agent_rules_writer'
14
14
  autoload :LlmsWriter, 'senren/rails/llms_writer'
15
- autoload :Installer, 'senren/rails/installer'
16
15
  autoload :Doctor, 'senren/rails/doctor'
17
16
  autoload :HostPaths, 'senren/rails/host_paths'
17
+ autoload :MarkerBlock, 'senren/rails/marker_block'
18
18
 
19
19
  def self.gem_root
20
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'
@@ -6,8 +6,8 @@ namespace :senren do
6
6
  desc 'Install one or more Senren components. Preferred: bin/rails senren:add button dialog. ' \
7
7
  "Legacy: bin/rails 'senren:add[button,dialog]'"
8
8
  task :add, [:names] do |_t, args|
9
- names = parse_names(args)
10
- options = parse_options
9
+ names = SenrenRakeArgs.names(args)
10
+ options = SenrenRakeArgs.options
11
11
 
12
12
  Senren::Rails::ComponentInstaller.new.install(
13
13
  names: names,
@@ -59,21 +59,47 @@ end
59
59
 
60
60
  # Helpers ---------------------------------------------------------------
61
61
 
62
- def parse_names(args)
63
- raw = []
64
- raw.concat(args.extras)
65
- raw << args[:names] if args[:names]
66
- raw.concat(ARGV.drop_while { |a| !a.start_with?('senren:') }.drop(1))
67
- Senren::Rails::ComponentInstaller.normalize_names(raw)
68
- 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
69
68
 
70
- def parse_options
71
- client_override =
72
- if ARGV.include?('--client')
73
- true
74
- elsif ARGV.include?('--no-client')
75
- false
76
- 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
77
76
 
78
- { 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
79
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
 
@@ -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
 
@@ -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
@@ -15,6 +15,14 @@ module Senren
15
15
  @multipart = multipart
16
16
  end
17
17
 
18
- 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)
19
27
  end
20
28
  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
@@ -0,0 +1,38 @@
1
+ <%= tag.article(**root_attrs("flex flex-col overflow-hidden rounded-(--senren-radius) border bg-[hsl(var(--senren-card))] shadow-sm", id: dom_id)) do %>
2
+ <div class="relative aspect-square w-full bg-[hsl(var(--senren-muted)/0.4)]">
3
+ <% if safe_image_url %>
4
+ <img src="<%= safe_image_url %>" alt="<%= title %>" loading="lazy" class="h-full w-full object-cover">
5
+ <% else %>
6
+ <div class="flex h-full w-full items-center justify-center text-sm text-[hsl(var(--senren-muted-foreground))]" aria-hidden="true">
7
+ No image
8
+ </div>
9
+ <% end %>
10
+
11
+ <% if badge %>
12
+ <span class="absolute left-2 top-2 inline-flex items-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-2 py-0.5 text-xs font-medium text-[hsl(var(--senren-primary-foreground))]">
13
+ <%= badge %>
14
+ </span>
15
+ <% end %>
16
+ </div>
17
+
18
+ <div class="flex flex-1 flex-col gap-2 p-4">
19
+ <h3 class="text-sm font-medium leading-tight text-[hsl(var(--senren-foreground))]">
20
+ <%= title %>
21
+ </h3>
22
+
23
+ <% if description %>
24
+ <p class="line-clamp-2 text-sm text-[hsl(var(--senren-muted-foreground))]"><%= description %></p>
25
+ <% end %>
26
+
27
+ <p class="mt-auto text-base font-semibold text-[hsl(var(--senren-foreground))]"><%= price %></p>
28
+
29
+ <%# A plain form: the server owns the cart, and Turbo handles the response. %>
30
+ <%= form_with url: safe_action_url, method: method, class: "mt-1" do %>
31
+ <button type="submit"
32
+ <%= "disabled" unless available? %>
33
+ class="inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] disabled:cursor-not-allowed disabled:opacity-50">
34
+ <%= button_label %>
35
+ </button>
36
+ <% end %>
37
+ </div>
38
+ <% end %>
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ # A product tile with an add-to-cart action.
5
+ #
6
+ # Adding to a cart is an ordinary form submission, so this component ships no
7
+ # Stimulus controller. That is deliberate: a listing page renders this tile
8
+ # many times, and a controller here would download JavaScript on a page that
9
+ # does not need any.
10
+ #
11
+ # Price is passed in already formatted. Currency formatting is locale- and
12
+ # money-library-specific and belongs to the host app; a UI library that
13
+ # guesses gets it wrong in another country.
14
+ class ProductCardComponent < BaseComponent
15
+ VARIANTS = {
16
+ default: 'border-[hsl(var(--senren-border))]',
17
+ featured: 'border-[hsl(var(--senren-primary))] ring-1 ring-[hsl(var(--senren-primary)/0.35)]'
18
+ }.freeze
19
+
20
+ SIZES = { md: '' }.freeze
21
+
22
+ def initialize(title:, price:, url:, image_url: nil, description: nil, badge: nil,
23
+ action_label: 'Add to cart', available: true, method: :post,
24
+ variant: :default, id: nil, class_name: nil, **html)
25
+ super(variant: variant, size: :md, class_name: class_name, **html)
26
+ @title = title
27
+ @price = price
28
+ @url = url
29
+ @image_url = image_url
30
+ @description = description
31
+ @badge = badge
32
+ @action_label = action_label
33
+ @available = available
34
+ @method = method
35
+ @dom_id = id || senren_dom_id(title)
36
+ end
37
+
38
+ attr_reader :title, :price, :image_url, :description, :badge, :action_label, :method, :dom_id
39
+
40
+ def available? = @available == true
41
+
42
+ # Untrusted values reach href/src attributes, so both go through the shared
43
+ # guards rather than being interpolated directly.
44
+ def safe_action_url = safe_url(@url)
45
+ def safe_image_url = safe_media_url(@image_url)
46
+
47
+ def button_label = available? ? action_label : 'Out of stock'
48
+ end
49
+ end