studio-engine 0.17.0 → 0.19.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +101 -0
  3. data/app/assets/tailwind/studio_engine/engine-motion.css +620 -5
  4. data/app/controllers/concerns/studio/magic_link_issuing.rb +39 -0
  5. data/app/controllers/magic_links_controller.rb +5 -14
  6. data/app/controllers/studio/local_emails_controller.rb +1 -1
  7. data/app/controllers/studio/local_reviews_controller.rb +50 -0
  8. data/app/controllers/style_controller.rb +25 -0
  9. data/app/mailers/user_mailer.rb +6 -9
  10. data/app/views/studio/modals/_crop_photo.html.erb +5 -1
  11. data/app/views/studio/modals/_host.html.erb +5 -0
  12. data/app/views/studio/modals/_image_upload.html.erb +16 -5
  13. data/app/views/studio/modals/_saving.html.erb +6 -2
  14. data/app/views/studio/modals/auth/_resend_footer.html.erb +25 -0
  15. data/app/views/studio/modals/blocks/_card_header.html.erb +83 -0
  16. data/app/views/studio/modals/blocks/_cta_redirect.html.erb +73 -0
  17. data/app/views/studio/modals/blocks/_onchain_success.html.erb +52 -0
  18. data/app/views/studio/modals/blocks/_progress_pill.html.erb +23 -0
  19. data/app/views/studio/modals/blocks/_shell.html.erb +35 -0
  20. data/app/views/studio/modals/blocks/_solana_tx_link.html.erb +42 -0
  21. data/app/views/studio/modals/shared/_age_attestation.html.erb +34 -0
  22. data/app/views/studio/modals/shared/_email_field.html.erb +70 -0
  23. data/app/views/studio/modals/templates/_action.html.erb +25 -0
  24. data/app/views/studio/modals/templates/_form.html.erb +25 -0
  25. data/app/views/studio/modals/templates/_status.html.erb +45 -0
  26. data/app/views/studio/modals/templates/_success.html.erb +23 -0
  27. data/app/views/studio/modals/templates/_wizard.html.erb +75 -0
  28. data/app/views/style/_modal_specimen.html.erb +120 -0
  29. data/app/views/style/_modals.html.erb +690 -0
  30. data/app/views/style/_specimen.html.erb +64 -0
  31. data/app/views/style/_tasks.html.erb +112 -0
  32. data/app/views/style/_theme.html.erb +222 -0
  33. data/app/views/style/_tricks.html.erb +323 -0
  34. data/app/views/style/index.html.erb +60 -0
  35. data/app/views/style/modals/_auth.html.erb +252 -0
  36. data/app/views/style/modals/_onchain_tx.html.erb +62 -0
  37. data/app/views/style/modals/_wallet_connect.html.erb +138 -0
  38. data/app/views/style/modals/_wallet_deposit.html.erb +56 -0
  39. data/lib/studio/version.rb +1 -1
  40. data/lib/studio.rb +43 -1
  41. metadata +29 -4
  42. data/app/controllers/design_system_controller.rb +0 -12
  43. data/app/views/design_system/_specimen.html.erb +0 -27
  44. data/app/views/design_system/index.html.erb +0 -232
@@ -15,6 +15,7 @@
15
15
  # sign_in_existing / sign_up_new building blocks.
16
16
  class MagicLinksController < ApplicationController
17
17
  include Studio::LinkConsumption
18
+ include Studio::MagicLinkIssuing
18
19
 
19
20
  skip_before_action :require_authentication
20
21
  layout false, only: :confirm
@@ -57,18 +58,8 @@ class MagicLinksController < ApplicationController
57
58
  redirect_to login_path, alert: "That sign-in link is invalid or has expired. Request a fresh one below."
58
59
  end
59
60
 
60
- private
61
-
62
- # Mint a token in the configured store. Default :signed keeps the legacy
63
- # stateless MessageVerifier link (URL: /magic_link/<token>); :database mints a
64
- # Studio::Link row (URL: /l/<token>) — the short, unified scheme. The mailer
65
- # builds the matching URL (see UserMailer#magic_link). sign_in_existing /
66
- # sign_up_new / safe_path come from Studio::LinkConsumption.
67
- def issue_magic_link(email, return_to)
68
- if Studio.magic_link_store == :database
69
- Studio::Link.create_magic_link(email: email, return_to: return_to).token
70
- else
71
- MagicLink.generate(email: email, return_to: return_to)
72
- end
73
- end
61
+ # issue_magic_link (mint in the configured store) comes from
62
+ # Studio::MagicLinkIssuing, shared with UserMailer — which builds the URL that
63
+ # consumes it so the mint and its landing URL cannot drift apart.
64
+ # sign_in_existing / sign_up_new / safe_path come from Studio::LinkConsumption.
74
65
  end
