studio-engine 0.17.0 → 0.18.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -0
  3. data/app/assets/tailwind/studio_engine/engine-motion.css +620 -5
  4. data/app/controllers/style_controller.rb +25 -0
  5. data/app/views/studio/modals/_crop_photo.html.erb +5 -1
  6. data/app/views/studio/modals/_host.html.erb +5 -0
  7. data/app/views/studio/modals/_image_upload.html.erb +16 -5
  8. data/app/views/studio/modals/_saving.html.erb +6 -2
  9. data/app/views/studio/modals/auth/_resend_footer.html.erb +25 -0
  10. data/app/views/studio/modals/blocks/_card_header.html.erb +83 -0
  11. data/app/views/studio/modals/blocks/_cta_redirect.html.erb +73 -0
  12. data/app/views/studio/modals/blocks/_onchain_success.html.erb +52 -0
  13. data/app/views/studio/modals/blocks/_progress_pill.html.erb +23 -0
  14. data/app/views/studio/modals/blocks/_shell.html.erb +35 -0
  15. data/app/views/studio/modals/blocks/_solana_tx_link.html.erb +42 -0
  16. data/app/views/studio/modals/shared/_age_attestation.html.erb +34 -0
  17. data/app/views/studio/modals/shared/_email_field.html.erb +70 -0
  18. data/app/views/studio/modals/templates/_action.html.erb +25 -0
  19. data/app/views/studio/modals/templates/_form.html.erb +25 -0
  20. data/app/views/studio/modals/templates/_status.html.erb +45 -0
  21. data/app/views/studio/modals/templates/_success.html.erb +23 -0
  22. data/app/views/studio/modals/templates/_wizard.html.erb +75 -0
  23. data/app/views/style/_modal_specimen.html.erb +120 -0
  24. data/app/views/style/_modals.html.erb +690 -0
  25. data/app/views/style/_specimen.html.erb +64 -0
  26. data/app/views/style/_tasks.html.erb +112 -0
  27. data/app/views/style/_theme.html.erb +222 -0
  28. data/app/views/style/_tricks.html.erb +323 -0
  29. data/app/views/style/index.html.erb +60 -0
  30. data/app/views/style/modals/_auth.html.erb +252 -0
  31. data/app/views/style/modals/_onchain_tx.html.erb +62 -0
  32. data/app/views/style/modals/_wallet_connect.html.erb +138 -0
  33. data/app/views/style/modals/_wallet_deposit.html.erb +56 -0
  34. data/lib/studio/version.rb +1 -1
  35. data/lib/studio.rb +27 -1
  36. metadata +32 -6
  37. data/app/controllers/design_system_controller.rb +0 -12
  38. data/app/views/design_system/_specimen.html.erb +0 -27
  39. data/app/views/design_system/index.html.erb +0 -232
