studio-engine 0.131.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 +96 -1
- data/README.md +65 -28
- data/app/assets/tailwind/studio_engine/engine.css +158 -2
- data/app/javascript/studio/application.js +1 -0
- data/app/javascript/studio/carousel.js +74 -1
- data/app/javascript/studio/controllers/carousel_controller.js +163 -9
- 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 +54 -11
- 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
|
@@ -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 %>">
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
progress bar, pulsing dots, button loader, full-screen loader, pull to
|
|
6
6
|
refresh, infinite scroll. Every specimen is the engine's own helper
|
|
7
7
|
(Studio::LoaderHelper, docs/LOADER.md), so what is on this page is what a
|
|
8
|
-
view gets
|
|
8
|
+
view gets, under its trick name (config/studio_tricks.yml): Spinner, Skeleton,
|
|
9
|
+
Progress Bar, Typing Dots, Busy Button, Overlay, Pull Refresh, Infinite Scroll.
|
|
10
|
+
The branded styles themselves are under Tricks.
|
|
9
11
|
|
|
10
12
|
The infinite-scroll specimen is live: its pages come from feed_path, the
|
|
11
13
|
admin-only demo feed (StyleController#loaders_feed) unless a caller names
|
|
@@ -16,18 +18,13 @@
|
|
|
16
18
|
# path Studio.routes draws.
|
|
17
19
|
feed_path = local_assigns[:feed_path] ||
|
|
18
20
|
(respond_to?(:admin_style_loaders_feed_path) ? admin_style_loaders_feed_path : "/admin/style/loaders_feed")
|
|
19
|
-
erb = ->(code) { "<" + "%= " + code + " %" + ">" }
|
|
20
|
-
erb_end = "<" + "% end %" + ">"
|
|
21
21
|
%>
|
|
22
22
|
<section id="loaders" class="section-target space-y-6" data-style-loaders>
|
|
23
23
|
<%= render "studio/section_header", title: "Loaders" %>
|
|
24
24
|
<p class="text-muted text-sm max-w-2xl">Eight ways to say wait. Match the loader to the wait.</p>
|
|
25
25
|
|
|
26
26
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
27
|
-
<%= render layout: "style/specimen",
|
|
28
|
-
locals: { klass: "studio_loader style: :spinner",
|
|
29
|
-
note: "A small ring for a short wait beside text or in a control.",
|
|
30
|
-
usage: erb.call("studio_loader style: :spinner") } do %>
|
|
27
|
+
<%= render layout: "style/specimen", locals: { trick: "spinner" } do %>
|
|
31
28
|
<div data-loader-specimen="spinner" class="flex items-center gap-4">
|
|
32
29
|
<%= studio_loader style: :spinner %>
|
|
33
30
|
<%= studio_loader style: :spinner, size: 32 %>
|
|
@@ -35,40 +32,28 @@
|
|
|
35
32
|
</div>
|
|
36
33
|
<% end %>
|
|
37
34
|
|
|
38
|
-
<%= render layout: "style/specimen",
|
|
39
|
-
locals: { klass: "studio_skeleton :avatar / :card / :lines / :media",
|
|
40
|
-
note: "Grey shapes of the content to come, so the page keeps its layout.",
|
|
41
|
-
usage: [erb.call("studio_skeleton :avatar, count: 3"), erb.call('studio_skeleton :card, count: 3, class: "sm:grid-cols-3"')].join("\n") } do %>
|
|
35
|
+
<%= render layout: "style/specimen", locals: { trick: "skeleton" } do %>
|
|
42
36
|
<div data-loader-specimen="skeleton" class="grid w-full gap-4">
|
|
43
37
|
<%= studio_skeleton :avatar, count: 2, label: "Loading people" %>
|
|
44
38
|
<%= studio_skeleton :lines, lines: 2, label: "Loading notes" %>
|
|
45
39
|
</div>
|
|
46
40
|
<% end %>
|
|
47
41
|
|
|
48
|
-
<%= render layout: "style/specimen",
|
|
49
|
-
locals: { klass: "studio_loader style: :bar, percent:",
|
|
50
|
-
note: "A bar for a wait you can measure; with no percent it sweeps.",
|
|
51
|
-
usage: [erb.call("studio_loader style: :bar, percent: 62"), erb.call("studio_loader style: :bar"), 'StudioLoader.setPercent(el, 80)'].join("\n") } do %>
|
|
42
|
+
<%= render layout: "style/specimen", locals: { trick: "progress_bar" } do %>
|
|
52
43
|
<div data-loader-specimen="bar" class="grid w-full gap-4">
|
|
53
44
|
<%= studio_loader style: :bar, percent: 62, label: "Uploading" %>
|
|
54
45
|
<%= studio_loader style: :bar, label: "Importing" %>
|
|
55
46
|
</div>
|
|
56
47
|
<% end %>
|
|
57
48
|
|
|
58
|
-
<%= render layout: "style/specimen",
|
|
59
|
-
locals: { klass: "studio_loader style: :dots",
|
|
60
|
-
note: "Three dots for a reply on its way, such as a chat answer.",
|
|
61
|
-
usage: erb.call("studio_loader style: :dots") } do %>
|
|
49
|
+
<%= render layout: "style/specimen", locals: { trick: "typing_dots" } do %>
|
|
62
50
|
<div data-loader-specimen="dots" class="flex items-center gap-6">
|
|
63
51
|
<%= studio_loader style: :dots %>
|
|
64
52
|
<span class="inline-flex items-center gap-2 rounded-2xl bg-inset px-3 py-2 text-sm text-body"><%= studio_loader style: :dots, size: 24, label: "Alex is typing" %></span>
|
|
65
53
|
</div>
|
|
66
54
|
<% end %>
|
|
67
55
|
|
|
68
|
-
<%= render layout: "style/specimen",
|
|
69
|
-
locals: { klass: "studio_loader_pending (inside a button)",
|
|
70
|
-
note: "The button keeps its place and shows that it is working.",
|
|
71
|
-
usage: ["<button class=\"btn btn-primary\">", " <span data-loader-idle>Save</span>", " #{erb.call('studio_loader_pending style: :spinner')}", " <span data-loader-busy>Saving…</span>", "</button>"].join("\n") } do %>
|
|
56
|
+
<%= render layout: "style/specimen", locals: { trick: "busy_button" } do %>
|
|
72
57
|
<div data-loader-specimen="button" class="flex flex-wrap items-center gap-3" x-data="{ busy: false }">
|
|
73
58
|
<button type="button" class="btn btn-primary" x-bind:aria-busy="busy ? 'true' : 'false'" aria-busy="false"
|
|
74
59
|
x-on:click="busy = true; setTimeout(() => busy = false, 2000)" data-test="style-loader-save">
|
|
@@ -81,10 +66,7 @@
|
|
|
81
66
|
</div>
|
|
82
67
|
<% end %>
|
|
83
68
|
|
|
84
|
-
<%= render layout: "style/specimen",
|
|
85
|
-
locals: { klass: "studio_loader_overlay (full screen)",
|
|
86
|
-
note: "The branded overlay, for a page that takes longer than 350ms.",
|
|
87
|
-
usage: ['window.StudioLoader.show("Caption")', "window.StudioLoader.hide()"].join("\n") } do %>
|
|
69
|
+
<%= render layout: "style/specimen", locals: { trick: "overlay" } do %>
|
|
88
70
|
<div data-loader-specimen="overlay">
|
|
89
71
|
<button type="button" class="btn btn-secondary" data-test="style-loaders-overlay"
|
|
90
72
|
x-data x-on:click="window.StudioLoader && window.StudioLoader.show('Loading your page…'); setTimeout(() => window.StudioLoader && window.StudioLoader.hide(), 2000)">
|
|
@@ -93,10 +75,7 @@
|
|
|
93
75
|
</div>
|
|
94
76
|
<% end %>
|
|
95
77
|
|
|
96
|
-
<%= render layout: "style/specimen",
|
|
97
|
-
locals: { klass: "studio_pull_to_refresh (phones only)",
|
|
98
|
-
note: "On a phone, pull down from the top of the page to reload it.",
|
|
99
|
-
usage: [erb.call('studio_pull_to_refresh frame: "feed" do'), " ...", erb_end].join("\n") } do %>
|
|
78
|
+
<%= render layout: "style/specimen", locals: { trick: "pull_refresh" } do %>
|
|
100
79
|
<%# Drawn still, mid-refresh: this wrapper has no controller, so it never arms. %>
|
|
101
80
|
<div data-loader-specimen="pull" class="studio-pull-refresh w-40 h-40 overflow-hidden rounded-2xl border border-subtle bg-surface pt-20 px-3" data-pull-state="refreshing">
|
|
102
81
|
<span class="studio-pull-refresh-indicator" aria-hidden="true">
|
|
@@ -106,10 +85,7 @@
|
|
|
106
85
|
</div>
|
|
107
86
|
<% end %>
|
|
108
87
|
|
|
109
|
-
<%= render layout: "style/specimen",
|
|
110
|
-
locals: { klass: "studio_infinite_scroll id:, page:, next_url:",
|
|
111
|
-
note: "The next page loads as the list's end comes near, and Load more stays.",
|
|
112
|
-
usage: [erb.call('studio_infinite_scroll id: "posts", page: @page, next_url: @next_url do'), " ...one page of items...", erb_end].join("\n") } do %>
|
|
88
|
+
<%= render layout: "style/specimen", locals: { trick: "infinite_scroll" } do %>
|
|
113
89
|
<div data-loader-specimen="infinite" class="w-full">
|
|
114
90
|
<%= render "style/loaders_feed_page", page: 1, feed_path: feed_path %>
|
|
115
91
|
</div>
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
card behind ours. So this section owns a tiny, self-contained store + overlay
|
|
15
15
|
and registers all specimen content itself.
|
|
16
16
|
|
|
17
|
-
Groups: Auth (the engine
|
|
18
|
-
|
|
17
|
+
Groups: Auth (the engine's standard sign-in card, studio/auth/_sign_in_modal,
|
|
18
|
+
in demo mode), Eligibility and entry (the
|
|
19
19
|
app-supplied-policy age-gate DOB primitive, gated by Studio.feature?(:age_gate)),
|
|
20
20
|
Profile (crop / upload, opened
|
|
21
21
|
through studio/cropper_assets), Web3 (wallet-connect and web3-step-up now
|
|
@@ -97,7 +97,8 @@
|
|
|
97
97
|
# THE AUTH METHOD-TOGGLE DEFAULTS USED TO LIVE HERE (ml_default / g_default /
|
|
98
98
|
# w_default / auth_toggles) and are gone with the card they configured. They
|
|
99
99
|
# belonged to style/modals/_auth, a MIRROR of turf-monster's sign-in modal,
|
|
100
|
-
# retired on 2026-09-09. The engine
|
|
100
|
+
# retired on 2026-09-09. The engine's own card (studio/auth/_sign_in_modal,
|
|
101
|
+
# 2026-11-06) has no toggles: its specimen runs it in demo mode as shipped.
|
|
101
102
|
#
|
|
102
103
|
# The rule they were built to enforce OUTLIVES them and is worth keeping: a
|
|
103
104
|
# TRIGGER must carry at least the gate its target's REGISTRATION carries, or
|
|
@@ -202,17 +203,15 @@
|
|
|
202
203
|
<div class="bg-surface rounded-xl border border-subtle shadow-2xl p-6 w-full"
|
|
203
204
|
:class="$store.dsModals.cardClasses()">
|
|
204
205
|
|
|
205
|
-
<%# --- Auth
|
|
206
|
-
<%#
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
<template x-if="$store.dsModals.current().id === 'ds-resend-footer'">
|
|
215
|
-
<div><%= render "style/modals/ds_resend_footer" %></div>
|
|
206
|
+
<%# --- Auth --- %>
|
|
207
|
+
<%# The engine's standard sign-in card (studio/auth/_sign_in_modal), the
|
|
208
|
+
REAL partial in demo mode: Google and Send post nothing, and the
|
|
209
|
+
link-sent step and resend cooldown play. It folds in the two pieces
|
|
210
|
+
that used to be carded alone here, studio/modals/shared/_email_field
|
|
211
|
+
and studio/modals/auth/_resend_footer (Alex McRitchie, 2026-11-06:
|
|
212
|
+
"this should use the standard auth component with Google and Email"). %>
|
|
213
|
+
<template x-if="$store.dsModals.current().id === 'ds-sign-in'">
|
|
214
|
+
<div><%= render "studio/auth/sign_in_modal", modal_store: "dsModals", demo: true %></div>
|
|
216
215
|
</template>
|
|
217
216
|
|
|
218
217
|
<%# --- Onboarding step (its card now lives in Profile) --- %>
|
|
@@ -452,36 +451,27 @@
|
|
|
452
451
|
<section id="modals-auth" class="space-y-5">
|
|
453
452
|
<div class="space-y-1">
|
|
454
453
|
<h3 class="type-h4 text-heading">Auth</h3>
|
|
455
|
-
<p class="text-muted text-sm">The engine
|
|
454
|
+
<p class="text-muted text-sm">The engine's standard sign-in card: Google, or, email magic link.</p>
|
|
456
455
|
</div>
|
|
457
456
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
458
|
-
<%# email field — the one piece every app's auth card renders. %>
|
|
459
457
|
<%= render layout: "style/modal_specimen", locals: {
|
|
460
|
-
label: "
|
|
461
|
-
reference: %(the
|
|
462
|
-
open_expr: "$store.dsModals.open('ds-
|
|
463
|
-
glow_when: ds_glow.call("ds-
|
|
458
|
+
label: "Sign in",
|
|
459
|
+
reference: %(the engine's standard sign-in card as a modal (studio/auth/_sign_in_modal; the page form is studio/auth/_sign_in_card) — Google, then "or", then the email magic link, and on send the link-sent step with the shared resend footer (studio/modals/auth/_resend_footer) under the shared email field (studio/modals/shared/_email_field). Locals: heading, subheading (default "Sign in to" the app name), return_to (kept to an in-app path, rides both Google and the magic link), preamble and extras (partials above Google and after it, for age attestation or a Solana wallet button), google, magic_link, modal_store, demo. Real use posts Google to /auth/google_oauth2 (button_to POST, Turbo off) and the email to POST /magic_link. THIS SPECIMEN IS demo: true, SO NOTHING POSTS: Google is an inert button, Send waits on a timer, and the resend cooldown is 5s instead of 60s. Open with $store.dsModals.open('ds-sign-in')),
|
|
460
|
+
open_expr: "$store.dsModals.open('ds-sign-in')",
|
|
461
|
+
glow_when: ds_glow.call("ds-sign-in") } do %>
|
|
464
462
|
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
|
|
465
|
-
<span class="block h-
|
|
466
|
-
<span class="
|
|
467
|
-
|
|
468
|
-
<span class="block h-1.5
|
|
469
|
-
<span class="block w-3 h-3 rounded-full ml-1" style="background: var(--color-success); opacity: .55"></span>
|
|
463
|
+
<span class="block h-2 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
|
|
464
|
+
<span class="flex items-center justify-center gap-1 h-6 w-full rounded border" style="border-color: var(--color-border-strong)">
|
|
465
|
+
<span class="block w-2.5 h-2.5 rounded-full" style="background: conic-gradient(#EA4335 0 25%, #FBBC05 0 50%, #34A853 0 75%, #4285F4 0)"></span>
|
|
466
|
+
<span class="block h-1.5 w-12 rounded" style="background: var(--color-text); opacity: .2"></span>
|
|
470
467
|
</span>
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
open_expr: "$store.dsModals.open('ds-resend-footer')",
|
|
479
|
-
glow_when: ds_glow.call("ds-resend-footer") } do %>
|
|
480
|
-
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
|
|
481
|
-
<div class="text-2xl leading-none">📬</div>
|
|
482
|
-
<span class="block h-1.5 w-24 mx-auto rounded" style="background: var(--color-text); opacity: .12"></span>
|
|
483
|
-
<span class="block h-1.5 w-16 mx-auto rounded" style="background: var(--color-primary); opacity: .7"></span>
|
|
484
|
-
<span class="block h-1.5 w-10 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
|
|
468
|
+
<span class="flex items-center gap-1.5">
|
|
469
|
+
<span class="block h-px flex-1" style="background: var(--color-border-strong)"></span>
|
|
470
|
+
<span class="block h-1 w-2 rounded" style="background: var(--color-text); opacity: .25"></span>
|
|
471
|
+
<span class="block h-px flex-1" style="background: var(--color-border-strong)"></span>
|
|
472
|
+
</span>
|
|
473
|
+
<span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
|
|
474
|
+
<span class="block h-5 w-full rounded" style="background: var(--color-primary)"></span>
|
|
485
475
|
</div>
|
|
486
476
|
<% end %>
|
|
487
477
|
</div>
|