@@ -23,7 +23,7 @@ module Studio
23
23
  private
24
24
 
25
25
  def require_local_development!
26
- head :not_found if Rails.env.production? || !request.local?
26
+ head :not_found unless Studio.local_tool_enabled?(request_local: request.local?)
27
27
  end
28
28
 
29
29
  def delivery_records
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # GET /_studio/local_review?email=<who>&return_to=<path> — the LOCAL half of
5
+ # the task board's WAITING APPROVAL button.
6
+ #
7
+ # Why it must live here, on the local app. A magic link signs the recipient
8
+ # into the app that MINTED it: the token is a row (or a signed blob) in that
9
+ # app's own store, and Studio::LinkToken.sanitize_path keeps `return_to` a
10
+ # same-origin PATH. So a link minted by the production board can only ever
11
+ # land on production — no matter what host the path belongs to. The board
12
+ # therefore stops minting and REDIRECTS here, to the local server named by the
13
+ # task's local_url, which mints in its own database and lands the operator
14
+ # signed-in on the page under review.
15
+ #
16
+ # It mints for whatever email it is handed, unauthenticated — so it is bound
17
+ # to the same floor as the local email inbox (Studio::LocalEmailsController):
18
+ # never in production, and only for a loopback request. That is the same
19
+ # exposure the inbox already carries, which hands every captured sign-in link
20
+ # to any local reader. It is a development desk convenience; it is not a
21
+ # sign-in path.
22
+ class LocalReviewsController < ApplicationController
23
+ include Studio::MagicLinkIssuing
24
+
25
+ skip_before_action :require_authentication, raise: false
26
+
27
+ before_action :require_local_development!
28
+
29
+ def show
30
+ email = Studio::LinkToken.normalize_email(params[:email])
31
+ return redirect_to(login_path, alert: MISSING_EMAIL) unless email.match?(URI::MailTo::EMAIL_REGEXP)
32
+
33
+ # return_to is passed through raw: BOTH stores sanitize it to a same-origin
34
+ # path on the way in (Studio::Link.create_magic_link and MagicLink.generate
35
+ # each call Studio::LinkToken.sanitize_path). Re-sanitizing here would be a
36
+ # second spelling of a rule that already has one owner — and a mutation run
37
+ # confirmed it guards nothing the store does not already guard.
38
+ token = issue_magic_link(email, params[:return_to])
39
+ redirect_to magic_link_url_for(token), allow_other_host: false
40
+ end
41
+
42
+ private
43
+
44
+ MISSING_EMAIL = "Add ?email=<your address> to mint a local review link."
45
+
46
+ def require_local_development!
47
+ head :not_found unless Studio.local_tool_enabled?(request_local: request.local?)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,25 @@
1
+ # The living style guide — a single admin-gated page (/admin/style) that renders
2
+ # the engine's shared UI primitives (buttons, surfaces, the seven motion
3
+ # primitives, the leveling badges, and the theme role tokens) live in the host
4
+ # app's theme. Mirrors ThemeSettingsController: a plain host-inherited controller,
5
+ # gated by `require_admin` from the already-included Studio::ErrorHandling concern.
6
+ # The view is a bare content wrapper that renders inside each host's
7
+ # application.html.erb, so it inherits that app's navbar + theme automatically.
8
+ #
9
+ # Reached at /admin/style (helper admin_style_path). The legacy /admin/design_system
10
+ # route redirects here and keeps its admin_design_system_path helper resolving, so a
11
+ # shipped host sidebar link that still points at the old helper keeps working.
12
+ class StyleController < ApplicationController
13
+ before_action :require_admin
14
+
15
+ # Load the live theme so the Theme section (the landing section, folded in from
16
+ # /admin/theme) shows this app's saved colors + the CSRF-protected editor.
17
+ # Best-effort: the section renders from Studio.theme_config defaults when no
18
+ # ThemeSetting row exists, so a load hiccup must not take the whole page down.
19
+ def index
20
+ @theme_setting = ThemeSetting.current if defined?(ThemeSetting)
21
+ @theme_defaults = Studio.theme_config if Studio.respond_to?(:theme_config)
22
+ rescue StandardError => e
23
+ Rails.logger.warn("[style] theme preload skipped: #{e.message}")
24
+ end
25
+ end
@@ -1,4 +1,10 @@
1
1
  class UserMailer < ApplicationMailer
