studio-engine 0.132.0 → 0.133.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 +77 -0
- data/README.md +52 -24
- data/app/assets/tailwind/studio_engine/engine.css +141 -0
- data/app/javascript/studio/application.js +1 -0
- data/app/javascript/studio/carousel.js +66 -1
- data/app/javascript/studio/controllers/carousel_controller.js +139 -8
- data/app/javascript/studio/controllers/page_header_controller.js +64 -1
- data/app/javascript/studio/controllers/style_section_nav_controller.js +26 -0
- data/app/javascript/studio/in_page_links.js +308 -0
- data/app/javascript/studio/page_header.js +17 -0
- data/app/views/studio/_carousel.html.erb +33 -5
- data/app/views/studio/_page_header.html.erb +64 -12
- data/app/views/studio/auth/_sign_in_body.html.erb +236 -0
- data/app/views/studio/auth/_sign_in_card.html.erb +50 -0
- data/app/views/studio/auth/_sign_in_modal.html.erb +36 -0
- data/app/views/studio/modals/auth/_resend_footer.html.erb +6 -0
- data/app/views/style/_glass.html.erb +4 -8
- data/app/views/style/_loaders.html.erb +11 -35
- data/app/views/style/_modals.html.erb +29 -39
- data/app/views/style/_scrims.html.erb +10 -20
- data/app/views/style/_site_footer.html.erb +10 -18
- data/app/views/style/_specimen.html.erb +13 -41
- data/app/views/style/_tasks.html.erb +4 -22
- data/app/views/style/_trick_label.html.erb +33 -0
- data/app/views/style/_tricks.html.erb +127 -207
- data/app/views/style/index.html.erb +2 -2
- data/app/views/style/modals/_wallet_connect.html.erb +2 -1
- data/config/studio_tricks.yml +470 -0
- data/lib/studio/page_header.rb +53 -0
- data/lib/studio/tricks.rb +110 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +6 -4
- metadata +9 -3
- data/app/views/style/modals/_ds_email_field.html.erb +0 -58
- data/app/views/style/modals/_ds_resend_footer.html.erb +0 -70
|
@@ -29,3 +29,20 @@ export function revealBy(box, tab, margin = 32) {
|
|
|
29
29
|
if (tab.right > box.right) return tab.right - box.right + margin
|
|
30
30
|
return 0
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
// Where the quick-links list goes when the browser has no anchor positioning: under the
|
|
34
|
+
// button, its right edge on the button's, and kept on the screen with a gap of `gap`. It
|
|
35
|
+
// opens above the button when there is no room below and more room above. Fixed
|
|
36
|
+
// coordinates, as the list is a popover in the top layer.
|
|
37
|
+
export function menuPlacement(button, list, viewport, gap = 8) {
|
|
38
|
+
const below = viewport.height - button.bottom
|
|
39
|
+
const above = button.top
|
|
40
|
+
const top = below < list.height + gap && above > below
|
|
41
|
+
? Math.max(gap, button.top - list.height - 4)
|
|
42
|
+
: button.bottom + 4
|
|
43
|
+
const left = Math.min(
|
|
44
|
+
Math.max(gap, button.right - list.width),
|
|
45
|
+
Math.max(gap, viewport.width - list.width - gap)
|
|
46
|
+
)
|
|
47
|
+
return { top, left }
|
|
48
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# locals: (label:, items:, per_view: 3, id: nil, slide_label: nil, data: {}, class_name: nil, style: :standard, caption: nil, start: nil) %>
|
|
1
|
+
<%# locals: (label:, items:, per_view: 3, id: nil, slide_label: nil, data: {}, class_name: nil, style: :standard, caption: nil, start: nil, autoplay: nil, loop: false) %>
|
|
2
2
|
<%# THE CAROUSEL: one sideways row of slides, the apps' standard for a set too
|
|
3
3
|
long to grid. Render it as a layout, so each slide is your own block:
|
|
4
4
|
|
|
@@ -31,6 +31,17 @@
|
|
|
31
31
|
it has scroll-initial-target; elsewhere the controller sets it
|
|
32
32
|
when it connects, with no glide and nothing announced. A link
|
|
33
33
|
to an id inside the row (#fragment) wins over the start.
|
|
34
|
+
loop true wraps the row: Next at the last slide goes to the first,
|
|
35
|
+
Previous at the first to the last, and neither arrow is ever
|
|
36
|
+
disabled. No slide is cloned, so a screen reader reads each once.
|
|
37
|
+
autoplay seconds between moves (a number above 0); nil, the default, is
|
|
38
|
+
off. The row moves one slide each interval and holds still while
|
|
39
|
+
the pointer is over the slides, while keyboard focus is in it,
|
|
40
|
+
while the tab is hidden or the row is off screen, and after a
|
|
41
|
+
swipe or press until the reader leaves it alone a while. A pause
|
|
42
|
+
button sits beside the arrows (WCAG 2.2.2). Under reduced motion
|
|
43
|
+
the row starts stopped, and Play starts it. Without loop it
|
|
44
|
+
stops at the last slide; Play then starts again from the first.
|
|
34
45
|
|
|
35
46
|
THE SPOTLIGHT (engine.css, carousel-spotlight) snaps each slide to the centre.
|
|
36
47
|
Where the browser has scroll-driven animations, each slide's own view timeline
|
|
@@ -52,7 +63,7 @@
|
|
|
52
63
|
slide a group with aria-roledescription slide and its own label, named arrows
|
|
53
64
|
that set aria-disabled at an end (not disabled, so focus stays on the arrow),
|
|
54
65
|
and a polite, atomic status line that speaks only after a press that moves the
|
|
55
|
-
row. A key pressed in a form field, an arrow-key widget (a slider, a tab list,
|
|
66
|
+
row (a looping row's arrows are never disabled; autoplay never speaks). A key pressed in a form field, an arrow-key widget (a slider, a tab list,
|
|
56
67
|
a listbox...) or a nested carousel stays that element's. %>
|
|
57
68
|
<%
|
|
58
69
|
per_view = per_view.to_f.clamp(1, 6)
|
|
@@ -70,6 +81,13 @@
|
|
|
70
81
|
when Integer then start.clamp(0, [total - 1, 0].max)
|
|
71
82
|
else raise ArgumentError, "studio/carousel start: #{start.inspect} is not an index or :middle"
|
|
72
83
|
end
|
|
84
|
+
autoplay = nil if autoplay == false
|
|
85
|
+
unless autoplay.nil? || (autoplay.is_a?(Numeric) && autoplay.positive? && autoplay.finite?)
|
|
86
|
+
raise ArgumentError, "studio/carousel autoplay: #{autoplay.inspect} is not a number of seconds above 0 or nil"
|
|
87
|
+
end
|
|
88
|
+
autoplay = autoplay.to_i if autoplay && autoplay == autoplay.floor
|
|
89
|
+
loop = loop ? true : false
|
|
90
|
+
wraps = loop && total > 1
|
|
73
91
|
arrow_noun = spotlight ? "slide" : "slides"
|
|
74
92
|
track_id = id.presence || "carousel-#{label.to_s.parameterize.presence || 'slides'}"
|
|
75
93
|
%>
|
|
@@ -77,7 +95,8 @@
|
|
|
77
95
|
role: "region",
|
|
78
96
|
aria: { roledescription: "carousel", label: label },
|
|
79
97
|
style: "--carousel-per-view: #{per_view}",
|
|
80
|
-
data: { studio_controller: "carousel", studio_action: "keydown->carousel#key", carousel_start: (start_index.to_s if start_index.positive?)
|
|
98
|
+
data: { studio_controller: "carousel", studio_action: "keydown->carousel#key", carousel_start: (start_index.to_s if start_index.positive?),
|
|
99
|
+
carousel_autoplay: (autoplay.to_s if autoplay), carousel_loop: ("" if loop) }.compact.merge(data)) do %>
|
|
81
100
|
<%# The track takes focus for the keys, so it carries a name: a group, since a
|
|
82
101
|
bare div may not be named. %>
|
|
83
102
|
<div id="<%= track_id %>" class="carousel-track" tabindex="0" role="group"
|
|
@@ -98,12 +117,21 @@
|
|
|
98
117
|
<% end %>
|
|
99
118
|
</div>
|
|
100
119
|
<div class="carousel-arrows">
|
|
120
|
+
<% if autoplay %>
|
|
121
|
+
<%# The pause button (WCAG 2.2.2). The controller renames it "Play slideshow"
|
|
122
|
+
and swaps the icon when the reader stops the row. %>
|
|
123
|
+
<button type="button" class="carousel-button carousel-autoplay" aria-label="Pause slideshow" aria-controls="<%= track_id %>"
|
|
124
|
+
data-carousel-target="toggle" data-studio-action="click->carousel#toggle">
|
|
125
|
+
<svg class="carousel-autoplay-pause" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><rect x="5.5" y="4.5" width="3" height="11" rx="1"/><rect x="11.5" y="4.5" width="3" height="11" rx="1"/></svg>
|
|
126
|
+
<svg class="carousel-autoplay-play" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M7 4.8v10.4a.8.8 0 0 0 1.2.7l8.2-5.2a.8.8 0 0 0 0-1.4L8.2 4.1A.8.8 0 0 0 7 4.8Z"/></svg>
|
|
127
|
+
</button>
|
|
128
|
+
<% end %>
|
|
101
129
|
<button type="button" class="carousel-button" aria-label="Previous <%= arrow_noun %>" aria-controls="<%= track_id %>"
|
|
102
|
-
aria-disabled="<%= start_index.zero? %>" data-carousel-target="prev" data-studio-action="click->carousel#prev">
|
|
130
|
+
aria-disabled="<%= !wraps && start_index.zero? %>" data-carousel-target="prev" data-studio-action="click->carousel#prev">
|
|
103
131
|
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12.5 15 7.5 10l5-5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
104
132
|
</button>
|
|
105
133
|
<button type="button" class="carousel-button" aria-label="Next <%= arrow_noun %>" aria-controls="<%= track_id %>"
|
|
106
|
-
<%= 'aria-disabled="true" '.html_safe if total.positive? && start_index == total - 1 %>data-carousel-target="next" data-studio-action="click->carousel#next">
|
|
134
|
+
<%= 'aria-disabled="true" '.html_safe if !wraps && total.positive? && start_index == total - 1 %>data-carousel-target="next" data-studio-action="click->carousel#next">
|
|
107
135
|
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m7.5 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
108
136
|
</button>
|
|
109
137
|
</div>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# locals: (title:, crumbs: [], crumb_label: nil, meta: nil, meta_title: nil, label: nil, tabs: [], tabs_data: {}, actions_html: nil, links: [], links_label: "More links", data: {}, row_class: "-mx-4 px-4") %>
|
|
1
|
+
<%# locals: (title:, crumbs: [], crumb_label: nil, meta: nil, meta_title: nil, label: nil, tabs: [], anchors: [], tabs_data: {}, menu: [], menu_label: "Links", action: nil, tools_html: nil, actions_html: nil, links: [], links_label: "More links", data: {}, row_class: "-mx-4 px-4") %>
|
|
2
2
|
<%# THE PAGE HEADER: the second layer of the pinned stack (docs/PAGE_HEADER.md).
|
|
3
3
|
navbar, then this row, then a section's header (studio/_section_header), then a
|
|
4
4
|
table's sticky header, each below the last.
|
|
@@ -19,12 +19,25 @@
|
|
|
19
19
|
meta short text beside the title, such as the version. Hidden on a
|
|
20
20
|
phone, where the tabs need the width.
|
|
21
21
|
meta_title the meta's title attribute.
|
|
22
|
-
label the
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
label the pill group's nav's accessible name. A row with tabs or
|
|
23
|
+
anchors needs it.
|
|
24
|
+
|
|
25
|
+
THE FOUR KINDS OF CONTROL on the right (docs/PAGE_HEADER.md, "The four kinds of
|
|
26
|
+
control"), always in this order and at the one height of .btn-sm:
|
|
27
|
+
|
|
28
|
+
tabs views of the same thing (Deployments | Triage), [{ label:, href:,
|
|
29
|
+
current:, data: }]. One pill group with the gliding pill. `current`
|
|
30
|
+
is the aria-current value ("page" or "true"), blank on the others.
|
|
31
|
+
anchors sections of this page, [{ label:, href: "#id", current:, data: }].
|
|
32
|
+
The same pill group; a same-page link scrolls, with no visit. A row
|
|
33
|
+
takes tabs or anchors, never both: it has one pill group.
|
|
34
|
+
tabs_data data attributes for the pill group's scroller.
|
|
35
|
+
menu related pages elsewhere, [{ label:, href:, data: }]: one neutral
|
|
36
|
+
button, menu_label ("Links"), that opens a list of them.
|
|
37
|
+
action the page's one primary action, { label:, href:, data: }. Rightmost.
|
|
38
|
+
tools_html local-only and dev controls, after the action, muted.
|
|
39
|
+
actions_html DEPRECATED: free HTML after the pill group. It renders for one
|
|
40
|
+
release and warns once per call site in development.
|
|
28
41
|
links the page's other links, [{ label:, href:, current: }], on a line of
|
|
29
42
|
their own at the foot of the row, so they stick with it.
|
|
30
43
|
links_label that line's accessible name.
|
|
@@ -79,13 +92,16 @@
|
|
|
79
92
|
<p class="page-header-meta label-upper"<%= %( title="#{ERB::Util.h(meta_title)}").html_safe if meta_title.present? %>><%= meta %></p>
|
|
80
93
|
<% end %>
|
|
81
94
|
</div>
|
|
82
|
-
<% if tabs.any? %>
|
|
83
|
-
|
|
95
|
+
<% raise ArgumentError, "studio/_page_header takes tabs: or anchors:, not both (one pill group)" if tabs.any? && anchors.any? %>
|
|
96
|
+
<% raise ArgumentError, "studio/_page_header takes one action: a Hash { label:, href: }" unless action.nil? || action.is_a?(Hash) %>
|
|
97
|
+
<% pills, pill_kind = tabs.any? ? [tabs, "tabs"] : [anchors, "anchors"] %>
|
|
98
|
+
<% if pills.any? %>
|
|
99
|
+
<%= tag.nav(class: "page-header-tabs", aria: { label: label.presence }, data: { page_header_kind: pill_kind }.merge(tabs_data)) do %>
|
|
84
100
|
<%# The glide: one pill, drawn once, that CSS anchors to the tab with aria-current and
|
|
85
101
|
slides from tab to tab (engine.css, "The gliding pill"). Each tab keeps its own
|
|
86
102
|
fill where the browser has no anchor positioning. %>
|
|
87
103
|
<div class="page-header-track">
|
|
88
|
-
<%
|
|
104
|
+
<% pills.each do |tab| %>
|
|
89
105
|
<%= link_to tab.fetch(:href),
|
|
90
106
|
class: "page-header-tab btn btn-sm #{tab[:current].present? ? 'btn-primary' : 'btn-neutral'}",
|
|
91
107
|
aria: { current: tab[:current].presence },
|
|
@@ -95,8 +111,44 @@
|
|
|
95
111
|
</div>
|
|
96
112
|
<% end %>
|
|
97
113
|
<% end %>
|
|
98
|
-
<% if actions_html.present? %>
|
|
99
|
-
|
|
114
|
+
<% if actions_html.present? && defined?(Studio::PageHeader) && Studio::PageHeader.warning_enabled? %>
|
|
115
|
+
<% site = caller_locations.find { |loc| loc.path.to_s.end_with?(".erb") && !loc.path.to_s.end_with?("_page_header.html.erb") } %>
|
|
116
|
+
<% Studio::PageHeader.deprecate_actions_html(site: site && "#{site.path}:#{site.lineno}") %>
|
|
117
|
+
<% end %>
|
|
118
|
+
<% if [actions_html, tools_html].any?(&:present?) || menu.any? || action.present? %>
|
|
119
|
+
<%# One cluster, in the fixed order: the old free HTML where it always stood, then
|
|
120
|
+
the menu, then the action, then the tools. Each control is .btn-sm, so all
|
|
121
|
+
four kinds share one height. %>
|
|
122
|
+
<div class="page-header-actions">
|
|
123
|
+
<%= actions_html if actions_html.present? %>
|
|
124
|
+
<% if menu.any? %>
|
|
125
|
+
<%# A disclosure, not role="menu": a list of links that Tab walks. The browser's
|
|
126
|
+
popover opens it from the button, closes it on Escape or a click outside,
|
|
127
|
+
and gives focus back to the button, with no script. The page-header
|
|
128
|
+
controller adds aria-expanded, and places the list where the browser has
|
|
129
|
+
no anchor positioning. %>
|
|
130
|
+
<% menu_id = "page-header-menu-#{SecureRandom.hex(4)}" %>
|
|
131
|
+
<div class="page-header-menu" data-page-header-kind="menu">
|
|
132
|
+
<button type="button" class="page-header-menu-button btn btn-sm btn-neutral"
|
|
133
|
+
popovertarget="<%= menu_id %>" aria-controls="<%= menu_id %>"><%= menu_label %><span class="page-header-menu-caret" aria-hidden="true"></span></button>
|
|
134
|
+
<div id="<%= menu_id %>" class="page-header-menu-list" popover="auto">
|
|
135
|
+
<ul>
|
|
136
|
+
<% menu.each do |item| %>
|
|
137
|
+
<li><%= link_to item.fetch(:label), item.fetch(:href), class: "page-header-menu-item", data: item.fetch(:data, {}) %></li>
|
|
138
|
+
<% end %>
|
|
139
|
+
</ul>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
<% end %>
|
|
143
|
+
<% if action.present? %>
|
|
144
|
+
<%= link_to action.fetch(:label), action.fetch(:href),
|
|
145
|
+
class: "page-header-action btn btn-sm btn-primary",
|
|
146
|
+
data: { page_header_kind: "action" }.merge(action.fetch(:data, {})) %>
|
|
147
|
+
<% end %>
|
|
148
|
+
<% if tools_html.present? %>
|
|
149
|
+
<div class="page-header-tools" data-page-header-kind="tools"><%= tools_html %></div>
|
|
150
|
+
<% end %>
|
|
151
|
+
</div>
|
|
100
152
|
<% end %>
|
|
101
153
|
<% if links.any? %>
|
|
102
154
|
<nav class="page-header-links" aria-label="<%= links_label %>">
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
The inside of the engine's standard sign-in card: Google, then "or", then the
|
|
3
|
+
email magic link, then the link-sent step with the resend footer. The page
|
|
4
|
+
card (studio/auth/_sign_in_card) and the modal (studio/auth/_sign_in_modal)
|
|
5
|
+
both render this, so the two cannot drift apart. Render one of those two, not
|
|
6
|
+
this one; the locals are theirs, documented in docs/SIGN_IN_CARD.md.
|
|
7
|
+
|
|
8
|
+
ONE ALPINE SCOPE, AND ITS SHAPE IS A CONTRACT. studio/modals/auth/_resend_footer
|
|
9
|
+
reads props.submitting, props.resendCooldown and props.resendError and calls
|
|
10
|
+
resendMagicLink() from the ENCLOSING x-data. This root is that x-data. Keep
|
|
11
|
+
those four names.
|
|
12
|
+
|
|
13
|
+
THE CONFIG RIDES A DATA ATTRIBUTE, NOT THE EXPRESSION. The endpoint, the
|
|
14
|
+
return path and the copy are server values. They go in as JSON in
|
|
15
|
+
data-sign-in-card, which ERB escapes, and the x-data reads them with
|
|
16
|
+
JSON.parse. No server value is spliced into JavaScript source.
|
|
17
|
+
|
|
18
|
+
DEMO MODE (demo: true) POSTS NOTHING. The style guide renders it. Google is a
|
|
19
|
+
plain type=button with no form around it. The email form has no action, and
|
|
20
|
+
send() waits on a timer instead of fetch(). The link-sent step and the resend
|
|
21
|
+
cooldown play as they do for real (cooldown 5s, not 60s). Guarded by
|
|
22
|
+
test/views/sign_in_card_test.rb and e2e/style_sign_in_card.spec.js.
|
|
23
|
+
|
|
24
|
+
REAL MODE posts as the hub's and Turf's cards do. Google is a button_to POST
|
|
25
|
+
to /auth/google_oauth2 with Turbo off; OmniAuth owns that path, and its
|
|
26
|
+
request phase is POST-only and CSRF-checked. When the app has no OAuth client
|
|
27
|
+
yet, studio/google_sign_in/_button draws its setup modal instead. The email
|
|
28
|
+
form is a real form_tag to the magic-link endpoint, so it still works with no
|
|
29
|
+
JavaScript; with JavaScript, send() posts the same fields by fetch() as JSON
|
|
30
|
+
and flips to the link-sent step only after the server says success.
|
|
31
|
+
%>
|
|
32
|
+
<%
|
|
33
|
+
demo = local_assigns.fetch(:demo, false) ? true : false
|
|
34
|
+
dom_id = local_assigns.fetch(:dom_id, "sign-in-card")
|
|
35
|
+
# Kept to a plain in-app path, as the server keeps it (Studio::LinkToken.sanitize_path).
|
|
36
|
+
return_to = Studio::LocalPath.local?(local_assigns[:return_to]) ? local_assigns[:return_to].to_s : nil
|
|
37
|
+
modal_store = local_assigns[:modal_store]
|
|
38
|
+
show_google = local_assigns.fetch(:google, demo || Studio.auth_method?(:google))
|
|
39
|
+
show_email = local_assigns.fetch(:magic_link, demo || Studio.auth_method?(:magic_link))
|
|
40
|
+
magic_link_path = local_assigns[:magic_link_path].presence ||
|
|
41
|
+
((respond_to?(:main_app) && main_app.respond_to?(:magic_link_request_path)) ? main_app.magic_link_request_path : "/magic_link")
|
|
42
|
+
google_path = return_to ? "#{Studio::GoogleSignIn::REQUEST_PATH}?#{{ return_to: return_to }.to_query}" : Studio::GoogleSignIn::REQUEST_PATH
|
|
43
|
+
google_label = local_assigns.fetch(:google_label, "Continue with Google")
|
|
44
|
+
link_noun = Studio::AuthLabels.link_noun(Studio.sign_in_label)
|
|
45
|
+
send_label = local_assigns.fetch(:send_label, "Email me a #{link_noun}")
|
|
46
|
+
ttl_minutes = (Studio.magic_link_ttl.to_i / 60).clamp(1, 24 * 60)
|
|
47
|
+
# A slot entry is a partial path, or { partial:, locals: }. A named local, not a
|
|
48
|
+
# block: see Studio::PartialBlock for why a partial cannot trust block_given?.
|
|
49
|
+
slot = lambda do |entries|
|
|
50
|
+
Array(entries).filter_map do |entry|
|
|
51
|
+
entry = { partial: entry } if entry.is_a?(String)
|
|
52
|
+
render(partial: entry.fetch(:partial), locals: entry.fetch(:locals, {})) if entry.is_a?(Hash)
|
|
53
|
+
end.join.html_safe
|
|
54
|
+
end
|
|
55
|
+
preamble_html = slot.call(local_assigns[:preamble])
|
|
56
|
+
extras_html = slot.call(local_assigns[:extras])
|
|
57
|
+
config = {
|
|
58
|
+
demo: demo,
|
|
59
|
+
endpoint: (demo ? nil : magic_link_path),
|
|
60
|
+
returnTo: return_to,
|
|
61
|
+
cooldown: (demo ? 5 : 60),
|
|
62
|
+
email: local_assigns[:email].to_s,
|
|
63
|
+
sendLabel: send_label,
|
|
64
|
+
messages: {
|
|
65
|
+
generic: "We could not send the link. Check your connection and try again.",
|
|
66
|
+
throttled: "Too many requests. Wait a minute, then try again.",
|
|
67
|
+
delivery_failed: Studio::MagicLinkIssuing::DELIVERY_FAILED
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
%>
|
|
71
|
+
<div data-sign-in-card="<%= config.to_json %>"
|
|
72
|
+
data-sign-in-card-mode="<%= demo ? "demo" : "live" %>"
|
|
73
|
+
x-data="{
|
|
74
|
+
cfg: JSON.parse($el.dataset.signInCard),
|
|
75
|
+
step: 'email',
|
|
76
|
+
email: '',
|
|
77
|
+
init() { this.email = this.cfg.email || ''; },
|
|
78
|
+
error: '',
|
|
79
|
+
demoNote: '',
|
|
80
|
+
props: { submitting: null, resendCooldown: 0, resendError: '' },
|
|
81
|
+
_timer: null,
|
|
82
|
+
get sending() { return this.props.submitting === 'magic-link'; },
|
|
83
|
+
async post() {
|
|
84
|
+
if (this.cfg.demo) { await new Promise((r) => setTimeout(r, 700)); return; }
|
|
85
|
+
const body = new URLSearchParams();
|
|
86
|
+
body.set('email', this.email.trim());
|
|
87
|
+
if (this.cfg.returnTo) body.set('return_to', this.cfg.returnTo);
|
|
88
|
+
const token = document.querySelector('meta[name=csrf-token]');
|
|
89
|
+
const resp = await fetch(this.cfg.endpoint, {
|
|
90
|
+
method: 'POST', credentials: 'same-origin',
|
|
91
|
+
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': token ? token.content : '' },
|
|
92
|
+
body: body.toString()
|
|
93
|
+
});
|
|
94
|
+
let payload = {};
|
|
95
|
+
try { payload = await resp.json(); } catch (e) { payload = {}; }
|
|
96
|
+
if (resp.ok && payload.success !== false) return;
|
|
97
|
+
if (resp.status === 429) throw new Error(this.cfg.messages.throttled);
|
|
98
|
+
if (payload.error === 'delivery_failed') throw new Error(this.cfg.messages.delivery_failed);
|
|
99
|
+
throw new Error(this.cfg.messages.generic);
|
|
100
|
+
},
|
|
101
|
+
startCooldown() {
|
|
102
|
+
clearInterval(this._timer);
|
|
103
|
+
this.props.resendCooldown = this.cfg.cooldown;
|
|
104
|
+
this._timer = setInterval(() => {
|
|
105
|
+
this.props.resendCooldown -= 1;
|
|
106
|
+
if (this.props.resendCooldown <= 0) { this.props.resendCooldown = 0; clearInterval(this._timer); }
|
|
107
|
+
}, 1000);
|
|
108
|
+
},
|
|
109
|
+
async send() {
|
|
110
|
+
if (!this.email.trim() || this.props.submitting) return;
|
|
111
|
+
this.error = '';
|
|
112
|
+
this.props.submitting = 'magic-link';
|
|
113
|
+
try {
|
|
114
|
+
await this.post();
|
|
115
|
+
this.step = 'sent';
|
|
116
|
+
this.startCooldown();
|
|
117
|
+
this.focusSoon(() => this.$refs.sentHeading);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
this.error = e.message || this.cfg.messages.generic;
|
|
120
|
+
} finally { this.props.submitting = null; }
|
|
121
|
+
},
|
|
122
|
+
async resendMagicLink() {
|
|
123
|
+
if (this.props.submitting || this.props.resendCooldown > 0) return;
|
|
124
|
+
this.props.resendError = '';
|
|
125
|
+
this.props.submitting = 'magic-link';
|
|
126
|
+
try { await this.post(); this.startCooldown(); }
|
|
127
|
+
catch (e) { this.props.resendError = e.message || this.cfg.messages.generic; }
|
|
128
|
+
finally { this.props.submitting = null; }
|
|
129
|
+
},
|
|
130
|
+
useAnotherEmail() {
|
|
131
|
+
clearInterval(this._timer);
|
|
132
|
+
this.props.resendCooldown = 0;
|
|
133
|
+
this.props.resendError = '';
|
|
134
|
+
this.step = 'email';
|
|
135
|
+
this.focusSoon(() => this.$refs.emailForm && this.$refs.emailForm.querySelector('input[type=email]'));
|
|
136
|
+
},
|
|
137
|
+
// Focus follows the step, for a keyboard or screen-reader user: the
|
|
138
|
+
// pressed button is hidden with its step. A hidden node cannot take
|
|
139
|
+
// focus, and x-show can reveal the new step a frame or more after the
|
|
140
|
+
// change (measured: still display none in the first frame), so wait
|
|
141
|
+
// frame by frame until it is rendered, ten frames at most.
|
|
142
|
+
focusSoon(target, tries = 10) {
|
|
143
|
+
requestAnimationFrame(() => {
|
|
144
|
+
const el = target();
|
|
145
|
+
if (el && el.offsetParent !== null) el.focus();
|
|
146
|
+
else if (tries > 1) this.focusSoon(target, tries - 1);
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
destroy() { clearInterval(this._timer); }
|
|
150
|
+
}">
|
|
151
|
+
|
|
152
|
+
<%# ---- Step 1: the ways in ---- %>
|
|
153
|
+
<div x-show="step === 'email'" data-sign-in-step="email">
|
|
154
|
+
<% if preamble_html.present? %>
|
|
155
|
+
<div class="mb-4" data-sign-in-slot="preamble"><%= preamble_html %></div>
|
|
156
|
+
<% end %>
|
|
157
|
+
|
|
158
|
+
<% if show_google %>
|
|
159
|
+
<% if demo %>
|
|
160
|
+
<button type="button" class="btn btn-google btn-lg w-full gap-3" data-sign-in-google="demo"
|
|
161
|
+
@click="demoNote = 'Demo only: Google sign-in does not run on the style guide.'">
|
|
162
|
+
<%= render "components/google_logo" %>
|
|
163
|
+
<%= google_label %>
|
|
164
|
+
</button>
|
|
165
|
+
<% elsif Studio::GoogleSignIn.button_posts? %>
|
|
166
|
+
<%= button_to google_path, method: :post,
|
|
167
|
+
form: { data: { sign_in_google: "ready" } },
|
|
168
|
+
data: { turbo: false },
|
|
169
|
+
class: "btn btn-google btn-lg w-full gap-3" do %>
|
|
170
|
+
<%= render "components/google_logo" %>
|
|
171
|
+
<%= google_label %>
|
|
172
|
+
<% end %>
|
|
173
|
+
<% else %>
|
|
174
|
+
<%= render "studio/google_sign_in/button", label: google_label %>
|
|
175
|
+
<% end %>
|
|
176
|
+
<% end %>
|
|
177
|
+
|
|
178
|
+
<% if extras_html.present? %>
|
|
179
|
+
<div class="mt-3 space-y-3" data-sign-in-slot="extras"><%= extras_html %></div>
|
|
180
|
+
<% end %>
|
|
181
|
+
|
|
182
|
+
<% if show_email && (show_google || extras_html.present?) %>
|
|
183
|
+
<div class="flex items-center gap-3 my-5 text-sm" aria-hidden="true">
|
|
184
|
+
<div class="flex-1 border-t border-strong"></div>
|
|
185
|
+
<span class="text-secondary">or</span>
|
|
186
|
+
<div class="flex-1 border-t border-strong"></div>
|
|
187
|
+
</div>
|
|
188
|
+
<% end %>
|
|
189
|
+
|
|
190
|
+
<% if show_email %>
|
|
191
|
+
<% form_attrs = { "x-ref" => "emailForm", "@submit.prevent" => "send()", novalidate: true, data: { sign_in_email_form: (demo ? "demo" : "live") } } %>
|
|
192
|
+
<% email_form_body = capture do %>
|
|
193
|
+
<label for="<%= dom_id %>-email" class="block text-sm text-secondary mb-2 font-medium">Email</label>
|
|
194
|
+
<%= render "studio/modals/shared/email_field",
|
|
195
|
+
name: "email", id: "#{dom_id}-email", required: true,
|
|
196
|
+
x_model: "email",
|
|
197
|
+
disabled_expr: "props.submitting === 'magic-link'" %>
|
|
198
|
+
<% if return_to %><input type="hidden" name="return_to" value="<%= return_to %>"><% end %>
|
|
199
|
+
<button type="submit" class="btn btn-primary btn-lg w-full mt-4 disabled:cursor-wait disabled:opacity-60"
|
|
200
|
+
:disabled="sending || !email.trim()">
|
|
201
|
+
<span x-text="sending ? 'Sending…' : cfg.sendLabel"><%= send_label %></span>
|
|
202
|
+
</button>
|
|
203
|
+
<p role="alert" x-show="error" x-cloak class="text-xs text-danger-ink text-center mt-2" x-text="error"></p>
|
|
204
|
+
<p class="text-xs text-muted text-center mt-2">One tap from your inbox. No password.</p>
|
|
205
|
+
<% end %>
|
|
206
|
+
<% if demo %>
|
|
207
|
+
<%= tag.form(**form_attrs) { email_form_body } %>
|
|
208
|
+
<% else %>
|
|
209
|
+
<%= form_tag(magic_link_path, method: :post, **form_attrs) { email_form_body } %>
|
|
210
|
+
<% end %>
|
|
211
|
+
<% end %>
|
|
212
|
+
|
|
213
|
+
<p x-show="demoNote" x-cloak class="text-xs text-muted text-center mt-3" x-text="demoNote" data-sign-in-demo-note></p>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<%# ---- Step 2: the link is on its way ---- %>
|
|
217
|
+
<div x-show="step === 'sent'" x-cloak class="text-center" data-sign-in-step="sent">
|
|
218
|
+
<div class="text-3xl leading-none mb-3" aria-hidden="true">📬</div>
|
|
219
|
+
<h3 x-ref="sentHeading" tabindex="-1" class="text-heading font-bold text-lg leading-tight outline-none">Check your email</h3>
|
|
220
|
+
<p class="text-sm text-body mt-2">
|
|
221
|
+
We sent a <%= link_noun %> to <strong class="text-heading" x-text="email.trim()"></strong>.
|
|
222
|
+
It works once and expires in <%= ttl_minutes %> minutes.
|
|
223
|
+
</p>
|
|
224
|
+
<div class="border-t border-subtle mt-5 pt-4">
|
|
225
|
+
<% if modal_store %>
|
|
226
|
+
<%= render "studio/modals/auth/resend_footer", modal_store: modal_store %>
|
|
227
|
+
<% else %>
|
|
228
|
+
<%= render "studio/modals/auth/resend_footer", close: false %>
|
|
229
|
+
<% end %>
|
|
230
|
+
</div>
|
|
231
|
+
<button type="button" @click="useAnotherEmail()"
|
|
232
|
+
class="block mx-auto mt-3 text-xs text-muted hover:text-secondary underline underline-offset-2">
|
|
233
|
+
Use a different email
|
|
234
|
+
</button>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
The engine's standard sign-in card, for a page: the app's heading, then one
|
|
3
|
+
card with Google, "or", and the email magic link. Sending the link flips the
|
|
4
|
+
same card to its link-sent step, with the resend footer. No modal is needed.
|
|
5
|
+
|
|
6
|
+
render "studio/auth/sign_in_card"
|
|
7
|
+
render "studio/auth/sign_in_card", return_to: "/contests/42",
|
|
8
|
+
subheading: "Sign in to play",
|
|
9
|
+
preamble: "studio/modals/shared/age_attestation",
|
|
10
|
+
extras: "shared/solana_wallet_connect"
|
|
11
|
+
|
|
12
|
+
Locals (all optional):
|
|
13
|
+
heading — default "Sign in to <Studio.app_name>" (Studio::AuthLabels.to_app)
|
|
14
|
+
subheading — default "New here? The same step creates your account."
|
|
15
|
+
logo — default true: Studio.logo_for("Auth Logo"), when the app has one
|
|
16
|
+
return_to — an in-app path the visitor lands on after sign-in. Anything
|
|
17
|
+
but a plain local path is dropped. Rides the magic-link POST
|
|
18
|
+
and the Google request URL (?return_to=).
|
|
19
|
+
preamble — a partial (or { partial:, locals: }, or an Array) above Google
|
|
20
|
+
extras — the same, after Google and before "or": extra providers
|
|
21
|
+
google — default Studio.auth_method?(:google)
|
|
22
|
+
magic_link — default Studio.auth_method?(:magic_link)
|
|
23
|
+
magic_link_path — default the engine's POST /magic_link
|
|
24
|
+
email — prefill for the email field
|
|
25
|
+
demo — default false. true posts nothing (the style guide).
|
|
26
|
+
dom_id — id prefix for the email field. Default "sign-in-card".
|
|
27
|
+
|
|
28
|
+
The body is studio/auth/_sign_in_body; docs/SIGN_IN_CARD.md has the contract.
|
|
29
|
+
%>
|
|
30
|
+
<%
|
|
31
|
+
heading = local_assigns[:heading].presence || Studio::AuthLabels.to_app(Studio.sign_in_label, Studio.app_name)
|
|
32
|
+
subheading = local_assigns.key?(:subheading) ? local_assigns[:subheading] : "New here? The same step creates your account."
|
|
33
|
+
logo_path = local_assigns.fetch(:logo, true) ? Studio.logo_for("Auth Logo") : nil
|
|
34
|
+
body_locals = local_assigns.slice(:return_to, :preamble, :extras, :google, :magic_link, :magic_link_path,
|
|
35
|
+
:email, :demo, :dom_id, :google_label, :send_label)
|
|
36
|
+
%>
|
|
37
|
+
<div class="min-h-[70vh] flex items-center justify-center" data-sign-in-card-page>
|
|
38
|
+
<div class="w-full max-w-md">
|
|
39
|
+
<div class="text-center mb-8">
|
|
40
|
+
<% if logo_path %>
|
|
41
|
+
<img src="<%= logo_path %>" alt="" class="w-20 h-20 rounded-full shadow-lg mx-auto mb-4">
|
|
42
|
+
<% end %>
|
|
43
|
+
<h1 class="text-3xl font-extrabold text-heading tracking-tight"><%= heading %></h1>
|
|
44
|
+
<% if subheading.present? %><p class="text-secondary mt-2"><%= subheading %></p><% end %>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="card shadow p-8">
|
|
47
|
+
<%= render "studio/auth/sign_in_body", **body_locals %>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
The engine's standard sign-in card, as a modal: the same body as
|
|
3
|
+
studio/auth/_sign_in_card (studio/auth/_sign_in_body), under a modal header
|
|
4
|
+
and a ×. Single root, for a modal host's <template x-if>:
|
|
5
|
+
|
|
6
|
+
<template x-if="$store.modals.current()?.id === 'sign-in'">
|
|
7
|
+
<div>render "studio/auth/sign_in_modal", return_to: request.path</div>
|
|
8
|
+
</template>
|
|
9
|
+
|
|
10
|
+
$store.modals.open('sign-in')
|
|
11
|
+
|
|
12
|
+
Locals: every local of studio/auth/_sign_in_card except logo, plus
|
|
13
|
+
modal_store — the Alpine store the × and the resend footer's Close close.
|
|
14
|
+
Default "modals". VALIDATED as a JS identifier
|
|
15
|
+
(Studio::JsIdentifier): it is spliced as a bare name.
|
|
16
|
+
dom_id defaults to "sign-in-modal", so a page can carry the page card and the
|
|
17
|
+
modal at once with no duplicate ids.
|
|
18
|
+
%>
|
|
19
|
+
<%
|
|
20
|
+
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
21
|
+
modal_store = Studio::JsIdentifier.validate!(
|
|
22
|
+
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
23
|
+
heading = local_assigns[:heading].presence || Studio::AuthLabels.to_app(Studio.sign_in_label, Studio.app_name)
|
|
24
|
+
subheading = local_assigns.key?(:subheading) ? local_assigns[:subheading] : "New here? The same step creates your account."
|
|
25
|
+
body_locals = local_assigns.slice(:return_to, :preamble, :extras, :google, :magic_link, :magic_link_path,
|
|
26
|
+
:email, :demo, :google_label, :send_label)
|
|
27
|
+
.merge(dom_id: local_assigns.fetch(:dom_id, "sign-in-modal"), modal_store: modal_store)
|
|
28
|
+
%>
|
|
29
|
+
<div class="relative text-left" data-sign-in-modal>
|
|
30
|
+
<%= render "studio/modals/blocks/close_x", modal_store: modal_store %>
|
|
31
|
+
<div class="mb-5 pr-6">
|
|
32
|
+
<h3 class="text-heading font-bold text-xl leading-tight"><%= heading %></h3>
|
|
33
|
+
<% if subheading.present? %><p class="text-secondary text-sm mt-1"><%= subheading %></p><% end %>
|
|
34
|
+
</div>
|
|
35
|
+
<%= render "studio/auth/sign_in_body", **body_locals %>
|
|
36
|
+
</div>
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
Locals:
|
|
12
12
|
modal_store — Alpine store name backing close(). Default "modals".
|
|
13
|
+
close — default true. false drops the Close button, for a host that
|
|
14
|
+
is not a modal (studio/auth/_sign_in_card on a page), where
|
|
15
|
+
there is no store to close.
|
|
13
16
|
|
|
14
17
|
ERROR INK, not a static red. The error paragraph is text-danger-ink, the
|
|
15
18
|
per-theme red Studio::ThemeResolver derives to a 4.5:1 target. It replaced
|
|
@@ -38,6 +41,7 @@
|
|
|
38
41
|
# IDENTIFIER position ($store.<name>) — VALIDATED, never escaped: Studio::JsIdentifier.
|
|
39
42
|
modal_store = Studio::JsIdentifier.validate!(
|
|
40
43
|
local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
44
|
+
show_close = local_assigns.fetch(:close, true)
|
|
41
45
|
%>
|
|
42
46
|
<button @click="resendMagicLink()" :disabled="props.submitting === 'magic-link' || (props.resendCooldown || 0) > 0"
|
|
43
47
|
class="block mx-auto text-sm text-primary hover:text-primary-300 underline underline-offset-2 disabled:opacity-50 disabled:no-underline">
|
|
@@ -48,4 +52,6 @@
|
|
|
48
52
|
</span>
|
|
49
53
|
</button>
|
|
50
54
|
<p role="alert" x-show="props.resendError" x-cloak class="text-xs text-danger-ink mt-3 text-center" x-text="props.resendError"></p>
|
|
55
|
+
<% if show_close %>
|
|
51
56
|
<button @click="$store.<%= modal_store %>.close()" class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">Close</button>
|
|
57
|
+
<% end %>
|
|
@@ -15,8 +15,7 @@
|
|
|
15
15
|
</div>
|
|
16
16
|
<div class="grid gap-4 md:grid-cols-2">
|
|
17
17
|
<%= render layout: "style/specimen",
|
|
18
|
-
locals: {
|
|
19
|
-
usage: %(config.surface_style = :glass, and <html data-surface-style="glass">),
|
|
18
|
+
locals: { trick: "glass",
|
|
20
19
|
disabled: !surface_on, disabled_label: "surface style is off on this app" } do %>
|
|
21
20
|
<div class="<%= glass_backing %> w-full space-y-2" data-test="style-glass-surfaces">
|
|
22
21
|
<div class="card p-3 text-sm text-heading">.card, the dense tier: no blur</div>
|
|
@@ -24,8 +23,7 @@
|
|
|
24
23
|
</div>
|
|
25
24
|
<% end %>
|
|
26
25
|
<%= render layout: "style/specimen",
|
|
27
|
-
locals: {
|
|
28
|
-
usage: %(config.sidebar_style = :glass),
|
|
26
|
+
locals: { trick: "glass_sidebar",
|
|
29
27
|
disabled: !sidebar_on, disabled_label: "glass sidebar is off on this app" } do %>
|
|
30
28
|
<div class="<%= glass_backing %> w-full" data-test="style-glass-sidebar">
|
|
31
29
|
<div class="flex flex-col" data-sidebar-glass-shell>
|
|
@@ -42,8 +40,7 @@
|
|
|
42
40
|
<% end %>
|
|
43
41
|
<% end %>
|
|
44
42
|
<%= render layout: "style/specimen",
|
|
45
|
-
locals: {
|
|
46
|
-
usage: %(.btn-primary, .btn-danger, .btn-neutral, .badge, .input-field),
|
|
43
|
+
locals: { trick: "glass", variant: "lit",
|
|
47
44
|
disabled: !surface_on, disabled_label: "surface style is off on this app" } do %>
|
|
48
45
|
<div class="w-full overflow-x-auto" data-test="style-lit-states">
|
|
49
46
|
<table class="text-xs text-muted">
|
|
@@ -68,8 +65,7 @@
|
|
|
68
65
|
</div>
|
|
69
66
|
<% end %>
|
|
70
67
|
<%= render layout: "style/specimen",
|
|
71
|
-
locals: {
|
|
72
|
-
usage: %(studio_icon("rocket", size: 22)) } do %>
|
|
68
|
+
locals: { trick: "icon" } do %>
|
|
73
69
|
<ul class="grid grid-cols-4 gap-3 w-full text-heading" data-test="style-glass-icons">
|
|
74
70
|
<% Studio::Icons.names.each do |name| %>
|
|
75
71
|
<li class="flex flex-col items-center gap-1 min-w-0" title="<%= name %>">
|