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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -1
  3. data/README.md +65 -28
  4. data/app/assets/tailwind/studio_engine/engine.css +158 -2
  5. data/app/javascript/studio/application.js +1 -0
  6. data/app/javascript/studio/carousel.js +74 -1
  7. data/app/javascript/studio/controllers/carousel_controller.js +163 -9
  8. data/app/javascript/studio/controllers/page_header_controller.js +64 -1
  9. data/app/javascript/studio/controllers/style_section_nav_controller.js +26 -0
  10. data/app/javascript/studio/in_page_links.js +308 -0
  11. data/app/javascript/studio/page_header.js +17 -0
  12. data/app/views/studio/_carousel.html.erb +54 -11
  13. data/app/views/studio/_page_header.html.erb +64 -12
  14. data/app/views/studio/auth/_sign_in_body.html.erb +236 -0
  15. data/app/views/studio/auth/_sign_in_card.html.erb +50 -0
  16. data/app/views/studio/auth/_sign_in_modal.html.erb +36 -0
  17. data/app/views/studio/modals/auth/_resend_footer.html.erb +6 -0
  18. data/app/views/style/_glass.html.erb +4 -8
  19. data/app/views/style/_loaders.html.erb +11 -35
  20. data/app/views/style/_modals.html.erb +29 -39
  21. data/app/views/style/_scrims.html.erb +10 -20
  22. data/app/views/style/_site_footer.html.erb +10 -18
  23. data/app/views/style/_specimen.html.erb +13 -41
  24. data/app/views/style/_tasks.html.erb +4 -22
  25. data/app/views/style/_trick_label.html.erb +33 -0
  26. data/app/views/style/_tricks.html.erb +127 -207
  27. data/app/views/style/index.html.erb +2 -2
  28. data/app/views/style/modals/_wallet_connect.html.erb +2 -1
  29. data/config/studio_tricks.yml +470 -0
  30. data/lib/studio/page_header.rb +53 -0
  31. data/lib/studio/tricks.rb +110 -0
  32. data/lib/studio/version.rb +1 -1
  33. data/lib/studio.rb +6 -4
  34. metadata +9 -3
  35. data/app/views/style/modals/_ds_email_field.html.erb +0 -58
  36. 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: { klass: "Studio.surface_style = :glass",
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: { klass: "Studio.sidebar_style = :glass",
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: { klass: "Lit from above, under surface_style = :glass",
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: { klass: "studio_icon(name, size: 18)",
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. The branded styles themselves are under Tricks.
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-owned FRAGMENTS an app's own sign-in card is built
18
- from — the engine ships no auth card), Eligibility and entry (the
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 ships no auth card to configure.
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 fragments --- %>
206
- <%# NOT an auth card. The engine owns no sign-in modal — each app cards
207
- its own on its own host section (turf-monster: style/host/_modals).
208
- These two register the PIECES every app's auth card is built from,
209
- which had no specimen until style/modals/_auth, a mirror of turf's
210
- card, was retired on 2026-09-09 and took their only render with it. %>
211
- <template x-if="$store.dsModals.current().id === 'ds-email-field'">
212
- <div><%= render "style/modals/ds_email_field" %></div>
213
- </template>
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 ships no sign-in card, only these pieces.</p>
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: "Email field",
461
- reference: %(the modal family's email input (studio/modals/shared/_email_field) — a themed <input type="email"> that either posts through its own `name` or feeds an enclosing x-model, with locals name/id/value/placeholder/required/autocomplete/autofocus/input_class/x_model/x_ref/disabled_expr/validator. THE `validator` LOCAL IS A FORK, NOT A FLAG: off, the partial is self-contained and the SERVER stays the only boundary; on, it wraps itself in x-data="emailValidator()" and paints a right-edge spinner/check/X that only works if the CONSUMER ships that factory, so passing it without one yields an Alpine scope error and a dead indicator. The specimen draws both faces side by side; this guide ships no emailValidator, which is why the second indicator stays blank. Open with $store.dsModals.open('ds-email-field')),
462
- open_expr: "$store.dsModals.open('ds-email-field')",
463
- glow_when: ds_glow.call("ds-email-field") } do %>
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-1.5 w-12 rounded" style="background: var(--color-text); opacity: .18"></span>
466
- <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
467
- <span class="flex items-center h-6 w-full rounded border px-1.5" style="border-color: var(--color-border-strong)">
468
- <span class="block h-1.5 flex-1 rounded" style="background: var(--color-text); opacity: .12"></span>
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
- </div>
472
- <% end %>
473
-
474
- <%# resend footer — the piece both link-sent steps share. %>
475
- <%= render layout: "style/modal_specimen", locals: {
476
- label: "Resend footer",
477
- reference: %(the magic-link resend footer (studio/modals/auth/_resend_footer) — the resend link, its error line and the Close beneath it, shared by an auth card's magic-link-sent and magic-link-resent steps because the two are identical, which is why it was extracted. IT RESOLVES FROM AN ENCLOSING x-data, NOT A STORE: it reads props.submitting, props.resendCooldown and props.resendError and calls resendMagicLink(), so a scope supplying none of them fails SILENTLY — nothing errors, the link simply never enables and the cooldown never counts. The specimen supplies a stand-in host so all three states are reachable, and shortens the cooldown from production's 60s to 5s because the partial does not own the duration, the host does. Open with $store.dsModals.open('ds-resend-footer')),
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>