@@ -0,0 +1,323 @@
1
+ <%# Tricks section of the living style guide (admin/style).
2
+ A board of style tricks you can SEE rendered live and hand to an agent to use
3
+ out of the box. Each specimen (via the shared _specimen partial) surfaces the
4
+ rendered primitive, its class name (one-click copy), and a copyable usage
5
+ snippet. Every class here is a REAL engine class (engine.css component layer
6
+ or engine-motion.css motion/effect/leveling layer), so a consumer that
7
+ bundles both styles the specimens for real.
8
+
9
+ The Leveling group renders the REAL .level-badge tiers ported into the engine
10
+ from Turf Monster, capability-gated by Studio.feature?(:leveling). When a host
11
+ has leveling OFF (McRitchie Studio's default), its specimens render "disabled
12
+ but present" via the _specimen `disabled:` local - visible and previewable,
13
+ but greyed and flagged, never hidden.
14
+
15
+ The color tokens/swatches now live in the Theme section (the landing section
16
+ owns "the colors"); this section is the trick board. %>
17
+ <%
18
+ button_variants = {
19
+ "primary" => "Primary",
20
+ "secondary" => "Secondary",
21
+ "success" => "Success",
22
+ "danger" => "Danger",
23
+ "warning" => "Warning",
24
+ "neutral" => "Neutral",
25
+ "outline" => "Outline",
26
+ "google" => "Continue with Google"
27
+ }
28
+
29
+ leveling_on = Studio.feature?(:leveling)
30
+ %>
31
+ <section id="tricks" class="space-y-12" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
32
+ <div class="space-y-1">
33
+ <h2 class="text-2xl font-bold text-heading">Tricks</h2>
34
+ <p class="text-muted text-sm max-w-2xl">
35
+ A board of style tricks you can see and hand to an agent to use out of the box.
36
+ Each specimen surfaces the rendered primitive, its <strong>class name (one-click
37
+ copy)</strong>, and a copyable usage snippet - the button, surface, motion, effect,
38
+ and leveling primitives. The color tokens live in <a href="#theme" class="underline">Theme</a>.
39
+ </p>
40
+ </div>
41
+
42
+ <!-- Buttons -->
43
+ <section class="space-y-5">
44
+ <div class="space-y-1">
45
+ <h3 class="text-xl font-bold text-heading">Buttons</h3>
46
+ <p class="text-muted text-sm">
47
+ <code class="font-mono text-2xs">.btn</code> base plus one role variant, and the
48
+ <code class="font-mono text-2xs">.btn-sm</code> / <code class="font-mono text-2xs">.btn-lg</code> sizes.
49
+ </p>
50
+ </div>
51
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
52
+ <% button_variants.each do |name, label| %>
53
+ <%= render layout: "style/specimen",
54
+ locals: { klass: ".btn .btn-#{name}",
55
+ usage: %(<button class="btn btn-#{name}">#{label}</button>) } do %>
56
+ <button type="button" class="btn btn-<%= name %>"><%= label %></button>
57
+ <% end %>
58
+ <% end %>
59
+
60
+ <%= render layout: "style/specimen",
61
+ locals: { klass: ".btn-sm / .btn (default) / .btn-lg",
62
+ usage: %(<button class="btn btn-primary btn-sm">Small</button>\n<button class="btn btn-primary">Default</button>\n<button class="btn btn-primary btn-lg">Large</button>) } do %>
63
+ <div class="flex flex-wrap items-center justify-center gap-3">
64
+ <button type="button" class="btn btn-primary btn-sm">Small</button>
65
+ <button type="button" class="btn btn-primary">Default</button>
66
+ <button type="button" class="btn btn-primary btn-lg">Large</button>
67
+ </div>
68
+ <% end %>
69
+ </div>
70
+ </section>
71
+
72
+ <!-- Surfaces, text and form -->
73
+ <section class="space-y-5">
74
+ <div class="space-y-1">
75
+ <h3 class="text-xl font-bold text-heading">Surfaces, text &amp; form</h3>
76
+ <p class="text-muted text-sm">Cards, badges, inputs, the empty state, and the uppercase meta label.</p>
77
+ </div>
78
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
79
+ <%= render layout: "style/specimen",
80
+ locals: { klass: ".card", usage: %(<div class="card p-5">...</div>) } do %>
81
+ <div class="card p-5 text-sm text-body w-full max-w-xs text-center">Surface card</div>
82
+ <% end %>
83
+
84
+ <%= render layout: "style/specimen",
85
+ locals: { klass: ".card-hover", usage: %(<div class="card-hover p-5">...</div>) } do %>
86
+ <div class="card-hover p-5 text-sm text-body w-full max-w-xs text-center">Hover me</div>
87
+ <% end %>
88
+
89
+ <%= render layout: "style/specimen",
90
+ locals: { klass: ".badge", usage: %(<span class="badge">Neutral</span>) } do %>
91
+ <div class="flex flex-wrap items-center justify-center gap-2">
92
+ <span class="badge text-body">Neutral</span>
93
+ <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">Success</span>
94
+ <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">Warning</span>
95
+ <span class="badge" style="color: var(--color-danger); border-color: var(--color-danger)">Danger</span>
96
+ </div>
97
+ <% end %>
98
+
99
+ <%= render layout: "style/specimen",
100
+ locals: { klass: ".input-field", usage: %(<input class="input-field" placeholder="you@example.com">) } do %>
101
+ <input type="text" class="input-field" placeholder="you@example.com" aria-label="Sample input">
102
+ <% end %>
103
+
104
+ <%= render layout: "style/specimen",
105
+ locals: { klass: ".label-upper", usage: %(<p class="label-upper">Section label</p>) } do %>
106
+ <p class="label-upper">Section label</p>
107
+ <% end %>
108
+
109
+ <%= render layout: "style/specimen",
110
+ locals: { klass: ".empty-state", usage: %(<div class="empty-state">Nothing here yet</div>) } do %>
111
+ <div class="empty-state w-full max-w-xs text-sm text-muted">Nothing here yet</div>
112
+ <% end %>
113
+ </div>
114
+ </section>
115
+
116
+ <!-- Motion primitives -->
117
+ <section class="space-y-5">
118
+ <div class="space-y-1">
119
+ <h3 class="text-xl font-bold text-heading">Motion primitives</h3>
120
+ <p class="text-muted text-sm">
121
+ The seven <code class="font-mono text-2xs">engine-motion.css</code> primitives. Each knob is a themeable
122
+ CSS variable - tune it inline with <code class="font-mono text-2xs">style="--knob: value"</code>.
123
+ </p>
124
+ </div>
125
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
126
+ <%= render layout: "style/specimen",
127
+ locals: { klass: ".studio-border-glow (rounded-xl wrapper)",
128
+ usage: %(<div class="studio-border-glow rounded-xl">...</div>) } do %>
129
+ <div class="studio-border-glow rounded-xl p-5 bg-surface text-center text-sm text-heading">Border glow</div>
130
+ <% end %>
131
+
132
+ <%= render layout: "style/specimen",
133
+ locals: { klass: ".spinner (knob: --spinner-size)",
134
+ usage: %(<span class="spinner"></span>\n<span class="spinner" style="--spinner-size: 2rem"></span>) } do %>
135
+ <span class="spinner"></span>
136
+ <span class="spinner" style="--spinner-size: 1.6rem"></span>
137
+ <span class="spinner" style="--spinner-size: 2.4rem; --spinner-thickness: 3px"></span>
138
+ <button type="button" class="btn btn-primary">
139
+ <span class="spinner" style="--spinner-color: currentColor; --spinner-track: rgba(255,255,255,.35)"></span>
140
+ <span class="ml-2">Saving</span>
141
+ </button>
142
+ <% end %>
143
+
144
+ <%= render layout: "style/specimen",
145
+ locals: { klass: ".loading-dots (3 span children)",
146
+ usage: %(<span class="loading-dots"><span></span><span></span><span></span></span>) } do %>
147
+ <span class="loading-dots"><span></span><span></span><span></span></span>
148
+ <span class="loading-dots" style="--loading-dots-size: 12px; --loading-dots-color: var(--color-cta)"><span></span><span></span><span></span></span>
149
+ <% end %>
150
+
151
+ <%= render layout: "style/specimen",
152
+ locals: { klass: ".sheen .sheen--loop",
153
+ usage: %(<span class="sheen sheen--loop">...</span>) } do %>
154
+ <span class="sheen sheen--loop badge text-body">Level 5 unlocked</span>
155
+ <span class="sheen sheen--loop card px-4 py-2 text-sm text-body" style="--sheen-duration: 3s">Sheen sweep</span>
156
+ <% end %>
157
+
158
+ <%= render layout: "style/specimen",
159
+ locals: { klass: ".ping .ping--dot (knob: --ping-color)",
160
+ usage: %(<span class="ping ping--dot"></span>) } do %>
161
+ <span class="ping ping--dot"></span>
162
+ <span class="ping ping--dot" style="--ping-color: var(--color-success)"></span>
163
+ <span class="ping ping--dot" style="--ping-color: var(--color-danger); --ping-size: 0.9rem"></span>
164
+ <% end %>
165
+
166
+ <%= render layout: "style/specimen",
167
+ locals: { klass: ".fade-edge-{t,b,l,r,x,y} (knob: --fade-edge-size)",
168
+ usage: %(<div class="fade-edge-y overflow-y-auto">...long content...</div>) } do %>
169
+ <div class="fade-edge-y overflow-y-auto h-32 w-full max-w-xs bg-surface rounded-lg p-3 text-sm text-body space-y-2">
170
+ <p>Scroll me - the top and bottom edges dissolve.</p>
171
+ <p>The mask softens content near either end of the container.</p>
172
+ <p>No JavaScript, no gradient overlay - just a mask-image.</p>
173
+ <p>It masks the element itself, so it works over any background.</p>
174
+ <p>Keep scrolling to watch the fade track the edge.</p>
175
+ </div>
176
+ <% end %>
177
+
178
+ <%= render layout: "style/specimen",
179
+ locals: { klass: ".progress-meter (knob: --progress)",
180
+ usage: %(<div class="progress-meter" style="--progress: 62%">\n <div class="progress-meter-track"><span class="progress-meter-label">62%</span></div>\n <div class="progress-meter-fill"><span class="progress-meter-label">62%</span></div>\n</div>) } do %>
181
+ <div class="w-full max-w-xs space-y-3">
182
+ <div class="progress-meter" style="--progress: 25%">
183
+ <div class="progress-meter-track"><span class="progress-meter-label">25%</span></div>
184
+ <div class="progress-meter-fill"><span class="progress-meter-label">25%</span></div>
185
+ </div>
186
+ <div class="progress-meter" style="--progress: 62%">
187
+ <div class="progress-meter-track"><span class="progress-meter-label">62%</span></div>
188
+ <div class="progress-meter-fill"><span class="progress-meter-label">62%</span></div>
189
+ </div>
190
+ <div class="progress-meter" style="--progress: 88%; --progress-fill: var(--color-success)">
191
+ <div class="progress-meter-track"><span class="progress-meter-label">88% complete</span></div>
192
+ <div class="progress-meter-fill"><span class="progress-meter-label">88% complete</span></div>
193
+ </div>
194
+ <div class="progress-meter progress-meter--indeterminate">
195
+ <div class="progress-meter-track"></div>
196
+ <div class="progress-meter-fill"></div>
197
+ </div>
198
+ </div>
199
+ <% end %>
200
+ </div>
201
+ </section>
202
+
203
+ <!-- Effects -->
204
+ <section class="space-y-5">
205
+ <div class="space-y-1">
206
+ <h3 class="text-xl font-bold text-heading">Effects</h3>
207
+ <p class="text-muted text-sm">
208
+ The <code class="font-mono text-2xs">engine-motion.css</code> visual-effect primitives beyond motion -
209
+ gradient text, a soft glow halo, a single-color rotating <strong>selection glow</strong>
210
+ (ported from Turf Monster's selected-team-card ring), frosted glass, and a conic wash. Each is
211
+ themed through the role tokens and tunable via CSS-var knobs.
212
+ </p>
213
+ </div>
214
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
215
+ <%= render layout: "style/specimen",
216
+ locals: { klass: ".text-gradient (knobs: --text-gradient-from / -to / -angle)",
217
+ usage: %(<h2 class="text-gradient text-3xl font-extrabold">Level up</h2>) } do %>
218
+ <div class="text-center space-y-2">
219
+ <p class="text-gradient text-3xl font-extrabold">Level up</p>
220
+ <p class="text-gradient text-xl font-bold" style="--text-gradient-from: var(--color-success); --text-gradient-to: var(--color-cta)">Season complete</p>
221
+ </div>
222
+ <% end %>
223
+
224
+ <%= render layout: "style/specimen",
225
+ locals: { klass: ".studio-glow / .studio-glow--pulse (knob: --studio-glow-color)",
226
+ usage: %(<button class="btn btn-primary studio-glow">Launch</button>) } do %>
227
+ <div class="flex flex-wrap items-center justify-center gap-6">
228
+ <button type="button" class="btn btn-primary studio-glow">Launch</button>
229
+ <span class="studio-glow studio-glow--pulse inline-flex h-12 w-12 items-center justify-center rounded-full text-white font-extrabold"
230
+ style="background: var(--color-cta); --studio-glow-color: var(--color-success)">10</span>
231
+ </div>
232
+ <% end %>
233
+
234
+ <%= render layout: "style/specimen",
235
+ locals: { klass: ".studio-team-glow (knob: --studio-team-glow-color)",
236
+ usage: %(<div class="studio-team-glow rounded-xl bg-surface p-5">Selected</div>\n<div class="studio-team-glow rounded-xl bg-surface p-5" style="--studio-team-glow-color: #E11D48">Ravens</div>) } do %>
237
+ <div class="flex flex-wrap items-center justify-center gap-6">
238
+ <div class="studio-team-glow rounded-xl bg-surface border border-subtle px-5 py-4 text-sm font-semibold text-heading text-center">Selected</div>
239
+ <div class="studio-team-glow rounded-xl bg-surface border border-subtle px-5 py-4 text-sm font-semibold text-heading text-center" style="--studio-team-glow-color: #E11D48">Ravens</div>
240
+ </div>
241
+ <% end %>
242
+
243
+ <%= render layout: "style/specimen",
244
+ locals: { klass: ".surface-glass (knobs: --surface-glass-opacity / -blur)",
245
+ usage: %(<div class="surface-glass rounded-xl p-4">...</div>) } do %>
246
+ <div class="conic-surface w-full max-w-xs rounded-2xl p-4" style="--conic-surface-duration: 22s">
247
+ <div class="surface-glass rounded-xl p-4 text-center text-sm text-heading">Frosted panel over a live backdrop</div>
248
+ </div>
249
+ <% end %>
250
+
251
+ <%= render layout: "style/specimen",
252
+ locals: { klass: ".conic-surface (knob: --conic-surface-duration)",
253
+ usage: %(<div class="conic-surface rounded-2xl p-8">...</div>) } do %>
254
+ <div class="conic-surface w-full max-w-xs rounded-2xl p-8 text-center">
255
+ <p class="text-heading font-bold">Ambient wash</p>
256
+ <p class="text-body text-xs mt-1">Content stays crisp above the rotating gradient.</p>
257
+ </div>
258
+ <% end %>
259
+ </div>
260
+ </section>
261
+
262
+ <!-- Leveling (capability-gated: Studio.feature?(:leveling)) -->
263
+ <section class="space-y-5">
264
+ <div class="space-y-1">
265
+ <div class="flex flex-wrap items-center gap-3">
266
+ <h3 class="text-xl font-bold text-heading">Leveling</h3>
267
+ <% if leveling_on %>
268
+ <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">enabled on this app</span>
269
+ <% else %>
270
+ <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">disabled on this app</span>
271
+ <% end %>
272
+ </div>
273
+ <p class="text-muted text-sm max-w-2xl">
274
+ The real <code class="font-mono text-2xs">.level-badge</code> tiers, gated by
275
+ <code class="font-mono text-2xs">Studio.feature?(:leveling)</code>. Ported into the engine
276
+ (<code class="font-mono text-2xs">engine-motion.css</code>) from Turf Monster's leveling - the
277
+ outline starter through the holographic Level 10 - so any consumer with leveling on ships the
278
+ polished ladder. The tiers theme off <code class="font-mono text-2xs">--color-primary-*</code>, so
279
+ they restyle per app. Leveling is <strong><%= leveling_on ? "on" : "off" %></strong> here, so these
280
+ render <%= leveling_on ? "live" : "disabled-but-present - visible and previewable, but greyed and flagged" %>.
281
+ </p>
282
+ </div>
283
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
284
+ <%= render layout: "style/specimen",
285
+ locals: { klass: ".level-badge .level-badge-1 … .level-badge-10",
286
+ disabled: !leveling_on,
287
+ usage: %(<span class="level-badge level-badge-10">Level 10</span>) } do %>
288
+ <div class="flex flex-wrap items-center justify-center gap-2">
289
+ <% (1..10).each do |lvl| %>
290
+ <span class="level-badge level-badge-<%= lvl %>">Level <%= lvl %></span>
291
+ <% end %>
292
+ </div>
293
+ <% end %>
294
+
295
+ <%= render layout: "style/specimen",
296
+ locals: { klass: ".level-badge (in a chip context)",
297
+ disabled: !leveling_on,
298
+ usage: %(<span class="text-heading font-semibold text-sm">Earn a free entry</span>\n<span class="level-badge level-badge-8">Level 8</span>) } do %>
299
+ <div class="w-full max-w-xs space-y-2">
300
+ <div class="card flex items-center justify-between gap-3 px-3 py-2">
301
+ <span class="text-heading font-semibold text-sm">Earn a free entry</span>
302
+ <span class="level-badge level-badge-8">Level 8</span>
303
+ </div>
304
+ <div class="card flex items-center justify-between gap-3 px-3 py-2">
305
+ <span class="text-heading font-semibold text-sm">Legendary status</span>
306
+ <span class="level-badge level-badge-10">Level 10</span>
307
+ </div>
308
+ </div>
309
+ <% end %>
310
+
311
+ <%= render layout: "style/specimen",
312
+ locals: { klass: ".level-up-pop (fires the pop + glow burst)",
313
+ disabled: !leveling_on,
314
+ usage: %(<span class="level-badge level-badge-10 level-up-pop">Level 10</span>) } do %>
315
+ <div x-data="{ pop: true }" class="flex flex-col items-center gap-3 py-2">
316
+ <span class="level-badge level-badge-10" :class="pop && 'level-up-pop'">Level 10</span>
317
+ <button type="button" class="btn btn-neutral btn-sm"
318
+ @click="pop=false; setTimeout(() => pop=true, 30)">Replay pop</button>
319
+ </div>
320
+ <% end %>
321
+ </div>
322
+ </section>
323
+ </section>
@@ -0,0 +1,60 @@
1
+ <%# admin/style - the living style guide.
2
+ A bare content wrapper (no `layout` call): it renders inside each host's
3
+ application.html.erb, inheriting that app's navbar and theme, so every
4
+ specimen restyles per app and in dark or light automatically.
5
+
6
+ Four sections, reached by the sticky section nav below and rendered as
7
+ sibling partials: Theme is the landing section (the color foundation - it
8
+ owns the role tokens/swatches, the folded-in /admin/theme editor, and the
9
+ live preview), then Modals (the shared host + engine card blocks), Tricks
10
+ (the button/surface/motion/effect/leveling primitives, framed as a board of
11
+ copy-paste-for-an-agent snippets), and Tasks (the shared board primitive).
12
+
13
+ Specimens use ONLY the engine's own classes (engine.css component layer plus
14
+ engine-motion.css motion/effect/leveling layer), so a consumer that bundles
15
+ engine.css and imports engine-motion.css styles them for real. The primitive
16
+ CSS is NOT inlined here - the consumer's Tailwind build provides it. Page
17
+ chrome uses the engine's own .card / .label-upper / theme tokens. %>
18
+ <%
19
+ sections = [
20
+ ["theme", "Theme"],
21
+ ["modals", "Modals"],
22
+ ["tricks", "Tricks"],
23
+ ["tasks", "Tasks"]
24
+ ]
25
+ %>
26
+ <div class="max-w-6xl mx-auto px-4 pb-16">
27
+ <header class="space-y-2 pt-8 pb-6">
28
+ <p class="label-upper">studio-engine &middot; v<%= Studio::VERSION %></p>
29
+ <h1 class="text-3xl font-bold text-heading">Style</h1>
30
+ <p class="text-body max-w-2xl">
31
+ A living gallery of the shared UI primitives, rendered live in this app's
32
+ theme. Everything below is styled by the engine's own
33
+ <code class="font-mono text-2xs bg-inset px-1.5 py-0.5 rounded">engine.css</code>
34
+ and
35
+ <code class="font-mono text-2xs bg-inset px-1.5 py-0.5 rounded">engine-motion.css</code>
36
+ layers - toggle this app between light and dark and every specimen follows.
37
+ </p>
38
+ </header>
39
+
40
+ <%# Section nav — sticky under the host navbar (--nav-h, 0 fallback). A pill per
41
+ section; the row scrolls horizontally on narrow screens, its edges softened
42
+ by the .fade-edge-x primitive. Order leads with Theme (the color foundation). %>
43
+ <nav class="sticky z-20 -mx-4 px-4 py-3 bg-surface border-y border-subtle"
44
+ style="top: var(--nav-h, 0px)" aria-label="Style guide sections">
45
+ <div class="fade-edge-x overflow-x-auto">
46
+ <div class="flex items-center gap-2 min-w-max">
47
+ <% sections.each do |anchor, label| %>
48
+ <a href="#<%= anchor %>" class="btn btn-neutral btn-sm"><%= label %></a>
49
+ <% end %>
50
+ </div>
51
+ </div>
52
+ </nav>
53
+
54
+ <div class="space-y-16 pt-10">
55
+ <%= render "style/theme" %>
56
+ <%= render "style/modals" %>
57
+ <%= render "style/tricks" %>
58
+ <%= render "style/tasks" %>
59
+ </div>
60
+ </div>
@@ -0,0 +1,252 @@
1
+ <%#
2
+ Auth wizard — the design-system port of turf-monster's modals/_auth. The
3
+ canonical sign-in step machine, faithful to the real modal's UX, wired to the
4
+ living style guide's page-scoped host ($store.dsModals) so it opens live on any
5
+ app (including McRitchie Studio) without an auth backend.
6
+
7
+ Steps (opened at any step via open('auth', { step, ... })):
8
+ credentials — Google + Solana + email magic-link request
9
+ magic-link-sent — "check your inbox" after the email request
10
+ magic-link-resent — success confirmation after a resend (directional slide)
11
+ redirect — countdown auto-redirect (drain CTA demo)
12
+
13
+ What differs from the production modal: the tokens / USDC funding sub-flow is
14
+ dropped (that is contest-entry-specific), and every credential CTA runs behind
15
+ a page stub — window.postMagicLink resolves { success: true } so the magic-link
16
+ step machine advances for real; Google briefly shows its waiting state then
17
+ resets; Solana swaps to the wallet-connect picker (a real ported specimen).
18
+ No real auth happens — the demo shows the real UI.
19
+
20
+ CRITICAL: rendered inside <template x-if="id==='auth'"> — Alpine requires a
21
+ SINGLE root, so everything lives inside the outer <div>. The x-data is a
22
+ double-quoted attribute: keep it free of double-quotes and backticks.
23
+ %>
24
+ <div x-data="{
25
+ email: '',
26
+ ageAttested: false,
27
+ ageError: false,
28
+ googleTimer: null,
29
+ attested() {
30
+ if (!this.termsOn()) return true;
31
+ if (this.ageAttested) { this.ageError = false; return true; }
32
+ this.ageError = true;
33
+ return false;
34
+ },
35
+ get props() {
36
+ var c = Alpine.store('dsModals').current();
37
+ return (c && c.props) || {};
38
+ },
39
+ // Method configuration — which credential methods render. props.methods
40
+ // overrides per-key; unset keys fall back to the app's Studio.auth_method?
41
+ // (wallet also needs the web3 capability). props.terms gates the age
42
+ // attestation. This standardizes the engine auth modal as method-configurable.
43
+ _methodDefaults: { magicLink: <%= Studio.auth_method?(:magic_link) %>, google: <%= Studio.auth_method?(:google) %>, wallet: <%= Studio.auth_method?(:wallet) && Studio.feature?(:web3) %> },
44
+ _termsDefault: true,
45
+ methodOn(m) {
46
+ var v = this.props.methods;
47
+ return (v && typeof v[m] === 'boolean') ? v[m] : !!this._methodDefaults[m];
48
+ },
49
+ termsOn() {
50
+ var t = this.props.terms;
51
+ return (typeof t === 'boolean') ? t : this._termsDefault;
52
+ },
53
+ get isCredentialsStep() {
54
+ var s = this.props.step;
55
+ if (!s) return true;
56
+ if (s === 'redirect' || s === 'magic-link-sent' || s === 'magic-link-resent') return false;
57
+ return true;
58
+ },
59
+ loginGoogle() {
60
+ if (!this.attested()) return;
61
+ var p = this.props;
62
+ if (!p || p.submitting) return;
63
+ p.googleError = '';
64
+ p.submitting = 'google';
65
+ var self = this;
66
+ this.googleTimer = setTimeout(function () {
67
+ var pp = self.props;
68
+ if (pp) pp.submitting = null;
69
+ }, 1200);
70
+ },
71
+ openWalletHub() {
72
+ if (!this.attested()) return;
73
+ Alpine.store('dsModals').swap('wallet-connect', { backTo: 'auth', ageAttested: true });
74
+ },
75
+ async submitMagicLink() {
76
+ if (!this.attested()) return;
77
+ var p = this.props;
78
+ if (!p || p.submitting) return;
79
+ p.formError = '';
80
+ if (!this.email) { p.formError = 'Enter your email.'; return; }
81
+ p.submitting = 'magic-link';
82
+ try {
83
+ var data = await window.postMagicLink(this.email, { ageAttested: true });
84
+ if (!data || !data.success) { p.formError = (data && data.error) || 'Could not send the link. Try again.'; p.submitting = null; return; }
85
+ Alpine.store('dsModals').advance({ step: 'magic-link-sent', sentEmail: this.email, submitting: null, formError: '' });
86
+ } catch (e) {
87
+ var p2 = this.props;
88
+ if (p2) { p2.formError = 'Network error. Try again.'; p2.submitting = null; }
89
+ }
90
+ },
91
+ _resendTimer: null,
92
+ startResendCooldown(seconds) {
93
+ var self = this;
94
+ var p = this.props;
95
+ if (!p) return;
96
+ p.resendCooldown = seconds || 60;
97
+ if (this._resendTimer) clearInterval(this._resendTimer);
98
+ this._resendTimer = setInterval(function () {
99
+ var pp = self.props;
100
+ if (!pp) { clearInterval(self._resendTimer); self._resendTimer = null; return; }
101
+ pp.resendCooldown = (pp.resendCooldown || 0) - 1;
102
+ if (pp.resendCooldown <= 0) {
103
+ pp.resendCooldown = 0;
104
+ clearInterval(self._resendTimer);
105
+ self._resendTimer = null;
106
+ }
107
+ }, 1000);
108
+ },
109
+ async resendMagicLink() {
110
+ var p = this.props;
111
+ if (!p || p.submitting || (p.resendCooldown || 0) > 0) return;
112
+ p.resendError = '';
113
+ p.submitting = 'magic-link';
114
+ try {
115
+ var data = await window.postMagicLink(p.sentEmail, { ageAttested: true });
116
+ if (data && data.success) {
117
+ Alpine.store('dsModals').advance({ step: 'magic-link-resent' });
118
+ this.startResendCooldown(60);
119
+ } else {
120
+ p.resendError = (data && data.error) || 'Could not resend — wait a moment and try again.';
121
+ }
122
+ } catch (e) {
123
+ p.resendError = 'Could not resend — check your connection and try again.';
124
+ }
125
+ p.submitting = null;
126
+ },
127
+ destroy() {
128
+ if (this._resendTimer) { clearInterval(this._resendTimer); this._resendTimer = null; }
129
+ if (this.googleTimer) { clearTimeout(this.googleTimer); this.googleTimer = null; }
130
+ }
131
+ }"
132
+ class="relative">
133
+
134
+ <%# === CREDENTIALS STEP ================================================== %>
135
+ <div x-show="isCredentialsStep" x-cloak>
136
+ <div class="relative mb-3 -mt-2">
137
+ <h3 class="text-heading font-bold text-lg leading-tight text-center pt-1">Sign in</h3>
138
+ <button @click="$store.dsModals.close()"
139
+ class="absolute top-0 right-0 -mr-2 text-secondary hover:text-heading text-xl leading-none"
140
+ aria-label="Close">&times;</button>
141
+ </div>
142
+ <%= render "studio/modals/blocks/progress_pill", current: 1, total: 3 %>
143
+ <p class="text-xs text-secondary mb-4 text-center"
144
+ x-text="'Your ' + ((props && props.picksRequired) || 6) + ' picks are saved — sign in to submit your lineup.'"></p>
145
+
146
+ <div class="mb-4" x-show="termsOn()">
147
+ <%= render "studio/modals/shared/age_attestation" %>
148
+ </div>
149
+
150
+ <%# The credential CTAs gate on props.submitting. It MUST be coerced with
151
+ !! — on a fresh open the modal is opened with { step, picksRequired }
152
+ and no submitting key, and x-bind:disabled on a reactive-undefined value
153
+ renders as disabled=true (verified live), which would inert the whole
154
+ form. !!props.submitting yields a real boolean so the form is enabled
155
+ until a CTA actually sets submitting. Keep the !! on every gate below. %>
156
+ <%# 1. Google — opens a popup in production; here it shows the waiting state.
157
+ Gated on methodOn('google') so the specimen's toggles configure it. %>
158
+ <button @click="loginGoogle()"
159
+ x-show="methodOn('google')"
160
+ :disabled="!!props.submitting"
161
+ class="btn btn-neutral btn-lg w-full gap-3 mb-3 disabled:cursor-wait">
162
+ <span x-show="props.submitting !== 'google'" class="inline-flex"><%= render "components/google_logo" %></span>
163
+ <span x-show="props.submitting === 'google'" class="spinner" aria-hidden="true"></span>
164
+ <span x-text="props.submitting === 'google' ? 'Waiting for Google…' : 'Google'"></span>
165
+ </button>
166
+ <template x-if="props.googleError">
167
+ <p class="text-red-400 text-xs -mt-2 mb-3" x-text="props.googleError"></p>
168
+ </template>
169
+
170
+ <%# 2. Solana — progresses to the Connect Wallet picker modal. Gated on
171
+ methodOn('wallet') (default OFF where web3 is off, e.g. McRitchie Studio). %>
172
+ <button @click="openWalletHub()" x-show="methodOn('wallet')" :disabled="!!props.submitting"
173
+ class="btn btn-neutral btn-lg w-full gap-3 mb-3 disabled:cursor-wait">
174
+ <svg width="18" height="14" viewBox="0 0 397 311" fill="none" x-show="props.submitting !== 'wallet'" aria-hidden="true">
175
+ <defs><linearGradient id="auth-solana-grad" x1="361" y1="-9" x2="153" y2="389" gradientUnits="userSpaceOnUse">
176
+ <stop offset="0" stop-color="#00FFA3"/><stop offset="1" stop-color="#DC1FFF"/>
177
+ </linearGradient></defs>
178
+ <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(#auth-solana-grad)"/>
179
+ <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(#auth-solana-grad)"/>
180
+ <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(#auth-solana-grad)"/>
181
+ </svg>
182
+ <span x-show="props.submitting === 'wallet'" class="spinner" aria-hidden="true"></span>
183
+ <span x-text="props.submitting === 'wallet' ? 'Connecting…' : 'Solana'"></span>
184
+ </button>
185
+
186
+ <%# "or" divider — only when magic-link AND a social method are both on. %>
187
+ <div class="relative my-4" x-show="methodOn('magicLink') && (methodOn('google') || methodOn('wallet'))">
188
+ <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-strong"></div></div>
189
+ <div class="relative flex justify-center text-sm"><span class="bg-surface px-3 text-secondary">or</span></div>
190
+ </div>
191
+
192
+ <%# 3. Email magic link — no password. postMagicLink stub advances the step.
193
+ Gated on methodOn('magicLink'). %>
194
+ <form @submit.prevent="submitMagicLink()" novalidate x-show="methodOn('magicLink')">
195
+ <div class="mb-3">
196
+ <label class="block text-xs text-secondary mb-1 font-medium">Email</label>
197
+ <%= render "studio/modals/shared/email_field",
198
+ name: nil, x_model: "email",
199
+ placeholder: "you@example.com", required: true,
200
+ disabled_expr: "!!props.submitting" %>
201
+ </div>
202
+ <template x-if="props.formError">
203
+ <p class="text-red-400 text-xs mb-3" x-text="props.formError"></p>
204
+ </template>
205
+ <button type="submit" :disabled="!!props.submitting"
206
+ class="btn btn-neutral btn-lg w-full gap-2 disabled:cursor-wait">
207
+ <span x-show="props.submitting === 'magic-link'" class="spinner" aria-hidden="true"></span>
208
+ <span x-text="props.submitting === 'magic-link' ? 'Sending link…' : 'Email Link'"></span>
209
+ </button>
210
+ <p class="text-xs text-muted text-center mt-2">No password needed — we'll email you a one-tap link.</p>
211
+ </form>
212
+ </div>
213
+
214
+ <%# === MAGIC-LINK-SENT STEP ============================================== %>
215
+ <template x-if="props.step === 'magic-link-sent'">
216
+ <div>
217
+ <%= render "studio/modals/blocks/card_header", icon_emoji: '📬', title: 'Check your inbox' do %>
218
+ We emailed <span class="text-heading font-medium break-all" x-text="props.sentEmail"></span>
219
+ a one-tap sign-in link. Check your email and
220
+ <span class="text-heading font-semibold">Click the Magic Link</span> to continue.
221
+ <% end %>
222
+ <%= render "studio/modals/auth/resend_footer", modal_store: "dsModals" %>
223
+ </div>
224
+ </template>
225
+
226
+ <%# === MAGIC-LINK-RESENT STEP =========================================== %>
227
+ <template x-if="props.step === 'magic-link-resent'">
228
+ <div>
229
+ <%= render "studio/modals/blocks/card_header", icon_color: 'primary', title: 'Link Resent!' do %>
230
+ We just resent <span class="text-heading font-medium break-all" x-text="props.sentEmail"></span>
231
+ a one-tap sign-in link. Check your email and
232
+ <span class="text-heading font-semibold">Click the Magic Link</span> to continue.
233
+ <% end %>
234
+ <%= render "studio/modals/auth/resend_footer", modal_store: "dsModals" %>
235
+ </div>
236
+ </template>
237
+
238
+ <%# === REDIRECT STEP ===================================================== %>
239
+ <template x-if="props.step === 'redirect'">
240
+ <div>
241
+ <%= render "studio/modals/blocks/card_header",
242
+ icon_emoji_key: "props.icon || '📍'",
243
+ title_key: "props.title || 'Heading out'",
244
+ subtitle_key: "props.message || 'Taking you to the next step.'" %>
245
+ <%= render "studio/modals/blocks/cta_redirect",
246
+ href_key: "props.url",
247
+ label_key: "props.cta || 'Go now'",
248
+ duration_seconds: 5,
249
+ modal_store: "dsModals" %>
250
+ </div>
251
+ </template>
252
+ </div>