studio-engine 0.74.7 → 0.74.8

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.
@@ -0,0 +1,58 @@
1
+ <%#
2
+ Living style guide specimen — the modal family's email input,
3
+ studio/modals/shared/_email_field.
4
+
5
+ THIS IS A BLOCK DEMO, NOT A CARD. The engine does not own an auth MODAL — the
6
+ sign-in card is each app's, mounted on that app's own host and reviewed on that
7
+ app's own section of this guide. What the engine owns is the PIECES that card
8
+ is built from, and this is the one every app's auth card renders. It had no
9
+ specimen of its own until 2026-09-09 because style/modals/_auth, a mirror of
10
+ turf-monster's card, happened to render it — so retiring the mirror is what
11
+ made the gap visible.
12
+
13
+ BOTH FACES ARE SHOWN because `validator` is a real fork, not a flag. Off, the
14
+ partial is self-contained and the SERVER stays the only boundary; on, it wraps
15
+ itself in x-data="emailValidator()" and paints a right-edge spinner / check / X
16
+ that only works if the CONSUMER ships that factory. An app that passes
17
+ validator: true without the factory gets an Alpine scope error and a dead
18
+ indicator, so the two are drawn side by side to make the dependency legible.
19
+ This page ships no emailValidator, which is why the right-hand field's
20
+ indicator stays blank — that is the demo, not a defect.
21
+
22
+ Single root: the outer <div> is the host's required root.
23
+ %>
24
+ <div class="relative">
25
+ <%= render "studio/modals/blocks/close_x", modal_store: "dsModals" %>
26
+
27
+ <div class="text-center pt-1 mb-4">
28
+ <h3 class="text-heading font-bold text-lg leading-tight">Email field</h3>
29
+ <p class="text-xs text-muted mt-1">studio/modals/shared/_email_field</p>
30
+ </div>
31
+
32
+ <div class="space-y-4" x-data="{ email: '' }">
33
+ <div>
34
+ <p class="text-2xs font-bold text-secondary uppercase tracking-wide mb-1.5">Plain</p>
35
+ <%= render "studio/modals/shared/email_field",
36
+ name: nil, x_model: "email", required: true %>
37
+ <p class="text-2xs text-muted mt-1.5">
38
+ No dependency beyond the theme. <code class="font-mono">x_model</code>
39
+ resolves in the ENCLOSING scope, so the value lands on the host component
40
+ rather than on the field.
41
+ </p>
42
+ </div>
43
+
44
+ <div>
45
+ <p class="text-2xs font-bold text-secondary uppercase tracking-wide mb-1.5">With validator</p>
46
+ <%= render "studio/modals/shared/email_field",
47
+ name: nil, x_model: "email", required: true, validator: true %>
48
+ <p class="text-2xs text-muted mt-1.5">
49
+ Adds <code class="font-mono">x-data="emailValidator()"</code> and the
50
+ right-edge indicator. <strong>The factory is the consumer&rsquo;s to
51
+ ship</strong> &mdash; this guide does not, so the indicator stays blank here.
52
+ </p>
53
+ </div>
54
+ </div>
55
+
56
+ <button type="button" @click="$store.dsModals.close()"
57
+ class="btn btn-primary btn-lg w-full mt-5">Done</button>
58
+ </div>
@@ -0,0 +1,70 @@
1
+ <%#
2
+ Living style guide specimen — the magic-link resend footer,
3
+ studio/modals/auth/_resend_footer.
4
+
5
+ A BLOCK DEMO, for the same reason as _ds_email_field: the engine owns this
6
+ fragment, not the auth card that renders it. Both of the auth card's
7
+ link-sent steps render it, and they are identical — which is the whole reason
8
+ it was extracted.
9
+
10
+ IT NEEDS A HOST, AND THAT IS THE POINT. The footer reads props.submitting,
11
+ props.resendCooldown and props.resendError, and calls resendMagicLink(), all
12
+ resolved from the ENCLOSING x-data — never from a store. So the x-data below
13
+ is a stand-in for the auth card's own component, and it is the contract this
14
+ specimen documents: drop this partial into a scope that supplies none of those
15
+ and nothing errors, the link simply never enables and the cooldown never
16
+ counts. The stub here drives a real 5s cooldown so all three states are
17
+ reachable: idle, in-flight, counting down.
18
+
19
+ THE COOLDOWN IS SHORTENED ON PURPOSE. Production counts 60s; a reviewer will
20
+ not wait 60s to watch a number tick, and a specimen nobody watches documents
21
+ nothing. The partial does not own the duration — the host does.
22
+
23
+ Single root: the outer <div> is the host's required root.
24
+ %>
25
+ <div class="relative"
26
+ x-data="{
27
+ props: { submitting: null, resendCooldown: 0, resendError: '' },
28
+ _timer: null,
29
+ resendMagicLink() {
30
+ if (this.props.submitting || this.props.resendCooldown > 0) return;
31
+ this.props.resendError = '';
32
+ this.props.submitting = 'magic-link';
33
+ setTimeout(() => {
34
+ this.props.submitting = null;
35
+ this.props.resendCooldown = 5;
36
+ this._timer = setInterval(() => {
37
+ this.props.resendCooldown -= 1;
38
+ if (this.props.resendCooldown <= 0) clearInterval(this._timer);
39
+ }, 1000);
40
+ }, 900);
41
+ },
42
+ fail() {
43
+ this.props.submitting = null;
44
+ this.props.resendCooldown = 0;
45
+ this.props.resendError = 'That link could not be sent. Try again in a moment.';
46
+ },
47
+ // Alpine calls destroy() when the component leaves the DOM. Without it the
48
+ // interval outlives the closed card and keeps decrementing a dead object.
49
+ destroy() { clearInterval(this._timer); }
50
+ }">
51
+ <%= render "studio/modals/blocks/close_x", modal_store: "dsModals" %>
52
+
53
+ <div class="text-center pt-1 mb-4">
54
+ <div class="text-2xl leading-none mb-2">📬</div>
55
+ <h3 class="text-heading font-bold text-lg leading-tight">Check your email</h3>
56
+ <p class="text-xs text-body mt-1">
57
+ The footer below is the shared piece &mdash; everything above it belongs to
58
+ the app&rsquo;s own card.
59
+ </p>
60
+ </div>
61
+
62
+ <div class="border-t border-subtle pt-4">
63
+ <%= render "studio/modals/auth/resend_footer", modal_store: "dsModals" %>
64
+ </div>
65
+
66
+ <button type="button" @click="fail()"
67
+ class="block mx-auto mt-4 text-2xs text-muted hover:text-secondary underline underline-offset-2">
68
+ Show the error line
69
+ </button>
70
+ </div>
@@ -0,0 +1,63 @@
1
+ <%#
2
+ Living style guide specimen — THE STACK-MECHANICS VEHICLE.
3
+
4
+ This card exists to be POKED AT, not to be copied. Everything under
5
+ "Stack behaviour" below the specimen grid — dismissibility, the
6
+ minimum-visible-duration floor, LIFO stacking, and advance() — needs a card on
7
+ the stack to demonstrate itself on, and the mechanics are the engine's while
8
+ the card is incidental.
9
+
10
+ IT REPLACED A MIRROR, AND THAT IS THE POINT. Until 2026-09-09 every one of
11
+ those demos ran on style/modals/_onchain_tx, a copy of turf-monster's
12
+ on-chain card. Borrowing a consumer's card as a test vehicle is how a copy
13
+ earns tenure: it drifts from the original, and deleting it takes six unrelated
14
+ demos down with it. This card is built from engine blocks only
15
+ (studio/modals/blocks/_card_header), belongs to no app, and answers to nothing
16
+ but the store — so it can never disagree with a card that ships.
17
+
18
+ THREE FACES ON ONE ID, which is exactly what advance() needs. props.state
19
+ picks the face and the entry is never replaced, so a timed transition patches a
20
+ LIVE card rather than pushing a new one. Three separate ids could not show that
21
+ — and the three cards those ids would name (ds-processing, ds-success,
22
+ ds-error) already have their own specimens in System & status, where they are
23
+ the subject rather than the vehicle.
24
+
25
+ It deliberately does NOT auto-resolve. blocks/_processing_card takes a
26
+ resolve_expr and self-terminates, which is right for a specimen of that block
27
+ and wrong here: a card that closes itself cannot demonstrate that Escape is
28
+ locked.
29
+
30
+ Single root: the outer <div> is the host's required root.
31
+ %>
32
+ <div x-data="{ get props() { var c = Alpine.store('dsModals').current(); return (c && c.props) || {}; } }">
33
+ <template x-if="(props.state || 'processing') === 'processing'">
34
+ <div>
35
+ <%= render "studio/modals/blocks/card_header",
36
+ spinner: true,
37
+ title_key: "props.title || 'Working…'",
38
+ subtitle_key: "props.message || 'This card is the stack-mechanics vehicle.'" %>
39
+ </div>
40
+ </template>
41
+
42
+ <template x-if="props.state === 'success'">
43
+ <div>
44
+ <%= render "studio/modals/blocks/card_header",
45
+ icon_color: "success",
46
+ title_key: "props.title || 'Done'",
47
+ subtitle_key: "props.message || 'Resolved in place — the stack entry was never replaced.'" %>
48
+ <button type="button" @click="$store.dsModals.close()"
49
+ class="btn btn-primary btn-lg w-full">Close</button>
50
+ </div>
51
+ </template>
52
+
53
+ <template x-if="props.state === 'error'">
54
+ <div>
55
+ <%= render "studio/modals/blocks/card_header",
56
+ icon: :error,
57
+ title_key: "props.title || 'That did not work'",
58
+ subtitle_key: "props.message || 'The error face of the same stack entry.'" %>
59
+ <button type="button" @click="$store.dsModals.close()"
60
+ class="btn btn-outline btn-lg w-full">Close</button>
61
+ </div>
62
+ </template>
63
+ </div>
@@ -55,29 +55,43 @@
55
55
  },