2
+ # magic_link_url_for — the URL that consumes the token, matched to
3
+ # Studio.magic_link_store. Shared with the issuers that MINT the token
4
+ # (MagicLinksController, Studio::LocalReviewsController) so the two halves
5
+ # cannot drift apart.
6
+ include Studio::MagicLinkIssuing
7
+
2
8
  # Branded shell (banner + card) for engine-sent UserMailer emails. An app with
3
9
  # its own UserMailer + branded_mailer layout (e.g. turf-monster) overrides both.
4
10
  layout "branded_mailer"
@@ -18,13 +24,4 @@ class UserMailer < ApplicationMailer
18
24
  @banner_alt = "Your #{@app_name} sign-in link"
19
25
  mail(to: email, subject: "Your #{@app_name} sign-in link")
20
26
  end
21
-
22
- private
23
-
24
- # Match the emailed URL to Studio.magic_link_store: the short /l/<token> for
25
- # the :database scheme, the legacy /magic_link/<token> for :signed. The
26
- # request side (MagicLinksController#issue_magic_link) mints the matching token.
27
- def magic_link_url_for(token)
28
- Studio.magic_link_via_l_route? ? link_url(token: token) : magic_link_url(token: token)
29
- end
30
27
  end
@@ -21,8 +21,12 @@
21
21
 
22
22
  Single root: lives inside the host's <template x-if>, so only the outer
23
23
  div is the top-level element here.
24
+
25
+ Local: store — Alpine store name this modal is mounted in. Default "modals".
26
+ The living style guide passes store: "dsModals" for its page-scoped host.
24
27
  %>
25
- <div x-data="cropPhotoModal()">
28
+ <% crop_store = local_assigns.fetch(:store, "modals") %>
29
+ <div x-data="cropPhotoModal({ store: '<%= crop_store %>' })">
26
30
  <h3 class="text-heading font-bold text-lg text-center mb-4">Crop Photo</h3>
27
31
 
28
32
  <template x-if="error">
@@ -8,6 +8,11 @@
8
8
  store registration, and the window.StudioModals.holdAtLeast(ms)
9
9
  helper. The animation keyframes ship inline in the style block
10
10
  below, so consumers need no extra CSS to get the full behavior.
11
+ A canonical copy of the SAME keyframes also lives in the shared
12
+ motion layer (app/assets/tailwind/studio_engine/engine-motion.css)
13
+ for pages that drive a modal host WITHOUT rendering this partial —
14
+ e.g. the living style guide's page-scoped dsModals host. The two are
15
+ byte-identical; keep them in sync if you retune a curve or duration.
11
16
  Consumer integration example and API reference: the "Modal host"
12
17
  section of the gem README.
13
18
  %>
@@ -26,6 +26,11 @@
26
26
  function cropPhotoModal(opts) {
27
27
  opts = opts || {};
28
28
  return {
29
+ // Which Alpine store this modal is mounted in. Defaults to 'modals' (the
30
+ // shared host); the living style guide passes { store: 'dsModals' } so the
31
+ // crop-photo modal opens on its page-scoped host without colliding with
32
+ // the app-level shared host.
33
+ _storeName: opts.store || 'modals',
29
34
  cropper: null,
30
35
  imageUrl: null,
31
36
  fromParent: false,
@@ -46,7 +51,7 @@
46
51
  dispatch: false,
47
52
 
48
53
  init() {
49
- var current = this.$store.modals.current();
54
+ var current = this.$store[this._storeName].current();
50
55
  var props = (current && current.props) || {};
51
56
  if (props.aspectRatio) this.aspectRatio = props.aspectRatio;
52
57
  if (props.maxWidth) this.maxWidth = props.maxWidth;
@@ -77,8 +82,14 @@
77
82
  });
78
83
  // Cropping in progress — lock the modal (no click-outside / escape) so
79
84
  // an accidental click doesn't discard the crop. Cancel still works.
80
- var cur = self.$store.modals.current();
81
- if (cur && cur.props) cur.props.dismissible = false;
85
+ // Also reflect the crop sub-state onto the store entry (cropReady) so
86
+ // an external observer can tell the picker state apart from the crop
87
+ // state — the modal's own `imageUrl` is LOCAL x-data and never lands on
88
+ // props, so props alone can't distinguish them. The living style guide
89
+ // reads this to move its active-card glow from the Image Upload card to
90
+ // the Crop Photo card the moment an image is loaded (picker -> cropper).
91
+ var cur = self.$store[self._storeName].current();
92
+ if (cur && cur.props) { cur.props.dismissible = false; cur.props.cropReady = true; }
82
93
  });
