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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +215 -0
- data/CONTRIBUTING.md +41 -3
- data/README.md +138 -33
- data/Rakefile +14 -1
- data/docs/components.md +10 -10
- data/docs/hot_reload.md +103 -0
- data/docs/performance_testing.md +7 -3
- data/docs/visual_style.md +80 -0
- data/lib/generators/senren/component/templates/controller.js.tt +7 -4
- data/lib/generators/senren/install/install_generator.rb +77 -0
- data/lib/generators/senren/install/templates/base_component.rb.tt +142 -4
- data/lib/generators/senren/install/templates/conventions.md.tt +57 -1
- data/lib/generators/senren/install/templates/senren_themes.css.tt +339 -0
- data/lib/senren/rails/agent_rules_writer.rb +65 -19
- data/lib/senren/rails/asset_path_guard.rb +128 -0
- data/lib/senren/rails/base_component_patch.rb +64 -0
- data/lib/senren/rails/component_copier.rb +101 -50
- data/lib/senren/rails/component_installer.rb +26 -0
- data/lib/senren/rails/doctor.rb +7 -4
- data/lib/senren/rails/engine.rb +23 -0
- data/lib/senren/rails/host_paths.rb +11 -2
- data/lib/senren/rails/marker_block.rb +81 -0
- data/lib/senren/rails/registry.rb +10 -4
- data/lib/senren/rails/safe_write.rb +169 -0
- data/lib/senren/rails/skill_writer.rb +47 -11
- data/lib/senren/rails/version.rb +1 -1
- data/lib/senren/rails.rb +1 -1
- data/lib/senren-ui.rb +15 -0
- data/lib/tasks/senren.rake +43 -17
- data/registry/components.yml +45 -0
- data/registry/recipes.yml +12 -0
- data/templates/components/accordion/accordion_component.html.erb +3 -3
- data/templates/components/alert_dialog/alert_dialog_component.html.erb +6 -3
- data/templates/components/alert_dialog/alert_dialog_component.rb +5 -1
- data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
- data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
- data/templates/components/avatar/avatar_component.rb +8 -1
- data/templates/components/breadcrumb/breadcrumb_component.html.erb +8 -6
- data/templates/components/button/button_component.html.erb +1 -1
- data/templates/components/button/button_component.rb +27 -1
- data/templates/components/calendar/calendar_component.html.erb +2 -2
- data/templates/components/card/card_component.html.erb +4 -6
- data/templates/components/carousel/carousel_component.html.erb +1 -1
- data/templates/components/cart/cart_component.html.erb +67 -0
- data/templates/components/cart/cart_component.rb +71 -0
- data/templates/components/checkbox/checkbox_component.rb +1 -1
- data/templates/components/clipboard/clipboard_component.html.erb +3 -3
- data/templates/components/collapsible/collapsible_component.html.erb +3 -3
- data/templates/components/combobox/combobox_component.html.erb +3 -3
- data/templates/components/command/command_component.html.erb +1 -1
- data/templates/components/command/command_component.rb +1 -1
- data/templates/components/context_menu/context_menu_component.html.erb +6 -3
- data/templates/components/date_picker/date_picker_component.html.erb +3 -3
- data/templates/components/dialog/dialog_component.html.erb +7 -3
- data/templates/components/dialog/dialog_component.rb +12 -1
- data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +6 -3
- data/templates/components/dropdown_menu/dropdown_menu_component.rb +57 -5
- data/templates/components/form/form_component.rb +9 -1
- data/templates/components/hover_card/hover_card_component.html.erb +6 -3
- data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +3 -2
- data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
- data/templates/components/link/link_component.html.erb +1 -1
- data/templates/components/native_select/native_select_component.html.erb +2 -2
- data/templates/components/pagination/pagination_component.html.erb +2 -2
- data/templates/components/popover/popover_component.html.erb +6 -3
- data/templates/components/product_card/product_card_component.html.erb +38 -0
- data/templates/components/product_card/product_card_component.rb +49 -0
- data/templates/components/progress/progress_component.html.erb +2 -2
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
- data/templates/components/search_input/search_input_component.html.erb +2 -2
- data/templates/components/separator/separator_component.rb +7 -0
- data/templates/components/sheet/sheet_component.html.erb +13 -4
- data/templates/components/sheet/sheet_component.rb +12 -1
- data/templates/components/shortcut_key/shortcut_key_component.html.erb +2 -2
- data/templates/components/sidebar/sidebar_component.html.erb +2 -2
- data/templates/components/switch/switch_component.html.erb +1 -1
- data/templates/components/table/table_component.html.erb +2 -2
- data/templates/components/tabs/tabs_component.html.erb +3 -3
- data/templates/components/tooltip/tooltip_component.html.erb +5 -3
- data/templates/components/tooltip/tooltip_component.rb +2 -2
- data/templates/components/top_nav/top_nav_component.html.erb +2 -2
- data/templates/components/typography/typography_component.rb +7 -0
- data/templates/controllers/accordion_controller.js +1 -1
- data/templates/controllers/alert_dialog_controller.js +31 -7
- data/templates/controllers/cart_controller.js +85 -0
- data/templates/controllers/clipboard_controller.js +12 -1
- data/templates/controllers/command_controller.js +3 -4
- data/templates/controllers/context_menu_controller.js +38 -11
- data/templates/controllers/data_table_controller.js +8 -3
- data/templates/controllers/dialog_controller.js +47 -21
- data/templates/controllers/dropdown_menu_controller.js +40 -27
- data/templates/controllers/hover_card_controller.js +8 -0
- data/templates/controllers/invite_member_dialog_controller.js +6 -0
- data/templates/controllers/masked_input_controller.js +8 -1
- data/templates/controllers/popover_controller.js +25 -10
- data/templates/controllers/rich_text_editor_lite_controller.js +165 -28
- data/templates/controllers/sheet_controller.js +41 -11
- metadata +14 -17
- 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.
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
-
|
|
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
|
data/lib/senren/rails/version.rb
CHANGED
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'
|
data/lib/tasks/senren.rake
CHANGED
|
@@ -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 =
|
|
10
|
-
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
data/registry/components.yml
CHANGED
|
@@ -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
|
-
|
|
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] %>"
|
|
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
|
-
|
|
16
|
+
<% end %>
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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 ||
|
|
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 :
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
-
|
|
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))]">−</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 %>
|