senren-ui 0.1.6 → 0.3.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +215 -0
  3. data/CONTRIBUTING.md +41 -3
  4. data/README.md +138 -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/docs/visual_style.md +80 -0
  10. data/lib/generators/senren/component/templates/controller.js.tt +7 -4
  11. data/lib/generators/senren/install/install_generator.rb +77 -0
  12. data/lib/generators/senren/install/templates/base_component.rb.tt +142 -4
  13. data/lib/generators/senren/install/templates/conventions.md.tt +57 -1
  14. data/lib/generators/senren/install/templates/senren_themes.css.tt +339 -0
  15. data/lib/senren/rails/agent_rules_writer.rb +65 -19
  16. data/lib/senren/rails/asset_path_guard.rb +128 -0
  17. data/lib/senren/rails/base_component_patch.rb +64 -0
  18. data/lib/senren/rails/component_copier.rb +101 -50
  19. data/lib/senren/rails/component_installer.rb +26 -0
  20. data/lib/senren/rails/doctor.rb +7 -4
  21. data/lib/senren/rails/engine.rb +23 -0
  22. data/lib/senren/rails/host_paths.rb +11 -2
  23. data/lib/senren/rails/marker_block.rb +81 -0
  24. data/lib/senren/rails/registry.rb +10 -4
  25. data/lib/senren/rails/safe_write.rb +169 -0
  26. data/lib/senren/rails/skill_writer.rb +47 -11
  27. data/lib/senren/rails/version.rb +1 -1
  28. data/lib/senren/rails.rb +1 -1
  29. data/lib/senren-ui.rb +15 -0
  30. data/lib/tasks/senren.rake +43 -17
  31. data/registry/components.yml +45 -0
  32. data/registry/recipes.yml +12 -0
  33. data/templates/components/accordion/accordion_component.html.erb +3 -3
  34. data/templates/components/alert_dialog/alert_dialog_component.html.erb +6 -3
  35. data/templates/components/alert_dialog/alert_dialog_component.rb +5 -1
  36. data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
  37. data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
  38. data/templates/components/avatar/avatar_component.rb +8 -1
  39. data/templates/components/breadcrumb/breadcrumb_component.html.erb +8 -6
  40. data/templates/components/button/button_component.html.erb +1 -1
  41. data/templates/components/button/button_component.rb +27 -1
  42. data/templates/components/calendar/calendar_component.html.erb +2 -2
  43. data/templates/components/card/card_component.html.erb +4 -6
  44. data/templates/components/carousel/carousel_component.html.erb +1 -1
  45. data/templates/components/cart/cart_component.html.erb +67 -0
  46. data/templates/components/cart/cart_component.rb +71 -0
  47. data/templates/components/checkbox/checkbox_component.rb +1 -1
  48. data/templates/components/clipboard/clipboard_component.html.erb +3 -3
  49. data/templates/components/collapsible/collapsible_component.html.erb +3 -3
  50. data/templates/components/combobox/combobox_component.html.erb +3 -3
  51. data/templates/components/command/command_component.html.erb +1 -1
  52. data/templates/components/command/command_component.rb +1 -1
  53. data/templates/components/context_menu/context_menu_component.html.erb +6 -3
  54. data/templates/components/date_picker/date_picker_component.html.erb +3 -3
  55. data/templates/components/dialog/dialog_component.html.erb +7 -3
  56. data/templates/components/dialog/dialog_component.rb +12 -1
  57. data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +6 -3
  58. data/templates/components/dropdown_menu/dropdown_menu_component.rb +57 -5
  59. data/templates/components/form/form_component.rb +9 -1
  60. data/templates/components/hover_card/hover_card_component.html.erb +6 -3
  61. data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +3 -2
  62. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
  63. data/templates/components/link/link_component.html.erb +1 -1
  64. data/templates/components/native_select/native_select_component.html.erb +2 -2
  65. data/templates/components/pagination/pagination_component.html.erb +2 -2
  66. data/templates/components/popover/popover_component.html.erb +6 -3
  67. data/templates/components/product_card/product_card_component.html.erb +38 -0
  68. data/templates/components/product_card/product_card_component.rb +49 -0
  69. data/templates/components/progress/progress_component.html.erb +2 -2
  70. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
  71. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
  72. data/templates/components/search_input/search_input_component.html.erb +2 -2
  73. data/templates/components/separator/separator_component.rb +7 -0
  74. data/templates/components/sheet/sheet_component.html.erb +13 -4
  75. data/templates/components/sheet/sheet_component.rb +12 -1
  76. data/templates/components/shortcut_key/shortcut_key_component.html.erb +2 -2
  77. data/templates/components/sidebar/sidebar_component.html.erb +2 -2
  78. data/templates/components/switch/switch_component.html.erb +1 -1
  79. data/templates/components/table/table_component.html.erb +2 -2
  80. data/templates/components/tabs/tabs_component.html.erb +3 -3
  81. data/templates/components/tooltip/tooltip_component.html.erb +5 -3
  82. data/templates/components/tooltip/tooltip_component.rb +2 -2
  83. data/templates/components/top_nav/top_nav_component.html.erb +2 -2
  84. data/templates/components/typography/typography_component.rb +7 -0
  85. data/templates/controllers/accordion_controller.js +1 -1
  86. data/templates/controllers/alert_dialog_controller.js +31 -7
  87. data/templates/controllers/cart_controller.js +85 -0
  88. data/templates/controllers/clipboard_controller.js +12 -1
  89. data/templates/controllers/command_controller.js +3 -4
  90. data/templates/controllers/context_menu_controller.js +38 -11
  91. data/templates/controllers/data_table_controller.js +8 -3
  92. data/templates/controllers/dialog_controller.js +47 -21
  93. data/templates/controllers/dropdown_menu_controller.js +40 -27
  94. data/templates/controllers/hover_card_controller.js +8 -0
  95. data/templates/controllers/invite_member_dialog_controller.js +6 -0
  96. data/templates/controllers/masked_input_controller.js +8 -1
  97. data/templates/controllers/popover_controller.js +25 -10
  98. data/templates/controllers/rich_text_editor_lite_controller.js +165 -28
  99. data/templates/controllers/sheet_controller.js +41 -11
  100. metadata +14 -17
  101. 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.3.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
