studio-engine 0.132.0 → 0.133.0

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