83
94
  },
84
95
 
@@ -112,7 +123,7 @@
112
123
 
113
124
  cancel() {
114
125
  if (this.cropper) { this.cropper.destroy(); this.cropper = null; }
115
- this.$store.modals.close();
126
+ this.$store[this._storeName].close();
116
127
  },
117
128
 
118
129
  confirm() {
@@ -134,7 +145,7 @@
134
145
  // dispatch mode: the opener's host owns the post-confirm flow
135
146
  // (processing modal -> success toast), so don't pop the stack here.
136
147
  // fromParent (no dispatch): close the modal now.
137
- if (!self.dispatch) self.$store.modals.close();
148
+ if (!self.dispatch) self.$store[self._storeName].close();
138
149
  }, "image/png");
139
150
  }
140
151
  };
@@ -1,7 +1,11 @@
1
1
  <%# Generic "saving" loading card, opened by window.submitFormWithProgress with
2
2
  a { title } prop while a Turbo form uploads. Register it in your modal-host
3
- block alongside crop-photo. Single root for the host's <template x-if>. %>
3
+ block alongside crop-photo. Single root for the host's <template x-if>.
4
+
5
+ Local: store — Alpine store name backing the title read. Default "modals".
6
+ The living style guide passes store: "dsModals" for its page-scoped host. %>
7
+ <% saving_store = local_assigns.fetch(:store, "modals") %>
4
8
  <div>
5
9
  <%= render "studio/modals/blocks/processing_card",
6
- title_key: "$store.modals.current()?.props?.title || 'Saving…'" %>
10
+ title_key: "$store.#{saving_store}.current()?.props?.title || 'Saving…'" %>
7
11
  </div>