56
56
  onInit() { this.ageAttested = this.props.ageAttested === true; },
57
57
  // DEMO walk continuity: the picked wallet resolves, then the card swaps to
58
- // the on-chain tx modal in its processing state, which auto-resolves to
59
- // on-chain success. So the guide's active-card glow follows
60
- // Connect Wallet -> Processing -> On-chain success.
58
+ // the stack-mechanics vehicle in its processing face, so the walk still ends
59
+ // somewhere rather than on a closed overlay.
60
+ //
61
+ // IT SWAPPED TO 'onchain-tx' UNTIL 2026-09-09, and that id is gone — it named
62
+ // a mirror of turf-monster's card. A swap to an UNREGISTERED id is the
63
+ // quietest failure this page has: the store accepts it, the overlay stays
64
+ // open, every <template x-if> misses, and the card renders EMPTY. Nothing
65
+ // throws and nothing logs. Keep this target on an id registered in
66
+ // style/_modals.html.erb.
61
67
  onConnected() {
62
68
  this.connecting = false;
63
69
  this.picking = '';
64
- Alpine.store('dsModals').swap('onchain-tx', {
65
- state: 'processing', demoResolve: true, demoError: false,
70
+ Alpine.store('dsModals').swap('ds-stack-demo', {
71
+ state: 'processing',
66
72
  title: 'Confirming on-chain',
67
73
  message: 'Waiting for the wallet signature\u2026'
68
74
  });
75
+ setTimeout(function () {
76
+ var c = Alpine.store('dsModals').current();
77
+ if (!c || c.id !== 'ds-stack-demo' || c._closing) return;
78
+ Alpine.store('dsModals').advance({
79
+ state: 'success', title: 'Wallet connected',
80
+ message: 'The picker handed off and this card resolved in place.'
81
+ });
82
+ }, 1600);
69
83
  },
70
84
  // No app to deep-link into from the guide — treat the mobile Phantom row
71
85
  // as picking Phantom so the card still walks somewhere.
72
86
  onDeepLink() { this.pick('Phantom'); },
87
+ // A REAL APP SWAPS BACK TO ITS OWN SIGN-IN CARD HERE, reading props.backTo
88
+ // — that is the partial's contract and solana-studio still honours it. This
89
+ // GUIDE cannot: the engine ships no auth card, and the 'auth' branch that
90
+ // used to live here pointed at style/modals/_auth, the retired
91
+ // turf-monster mirror. An unregistered swap target renders an EMPTY card
92
+ // silently, so the branch was removed rather than left aimed at nothing.
73
93
  onBack() {
74
- if (this.props.backTo === 'auth') {
75
- Alpine.store('dsModals').swap('auth',
76
- { step: 'credentials', submitting: null, formError: '' },
77
- { direction: 'back' });
78
- } else {
79
- Alpine.store('dsModals').close();
80
- }
94
+ Alpine.store('dsModals').close();
81
95
  }
82
96
  JS
83
97
  %>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.74.7"
2
+ VERSION = "0.74.8"
3
3
  end
data/lib/studio.rb CHANGED
@@ -916,8 +916,10 @@ module Studio
916
916
  # the OAuth popup DO stay app-side.
917
917
  #
918
918
  # The gate is auth_methods, NOT auth_methods && feature?(:web3), even though
919
- # the auth modal computes its wallet button from both
920
- # (app/views/style/modals/_auth.html.erb). Deliberate: auth_methods says
919
+ # an app's auth modal computes its wallet button from both (turf-monster's
920
+ # app/views/modals/_auth.html.erb; this engine ships no auth card, and the
921
+ # style-guide copy that used to be cited here was a mirror of turf's,
922
+ # retired 2026-09-09). Deliberate: auth_methods says
921
923
  # which CREDENTIALS this app accepts, features gates PRODUCT SURFACES, and
922
924
  # these three paths are the credential exchange itself. phantom_callback is
923
925
  # the mobile deep-link RETURN url — a wallet app that declared :wallet but
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.74.7
4
+ version: 0.74.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -519,17 +519,15 @@ files:
519
519
  - app/views/style/board/_demo_card.html.erb
520
520
  - app/views/style/index.html.erb
521
521
  - app/views/style/modals/_age_gate.html.erb
522
- - app/views/style/modals/_auth.html.erb
523
522
  - app/views/style/modals/_birthday.html.erb
524
523
  - app/views/style/modals/_ds_close_x.html.erb
525
- - app/views/style/modals/_ds_onramp_hub.html.erb
524
+ - app/views/style/modals/_ds_email_field.html.erb
526
525
  - app/views/style/modals/_ds_rail_row.html.erb
527
- - app/views/style/modals/_ds_wallet_topup.html.erb
526
+ - app/views/style/modals/_ds_resend_footer.html.erb
527
+ - app/views/style/modals/_ds_stack_demo.html.erb
528
528
  - app/views/style/modals/_email_change_pending.html.erb
529
- - app/views/style/modals/_entry_tokens.html.erb
530
529
  - app/views/style/modals/_it_begins.html.erb
531
530
  - app/views/style/modals/_newsletter_email.html.erb
532
- - app/views/style/modals/_onchain_tx.html.erb
533
531
  - app/views/style/modals/_rate_limit_general.html.erb
534
532
  - app/views/style/modals/_unsubscribe_confirm.html.erb
535
533
  - app/views/style/modals/_wallet_connect.html.erb
@@ -1,307 +0,0 @@
1
- <%#
2
- Auth wizard — the design-system port of turf-monster's modals/_auth. The
3
- canonical sign-in step machine, faithful to the real modal's UX, wired to the
4
- living style guide's page-scoped host ($store.dsModals) so it opens live on any
5
- app (including McRitchie Studio) without an auth backend.
6
-
7
- Steps (opened at any step via open('auth', { step, ... })):
8
- credentials — Google + Solana + email magic-link request
9
- magic-link-sent — "check your inbox" after the email request
10
- magic-link-resent — success confirmation after a resend (directional slide)
11
- redirect — countdown auto-redirect (drain CTA demo)
12
-
13
- What differs from the production modal: the tokens / USDC funding sub-flow is
14
- dropped (that is contest-entry-specific), and every credential CTA runs behind
15
- a page stub — window.postMagicLink resolves { success: true } so the magic-link
16
- step machine advances for real; Google briefly shows its waiting state then
17
- resets; Solana swaps to the wallet-connect picker (a real ported specimen).
18
- No real auth happens — the demo shows the real UI.
19
-
20
- LOCALS:
21
- web3_gem (Boolean, required) — does solana-studio resolve, meaning is the
22
- wallet-connect picker on the view path. style/_modals owns the lookup and
23
- registers wallet-connect behind the same flag. Fetched without a default on
24
- purpose: a caller that forgets it should fail loudly here rather than
25
- silently drop Solana sign-in from an app that has the gem.
26
-
27
- THE CREDENTIAL SLOT. Google and magic-link are implemented by this engine, so
28
- they render below directly. Wallet is not: it belongs to the web3 bolt-on, and
29
- a base-template app — a newsletter, a hub — must not carry markup it can never
30
- render. So the wallet button is CONTRIBUTED by whichever layer implements it,
31
- as a partial this file merely looks for:
32
-
33
- solana_studio/auth/wallet_credential
34
-
35
- Bundling the gem IS the registration. An app without that layer renders
36
- nothing here and carries no wallet markup at all, rather than shipping a
37
- button hidden behind a flag.
38
-
39
- TWO LAYERS ANSWER TWO DIFFERENT QUESTIONS, and they are deliberately NOT
40
- merged into one:
41
-
42
- Ruby, below IS IT IMPLEMENTED. Is the picker this button swaps to
43
- registered (web3_gem), and does a layer actually ship the
44
- credential partial. This gates the RENDER.
45
- Alpine, in the SHOULD IT SHOW. methodOn('wallet'), which reads
46
- contributed props.methods first so the Sign in card's toggle overrides
47
- partial the server default outright. This gates VISIBILITY.
48
-
49
- KEEPING POLICY OUT OF THE RUBY GATE IS LOAD-BEARING, and it is why
50
- auth_method?(:wallet) and feature?(:web3) are NOT terms in the render gate.
51
- Fold them in and the button leaves the DOM on a web3-off app that bundles the
52
- gem — and then ticking Solana Wallet on the Sign in card turns the divider
53
- below on above a button that is not there, because the divider reads
54
- methodOn('wallet') too. Measured: that is the exact failure this slot was
55
- meant to remove, reintroduced one layer up.
56
-
57
- NOT gated on the slot, deliberately: _methodDefaults.wallet below. It stays
58
- the app's own policy answer and is unchanged by the move. It is the fallback
59
- methodOn uses when an opener passes no methods hash, and on a base app nothing
60
- reaches it — the Sign in card always passes explicit booleans, and the only
61
- opener that omits them is the wallet picker's back button, which needs the gem
62
- to exist at all.
63
-
64
- CRITICAL: rendered inside <template x-if="id==='auth'"> — Alpine requires a
65
- SINGLE root, so everything lives inside the outer <div>. The x-data is a
66
- double-quoted attribute: keep it free of double-quotes and backticks.
67
- %>
68
- <% web3_gem = local_assigns.fetch(:web3_gem) %>
69
- <%# BOTH terms are load-bearing, and they fail differently.
70
-
71
- web3_gem is the picker this button swaps to actually REGISTERED. A layer
72
- that ships the credential partial without the picker draws a button that
73
- opens an empty panel — not hypothetical: solana-studio 0.5.2 shipped
74
- exactly that pair, the credential without wallet_connect.
75
- exists? does a layer ship the button AT ALL. Without it, an app whose
76
- picker resolves but whose credential does not raises Missing partial in
77
- front of someone signing in, instead of rendering no button.
78
-
79
- Same three-term lookup_context.exists? the modal host uses for host_extras
80
- (studio/modals/_host.html.erb): name, prefixes, partial. %>
81
- <% wallet_credential = "solana_studio/auth/wallet_credential" %>
82
- <% wallet_credential_available = web3_gem &&
83
- lookup_context.exists?("wallet_credential", ["solana_studio/auth"], true) %>
84
- <div x-data="{
85
- email: '',
86
- ageAttested: false,
87
- ageError: false,
88
- googleTimer: null,
89
- attested() {
90
- if (!this.termsOn()) return true;
91
- if (this.ageAttested) { this.ageError = false; return true; }
92
- this.ageError = true;
93
- return false;
94
- },
95
- get props() {
96
- var c = Alpine.store('dsModals').current();
97
- return (c && c.props) || {};
98
- },
99
- // Method configuration — which credential methods render. props.methods
100
- // overrides per-key; unset keys fall back to the app's Studio.auth_method?
101
- // (wallet also needs the web3 capability). props.terms gates the age
102
- // attestation. This standardizes the engine auth modal as method-configurable.
103
- _methodDefaults: { magicLink: <%= Studio.auth_method?(:magic_link) %>, google: <%= Studio.auth_method?(:google) %>, wallet: <%= Studio.auth_method?(:wallet) && Studio.feature?(:web3) %> },
104
- _termsDefault: true,
105
- methodOn(m) {
106
- var v = this.props.methods;
107
- return (v && typeof v[m] === 'boolean') ? v[m] : !!this._methodDefaults[m];
108
- },
109
- termsOn() {
110
- var t = this.props.terms;
111
- return (typeof t === 'boolean') ? t : this._termsDefault;
112
- },
113
- get isCredentialsStep() {
114
- var s = this.props.step;
115
- if (!s) return true;
116
- if (s === 'redirect' || s === 'magic-link-sent' || s === 'magic-link-resent') return false;
117
- return true;
118
- },
119
- loginGoogle() {
120
- if (!this.attested()) return;
121
- var p = this.props;
122
- if (!p || p.submitting) return;
123
- p.googleError = '';
124
- p.submitting = 'google';
125
- var self = this;
126
- this.googleTimer = setTimeout(function () {
127
- var pp = self.props;
128
- if (pp) pp.submitting = null;
129
- }, 1200);
130
- },
131
- async submitMagicLink() {
132
- if (!this.attested()) return;
133
- var p = this.props;
134
- if (!p || p.submitting) return;
135
- p.formError = '';
136
- if (!this.email) { p.formError = 'Enter your email.'; return; }
137
- p.submitting = 'magic-link';
138
- try {
139
- var data = await window.postMagicLink(this.email, { ageAttested: true });
140
- if (!data || !data.success) { p.formError = (data && data.error) || 'Could not send the link. Try again.'; p.submitting = null; return; }
141
- Alpine.store('dsModals').advance({ step: 'magic-link-sent', sentEmail: this.email, submitting: null, formError: '' });
142
- } catch (e) {
143
- var p2 = this.props;
144
- if (p2) { p2.formError = 'Network error. Try again.'; p2.submitting = null; }
145
- }
146
- },
147
- _resendTimer: null,
148
- startResendCooldown(seconds) {
149
- var self = this;
150
- var p = this.props;
151
- if (!p) return;
152
- p.resendCooldown = seconds || 60;
153
- if (this._resendTimer) clearInterval(this._resendTimer);
154
- this._resendTimer = setInterval(function () {
155
- var pp = self.props;
156
- if (!pp) { clearInterval(self._resendTimer); self._resendTimer = null; return; }
157
- pp.resendCooldown = (pp.resendCooldown || 0) - 1;
158
- if (pp.resendCooldown <= 0) {
159
- pp.resendCooldown = 0;
160
- clearInterval(self._resendTimer);
161
- self._resendTimer = null;
162
- }
163
- }, 1000);
164
- },
165
- async resendMagicLink() {
166
- var p = this.props;
167
- if (!p || p.submitting || (p.resendCooldown || 0) > 0) return;
168
- p.resendError = '';
169
- p.submitting = 'magic-link';
170
- try {
171
- var data = await window.postMagicLink(p.sentEmail, { ageAttested: true });
172
- if (data && data.success) {
173
- Alpine.store('dsModals').advance({ step: 'magic-link-resent' });
174
- this.startResendCooldown(60);
175
- } else {
176
- p.resendError = (data && data.error) || 'Could not resend — wait a moment and try again.';
177
- }
178
- } catch (e) {
179
- p.resendError = 'Could not resend — check your connection and try again.';
180
- }
181
- p.submitting = null;
182
- },
183
- destroy() {
184
- if (this._resendTimer) { clearInterval(this._resendTimer); this._resendTimer = null; }
185
- if (this.googleTimer) { clearTimeout(this.googleTimer); this.googleTimer = null; }
186
- }
187
- }"
188
- class="relative">
189
-
190
- <%# === CREDENTIALS STEP ================================================== %>
191
- <div x-show="isCredentialsStep" x-cloak>
192
- <div class="relative mb-3 -mt-2">
193
- <h3 class="text-heading font-bold text-lg leading-tight text-center pt-1">Sign in</h3>
194
- <button @click="$store.dsModals.close()"
195
- class="absolute top-0 right-0 -mr-2 text-secondary hover:text-heading text-xl leading-none"
196
- aria-label="Close">&times;</button>
197
- </div>
198
- <%= render "studio/modals/blocks/progress_pill", current: 1, total: 3 %>
199
- <p class="text-xs text-secondary mb-4 text-center"
200
- x-text="'Your ' + ((props && props.picksRequired) || 6) + ' picks are saved — sign in to submit your lineup.'"></p>
201
-
202
- <div class="mb-4" x-show="termsOn()">
203
- <%= render "studio/modals/shared/age_attestation" %>
204
- </div>
205
-
206
- <%# The credential CTAs gate on props.submitting. It MUST be coerced with
207
- !! — on a fresh open the modal is opened with { step, picksRequired }
208
- and no submitting key, and x-bind:disabled on a reactive-undefined value
209
- renders as disabled=true (verified live), which would inert the whole
210
- form. !!props.submitting yields a real boolean so the form is enabled
211
- until a CTA actually sets submitting. Keep the !! on every gate below. %>
212
- <%# 1. Google — opens a popup in production; here it shows the waiting state.
213
- Gated on methodOn('google') so the specimen's toggles configure it. %>
214
- <button @click="loginGoogle()"
215
- x-show="methodOn('google')"
216
- :disabled="!!props.submitting"
217
- class="btn btn-neutral btn-lg w-full gap-3 mb-3 disabled:cursor-wait">
218
- <span x-show="props.submitting !== 'google'" class="inline-flex"><%= render "components/google_logo" %></span>
219
- <span x-show="props.submitting === 'google'" class="spinner" aria-hidden="true"></span>
220
- <span x-text="props.submitting === 'google' ? 'Waiting for Google…' : 'Google'"></span>
221
- </button>
222
- <template x-if="props.googleError">
223
- <p role="alert" class="text-red-400 text-xs -mt-2 mb-3" x-text="props.googleError"></p>
224
- </template>
225
-
226
- <%# 2. Wallet — CONTRIBUTED, not written here. See the credential-slot note
227
- at the top of this file. The partial owns the button, its brand mark
228
- and its click handler; this engine owns only the decision to look for
229
- it. An app with no web3 layer on its view path renders nothing at all
230
- here, which is the whole point: the base template ships no wallet
231
- markup.
232
-
233
- Visibility is still Alpine's. The contributed partial carries
234
- x-show methodOn('wallet'), so the Sign in card's toggle drives it
235
- exactly as it drove the button that used to live here, and the divider
236
- below can never float above a button the DOM is missing. %>
237
- <% if wallet_credential_available %>
238
- <%= render wallet_credential, modal_store: "dsModals" %>
239
- <% end %>
240
-
241
- <%# "or" divider — only when magic-link AND a social method are both on. %>
242
- <div class="relative my-4" x-show="methodOn('magicLink') && (methodOn('google') || methodOn('wallet'))">
243
- <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-strong"></div></div>
244
- <div class="relative flex justify-center text-sm"><span class="bg-surface px-3 text-secondary">or</span></div>
245
- </div>
246
-
247
- <%# 3. Email magic link — no password. postMagicLink stub advances the step.
248
- Gated on methodOn('magicLink'). %>
249
- <form @submit.prevent="submitMagicLink()" novalidate x-show="methodOn('magicLink')">
250
- <div class="mb-3">
251
- <label class="block text-xs text-secondary mb-1 font-medium">Email</label>
252
- <%= render "studio/modals/shared/email_field",
253
- name: nil, x_model: "email",
254
- placeholder: "you@example.com", required: true,
255
- disabled_expr: "!!props.submitting" %>
256
- </div>
257
- <template x-if="props.formError">
258
- <p role="alert" class="text-red-400 text-xs mb-3" x-text="props.formError"></p>
259
- </template>
260
- <button type="submit" :disabled="!!props.submitting"
261
- class="btn btn-neutral btn-lg w-full gap-2 disabled:cursor-wait">
262
- <span x-show="props.submitting === 'magic-link'" class="spinner" aria-hidden="true"></span>
263
- <span x-text="props.submitting === 'magic-link' ? 'Sending link…' : 'Email Link'"></span>
264
- </button>
265
- <p class="text-xs text-muted text-center mt-2">No password needed — we'll email you a one-tap link.</p>
266
- </form>
267
- </div>
268
-
269
- <%# === MAGIC-LINK-SENT STEP ============================================== %>
270
- <template x-if="props.step === 'magic-link-sent'">
271
- <div>
272
- <%= render "studio/modals/blocks/card_header", icon_emoji: '📬', title: 'Check your inbox' do %>
273
- We emailed <span class="text-heading font-medium break-all" x-text="props.sentEmail"></span>
274
- a one-tap sign-in link. Check your email and
275
- <span class="text-heading font-semibold">Click the Magic Link</span> to continue.
276
- <% end %>
277
- <%= render "studio/modals/auth/resend_footer", modal_store: "dsModals" %>
278
- </div>
279
- </template>
280
-
281
- <%# === MAGIC-LINK-RESENT STEP =========================================== %>
282
- <template x-if="props.step === 'magic-link-resent'">
283
- <div>
284
- <%= render "studio/modals/blocks/card_header", icon_color: 'primary', title: 'Link Resent!' do %>
285
- We just resent <span class="text-heading font-medium break-all" x-text="props.sentEmail"></span>
286
- a one-tap sign-in link. Check your email and
287
- <span class="text-heading font-semibold">Click the Magic Link</span> to continue.
288
- <% end %>
289
- <%= render "studio/modals/auth/resend_footer", modal_store: "dsModals" %>
290
- </div>
291
- </template>
292
-
293
- <%# === REDIRECT STEP ===================================================== %>
294
- <template x-if="props.step === 'redirect'">
295
- <div>
296
- <%= render "studio/modals/blocks/card_header",
297
- icon_emoji_key: "props.icon || '📍'",
298
- title_key: "props.title || 'Heading out'",
299
- subtitle_key: "props.message || 'Taking you to the next step.'" %>
300
- <%= render "studio/modals/blocks/cta_redirect",
301
- href_key: "props.url",
302
- label_key: "props.cta || 'Go now'",
303
- duration_seconds: 5,
304
- modal_store: "dsModals" %>
305
- </div>
306
- </template>
307
- </div>