@@ -1,4 +1,4 @@
1
- <div <%= tag.attributes(**root_attrs("divide-y divide-[hsl(var(--senren-border))] rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-background))]", data: { controller: "senren--accordion", "senren--accordion-multiple-value": multiple? })) %>>
1
+ <%= tag.div(**root_attrs("divide-y divide-[hsl(var(--senren-border))] rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-background))]", data: { controller: "senren--accordion", "senren--accordion-multiple-value": multiple? })) do %>
2
2
  <% items.each_with_index do |item, index| %>
3
3
  <% expanded = open_item?(item, index) %>
4
4
  <section>
@@ -8,9 +8,9 @@
8
8
  <span aria-hidden="true" class="text-[hsl(var(--senren-muted-foreground))]">+</span>
9
9
  </button>
10
10
  </h3>
11
- <div id="<%= item[:id] %>-panel" data-senren--accordion-target="panel" data-panel-id="<%= item[:id] %>" <%= "hidden" unless expanded %> class="px-4 pb-4 text-sm leading-6 text-[hsl(var(--senren-muted-foreground))]">
11
+ <div id="<%= item[:id] %>-panel" data-senren--accordion-target="panel" data-panel-id="<%= item[:id] %>" <% unless expanded %>hidden<% end %> class="px-4 pb-4 text-sm leading-6 text-[hsl(var(--senren-muted-foreground))]">
12
12
  <%= item[:content].presence || content %>
13
13
  </div>