@@ -0,0 +1,25 @@
1
+ <%#
2
+ Resend-link + error + Close footer, shared by the auth modal's
3
+ magic-link-sent and magic-link-resent steps (identical in both). Ported from
4
+ turf-monster. Rendered INSIDE each step's root <div> (so the parent
5
+ <template x-if>'s single-root rule is unaffected) and within the auth modal's
6
+ x-data scope, so props.* and resendMagicLink() resolve from the host
7
+ component. Resend is a LINK; disabled during an in-flight resend and the 60s
8
+ cooldown, which it counts down inline. Keep this free of double-quotes and
9
+ backticks inside the Alpine expressions.
10
+
11
+ Locals:
12
+ modal_store — Alpine store name backing close(). Default "modals".
13
+ %>
14
+ <%
15
+ modal_store = local_assigns.fetch(:modal_store, "modals")
16
+ %>
17
+ <button @click="resendMagicLink()" :disabled="props.submitting === 'magic-link' || (props.resendCooldown || 0) > 0"
18
+ class="block mx-auto text-sm text-primary hover:text-primary-300 underline underline-offset-2 disabled:opacity-50 disabled:no-underline">
19
+ <span class="inline-flex items-center justify-center gap-1.5">
20
+ <span x-show="props.submitting === 'magic-link'" class="spinner" aria-hidden="true"></span>
21
+ <span x-text="props.submitting === 'magic-link' ? 'Resending…' : ((props.resendCooldown || 0) > 0 ? ('Resend available in ' + props.resendCooldown + 's') : 'Resend link')"></span>
22
+ </span>
23
+ </button>
24
+ <p x-show="props.resendError" x-cloak class="text-xs text-red-400 mt-3 text-center" x-text="props.resendError"></p>
25
+ <button @click="$store.<%= modal_store %>.close()" class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">Close</button>
@@ -0,0 +1,83 @@
1
+ <%#
2
+ Shared modal card header — icon pill + title + subtext. Owns the visual top
3
+ of every success / confirmation / status modal so the header style stays
4
+ aligned across the family. Each callsite slots its own action area (button,
5
+ link, hold button) BELOW this header. Ported from turf-monster into the engine
6
+ (studio-engine v0.18) so the shared modal blocks live in one place.
7
+
8
+ Locals (all optional except title/title_key):
9
+ title: static title string
10
+ title_key: Alpine expression for the title (x-text). Priority over title.
11
+ subtitle: static subtext string
12
+ subtitle_key: Alpine expression for the subtext (x-text). Priority over subtitle.
13
+ icon_color: color token for the default check — 'primary' (default),
14
+ 'success', 'warning', 'mint'.
15
+ icon_emoji: large emoji (text-5xl, no pill) instead of the default check.
16
+ icon_emoji_key: Alpine expression returning the emoji (x-text). Priority
17
+ over icon_emoji.
18
+ icon: :error — X mark in a red pill (hard error states).
19
+ :external_tab — diagonal arrow pill (action in another tab).
20
+ spinner: truthy — three bouncing dots instead of the check pill,
21
+ for processing / loading states.
22
+ size: :md (default) OR :lg (celebration end-state, text-3xl title).
23
+
24
+ Block: if given, the block's content replaces the subtitle area. Explicit
25
+ subtitle locals are checked BEFORE block_given? — a partial's block_given? can
26
+ read TRUE off the layout yield even with no block passed (see the engine's
27
+ Rails-partial block_given? note), so lead with the locals.
28
+ %>
29
+ <%
30
+ icon_color = local_assigns[:icon_color] || 'primary'
31
+ size = local_assigns[:size] || :md
32
+ pill_class = size == :lg ? 'w-14 h-14' : 'w-12 h-12'
33
+ svg_class = size == :lg ? 'w-7 h-7' : 'w-6 h-6'
34
+ title_cls = size == :lg ? 'text-3xl font-bold text-heading mb-2' : 'text-lg font-bold text-heading mb-2'
35
+ sub_cls = size == :lg ? 'text-sm text-secondary mb-4' : 'text-xs text-secondary mb-5'
36
+ %>
37
+ <div class="text-center">
38
+ <% if local_assigns[:spinner] %>
39
+ <div class="mx-auto <%= pill_class %> flex items-center justify-center mb-4">
40
+ <div class="loading-dots">
41
+ <span></span>
42
+ <span></span>
43
+ <span></span>
44
+ </div>
45
+ </div>
46
+ <% elsif local_assigns[:icon] == :error %>
47
+ <div class="mx-auto <%= pill_class %> rounded-full bg-red-500/15 flex items-center justify-center mb-4">
48
+ <svg class="<%= svg_class %> text-red-400" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
49
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
50
+ </svg>
51
+ </div>
52
+ <% elsif local_assigns[:icon] == :external_tab %>
53
+ <div class="mx-auto <%= pill_class %> rounded-full bg-<%= icon_color %>/15 flex items-center justify-center mb-4">
54
+ <svg class="<%= svg_class %> text-<%= icon_color %>" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
55
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25"/>
56
+ </svg>
57
+ </div>
58
+ <% elsif local_assigns[:icon_emoji_key] %>
59
+ <div class="text-5xl mb-4 leading-none" x-text="<%= icon_emoji_key %>"></div>
60
+ <% elsif local_assigns[:icon_emoji] %>
61
+ <div class="text-5xl mb-4 leading-none"><%= icon_emoji %></div>
62
+ <% else %>
63
+ <div class="mx-auto <%= pill_class %> rounded-full bg-<%= icon_color %>/15 flex items-center justify-center mb-4">
64
+ <svg class="<%= svg_class %> text-<%= icon_color %>" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
65
+ <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
66
+ </svg>
67
+ </div>
68
+ <% end %>
69
+
70
+ <% if local_assigns[:title_key] %>
71
+ <h3 class="<%= title_cls %>" x-text="<%= title_key %>"></h3>
72
+ <% elsif local_assigns[:title] %>
73
+ <h3 class="<%= title_cls %>"><%= title %></h3>
74
+ <% end %>
75
+
76
+ <% if local_assigns[:subtitle_key] %>
77
+ <p class="<%= sub_cls %>" x-text="<%= subtitle_key %>"></p>
78
+ <% elsif local_assigns[:subtitle] %>
79
+ <p class="<%= sub_cls %>"><%= subtitle %></p>
80
+ <% elsif block_given? %>
81
+ <p class="<%= sub_cls %>"><%= yield %></p>
82
+ <% end %>
83
+ </div>
@@ -0,0 +1,73 @@
1
+ <%#
2
+ Primary CTA with a drain animation that auto-redirects when the countdown
3
+ hits zero. Used on celebration cards where the user is leaving the modal
4
+ anyway — the drain is both a progress bar AND a click-now affordance. Ported
5
+ from turf-monster.
6
+
7
+ The drain always plays. The redirect is gated:
8
+ - URL truthy → drains, then window.location = URL
9
+ - URL null → drains; timer fires a no-op, a click falls through to
10
+ closing the modal (so a gallery preview stays alive).
11
+
12
+ Locals (optional except href_key):
13
+ href_key — Alpine expression: the destination URL or null. Re-read
14
+ inside the timer fire so callers can lazy-resolve.
15
+ label_html — Button label HTML. Default "Continue".
16
+ label_key — Alpine expression returning the label (x-text). Priority
17
+ over label_html.
18
+ duration_seconds — Drain + redirect timer in seconds. Default 5.
19
+ modal_store — Alpine store name backing close(). Default "modals".
20
+
21
+ Co-located x-data self-manages the timer. Keep the x-data free of
22
+ double-quotes and backticks — it is a double-quoted HTML attribute.
23
+ %>
24
+ <%
25
+ href_key = local_assigns.fetch(:href_key)
26
+ label_html = local_assigns.fetch(:label_html, "Continue")
27
+ duration_seconds = local_assigns.fetch(:duration_seconds, 5)
28
+ modal_store = local_assigns.fetch(:modal_store, "modals")
29
+ %>
30
+ <div x-data="{
31
+ _redirectTimer: null,
32
+ redirecting: false,
33
+ go(url) {
34
+ if (!url) return false;
35
+ this.redirecting = true;
36
+ try { window.location.href = url; } catch (_) {}
37
+ return true;
38
+ },
39
+ init() {
40
+ var self = this;
41
+ self._redirectTimer = setTimeout(function () {
42
+ self._redirectTimer = null;
43
+ self.go(<%= href_key %>);
44
+ }, <%= duration_seconds * 1000 %>);
45
+ },
46
+ destroy() {
47
+ if (this._redirectTimer) { clearTimeout(this._redirectTimer); this._redirectTimer = null; }
48
+ }
49
+ }">
50
+ <a :href="(<%= href_key %>) || '#'"
51
+ @click="
52
+ if (redirecting) { $event.preventDefault(); return; }
53
+ if ($event.metaKey || $event.ctrlKey || $event.shiftKey) return;
54
+ $event.preventDefault();
55
+ var u = (<%= href_key %>);
56
+ if (u) { go(u); } else { $store.<%= modal_store %>.close(); }
57
+ "
58
+ :class="redirecting ? 'pointer-events-none' : ''"
59
+ :aria-disabled="redirecting ? 'true' : 'false'"
60
+ class="btn btn-primary btn-lg w-full relative overflow-hidden no-underline">
61
+ <div class="absolute inset-0 pointer-events-none origin-left"
62
+ x-show="!redirecting"
63
+ style="background: rgba(255,255,255,0.18); animation: studio-modal-drain <%= duration_seconds %>s linear forwards;"></div>
64
+ <% if local_assigns[:label_key] %>
65
+ <span class="relative z-10" x-show="!redirecting" x-text="<%= label_key %>"></span>
66
+ <% else %>
67
+ <span class="relative z-10" x-show="!redirecting"><%= label_html.html_safe %></span>
68
+ <% end %>
69
+ <span class="relative z-10 inline-flex items-center justify-center" x-show="redirecting" x-cloak>
70
+ <span class="spinner" style="--spinner-color: currentColor" aria-hidden="true"></span>
71
+ </span>
72
+ </a>
73
+ </div>
@@ -0,0 +1,52 @@
1
+ <%#
2
+ Generic on-chain transaction success card — the default-fallback success
3
+ state for any Solana transaction that isn't a domain-specific celebration
4
+ (faucet mint, on-chain rename, contest creation). Ported from turf-monster.
5
+
6
+ Same visual spine as a rich entry card (card_header at :lg with the primary
7
+ check pill + branded Solana tx link) MINUS the enrichments: no seeds bar, no
8
+ level-up, no auto-redirect drain. The CTA is a plain optional link; when no
9
+ cta label/href is supplied the card degrades to just the Dismiss link.
10
+
11
+ Single root: content lives in one <div> so Alpine's <template x-if> clones it
12
+ whole.
13
+
14
+ Locals:
15
+ tx_signature_key — Alpine expression returning the tx hash (link mounts when truthy)
16
+ title_key — Alpine expression returning the headline
17
+ subtitle_key — Alpine expression returning the sub-headline
18
+ cta_label_key — Alpine expression returning the CTA label (button mounts when truthy)
19
+ cta_href_key — Alpine expression returning the CTA href
20
+ cluster_param — explorer cluster query, forwarded to solana_tx_link. Default "".
21
+ modal_store — Alpine store name backing close(). Default "modals".
22
+ %>
23
+ <%
24
+ tx_signature_key = local_assigns.fetch(:tx_signature_key)
25
+ title_key = local_assigns.fetch(:title_key)
26
+ subtitle_key = local_assigns.fetch(:subtitle_key)
27
+ cta_label_key = local_assigns.fetch(:cta_label_key)
28
+ cta_href_key = local_assigns.fetch(:cta_href_key)
29
+ cluster_param = local_assigns.fetch(:cluster_param, "")
30
+ modal_store = local_assigns.fetch(:modal_store, "modals")
31
+ %>
32
+ <div>
33
+ <%= render "studio/modals/blocks/card_header",
34
+ size: :lg,
35
+ icon_color: 'primary',
36
+ title_key: title_key,
37
+ subtitle_key: subtitle_key %>
38
+
39
+ <%= render "studio/modals/blocks/solana_tx_link",
40
+ tx_signature_key: tx_signature_key, cluster_param: cluster_param %>
41
+
42
+ <template x-if="(<%= cta_label_key %>)">
43
+ <a :href="(<%= cta_href_key %>) || '#'"
44
+ class="btn btn-primary btn-lg w-full no-underline"
45
+ x-text="<%= cta_label_key %>"></a>
46
+ </template>
47
+
48
+ <button @click="$store.<%= modal_store %>.close()"
49
+ class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
50
+ Dismiss
51
+ </button>
52
+ </div>
@@ -0,0 +1,23 @@
1
+ <%#
2
+ Segmented progress pill — N rounded bars in a row, the first `current`
3
+ filled in primary, the rest muted outlines. Drops into any wizard-style modal
4
+ step to show position in a multi-step flow. Ported from turf-monster.
5
+
6
+ Locals:
7
+ current — 1-indexed step number to fill up to (current: 2, total: 3 fills
8
+ 2/3). Required.
9
+ total — total segment count. Default 3.
10
+ %>
11
+ <%
12
+ current = local_assigns.fetch(:current)
13
+ total = local_assigns.fetch(:total, 3)
14
+ %>
15
+ <div class="flex items-center justify-center gap-1.5 mx-auto max-w-[200px] mb-4">
16
+ <% total.times do |i| %>
17
+ <% if (i + 1) <= current %>
18
+ <div class="h-1.5 flex-1 rounded-full bg-primary"></div>
19
+ <% else %>
20
+ <div class="h-1.5 flex-1 rounded-full bg-inset border border-subtle"></div>
21
+ <% end %>
22
+ <% end %>
23
+ </div>
@@ -0,0 +1,35 @@
1
+ <%#
2
+ Shared modal shell — opt-in convenience wrapper for a title row + close ×.
3
+ Ported from turf-monster. Wrap content with:
4
+ render layout: "studio/modals/blocks/shell", locals: { title: "…" } do … end
5
+
6
+ Locals (all optional):
7
+ title — top-row title string.
8
+ dismissible — default true; suppress the close button when false. (The host
9
+ gates escape + click-outside on props.dismissible at open()
10
+ time, not this flag.)
11
+ modal_store — Alpine store name backing close(). Default "modals".
12
+ %>
13
+ <%
14
+ shell_title = local_assigns[:title]
15
+ shell_dismissible = local_assigns.fetch(:dismissible, true)
16
+ modal_store = local_assigns.fetch(:modal_store, "modals")
17
+ %>
18
+ <div>
19
+ <% if shell_title || shell_dismissible %>
20
+ <div class="flex items-start justify-between gap-3 -mt-2 -mr-2 mb-3">
21
+ <% if shell_title %>
22
+ <h3 class="text-heading font-bold text-lg leading-tight pt-1"><%= shell_title %></h3>
23
+ <% else %>
24
+ <span></span>
25
+ <% end %>
26
+ <% if shell_dismissible %>
27
+ <button type="button" @click="$store.<%= modal_store %>.close()"
28
+ class="text-secondary hover:text-heading text-xl leading-none flex-shrink-0"
29
+ aria-label="Close">&times;</button>
30
+ <% end %>
31
+ </div>
32
+ <% end %>
33
+
34
+ <%= yield %>
35
+ </div>
@@ -0,0 +1,42 @@
1
+ <%#
2
+ Branded Solana explorer tx link — gradient Solana mark + truncated signature
3
+ + external-link glyph in a subtle bordered pill. Shared by the on-chain
4
+ success cards (studio/modals/blocks/_onchain_success and a consumer's entry
5
+ card). Mounts only when the signature expression is truthy. Ported from
6
+ turf-monster; the cluster query is a plain local so the engine partial carries
7
+ no Solana::Config dependency.
8
+
9
+ Only one success card is ever in the DOM at a time, so the fixed gradient id
10
+ is collision-safe.
11
+
12
+ Locals:
13
+ tx_signature_key — Alpine expression returning the Solana tx hash. Required.
14
+ cluster_param — appended to the explorer URL (e.g. "?cluster=devnet").
15
+ Default "" (mainnet).
16
+ %>
17
+ <%
18
+ tx_signature_key = local_assigns.fetch(:tx_signature_key)
19
+ cluster_param = local_assigns.fetch(:cluster_param, "")
20
+ %>
21
+ <template x-if="(<%= tx_signature_key %>)">
22
+ <div class="text-center mb-3 -mt-1">
23
+ <a :href="'https://explorer.solana.com/tx/' + (<%= tx_signature_key %>) + '<%= cluster_param %>'"
24
+ target="_blank" rel="noopener"
25
+ class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-subtle hover:border-primary/50 transition group no-underline"
26
+ style="background: rgb(var(--color-primary-500-rgb) / 0.06);">
27
+ <svg width="14" height="11" viewBox="0 0 397 311" fill="none">
28
+ <defs><linearGradient id="tx-solana-grad" x1="361" y1="-9" x2="153" y2="389" gradientUnits="userSpaceOnUse">
29
+ <stop offset="0" stop-color="#00FFA3"/><stop offset="1" stop-color="#DC1FFF"/>
30
+ </linearGradient></defs>
31
+ <path d="M65 234c2-2 6-4 9-4h317c6 0 9 7 5 11l-63 63c-2 2-6 4-9 4H6c-6 0-9-7-5-11l64-63z" fill="url(#tx-solana-grad)"/>
32
+ <path d="M65 4c2-2 6-4 9-4h317c6 0 9 7 5 11l-63 63c-2 2-6 4-9 4H6c-6 0-9-7-5-11L65 4z" fill="url(#tx-solana-grad)"/>
33
+ <path d="M333 119c-2-2-6-4-9-4H7c-6 0-9 7-5 11l63 63c2 2 6 4 9 4h317c6 0 9-7 5-11l-63-63z" fill="url(#tx-solana-grad)"/>
34
+ </svg>
35
+ <span class="font-mono text-[11px] text-secondary group-hover:text-primary transition"
36
+ x-text="(<%= tx_signature_key %>) ? ((<%= tx_signature_key %>).substring(0, 16) + '…') : ''"></span>
37
+ <svg class="w-3 h-3 text-muted group-hover:text-primary transition" fill="none" stroke="currentColor" viewBox="0 0 24 24">
38
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
39
+ </svg>
40
+ </a>
41
+ </div>
42
+ </template>
@@ -0,0 +1,34 @@
1
+ <%#
2
+ Legal-age attestation checkbox — underwriting compliance for skill-based
3
+ contests. Ported from turf-monster into the engine. Binds x-model into the
4
+ caller's Alpine scope; the caller gates its CTAs on that property. The SERVER
5
+ is the real boundary — every signup endpoint rejects new account creation
6
+ without the attestation.
7
+
8
+ Unlike the turf-monster original, this engine partial does NOT self-gate on an
9
+ AppFlags flag — a consumer that wants the attestation renders it; one that
10
+ doesn't simply omits the render. Callers that omit it initialize their
11
+ ageAttested Alpine state to true so every CTA gate passes.
12
+
13
+ Locals (all optional):
14
+ x_model — Alpine model target (default "ageAttested")
15
+ error_model — Alpine error-flag target (default "ageError")
16
+ %>
17
+ <%
18
+ x_model = local_assigns.fetch(:x_model, "ageAttested")
19
+ error_model = local_assigns.fetch(:error_model, "ageError")
20
+ %>
21
+ <div>
22
+ <label class="flex items-start gap-2.5 text-xs text-secondary leading-snug cursor-pointer select-none">
23
+ <input type="checkbox" x-model="<%= x_model %>" @change="<%= error_model %> = false"
24
+ data-age-attestation
25
+ class="mt-0.5 w-4 h-4 shrink-0 rounded accent-primary cursor-pointer">
26
+ <span>
27
+ I confirm I am of legal age to participate in skill-based contests in my
28
+ state (18+ in most states; 19+ in AL/NE; 21+ in IA/MA/VA).
29
+ </span>
30
+ </label>
31
+ <p x-show="<%= error_model %>" x-cloak class="text-xs text-red-400 mt-1.5 ml-6">
32
+ Please confirm you are of legal age to continue.
33
+ </p>
34
+ </div>