14
14
  </section>
15
15
  <% end %>
16
- </div>
16
+ <% end %>
@@ -1,4 +1,7 @@
1
- <div data-controller="senren--alert-dialog" data-senren-component="alert_dialog" id="<%= dom_id %>">
1
+ <%# Root goes through root_attrs so a caller's class: and data: reach it.
2
+ Hand-writing these attributes dropped both -- which is why a Stimulus
3
+ value could not be set from the server. %>
4
+ <%= tag.div(**wrapper_attrs(id: dom_id, data: { controller: "senren--alert-dialog" })) do %>
2
5
  <% if trigger? %>
3
6
  <span data-action="click->senren--alert-dialog#open" data-senren--alert-dialog-target="trigger">
4
7
  <%= trigger %>
@@ -15,7 +18,7 @@
15
18
  <div data-senren--alert-dialog-target="panel" hidden
16
19
  role="alertdialog" aria-modal="true" aria-labelledby="<%= dom_id %>-title" aria-describedby="<%= dom_id %>-desc"
17
20
  tabindex="-1"
18
- class="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] text-[hsl(var(--senren-popover-foreground))] p-6 shadow-lg">
21
+ class="<%= panel_class("fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-popover))] text-[hsl(var(--senren-popover-foreground))] p-6 shadow-lg") %>">
19
22
  <% if title? %>
20
23
  <h2 id="<%= dom_id %>-title" class="text-lg font-semibold"><%= title %></h2>
21
24
  <% end %>
@@ -31,4 +34,4 @@
31
34
  <% end %>
32
35
  </div>
33
36
  </div>
34
- </div>
37
+ <% end %>
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Senren
4
+ # Unlike Dialog and Sheet, this does not close when the overlay is clicked.
5
+ # An alert dialog exists to make someone choose, and dismissing it by
6
+ # clicking beside it is indistinguishable from choosing Cancel by accident.
7
+ # Escape still closes it, because a keyboard user needs a way out.
4
8
  class AlertDialogComponent < BaseComponent
5
9
  renders_one :trigger
6
10
  renders_one :title
@@ -13,7 +17,7 @@ module Senren
13
17
 
14
18
  def initialize(variant: :default, id: nil, class_name: nil, **html)
15
19
  super(variant: variant, size: :md, class_name: class_name, **html)
16
- @dom_id = id || "senren-alert-dialog-#{SecureRandom.hex(3)}"
20
+ @dom_id = id || senren_dom_id
17
21
  end
18
22
 
19
23
  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 autocomplete="off" 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
 
@@ -1,5 +1,9 @@
1
- <% if items.any? %>
2
- <nav <%= tag.attributes(**root_attrs("text-sm text-[hsl(var(--senren-muted-foreground))]", aria: { label: label })) %>>
1
+ <%# The <nav> is outside the branch on purpose. It used to be written twice,
2
+ once per branch, with the same root_attrs call duplicated -- so a change to
3
+ the root had to be made in two places and the empty case was one edit away
4
+ from silently losing its aria-label. %>
5
+ <%= tag.nav(**root_attrs("text-sm text-[hsl(var(--senren-muted-foreground))]", aria: { label: label })) do %>
6
+ <% if items.any? %>
3
7
  <ol class="flex flex-wrap items-center gap-2">
4
8
  <% items.each_with_index do |item, index| %>
5
9
  <% current = index == items.length - 1 || item[:href].blank? %>
@@ -15,9 +19,7 @@
15
19
  </li>
16
20
  <% end %>
17
21
  </ol>
18
- </nav>
19
- <% else %>
20
- <nav <%= tag.attributes(**root_attrs("text-sm text-[hsl(var(--senren-muted-foreground))]", aria: { label: label })) %>>
22
+ <% else %>
21
23
  <%= content %>
22
- </nav>
24
+ <% end %>
23
25
  <% 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: safe_url(href)) %>
3
+ <%= tag.a content, href: safe_url(href), **root_attrs(base) %>
4
4
  <% else %>
5
5
  <%= tag.button content, **root_attrs(base, type: type) %>
6
6
  <% end %>
@@ -17,7 +17,33 @@ module Senren
17
17
  lg: 'h-12 px-6 text-base'
18
18
  }.freeze
19
19
 
20
- def initialize(variant: :default, size: :md, type: 'button', as: :button, href: nil, class_name: nil, **html)
20
+ # FOR AI AGENTS AND HUMANS -- how `type` behaves, and why it changed.
21
+ #
22
+ # `type:` defaults to nil, so the attribute is omitted and the browser's own
23
+ # rule applies: a <button> inside a <form> submits it, one outside a form
24
+ # does nothing. Write what you would write in plain HTML; it behaves the
25
+ # same.
26
+ #
27
+ # <%= form_with url: sessions_path do %>
28
+ # <%= render(Senren::ButtonComponent.new(variant: :primary)) { "Sign in" } %>
29
+ # <% end %> # submits, as HTML says
30
+ #
31
+ # <%= render(Senren::ButtonComponent.new(type: :button)) { "Open" } %>
32
+ # # a trigger: say so
33
+ #
34
+ # This used to default to "button", which silently overrode the platform. A
35
+ # button rendered inside a form did nothing when clicked -- no submit, no
36
+ # navigation, nothing in the console -- and that is what users reported:
37
+ # "I click submit and it does not redirect". Every auth example this library
38
+ # ships had the bug, so anyone copying a snippet inherited it.
39
+ #
40
+ # The trade is deliberate. The old default failed silently; this one fails
41
+ # loudly, by submitting a form you did not mean to submit. A visible wrong
42
+ # is cheaper to find than an invisible nothing.
43
+ #
44
+ # Agents: writing a form? Do not pass `type:` at all. Writing a trigger for
45
+ # a dialog, sheet, or menu, which must not submit? Pass `type: :button`.
46
+ def initialize(variant: :default, size: :md, type: nil, as: :button, href: nil, class_name: nil, **html)
21
47
  super(variant: variant, size: size, class_name: class_name, **html)
22
48
  @type = type
23
49
  @as = href ? :a : as
@@ -1,4 +1,4 @@
1
- <div <%= tag.attributes(**root_attrs("w-72 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] p-3 text-[hsl(var(--senren-card-foreground))]", data: { controller: "senren--calendar" })) %>>
1
+ <%= tag.div(**root_attrs("w-72 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-card))] p-3 text-[hsl(var(--senren-card-foreground))]", data: { controller: "senren--calendar" })) do %>
2
2
  <% if name.present? %>
3
3
  <input type="hidden" name="<%= name %>" value="<%= selected&.iso8601 %>" data-senren--calendar-target="value">
4
4
  <% end %>
@@ -18,4 +18,4 @@
18
18
  </button>
19
19
  <% end %>
20
20
  </div>
21
- </div>
21
+ <% end %>
@@ -2,12 +2,10 @@
2
2
  <% if header? %>
3
3
  <div class="flex flex-col space-y-1.5 p-6 border-b border-[hsl(var(--senren-border))]"><%= header %></div>
4
4
  <% end %>
5
- <% if body? %>
6
- <div class="p-6"><%= body %></div>
7
- <% else %>
8
- <div class="p-6"><%= content %></div>
9
- <% end %>
5
+ <%# One <div>, not the same one in both branches: the padding is the card's, %>
6
+ <%# and duplicating the element meant a change to it had to be made twice. %>
7
+ <div class="p-6"><%= body? ? body : content %></div>
10
8
  <% if footer? %>
11
- <div class="flex items-center p-6 pt-0 border-t border-[hsl(var(--senren-border))]"><%= footer %></div>
9
+ <div class="flex items-center gap-2 p-6 border-t border-[hsl(var(--senren-border))]"><%= footer %></div>
12
10
  <% end %>
13
11
  <% end %>
@@ -6,7 +6,7 @@
6
6
  <% slides.each_with_index do |slide, index| %>
7
7
  <article data-senren--carousel-target="slide" data-index="<%= index %>" class="<%= index.zero? ? '' : 'hidden' %>">
8
8
  <% if slide[:image_url].present? %>
9
- <img src="<%= slide[:image_url] %>" alt="<%= slide[:alt] || slide[:title] %>" class="aspect-video w-full object-cover">
9
+ <%= image_tag slide[:image_url], alt: slide[:alt] || slide[:title], class: "aspect-video w-full object-cover" %>
10
10
  <% else %>
11
11
  <div class="relative flex aspect-video items-end overflow-hidden bg-[hsl(var(--senren-muted)/0.45)] p-6">
12
12
  <svg aria-hidden="true" viewBox="0 0 960 540" preserveAspectRatio="none" class="absolute inset-0 h-full w-full">
@@ -0,0 +1,67 @@
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
+ <%# Emitted so the `removed` event can carry it. The component
16
+ accepted remove_url: and normalised it, then never wrote it
17
+ anywhere -- an advertised input that reached nothing, so the
18
+ consumer listening for the event had no way to learn where to
19
+ send the delete. %>
20
+ <% if item[:remove_url].present? %>data-remove-url="<%= safe_url(item[:remove_url]) %>"<% end %>
21
+ data-price-cents="<%= item[:price_cents] %>">
22
+ <% if (image = safe_media_url(item[:image_url])) %>
23
+ <%= image_tag image, alt: "", loading: "lazy", class: "h-12 w-12 shrink-0 rounded-(--senren-radius) object-cover" %>
24
+ <% else %>
25
+ <div class="h-12 w-12 shrink-0 rounded-(--senren-radius) bg-[hsl(var(--senren-muted)/0.5)]" aria-hidden="true"></div>
26
+ <% end %>
27
+
28
+ <div class="min-w-0 flex-1">
29
+ <p class="truncate text-sm font-medium text-[hsl(var(--senren-foreground))]"><%= item[:name] %></p>
30
+ <p class="text-sm text-[hsl(var(--senren-muted-foreground))]" data-senren--cart-target="lineTotal"><%= line_total(item) %></p>
31
+ </div>
32
+
33
+ <div class="flex items-center gap-1" role="group" aria-label="<%= item[:name] %> quantity">
34
+ <button type="button"
35
+ data-action="click->senren--cart#decrement"
36
+ aria-label="Decrease <%= item[:name] %> quantity"
37
+ 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>
38
+
39
+ <output class="w-8 text-center text-sm tabular-nums text-[hsl(var(--senren-foreground))]"
40
+ data-senren--cart-target="quantity"><%= item[:quantity] %></output>
41
+
42
+ <button type="button"
43
+ data-action="click->senren--cart#increment"
44
+ aria-label="Increase <%= item[:name] %> quantity"
45
+ 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>
46
+ </div>
47
+
48
+ <button type="button"
49
+ data-action="click->senren--cart#remove"
50
+ aria-label="Remove <%= item[:name] %>"
51
+ 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>
52
+ </li>
53
+ <% end %>
54
+ </ul>
55
+
56
+ <footer class="flex items-center justify-between gap-3 border-t border-[hsl(var(--senren-border))] px-4 py-3">
57
+ <div class="text-sm text-[hsl(var(--senren-muted-foreground))]">
58
+ Subtotal
59
+ <span class="ml-1 font-semibold text-[hsl(var(--senren-foreground))]" data-senren--cart-target="subtotal"><%= subtotal %></span>
60
+ </div>
61
+
62
+ <% if safe_checkout_url %>
63
+ <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>
64
+ <% end %>
65
+ </footer>
66
+ <% end %>
67
+ <% end %>