studio-engine 0.21.0 → 0.22.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -0
- data/app/assets/javascripts/studio/canvas_confetti.js +17 -0
- data/app/assets/javascripts/studio/studio_confetti.js +93 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +47 -0
- data/app/views/layouts/studio/_head.html.erb +7 -1
- data/app/views/studio/_leveling_activity_assets.html.erb +174 -0
- data/app/views/studio/modals/blocks/_change_username.html.erb +46 -0
- data/app/views/studio/modals/blocks/_leveling_activity.html.erb +159 -0
- data/app/views/style/_modals.html.erb +141 -0
- data/app/views/style/_tricks.html.erb +46 -0
- data/lib/studio/engine.rb +2 -0
- data/lib/studio/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 15b4c45fed9c5a2fd6056fca154236cbc12b778c483af5922486e47fcec6baac
|
|
4
|
+
data.tar.gz: d1efa67d91b858e9ba81f9fea1cb63d4b73c1e76a97c08dd0a8ca2afcd0b4522
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ec7d708e3665fbccf82ee641f82110cc8adfda8dd19528b3748807697ae6127db02c9579e1ef414051e541ea28005cc6b525b554e46ab1d1abd11cc959f48e74
|
|
7
|
+
data.tar.gz: dbc43339aeaad12be683b260e4cfcac8aa70f9fbf3a23be136b161790ce4468c8711f1e45c2fd608257df4bf867427e7cf3f8b8e52b0100e218c6de6a85dce1c
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,94 @@
|
|
|
2
2
|
|
|
3
3
|
The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package with `gem "studio-engine", "~> 0.6"`; bumping the gem version and updating consumer lockfiles is a release.
|
|
4
4
|
|
|
5
|
+
## 0.22.0 — 2026-07-28
|
|
6
|
+
|
|
7
|
+
Two additive batches for the living style guide, converged under one release.
|
|
8
|
+
No existing shared primitive changes its defaults, so MS and TM render identically
|
|
9
|
+
on the bump.
|
|
10
|
+
|
|
11
|
+
Modal convergence, Phase 3: homes Turf Monster's quest/leveling activity modals
|
|
12
|
+
(change username + the quest flows) as engine primitives, each supporting BOTH
|
|
13
|
+
modes behind the `:leveling` capability flag. Plus a Tricks batch: three
|
|
14
|
+
celebration/attention effects lifted from Turf Monster into engine primitives, all
|
|
15
|
+
demoed on `/admin/style`.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Leveling-activity modal — `studio/modals/blocks/_leveling_activity`.** ONE
|
|
20
|
+
primitive, **two modes**, decided by `Studio.feature?(:leveling)`:
|
|
21
|
+
- **leveling on** — the full quest framing: a "Quest N of N" pill, and on
|
|
22
|
+
success the seeds celebration (progress bar + level-up + Free Entry), composed
|
|
23
|
+
from the existing `card_header` + `_seeds_bar` chrome.
|
|
24
|
+
- **leveling off** — the plain action modal: just the action + Save, then a
|
|
25
|
+
Saved state. No quest pill, no seeds. This is the McRitchie Studio shape (MS
|
|
26
|
+
has no leveling; Turf Monster does).
|
|
27
|
+
|
|
28
|
+
It renders a title, app-authored description, an optional single-line input
|
|
29
|
+
(`input` / `input_type` / validation attrs), and a configurable CTA. Callers
|
|
30
|
+
override the `leveling` gate per callsite (the style guide passes both) or let
|
|
31
|
+
it default to the app flag.
|
|
32
|
+
|
|
33
|
+
- **Change-username modal — `studio/modals/blocks/_change_username`.** A named
|
|
34
|
+
engine primitive and a thin specialization of `_leveling_activity` with username
|
|
35
|
+
defaults (3–30 char input, the on-chain-neutral default copy, a
|
|
36
|
+
`studio:username-saved` event). This is the primary migrated modal.
|
|
37
|
+
|
|
38
|
+
- **`levelingActionModal` factory — `studio/_leveling_activity_assets`.** The
|
|
39
|
+
Alpine data behind both modals, shipped at page level (a `<script>` cloned out
|
|
40
|
+
of a modal-host template never runs), like `ageVerifyModal`.
|
|
41
|
+
|
|
42
|
+
**CRITICAL BOUNDARY — UI ONLY.** The actual save is an **app-supplied callback**:
|
|
43
|
+
the modal POSTs to `submit_url` and reacts to a **domain-neutral** JSON contract
|
|
44
|
+
(`{ status: "saved" | "needs_step" | "error" }`). Any second step some apps need
|
|
45
|
+
(e.g. a managed-wallet write) is delegated **opaquely**: the engine hands the
|
|
46
|
+
app-returned `challenge` blob to an app-supplied `window[finalize_hook]` and
|
|
47
|
+
POSTs back the opaque `proof` the app returns — it never inspects, parses, or
|
|
48
|
+
decodes either blob. No wallet taxonomy, signing, keys, or on-chain writes live
|
|
49
|
+
anywhere in the engine; the custodial-vs-Phantom branch stays server-side. Turf
|
|
50
|
+
Monster wires the managed-wallet write; McRitchie Studio wires a trivial endpoint
|
|
51
|
+
that returns `{ status: "saved" }`.
|
|
52
|
+
|
|
53
|
+
- **Living style guide — new "Leveling activities" group.** Adds four specimens to
|
|
54
|
+
the `/admin/style` Modals section — **change username** and a generic **quest
|
|
55
|
+
activity** (a newsletter-join), each shown **both ways** (leveling on and off) so
|
|
56
|
+
the two modes are visible regardless of the app's flag. Each demo resolves the
|
|
57
|
+
save locally, wires the active-card glow, and carries an agent-ready reference.
|
|
58
|
+
|
|
59
|
+
- **`window.studioConfetti` — a zero-dependency, no-CDN confetti effect.**
|
|
60
|
+
canvas-confetti (v1.9.3, MIT) is now **vendored into the engine**
|
|
61
|
+
(`app/assets/javascripts/studio/canvas_confetti.js`) and shipped through the
|
|
62
|
+
asset pipeline instead of loaded from a CDN — CSP-safe (same-origin `:self`),
|
|
63
|
+
zero per-app dependency. `studio/studio_confetti.js` builds two callable
|
|
64
|
+
effects on top, both honoring `prefers-reduced-motion: reduce`:
|
|
65
|
+
- **`studioConfetti.burst(target)`** — the radial card-burst that explodes from
|
|
66
|
+
*behind* a card/modal, emanating from its center. Aim it with a DOM element,
|
|
67
|
+
a selector, or an `{x, y}` origin (0..1 viewport space); defaults to screen
|
|
68
|
+
center. Ported from turf-monster's `fireConfettiFromModal`.
|
|
69
|
+
- **`studioConfetti.cannons()`** — the full-screen "you joined a contest"
|
|
70
|
+
celebration: a center burst plus left + right side-cannons firing inward.
|
|
71
|
+
Same choreography as the engine head's `window.fireSuccessConfetti`.
|
|
72
|
+
|
|
73
|
+
- **`.pulse-cta` — an attention pulse/glow for a CTA button**
|
|
74
|
+
(`engine-motion.css`). A gentle scale + expanding fading ring on a loop, themed
|
|
75
|
+
off the CTA role token (`--pulse-cta-color`, default `--color-cta`) so it
|
|
76
|
+
restyles per app, reduced-motion aware. Ported from turf-monster's
|
|
77
|
+
"Change Username" quest CTA (`.quest-pulse`). Named `.pulse-cta` (not
|
|
78
|
+
`.btn-pulse`) so it stays a plain motion-layer effect, not a `.btn` component
|
|
79
|
+
variant the `engine.css` contract would demand.
|
|
80
|
+
|
|
81
|
+
- **Three `/admin/style` Tricks specimens** — a "Confetti & pulse" group renders
|
|
82
|
+
all three live: a "Fire burst" and "Fire cannons" demo (each surfacing the
|
|
83
|
+
`window.studioConfetti.*` call name + a copyable snippet) and the `.pulse-cta`
|
|
84
|
+
button.
|
|
85
|
+
|
|
86
|
+
### Changed
|
|
87
|
+
|
|
88
|
+
- The engine head (`layouts/studio/_head.html.erb`) now loads the **vendored**
|
|
89
|
+
canvas-confetti via `javascript_include_tag` rather than the jsDelivr CDN.
|
|
90
|
+
`window.fireSuccessConfetti` is unchanged and rides the same global — behavior
|
|
91
|
+
is identical, only the script source moves from CDN to a bundled engine asset.
|
|
92
|
+
|
|
5
93
|
## 0.21.0 — 2026-07-27
|
|
6
94
|
|
|
7
95
|
Phase 2 of the studio-engine modal convergence: homes the entry-time age-gate DOB
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/*
|
|
2
|
+
canvas-confetti v1.9.3 — VENDORED into studio-engine (no CDN, CSP-safe).
|
|
3
|
+
Source: https://github.com/catdad/canvas-confetti (MIT, (c) Kiril Vatev).
|
|
4
|
+
Minified build: https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js
|
|
5
|
+
Shipped as an engine asset so consumers get zero-per-app confetti with no
|
|
6
|
+
third-party CDN dependency. Defines the global `confetti`; studio_confetti.js
|
|
7
|
+
builds window.studioConfetti (burst/cannons) on top. To re-vendor on a bump:
|
|
8
|
+
re-fetch the pinned dist file above, keep this header, bump the version.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Minified by jsDelivr using Terser v5.37.0.
|
|
12
|
+
* Original file: /npm/canvas-confetti@1.9.3/dist/confetti.browser.js
|
|
13
|
+
*
|
|
14
|
+
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
|
15
|
+
*/
|
|
16
|
+
!function(t,e){!function t(e,a,n,r){var o=!!(e.Worker&&e.Blob&&e.Promise&&e.OffscreenCanvas&&e.OffscreenCanvasRenderingContext2D&&e.HTMLCanvasElement&&e.HTMLCanvasElement.prototype.transferControlToOffscreen&&e.URL&&e.URL.createObjectURL),i="function"==typeof Path2D&&"function"==typeof DOMMatrix,l=function(){if(!e.OffscreenCanvas)return!1;var t=new OffscreenCanvas(1,1),a=t.getContext("2d");a.fillRect(0,0,1,1);var n=t.transferToImageBitmap();try{a.createPattern(n,"no-repeat")}catch(t){return!1}return!0}();function s(){}function c(t){var n=a.exports.Promise,r=void 0!==n?n:e.Promise;return"function"==typeof r?new r(t):(t(s,s),null)}var h,f,u,d,m,g,p,b,M,v,y,w=(h=l,f=new Map,{transform:function(t){if(h)return t;if(f.has(t))return f.get(t);var e=new OffscreenCanvas(t.width,t.height);return e.getContext("2d").drawImage(t,0,0),f.set(t,e),e},clear:function(){f.clear()}}),x=(m=Math.floor(1e3/60),g={},p=0,"function"==typeof requestAnimationFrame&&"function"==typeof cancelAnimationFrame?(u=function(t){var e=Math.random();return g[e]=requestAnimationFrame((function a(n){p===n||p+m-1<n?(p=n,delete g[e],t()):g[e]=requestAnimationFrame(a)})),e},d=function(t){g[t]&&cancelAnimationFrame(g[t])}):(u=function(t){return setTimeout(t,m)},d=function(t){return clearTimeout(t)}),{frame:u,cancel:d}),C=(v={},function(){if(b)return b;if(!n&&o){var e=["var CONFETTI, SIZE = {}, module = {};","("+t.toString()+")(this, module, true, SIZE);","onmessage = function(msg) {"," if (msg.data.options) {"," CONFETTI(msg.data.options).then(function () {"," if (msg.data.callback) {"," postMessage({ callback: msg.data.callback });"," }"," });"," } else if (msg.data.reset) {"," CONFETTI && CONFETTI.reset();"," } else if (msg.data.resize) {"," SIZE.width = msg.data.resize.width;"," SIZE.height = msg.data.resize.height;"," } else if (msg.data.canvas) {"," SIZE.width = msg.data.canvas.width;"," SIZE.height = msg.data.canvas.height;"," CONFETTI = module.exports.create(msg.data.canvas);"," }","}"].join("\n");try{b=new Worker(URL.createObjectURL(new Blob([e])))}catch(t){return void 0!==typeof console&&"function"==typeof console.warn&&console.warn("🎊 Could not load worker",t),null}!function(t){function e(e,a){t.postMessage({options:e||{},callback:a})}t.init=function(e){var a=e.transferControlToOffscreen();t.postMessage({canvas:a},[a])},t.fire=function(a,n,r){if(M)return e(a,null),M;var o=Math.random().toString(36).slice(2);return M=c((function(n){function i(e){e.data.callback===o&&(delete v[o],t.removeEventListener("message",i),M=null,w.clear(),r(),n())}t.addEventListener("message",i),e(a,o),v[o]=i.bind(null,{data:{callback:o}})}))},t.reset=function(){for(var e in t.postMessage({reset:!0}),v)v[e](),delete v[e]}}(b)}return b}),I={particleCount:50,angle:90,spread:45,startVelocity:45,decay:.9,gravity:1,drift:0,ticks:200,x:.5,y:.5,shapes:["square","circle"],zIndex:100,colors:["#26ccff","#a25afd","#ff5e7e","#88ff5a","#fcff42","#ffa62d","#ff36ff"],disableForReducedMotion:!1,scalar:1};function T(t,e,a){return function(t,e){return e?e(t):t}(t&&null!=t[e]?t[e]:I[e],a)}function E(t){return t<0?0:Math.floor(t)}function P(t){return parseInt(t,16)}function S(t){return t.map(O)}function O(t){var e=String(t).replace(/[^0-9a-f]/gi,"");return e.length<6&&(e=e[0]+e[0]+e[1]+e[1]+e[2]+e[2]),{r:P(e.substring(0,2)),g:P(e.substring(2,4)),b:P(e.substring(4,6))}}function k(t){t.width=document.documentElement.clientWidth,t.height=document.documentElement.clientHeight}function B(t){var e=t.getBoundingClientRect();t.width=e.width,t.height=e.height}function F(t,e){e.x+=Math.cos(e.angle2D)*e.velocity+e.drift,e.y+=Math.sin(e.angle2D)*e.velocity+e.gravity,e.velocity*=e.decay,e.flat?(e.wobble=0,e.wobbleX=e.x+10*e.scalar,e.wobbleY=e.y+10*e.scalar,e.tiltSin=0,e.tiltCos=0,e.random=1):(e.wobble+=e.wobbleSpeed,e.wobbleX=e.x+10*e.scalar*Math.cos(e.wobble),e.wobbleY=e.y+10*e.scalar*Math.sin(e.wobble),e.tiltAngle+=.1,e.tiltSin=Math.sin(e.tiltAngle),e.tiltCos=Math.cos(e.tiltAngle),e.random=Math.random()+2);var a=e.tick++/e.totalTicks,n=e.x+e.random*e.tiltCos,r=e.y+e.random*e.tiltSin,o=e.wobbleX+e.random*e.tiltCos,l=e.wobbleY+e.random*e.tiltSin;if(t.fillStyle="rgba("+e.color.r+", "+e.color.g+", "+e.color.b+", "+(1-a)+")",t.beginPath(),i&&"path"===e.shape.type&&"string"==typeof e.shape.path&&Array.isArray(e.shape.matrix))t.fill(function(t,e,a,n,r,o,i){var l=new Path2D(t),s=new Path2D;s.addPath(l,new DOMMatrix(e));var c=new Path2D;return c.addPath(s,new DOMMatrix([Math.cos(i)*r,Math.sin(i)*r,-Math.sin(i)*o,Math.cos(i)*o,a,n])),c}(e.shape.path,e.shape.matrix,e.x,e.y,.1*Math.abs(o-n),.1*Math.abs(l-r),Math.PI/10*e.wobble));else if("bitmap"===e.shape.type){var s=Math.PI/10*e.wobble,c=.1*Math.abs(o-n),h=.1*Math.abs(l-r),f=e.shape.bitmap.width*e.scalar,u=e.shape.bitmap.height*e.scalar,d=new DOMMatrix([Math.cos(s)*c,Math.sin(s)*c,-Math.sin(s)*h,Math.cos(s)*h,e.x,e.y]);d.multiplySelf(new DOMMatrix(e.shape.matrix));var m=t.createPattern(w.transform(e.shape.bitmap),"no-repeat");m.setTransform(d),t.globalAlpha=1-a,t.fillStyle=m,t.fillRect(e.x-f/2,e.y-u/2,f,u),t.globalAlpha=1}else if("circle"===e.shape)t.ellipse?t.ellipse(e.x,e.y,Math.abs(o-n)*e.ovalScalar,Math.abs(l-r)*e.ovalScalar,Math.PI/10*e.wobble,0,2*Math.PI):function(t,e,a,n,r,o,i,l,s){t.save(),t.translate(e,a),t.rotate(o),t.scale(n,r),t.arc(0,0,1,i,l,s),t.restore()}(t,e.x,e.y,Math.abs(o-n)*e.ovalScalar,Math.abs(l-r)*e.ovalScalar,Math.PI/10*e.wobble,0,2*Math.PI);else if("star"===e.shape)for(var g=Math.PI/2*3,p=4*e.scalar,b=8*e.scalar,M=e.x,v=e.y,y=5,x=Math.PI/y;y--;)M=e.x+Math.cos(g)*b,v=e.y+Math.sin(g)*b,t.lineTo(M,v),g+=x,M=e.x+Math.cos(g)*p,v=e.y+Math.sin(g)*p,t.lineTo(M,v),g+=x;else t.moveTo(Math.floor(e.x),Math.floor(e.y)),t.lineTo(Math.floor(e.wobbleX),Math.floor(r)),t.lineTo(Math.floor(o),Math.floor(l)),t.lineTo(Math.floor(n),Math.floor(e.wobbleY));return t.closePath(),t.fill(),e.tick<e.totalTicks}function A(t,a){var i,l=!t,s=!!T(a||{},"resize"),h=!1,f=T(a,"disableForReducedMotion",Boolean),u=o&&!!T(a||{},"useWorker")?C():null,d=l?k:B,m=!(!t||!u)&&!!t.__confetti_initialized,g="function"==typeof matchMedia&&matchMedia("(prefers-reduced-motion)").matches;function p(e,a,o){for(var l,s,h,f,u,m=T(e,"particleCount",E),g=T(e,"angle",Number),p=T(e,"spread",Number),b=T(e,"startVelocity",Number),M=T(e,"decay",Number),v=T(e,"gravity",Number),y=T(e,"drift",Number),C=T(e,"colors",S),I=T(e,"ticks",Number),P=T(e,"shapes"),O=T(e,"scalar"),k=!!T(e,"flat"),B=function(t){var e=T(t,"origin",Object);return e.x=T(e,"x",Number),e.y=T(e,"y",Number),e}(e),A=m,R=[],N=t.width*B.x,z=t.height*B.y;A--;)R.push((l={x:N,y:z,angle:g,spread:p,startVelocity:b,color:C[A%C.length],shape:P[(f=0,u=P.length,Math.floor(Math.random()*(u-f))+f)],ticks:I,decay:M,gravity:v,drift:y,scalar:O,flat:k},s=void 0,h=void 0,s=l.angle*(Math.PI/180),h=l.spread*(Math.PI/180),{x:l.x,y:l.y,wobble:10*Math.random(),wobbleSpeed:Math.min(.11,.1*Math.random()+.05),velocity:.5*l.startVelocity+Math.random()*l.startVelocity,angle2D:-s+(.5*h-Math.random()*h),tiltAngle:(.5*Math.random()+.25)*Math.PI,color:l.color,shape:l.shape,tick:0,totalTicks:l.ticks,decay:l.decay,drift:l.drift,random:Math.random()+2,tiltSin:0,tiltCos:0,wobbleX:0,wobbleY:0,gravity:3*l.gravity,ovalScalar:.6,scalar:l.scalar,flat:l.flat}));return i?i.addFettis(R):(i=function(t,e,a,o,i){var l,s,h=e.slice(),f=t.getContext("2d"),u=c((function(e){function c(){l=s=null,f.clearRect(0,0,o.width,o.height),w.clear(),i(),e()}l=x.frame((function e(){!n||o.width===r.width&&o.height===r.height||(o.width=t.width=r.width,o.height=t.height=r.height),o.width||o.height||(a(t),o.width=t.width,o.height=t.height),f.clearRect(0,0,o.width,o.height),(h=h.filter((function(t){return F(f,t)}))).length?l=x.frame(e):c()})),s=c}));return{addFettis:function(t){return h=h.concat(t),u},canvas:t,promise:u,reset:function(){l&&x.cancel(l),s&&s()}}}(t,R,d,a,o),i.promise)}function b(a){var n=f||T(a,"disableForReducedMotion",Boolean),r=T(a,"zIndex",Number);if(n&&g)return c((function(t){t()}));l&&i?t=i.canvas:l&&!t&&(t=function(t){var e=document.createElement("canvas");return e.style.position="fixed",e.style.top="0px",e.style.left="0px",e.style.pointerEvents="none",e.style.zIndex=t,e}(r),document.body.appendChild(t)),s&&!m&&d(t);var o={width:t.width,height:t.height};function b(){if(u){var e={getBoundingClientRect:function(){if(!l)return t.getBoundingClientRect()}};return d(e),void u.postMessage({resize:{width:e.width,height:e.height}})}o.width=o.height=null}function M(){i=null,s&&(h=!1,e.removeEventListener("resize",b)),l&&t&&(document.body.contains(t)&&document.body.removeChild(t),t=null,m=!1)}return u&&!m&&u.init(t),m=!0,u&&(t.__confetti_initialized=!0),s&&!h&&(h=!0,e.addEventListener("resize",b,!1)),u?u.fire(a,o,M):p(a,o,M)}return b.reset=function(){u&&u.reset(),i&&i.reset()},b}function R(){return y||(y=A(null,{useWorker:!0,resize:!0})),y}a.exports=function(){return R().apply(this,arguments)},a.exports.reset=function(){R().reset()},a.exports.create=A,a.exports.shapeFromPath=function(t){if(!i)throw new Error("path confetti are not supported in this browser");var e,a;"string"==typeof t?e=t:(e=t.path,a=t.matrix);var n=new Path2D(e),r=document.createElement("canvas").getContext("2d");if(!a){for(var o,l,s=1e3,c=s,h=s,f=0,u=0,d=0;d<s;d+=2)for(var m=0;m<s;m+=2)r.isPointInPath(n,d,m,"nonzero")&&(c=Math.min(c,d),h=Math.min(h,m),f=Math.max(f,d),u=Math.max(u,m));o=f-c,l=u-h;var g=Math.min(10/o,10/l);a=[g,0,0,g,-Math.round(o/2+c)*g,-Math.round(l/2+h)*g]}return{type:"path",path:e,matrix:a}},a.exports.shapeFromText=function(t){var e,a=1,n="#000000",r='"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", "EmojiOne Color", "Android Emoji", "Twemoji Mozilla", "system emoji", sans-serif';"string"==typeof t?e=t:(e=t.text,a="scalar"in t?t.scalar:a,r="fontFamily"in t?t.fontFamily:r,n="color"in t?t.color:n);var o=10*a,i=o+"px "+r,l=new OffscreenCanvas(o,o),s=l.getContext("2d");s.font=i;var c=s.measureText(e),h=Math.ceil(c.actualBoundingBoxRight+c.actualBoundingBoxLeft),f=Math.ceil(c.actualBoundingBoxAscent+c.actualBoundingBoxDescent),u=c.actualBoundingBoxLeft+2,d=c.actualBoundingBoxAscent+2;h+=4,f+=4,(s=(l=new OffscreenCanvas(h,f)).getContext("2d")).font=i,s.fillStyle=n,s.fillText(e,u,d);var m=1/a;return{type:"bitmap",bitmap:l.transferToImageBitmap(),matrix:[m,0,0,m,-h*m/2,-f*m/2]}}}(function(){return void 0!==t?t:"undefined"!=typeof self?self:this||{}}(),e,!1),t.confetti=e.exports}(window,{});
|
|
17
|
+
//# sourceMappingURL=/sm/0e9ac22f62a550282b886b288da51d7892173a94bbd286c2ffc6e7b881509c88.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/*
|
|
2
|
+
studio-engine — window.studioConfetti: two zero-dependency celebration effects
|
|
3
|
+
built on the vendored canvas-confetti (studio/canvas_confetti.js, no CDN).
|
|
4
|
+
|
|
5
|
+
Ported faithfully from turf-monster:
|
|
6
|
+
- burst(target) → the radial card-burst that explodes from BEHIND a card or
|
|
7
|
+
modal, emanating from its center (TM solana_utils.js fireConfettiFromModal /
|
|
8
|
+
shared/_alpine_factories fireQuestBadgeConfetti). Pass the DOM element (or a
|
|
9
|
+
selector, or an {x,y} origin in 0..1 viewport space) to aim it; defaults to
|
|
10
|
+
screen center.
|
|
11
|
+
- cannons(opts) → the full-screen "you joined a contest" celebration: a center
|
|
12
|
+
burst plus left + right side-cannons firing inward and a top shell (the same
|
|
13
|
+
choreography as the engine head's window.fireSuccessConfetti).
|
|
14
|
+
|
|
15
|
+
Both honor prefers-reduced-motion: reduce (no-op, and disableForReducedMotion is
|
|
16
|
+
also passed through to canvas-confetti as a second belt). Colors come from
|
|
17
|
+
window.CONFETTI_COLORS when a host sets it, else a neutral festive default.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
window.studioConfetti.burst(cardEl); // radial burst behind a card/modal
|
|
21
|
+
window.studioConfetti.cannons(); // side-cannons full-screen celebration
|
|
22
|
+
*/
|
|
23
|
+
(function () {
|
|
24
|
+
"use strict";
|
|
25
|
+
|
|
26
|
+
var DEFAULT_COLORS = [
|
|
27
|
+
"#4BAF50", "#8E82FE", "#06D6A0", "#FF7C47",
|
|
28
|
+
"#FFD700", "#00BFFF", "#FF6B9D", "#C084FC"
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function colors(opts) {
|
|
32
|
+
return (opts && opts.colors) || window.CONFETTI_COLORS || DEFAULT_COLORS;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function reducedMotion() {
|
|
36
|
+
return !!(window.matchMedia &&
|
|
37
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Resolve a burst origin ({x,y} in 0..1 viewport space) from an element, a
|
|
41
|
+
// selector, or a literal {x,y}. Falls back to screen center when the target is
|
|
42
|
+
// missing or not laid out (e.g. a card that just unmounted).
|
|
43
|
+
function originFor(target) {
|
|
44
|
+
var el = (typeof target === "string") ? document.querySelector(target) : target;
|
|
45
|
+
if (el && el.nodeType === 1) {
|
|
46
|
+
var r = el.getBoundingClientRect();
|
|
47
|
+
if (r.width || r.height) {
|
|
48
|
+
return {
|
|
49
|
+
x: (r.left + r.width / 2) / window.innerWidth,
|
|
50
|
+
y: (r.top + r.height / 2) / window.innerHeight
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (target && typeof target.x === "number" && typeof target.y === "number") {
|
|
55
|
+
return { x: target.x, y: target.y };
|
|
56
|
+
}
|
|
57
|
+
return { x: 0.5, y: 0.5 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
var studioConfetti = {
|
|
61
|
+
// Radial burst from behind a card/modal — spread:360 fires in every direction
|
|
62
|
+
// from the card center, low velocity + gravity keep it clustered around the
|
|
63
|
+
// card rather than blasting off-screen. A smaller follow-shell layers texture,
|
|
64
|
+
// and two bottom-corner sweeps give the moment theatre.
|
|
65
|
+
burst: function (target, opts) {
|
|
66
|
+
if (typeof confetti === "undefined" || reducedMotion()) return;
|
|
67
|
+
opts = opts || {};
|
|
68
|
+
var origin = originFor(target);
|
|
69
|
+
var c = colors(opts);
|
|
70
|
+
var z = opts.zIndex || 9999;
|
|
71
|
+
confetti({ particleCount: 140, angle: 90, spread: 360, origin: origin, colors: c, zIndex: z, startVelocity: 30, gravity: 0.7, ticks: 220, scalar: 1.0, disableForReducedMotion: true });
|
|
72
|
+
setTimeout(function () {
|
|
73
|
+
confetti({ particleCount: 60, angle: 60, spread: 70, origin: { x: Math.max(0, origin.x - 0.18), y: Math.min(1, origin.y + 0.05) }, colors: c, zIndex: z, startVelocity: 40, gravity: 0.9, ticks: 200, scalar: 0.9, disableForReducedMotion: true });
|
|
74
|
+
confetti({ particleCount: 60, angle: 120, spread: 70, origin: { x: Math.min(1, origin.x + 0.18), y: Math.min(1, origin.y + 0.05) }, colors: c, zIndex: z, startVelocity: 40, gravity: 0.9, ticks: 200, scalar: 0.9, disableForReducedMotion: true });
|
|
75
|
+
}, 180);
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
// Full-screen side-cannon celebration: center burst, then left + right cannons
|
|
79
|
+
// firing inward, then a wide top shell. Mirrors window.fireSuccessConfetti.
|
|
80
|
+
cannons: function (opts) {
|
|
81
|
+
if (typeof confetti === "undefined" || reducedMotion()) return;
|
|
82
|
+
opts = opts || {};
|
|
83
|
+
var c = colors(opts);
|
|
84
|
+
var z = opts.zIndex || 9999;
|
|
85
|
+
confetti({ particleCount: 150, spread: 100, origin: { x: 0.5, y: 0.5 }, colors: c, zIndex: z, startVelocity: 45, gravity: 0.8, ticks: 300, scalar: 1.2, disableForReducedMotion: true });
|
|
86
|
+
setTimeout(function () { confetti({ particleCount: 80, angle: 60, spread: 60, origin: { x: 0, y: 0.6 }, colors: c, zIndex: z, startVelocity: 55, gravity: 1, ticks: 250, disableForReducedMotion: true }); }, 150);
|
|
87
|
+
setTimeout(function () { confetti({ particleCount: 80, angle: 120, spread: 60, origin: { x: 1, y: 0.6 }, colors: c, zIndex: z, startVelocity: 55, gravity: 1, ticks: 250, disableForReducedMotion: true }); }, 150);
|
|
88
|
+
setTimeout(function () { confetti({ particleCount: 100, spread: 160, origin: { x: 0.5, y: 0.3 }, colors: c, zIndex: z, startVelocity: 30, gravity: 1.2, ticks: 200, scalar: 0.8, disableForReducedMotion: true }); }, 400);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
window.studioConfetti = studioConfetti;
|
|
93
|
+
})();
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
.studio-glow soft themed glow halo (authored fresh)
|
|
22
22
|
.surface-glass frosted translucent glass panel (authored fresh)
|
|
23
23
|
.conic-surface slow rotating conic-gradient wash (authored fresh)
|
|
24
|
+
.pulse-cta attention pulse/glow for a CTA (ported, TM)
|
|
24
25
|
|
|
25
26
|
Leveling:
|
|
26
27
|
.level-badge(-1..-10) progressive level pill ladder (ported, TM)
|
|
@@ -1111,3 +1112,49 @@ body.modal-open { overflow: hidden; }
|
|
|
1111
1112
|
@keyframes modal-card-slide-out-left { from { opacity: 1; } to { opacity: 0; } }
|
|
1112
1113
|
@keyframes modal-card-slide-in-right { from { opacity: 0; } to { opacity: 1; } }
|
|
1113
1114
|
}
|
|
1115
|
+
|
|
1116
|
+
/* =============================================================================
|
|
1117
|
+
12. pulse-cta — attention pulse/glow for a CTA button
|
|
1118
|
+
A gentle "look at me" beat: the element scales up a hair while an expanding,
|
|
1119
|
+
fading ring radiates from its edge, then rests, on a loop. Ported from turf-
|
|
1120
|
+
monster's "Change Username" quest CTA (.quest-pulse), re-themed off the CTA
|
|
1121
|
+
role token so it restyles per app (purple on McRitchie Studio) rather than TM's
|
|
1122
|
+
hardcoded green. Drop it on a .btn (or any element with a solid background); for
|
|
1123
|
+
a green "go" pulse set --pulse-cta-color: var(--color-success) at the call site.
|
|
1124
|
+
Named pulse-cta (not btn-pulse) so it stays a plain motion-layer effect, not a
|
|
1125
|
+
.btn component variant the engine.css component contract would demand.
|
|
1126
|
+
|
|
1127
|
+
Usage: <button class="btn btn-primary pulse-cta">Change Username</button>
|
|
1128
|
+
green pulse: style="--pulse-cta-color: var(--color-success)"
|
|
1129
|
+
Tune: --pulse-cta-color / --pulse-cta-speed / --pulse-cta-scale /
|
|
1130
|
+
--pulse-cta-ring / --pulse-cta-strength
|
|
1131
|
+
========================================================================== */
|
|
1132
|
+
.pulse-cta {
|
|
1133
|
+
--pulse-cta-color: var(--color-cta);
|
|
1134
|
+
--pulse-cta-speed: 3s;
|
|
1135
|
+
--pulse-cta-scale: 1.03;
|
|
1136
|
+
--pulse-cta-ring: 10px;
|
|
1137
|
+
--pulse-cta-strength: 0.5;
|
|
1138
|
+
animation: pulse-cta var(--pulse-cta-speed) ease-out infinite;
|
|
1139
|
+
}
|
|
1140
|
+
@keyframes pulse-cta {
|
|
1141
|
+
0% {
|
|
1142
|
+
transform: scale(1);
|
|
1143
|
+
box-shadow: 0 0 0 0 color-mix(in srgb, var(--pulse-cta-color) calc(var(--pulse-cta-strength) * 100%), transparent);
|
|
1144
|
+
}
|
|
1145
|
+
10% {
|
|
1146
|
+
transform: scale(var(--pulse-cta-scale));
|
|
1147
|
+
box-shadow: 0 0 0 calc(var(--pulse-cta-ring) * 0.6) color-mix(in srgb, var(--pulse-cta-color) calc(var(--pulse-cta-strength) * 50%), transparent);
|
|
1148
|
+
}
|
|
1149
|
+
22% {
|
|
1150
|
+
transform: scale(1);
|
|
1151
|
+
box-shadow: 0 0 0 var(--pulse-cta-ring) color-mix(in srgb, var(--pulse-cta-color) 0%, transparent);
|
|
1152
|
+
}
|
|
1153
|
+
100% {
|
|
1154
|
+
transform: scale(1);
|
|
1155
|
+
box-shadow: 0 0 0 0 color-mix(in srgb, var(--pulse-cta-color) 0%, transparent);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1159
|
+
.pulse-cta { animation: none; }
|
|
1160
|
+
}
|
|
@@ -21,7 +21,13 @@
|
|
|
21
21
|
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
|
22
22
|
|
|
23
23
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
24
|
-
|
|
24
|
+
<%# canvas-confetti is VENDORED into the engine (studio/canvas_confetti.js) and
|
|
25
|
+
shipped through the asset pipeline — no CDN, CSP-safe (same-origin :self),
|
|
26
|
+
zero-per-app dependency. Defines the global `confetti`; studio_confetti.js
|
|
27
|
+
then builds window.studioConfetti (burst/cannons). fireSuccessConfetti below
|
|
28
|
+
is unchanged and rides the same global. %>
|
|
29
|
+
<%= javascript_include_tag "studio/canvas_confetti", "data-turbo-track": "reload" %>
|
|
30
|
+
<%= javascript_include_tag "studio/studio_confetti", "data-turbo-track": "reload" %>
|
|
25
31
|
<script>
|
|
26
32
|
window.fireSuccessConfetti = function() {
|
|
27
33
|
if (typeof confetti === 'undefined') return;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
levelingActionModal factory — the Alpine data behind the engine leveling-activity
|
|
3
|
+
modals (studio/modals/blocks/_leveling_activity and its _change_username
|
|
4
|
+
specialization). Ships here, at PAGE level, on purpose: the modal partial mounts
|
|
5
|
+
inside a modal host's single-root template, and a script cloned out of a template
|
|
6
|
+
never runs. So a consumer renders THIS once in its layout (like the other shared
|
|
7
|
+
Alpine factories), and the modal partial anywhere below just references
|
|
8
|
+
window.levelingActionModal. Mirrors how studio/_age_verify_assets homes the
|
|
9
|
+
ageVerifyModal factory.
|
|
10
|
+
|
|
11
|
+
CRITICAL BOUNDARY — this factory is UI ONLY. It POSTs to an app-supplied
|
|
12
|
+
submitUrl and reacts to a NEUTRAL JSON contract; it holds ZERO on-chain logic,
|
|
13
|
+
no wallet taxonomy, no signing. Any second step some apps need (e.g. a wallet
|
|
14
|
+
approval) is delegated OPAQUELY: the engine hands the app-returned "challenge"
|
|
15
|
+
blob to an app-supplied window[finalizeHook] and POSTs back whatever opaque
|
|
16
|
+
"proof" the app returns. The engine never inspects, parses, or decodes either
|
|
17
|
+
blob, so the whole wallet dance stays in the app.
|
|
18
|
+
|
|
19
|
+
Wire contract (submitUrl + finalizeUrl responses are JSON):
|
|
20
|
+
POST submitUrl { value } -> one of:
|
|
21
|
+
{ status: "saved", seeds_earned?, seeds_total? } done
|
|
22
|
+
{ status: "needs_step", challenge, token } app step required
|
|
23
|
+
{ status: "error", message } show message
|
|
24
|
+
when needs_step and a finalizeHook is wired:
|
|
25
|
+
proof = await window[finalizeHook](challenge, { token, onProgress })
|
|
26
|
+
POST finalizeUrl { token, proof } -> { status: "saved" | "error", ... }
|
|
27
|
+
|
|
28
|
+
opts: { hasInput, initialValue, minLength, submitUrl, finalizeUrl, finalizeHook,
|
|
29
|
+
savedEvent, store, leveling, demo, seedsPerLevel }.
|
|
30
|
+
hasInput true renders + validates a single-line input; false is a
|
|
31
|
+
no-field activity (a plain "Complete" action).
|
|
32
|
+
initialValue seed value for the input (a plain string; never a model).
|
|
33
|
+
minLength client-only min length for the input (0 = none).
|
|
34
|
+
submitUrl app endpoint the action POSTs to (the app owns persistence).
|
|
35
|
+
finalizeUrl app endpoint for the opaque second step (omit if none).
|
|
36
|
+
finalizeHook name of a window async fn the app supplies for the step (omit
|
|
37
|
+
if none); it owns EVERY app-side detail of that step.
|
|
38
|
+
savedEvent DOM event dispatched on success, carrying the app payload, so
|
|
39
|
+
the app can run its own follow-on. Default "studio:activity-saved".
|
|
40
|
+
store Alpine modal store name backing close(). Default "modals".
|
|
41
|
+
leveling true shows the seeds celebration on success (a UI flourish).
|
|
42
|
+
demo style-guide preview: resolve locally, skip the network POST.
|
|
43
|
+
seedsPerLevel seeds per level for the celebration bar. Default 100.
|
|
44
|
+
%>
|
|
45
|
+
<script>
|
|
46
|
+
(function () {
|
|
47
|
+
if (window.levelingActionModal) return;
|
|
48
|
+
window.levelingActionModal = function (opts) {
|
|
49
|
+
opts = opts || {};
|
|
50
|
+
return {
|
|
51
|
+
hasInput: !!opts.hasInput,
|
|
52
|
+
value: opts.initialValue || "",
|
|
53
|
+
original: opts.initialValue || "",
|
|
54
|
+
minLength: parseInt(opts.minLength, 10) || 0,
|
|
55
|
+
submitUrl: opts.submitUrl || "",
|
|
56
|
+
finalizeUrl: opts.finalizeUrl || "",
|
|
57
|
+
finalizeHook: opts.finalizeHook || "",
|
|
58
|
+
savedEvent: opts.savedEvent || "studio:activity-saved",
|
|
59
|
+
store: opts.store || "modals",
|
|
60
|
+
leveling: !!opts.leveling,
|
|
61
|
+
demo: !!opts.demo,
|
|
62
|
+
seedsPerLevel: parseInt(opts.seedsPerLevel, 10) || 100,
|
|
63
|
+
saving: false,
|
|
64
|
+
error: "",
|
|
65
|
+
saved: false,
|
|
66
|
+
celebrate: false,
|
|
67
|
+
progressLabel: "",
|
|
68
|
+
seedsEarned: 0,
|
|
69
|
+
seedsTotal: 0,
|
|
70
|
+
|
|
71
|
+
// Actionable while there is a real change to save (input activities) or
|
|
72
|
+
// until the one-shot action has fired (no-field activities).
|
|
73
|
+
get changed() {
|
|
74
|
+
if (!this.hasInput) return !this.saved;
|
|
75
|
+
var v = (this.value || "").trim();
|
|
76
|
+
if (v.length < this.minLength) return false;
|
|
77
|
+
return v !== (this.original || "").trim();
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
_dispatch: function (payload) {
|
|
81
|
+
try {
|
|
82
|
+
window.dispatchEvent(new CustomEvent(this.savedEvent, { detail: payload || {} }));
|
|
83
|
+
} catch (e) {}
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
_finishSaved: function (payload) {
|
|
87
|
+
payload = payload || {};
|
|
88
|
+
this.saving = false;
|
|
89
|
+
this.saved = true;
|
|
90
|
+
this.progressLabel = "";
|
|
91
|
+
if (this.hasInput) this.original = (this.value || "").trim();
|
|
92
|
+
this._dispatch(payload);
|
|
93
|
+
if (this.leveling) {
|
|
94
|
+
this.seedsEarned = parseInt(payload.seeds_earned, 10) || 0;
|
|
95
|
+
this.seedsTotal = parseInt(payload.seeds_total, 10) || 0;
|
|
96
|
+
this.celebrate = true;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
_post: function (url, body) {
|
|
101
|
+
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
|
|
102
|
+
var fetcher = window.authedFetch || window.fetch;
|
|
103
|
+
return fetcher(url, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
|
|
106
|
+
body: JSON.stringify(body || {})
|
|
107
|
+
}).then(function (r) {
|
|
108
|
+
return r.json().then(function (j) { return { ok: r.ok, body: j || {} }; });
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
save: function () {
|
|
113
|
+
if (this.saving || !this.changed) return;
|
|
114
|
+
this.saving = true;
|
|
115
|
+
this.error = "";
|
|
116
|
+
var self = this;
|
|
117
|
+
if (this.demo) {
|
|
118
|
+
// Style-guide preview: no backend. Resolve to a level-crossing payload
|
|
119
|
+
// so the leveling celebration demonstrates its full flourish.
|
|
120
|
+
setTimeout(function () {
|
|
121
|
+
self._finishSaved({ seeds_earned: 30, seeds_total: self.seedsPerLevel });
|
|
122
|
+
}, 600);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
var body = this.hasInput ? { value: (this.value || "").trim() } : {};
|
|
126
|
+
this._post(this.submitUrl, body)
|
|
127
|
+
.then(function (res) { return self._handle(res); })
|
|
128
|
+
.catch(function (e) {
|
|
129
|
+
self.error = (e && e.message) || "Something went wrong. Please try again.";
|
|
130
|
+
self.saving = false;
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
_handle: function (res) {
|
|
135
|
+
var body = (res && res.body) || {};
|
|
136
|
+
var status = body.status || (res && res.ok ? "saved" : "error");
|
|
137
|
+
if (status === "saved") { this._finishSaved(body); return; }
|
|
138
|
+
if (status === "needs_step") { return this._step(body); }
|
|
139
|
+
this.error = body.message || "Something went wrong. Please try again.";
|
|
140
|
+
this.saving = false;
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// The opaque second step. The engine hands the app's "challenge" blob to
|
|
144
|
+
// the app-supplied hook and POSTs back the app's "proof" blob — it inspects
|
|
145
|
+
// neither, so every app-side detail of the step stays in the app.
|
|
146
|
+
_step: function (body) {
|
|
147
|
+
var hook = this.finalizeHook && window[this.finalizeHook];
|
|
148
|
+
var self = this;
|
|
149
|
+
if (typeof hook !== "function") {
|
|
150
|
+
this.error = "This step isn't available.";
|
|
151
|
+
this.saving = false;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
return Promise.resolve(
|
|
155
|
+
hook(body.challenge, {
|
|
156
|
+
token: body.token,
|
|
157
|
+
onProgress: function (label) { self.progressLabel = label || ""; }
|
|
158
|
+
})
|
|
159
|
+
).then(function (proof) {
|
|
160
|
+
return self._post(self.finalizeUrl, { token: body.token, proof: proof });
|
|
161
|
+
}).then(function (res) {
|
|
162
|
+
var rb = (res && res.body) || {};
|
|
163
|
+
var status = rb.status || (res && res.ok ? "saved" : "error");
|
|
164
|
+
if (status === "saved") { self._finishSaved(rb); }
|
|
165
|
+
else { self.error = rb.message || "Couldn't complete the step."; self.saving = false; }
|
|
166
|
+
}).catch(function (e) {
|
|
167
|
+
self.error = (e && e.message) || "Couldn't complete the step.";
|
|
168
|
+
self.saving = false;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
})();
|
|
174
|
+
</script>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Change-username modal — a named engine primitive, and a thin specialization of
|
|
3
|
+
studio/modals/blocks/_leveling_activity with username defaults. TWO modes via
|
|
4
|
+
the :leveling flag (quest pill + seeds celebration when on; plain input + Save +
|
|
5
|
+
Saved when off).
|
|
6
|
+
|
|
7
|
+
CRITICAL BOUNDARY — UI ONLY. The engine renders the input, the Save button, the
|
|
8
|
+
saved state, and the optional leveling chrome. The actual username SAVE is an
|
|
9
|
+
APP-SUPPLIED callback: the modal POSTs to submit_url and, if the app returns a
|
|
10
|
+
second step, delegates it opaquely to an app-supplied finalize_hook (the app's
|
|
11
|
+
managed-wallet write). No wallet, signing, keys, or on-chain writes live here.
|
|
12
|
+
|
|
13
|
+
Locals (beyond _leveling_activity's):
|
|
14
|
+
current_username (String, REQUIRED) — seed value for the input (a plain
|
|
15
|
+
string; never current_user — no model coupling in the engine).
|
|
16
|
+
submit_url (String, REQUIRED) — app endpoint the username POSTs to.
|
|
17
|
+
description (String) — app-authored lead copy. Pass the wallet-aware line
|
|
18
|
+
here (e.g. "Your username is stored on-chain. Changes are
|
|
19
|
+
saved via your managed wallet") — the engine default stays
|
|
20
|
+
neutral so no on-chain assumption is baked in.
|
|
21
|
+
|
|
22
|
+
Everything else (finalize_url, finalize_hook, saved_event, quest_label,
|
|
23
|
+
leveling, modal_store, demo, celebration copy) passes straight through.
|
|
24
|
+
%>
|
|
25
|
+
<%
|
|
26
|
+
current_username = local_assigns.fetch(:current_username)
|
|
27
|
+
|
|
28
|
+
username_locals = local_assigns.except(:current_username).reverse_merge(
|
|
29
|
+
title: "Change Username",
|
|
30
|
+
description: "Pick a name that's right for you.",
|
|
31
|
+
input: true,
|
|
32
|
+
initial_value: current_username.to_s,
|
|
33
|
+
min_length: 3,
|
|
34
|
+
max_length: 30,
|
|
35
|
+
pattern: "[a-zA-Z0-9_-]+",
|
|
36
|
+
pattern_title: "3-30 characters — letters, numbers, hyphens, and underscores",
|
|
37
|
+
placeholder: "username",
|
|
38
|
+
cta_label: "Save",
|
|
39
|
+
saved_label: "Saved",
|
|
40
|
+
saved_event: "studio:username-saved",
|
|
41
|
+
icon_emoji: "🌱",
|
|
42
|
+
celebrate_title: "Great Username",
|
|
43
|
+
celebrate_subtitle: "Nice — you earned seeds for updating your profile. Keep completing quests to earn a Free Entry."
|
|
44
|
+
)
|
|
45
|
+
%>
|
|
46
|
+
<%= render "studio/modals/blocks/leveling_activity", username_locals %>
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Leveling-activity modal — the engine primitive behind TM's quest/leveling
|
|
3
|
+
activities (change username, join newsletter, and the rest). ONE primitive,
|
|
4
|
+
TWO modes, decided by the app's :leveling capability:
|
|
5
|
+
|
|
6
|
+
leveling ON — the full quest framing: a "Quest N of N" pill, and on success
|
|
7
|
+
the seeds celebration (progress bar + level-up + Free Entry).
|
|
8
|
+
leveling OFF — the plain action modal: just the action + Save, and a Saved
|
|
9
|
+
state on success. No quest pill, no seeds. (This is the
|
|
10
|
+
McRitchie-Studio shape: MS has no leveling, TM does.)
|
|
11
|
+
|
|
12
|
+
Same primitive; the flag decides. The leveling chrome (quest pill, seeds bar) is
|
|
13
|
+
engine-owned UI — it is NOT on-chain.
|
|
14
|
+
|
|
15
|
+
CRITICAL BOUNDARY — UI ONLY. There is NO wallet, signing, key, or on-chain write
|
|
16
|
+
anywhere in this primitive. The actual save is an APP-SUPPLIED callback: the
|
|
17
|
+
modal POSTs to submit_url and reacts to a neutral JSON contract; any wallet step
|
|
18
|
+
is delegated opaquely to an app-supplied finalize_hook (see the factory,
|
|
19
|
+
studio/_leveling_activity_assets). TM wires the managed-wallet write; MS wires a
|
|
20
|
+
trivial endpoint. Requires window.levelingActionModal — render
|
|
21
|
+
studio/leveling_activity_assets once at layout level.
|
|
22
|
+
|
|
23
|
+
Locals:
|
|
24
|
+
submit_url (String, REQUIRED) — app endpoint the action POSTs to.
|
|
25
|
+
title (String) — modal title. Default "Complete activity".
|
|
26
|
+
description (String) — the app-authored lead copy. App-owned so wallet-aware
|
|
27
|
+
prose ("saved via your managed wallet") lives app-side, never here.
|
|
28
|
+
input (Boolean) — render a single-line input. Default false.
|
|
29
|
+
input_type (String) — the input type (e.g. "text", "email"). Default "text".
|
|
30
|
+
initial_value (String) — seed value for the input (a plain string).
|
|
31
|
+
placeholder / min_length / max_length / pattern / pattern_title — input attrs.
|
|
32
|
+
cta_label (String) — action button label. Default "Complete".
|
|
33
|
+
saved_label (String) — label once saved. Default "Done".
|
|
34
|
+
saving_label (String) — label while saving. Default "Saving…".
|
|
35
|
+
finalize_url (String) — endpoint for the opaque second step (omit if none).
|
|
36
|
+
finalize_hook (String) — window async fn name for the step (omit if none).
|
|
37
|
+
saved_event (String) — DOM event dispatched on success. Default
|
|
38
|
+
"studio:activity-saved".
|
|
39
|
+
quest_label (String) — the "Quest N of N" pill text (leveling only).
|
|
40
|
+
icon_emoji (String) — celebration glyph. Default "🌱".
|
|
41
|
+
celebrate_title (String) — celebration headline. Default "Activity complete".
|
|
42
|
+
celebrate_subtitle (String) — celebration subtext.
|
|
43
|
+
free_entry_label (String) — level-up reward line. Default "Free Entry Earned 🎟️".
|
|
44
|
+
seeds_per_level (Integer) — seeds per level. Default 100.
|
|
45
|
+
leveling (Boolean) — override the :leveling gate. Defaults to
|
|
46
|
+
Studio.feature?(:leveling); the style guide passes both to show
|
|
47
|
+
each mode.
|
|
48
|
+
modal_store (String) — Alpine store name backing close(). Default "modals".
|
|
49
|
+
demo (Boolean) — style-guide preview: resolve locally, no POST.
|
|
50
|
+
|
|
51
|
+
Single root element (the modal host mounts this through its own template x-if).
|
|
52
|
+
%>
|
|
53
|
+
<%
|
|
54
|
+
submit_url = local_assigns.fetch(:submit_url)
|
|
55
|
+
title = local_assigns.fetch(:title, "Complete activity")
|
|
56
|
+
description = local_assigns.fetch(:description, nil)
|
|
57
|
+
input = local_assigns.fetch(:input, false)
|
|
58
|
+
input_type = local_assigns.fetch(:input_type, "text")
|
|
59
|
+
initial_value = local_assigns.fetch(:initial_value, "")
|
|
60
|
+
placeholder = local_assigns.fetch(:placeholder, "")
|
|
61
|
+
min_length = local_assigns.fetch(:min_length, 0)
|
|
62
|
+
max_length = local_assigns.fetch(:max_length, nil)
|
|
63
|
+
pattern = local_assigns.fetch(:pattern, nil)
|
|
64
|
+
pattern_title = local_assigns.fetch(:pattern_title, nil)
|
|
65
|
+
cta_label = local_assigns.fetch(:cta_label, "Complete")
|
|
66
|
+
saved_label = local_assigns.fetch(:saved_label, "Done")
|
|
67
|
+
saving_label = local_assigns.fetch(:saving_label, "Saving…")
|
|
68
|
+
finalize_url = local_assigns.fetch(:finalize_url, "")
|
|
69
|
+
finalize_hook = local_assigns.fetch(:finalize_hook, "")
|
|
70
|
+
saved_event = local_assigns.fetch(:saved_event, "studio:activity-saved")
|
|
71
|
+
quest_label = local_assigns.fetch(:quest_label, nil)
|
|
72
|
+
icon_emoji = local_assigns.fetch(:icon_emoji, "🌱")
|
|
73
|
+
celebrate_title = local_assigns.fetch(:celebrate_title, "Activity complete")
|
|
74
|
+
celebrate_subtitle = local_assigns.fetch(:celebrate_subtitle, "Nice — you earned seeds. Keep completing quests to earn a Free Entry.")
|
|
75
|
+
free_entry_label = local_assigns.fetch(:free_entry_label, "Free Entry Earned 🎟️")
|
|
76
|
+
seeds_per_level = local_assigns.fetch(:seeds_per_level, 100)
|
|
77
|
+
leveling = local_assigns.fetch(:leveling, Studio.feature?(:leveling))
|
|
78
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
79
|
+
demo = local_assigns.fetch(:demo, false)
|
|
80
|
+
%>
|
|
81
|
+
<div x-data="levelingActionModal({
|
|
82
|
+
hasInput: <%= input ? 'true' : 'false' %>,
|
|
83
|
+
initialValue: '<%= j initial_value.to_s %>',
|
|
84
|
+
minLength: <%= min_length.to_i %>,
|
|
85
|
+
submitUrl: '<%= j submit_url %>',
|
|
86
|
+
finalizeUrl: '<%= j finalize_url %>',
|
|
87
|
+
finalizeHook: '<%= j finalize_hook %>',
|
|
88
|
+
savedEvent: '<%= j saved_event %>',
|
|
89
|
+
store: '<%= j modal_store %>',
|
|
90
|
+
leveling: <%= leveling ? 'true' : 'false' %>,
|
|
91
|
+
demo: <%= demo ? 'true' : 'false' %>,
|
|
92
|
+
seedsPerLevel: <%= seeds_per_level.to_i %>
|
|
93
|
+
})">
|
|
94
|
+
|
|
95
|
+
<%# FORM VIEW — the action. Single root inside the template. %>
|
|
96
|
+
<template x-if="!celebrate">
|
|
97
|
+
<div>
|
|
98
|
+
<%= render layout: "studio/modals/blocks/shell", locals: { title: title, modal_store: modal_store } do %>
|
|
99
|
+
<% if leveling && quest_label.present? %>
|
|
100
|
+
<div class="mb-3">
|
|
101
|
+
<span class="inline-block text-[10px] font-semibold uppercase tracking-wide text-primary bg-primary/10 px-2 py-0.5 rounded-full whitespace-nowrap"><%= quest_label %></span>
|
|
102
|
+
</div>
|
|
103
|
+
<% end %>
|
|
104
|
+
|
|
105
|
+
<% if description.present? %>
|
|
106
|
+
<p class="text-sm text-muted mb-4"><%= description %></p>
|
|
107
|
+
<% end %>
|
|
108
|
+
|
|
109
|
+
<template x-if="error">
|
|
110
|
+
<p class="text-red-400 text-sm mb-3" x-text="error"></p>
|
|
111
|
+
</template>
|
|
112
|
+
|
|
113
|
+
<form @submit.prevent="save()" class="space-y-4">
|
|
114
|
+
<% if input %>
|
|
115
|
+
<input type="<%= input_type %>" x-model="value" class="input-field w-full"
|
|
116
|
+
placeholder="<%= placeholder %>"
|
|
117
|
+
<%= "minlength=\"#{min_length}\"".html_safe if min_length.to_i.positive? %>
|
|
118
|
+
<%= "maxlength=\"#{max_length}\"".html_safe if max_length.present? %>
|
|
119
|
+
<%= "pattern=\"#{pattern}\"".html_safe if pattern.present? %>
|
|
120
|
+
<%= "title=\"#{pattern_title}\"".html_safe if pattern_title.present? %>
|
|
121
|
+
autofocus>
|
|
122
|
+
<% end %>
|
|
123
|
+
<button type="submit" class="btn btn-primary btn-lg w-full" :disabled="saving || !changed">
|
|
124
|
+
<span x-show="!saving" x-text="changed ? '<%= j cta_label %>' : '<%= j saved_label %>'"><%= cta_label %></span>
|
|
125
|
+
<span x-show="saving" class="inline-flex items-center justify-center gap-2" style="display: none;">
|
|
126
|
+
<span class="cta-spinner" aria-hidden="true"></span>
|
|
127
|
+
<span x-text="progressLabel || '<%= j saving_label %>'"><%= saving_label %></span>
|
|
128
|
+
</span>
|
|
129
|
+
</button>
|
|
130
|
+
</form>
|
|
131
|
+
<% end %>
|
|
132
|
+
</div>
|
|
133
|
+
</template>
|
|
134
|
+
|
|
135
|
+
<%# CELEBRATION VIEW — leveling ONLY. Rendered at ERB time only when leveling is
|
|
136
|
+
on, so a leveling-off modal ships NO seeds chrome at all (not merely
|
|
137
|
+
unmounted). Mounts once celebrate flips true so the seeds bar animates from
|
|
138
|
+
the freshly-saved payload. Single root. %>
|
|
139
|
+
<% if leveling %>
|
|
140
|
+
<template x-if="celebrate">
|
|
141
|
+
<div>
|
|
142
|
+
<%= render "studio/modals/blocks/card_header",
|
|
143
|
+
size: :lg, icon_emoji: icon_emoji,
|
|
144
|
+
title: celebrate_title, subtitle: celebrate_subtitle %>
|
|
145
|
+
|
|
146
|
+
<div class="mb-5">
|
|
147
|
+
<%= render "studio/modals/blocks/seeds_bar",
|
|
148
|
+
seeds_earned_key: "seedsEarned", seeds_total_key: "seedsTotal",
|
|
149
|
+
seeds_per_level: seeds_per_level, free_entry_label: free_entry_label %>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<button type="button" @click="$store.<%= modal_store %>.close()"
|
|
153
|
+
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
|
|
154
|
+
Close
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
</template>
|
|
158
|
+
<% end %>
|
|
159
|
+
</div>
|
|
@@ -255,6 +255,11 @@
|
|
|
255
255
|
specimen opens live. A consumer app renders this in its layout the same way. %>
|
|
256
256
|
<%= render "studio/age_verify_assets" %>
|
|
257
257
|
|
|
258
|
+
<%# levelingActionModal factory — page level (never inside the overlay template,
|
|
259
|
+
where a cloned script would not run) so the change-username + quest-activity
|
|
260
|
+
specimens open live. A consumer app renders this in its layout the same way. %>
|
|
261
|
+
<%= render "studio/leveling_activity_assets" %>
|
|
262
|
+
|
|
258
263
|
<%# ---- The overlay: one backdrop + card; every specimen's real content is a
|
|
259
264
|
single-root <template x-if> gated on the current modal id. Faithful to the
|
|
260
265
|
shared host — backdrop fade, scroll lock, escape + click-self dismissal
|
|
@@ -376,6 +381,49 @@
|
|
|
376
381
|
subtitle: "You leveled up! A Free Entry lands in your account soon. Keep leveling for more." %></div>
|
|
377
382
|
</template>
|
|
378
383
|
|
|
384
|
+
<%# --- Leveling activities: change username + quest activity --- %>
|
|
385
|
+
<%# Each id renders the SAME engine primitive with an EXPLICIT leveling:
|
|
386
|
+
flag, so BOTH modes stay visible here regardless of this app's real
|
|
387
|
+
:leveling setting. All demo:true — the save resolves locally (no
|
|
388
|
+
backend), and the on-chain-accurate copy is passed as an app-authored
|
|
389
|
+
description (the engine default stays neutral). %>
|
|
390
|
+
<template x-if="$store.dsModals.current().id === 'change-username'">
|
|
391
|
+
<div><%= render "studio/modals/blocks/change_username",
|
|
392
|
+
modal_store: "dsModals", demo: true, leveling: true,
|
|
393
|
+
current_username: "turfmonster",
|
|
394
|
+
submit_url: "#demo-change-username",
|
|
395
|
+
description: "Your username is stored on-chain. Changes are saved via your managed wallet.",
|
|
396
|
+
quest_label: "Quest 1 of 4" %></div>
|
|
397
|
+
</template>
|
|
398
|
+
<template x-if="$store.dsModals.current().id === 'change-username-plain'">
|
|
399
|
+
<div><%= render "studio/modals/blocks/change_username",
|
|
400
|
+
modal_store: "dsModals", demo: true, leveling: false,
|
|
401
|
+
current_username: "mcritchie",
|
|
402
|
+
submit_url: "#demo-change-username",
|
|
403
|
+
description: "Choose the name shown across your account." %></div>
|
|
404
|
+
</template>
|
|
405
|
+
<template x-if="$store.dsModals.current().id === 'quest-activity'">
|
|
406
|
+
<div><%= render "studio/modals/blocks/leveling_activity",
|
|
407
|
+
modal_store: "dsModals", demo: true, leveling: true,
|
|
408
|
+
submit_url: "#demo-quest",
|
|
409
|
+
title: "Join the Newsletter",
|
|
410
|
+
description: "Sports news and contest updates — complete the quest for seeds.",
|
|
411
|
+
input: true, input_type: "email", placeholder: "you@example.com",
|
|
412
|
+
cta_label: "Subscribe", saved_label: "Subscribed", saving_label: "Subscribing…",
|
|
413
|
+
quest_label: "Quest 2 of 4",
|
|
414
|
+
icon_emoji: "📬", celebrate_title: "You're in",
|
|
415
|
+
celebrate_subtitle: "Nice — you earned seeds for joining. Keep completing quests to earn a Free Entry." %></div>
|
|
416
|
+
</template>
|
|
417
|
+
<template x-if="$store.dsModals.current().id === 'quest-activity-plain'">
|
|
418
|
+
<div><%= render "studio/modals/blocks/leveling_activity",
|
|
419
|
+
modal_store: "dsModals", demo: true, leveling: false,
|
|
420
|
+
submit_url: "#demo-quest",
|
|
421
|
+
title: "Join the Newsletter",
|
|
422
|
+
description: "Sports news and contest updates.",
|
|
423
|
+
input: true, input_type: "email", placeholder: "you@example.com",
|
|
424
|
+
cta_label: "Subscribe", saved_label: "Subscribed", saving_label: "Subscribing…" %></div>
|
|
425
|
+
</template>
|
|
426
|
+
|
|
379
427
|
</div>
|
|
380
428
|
</div>
|
|
381
429
|
</template>
|
|
@@ -859,4 +907,97 @@
|
|
|
859
907
|
<% end %>
|
|
860
908
|
</div>
|
|
861
909
|
</section>
|
|
910
|
+
|
|
911
|
+
<%# ===================================================================== %>
|
|
912
|
+
<%# 7. LEVELING ACTIVITIES — change username + quest activities %>
|
|
913
|
+
<%# ===================================================================== %>
|
|
914
|
+
<section class="space-y-5">
|
|
915
|
+
<div class="space-y-1">
|
|
916
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
917
|
+
<h3 class="text-xl font-bold text-heading">Leveling activities</h3>
|
|
918
|
+
<% if leveling_on %>
|
|
919
|
+
<span class="badge" style="color: var(--color-success); border-color: var(--color-success)">leveling on</span>
|
|
920
|
+
<% else %>
|
|
921
|
+
<span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">leveling off</span>
|
|
922
|
+
<% end %>
|
|
923
|
+
</div>
|
|
924
|
+
<p class="text-muted text-sm">
|
|
925
|
+
The quest/leveling action modals
|
|
926
|
+
(<code class="font-mono text-2xs">studio/modals/blocks/_change_username</code>,
|
|
927
|
+
<code class="font-mono text-2xs">studio/modals/blocks/_leveling_activity</code>),
|
|
928
|
+
migrated from Turf Monster. ONE primitive, <strong>two modes</strong>, decided
|
|
929
|
+
by <code class="font-mono text-2xs">Studio.feature?(:leveling)</code>:
|
|
930
|
+
<strong>leveling on</strong> adds the quest pill + the seeds level-up
|
|
931
|
+
celebration; <strong>leveling off</strong> is the plain action modal (just
|
|
932
|
+
the input + Save + a Saved state — the McRitchie Studio shape). Each modal is
|
|
933
|
+
shown <strong>both ways</strong> below (the specimens pass an explicit
|
|
934
|
+
<code class="font-mono text-2xs">leveling:</code> so both render regardless of
|
|
935
|
+
this app's flag). The actual save is an <strong>app-supplied callback</strong>:
|
|
936
|
+
the modal POSTs to <code class="font-mono text-2xs">submit_url</code> and, for
|
|
937
|
+
an app that needs a wallet step, delegates it opaquely to an app-supplied
|
|
938
|
+
<code class="font-mono text-2xs">finalize_hook</code> — <strong>no wallet,
|
|
939
|
+
signing, or on-chain write lives in the engine</strong>. (These demos resolve
|
|
940
|
+
the save locally.)
|
|
941
|
+
</p>
|
|
942
|
+
</div>
|
|
943
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
944
|
+
<%# Change username — leveling ON: quest pill + seeds celebration on save. %>
|
|
945
|
+
<%= render layout: "style/modal_specimen", locals: {
|
|
946
|
+
label: "Change username (leveling)",
|
|
947
|
+
reference: %(the "Change username" modal WITH leveling (studio-engine studio/modals/blocks/_change_username, leveling: true) — quest pill + seeds level-up celebration on save. The on-chain save is an app-supplied callback (submit_url + an opaque finalize_hook); the engine is UI only. Open with $store.dsModals.open('change-username')),
|
|
948
|
+
open_expr: "$store.dsModals.open('change-username')",
|
|
949
|
+
glow_when: ds_glow.call("change-username") } do %>
|
|
950
|
+
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
|
|
951
|
+
<div class="flex items-center justify-between gap-2">
|
|
952
|
+
<span class="block h-2 w-16 rounded" style="background: var(--color-text); opacity: .18"></span>
|
|
953
|
+
<span class="text-[8px] font-semibold uppercase rounded-full px-1.5 py-0.5" style="color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 12%, transparent)">Quest 1/4</span>
|
|
954
|
+
</div>
|
|
955
|
+
<span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
|
|
956
|
+
<span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
|
|
957
|
+
</div>
|
|
958
|
+
<% end %>
|
|
959
|
+
|
|
960
|
+
<%# Change username — leveling OFF: the plain MS-style action + Save. %>
|
|
961
|
+
<%= render layout: "style/modal_specimen", locals: {
|
|
962
|
+
label: "Change username (plain)",
|
|
963
|
+
reference: %(the "Change username" modal WITHOUT leveling (studio-engine studio/modals/blocks/_change_username, leveling: false) — the plain McRitchie Studio shape: input + Save, then a Saved state. No quest pill, no seeds. Same primitive, flag off. Open with $store.dsModals.open('change-username-plain')),
|
|
964
|
+
open_expr: "$store.dsModals.open('change-username-plain')",
|
|
965
|
+
glow_when: ds_glow.call("change-username-plain") } do %>
|
|
966
|
+
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
|
|
967
|
+
<span class="block h-2 w-16 rounded" style="background: var(--color-text); opacity: .18"></span>
|
|
968
|
+
<span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
|
|
969
|
+
<span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
|
|
970
|
+
</div>
|
|
971
|
+
<% end %>
|
|
972
|
+
|
|
973
|
+
<%# Quest activity — leveling ON: generic quest (newsletter join) + seeds. %>
|
|
974
|
+
<%= render layout: "style/modal_specimen", locals: {
|
|
975
|
+
label: "Quest activity (leveling)",
|
|
976
|
+
reference: %(a generic quest activity WITH leveling (studio-engine studio/modals/blocks/_leveling_activity, leveling: true) — here a "Join the Newsletter" quest: quest pill + seeds celebration on complete. Covers join-newsletter / send-message and the rest as ONE parameterized primitive. Open with $store.dsModals.open('quest-activity')),
|
|
977
|
+
open_expr: "$store.dsModals.open('quest-activity')",
|
|
978
|
+
glow_when: ds_glow.call("quest-activity") } do %>
|
|
979
|
+
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
|
|
980
|
+
<div class="flex items-center justify-between gap-2">
|
|
981
|
+
<span class="text-lg leading-none">📬</span>
|
|
982
|
+
<span class="text-[8px] font-semibold uppercase rounded-full px-1.5 py-0.5" style="color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 12%, transparent)">Quest 2/4</span>
|
|
983
|
+
</div>
|
|
984
|
+
<span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
|
|
985
|
+
<span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
|
|
986
|
+
</div>
|
|
987
|
+
<% end %>
|
|
988
|
+
|
|
989
|
+
<%# Quest activity — leveling OFF: the plain action (no quest, no seeds). %>
|
|
990
|
+
<%= render layout: "style/modal_specimen", locals: {
|
|
991
|
+
label: "Quest activity (plain)",
|
|
992
|
+
reference: %(the SAME generic activity WITHOUT leveling (studio-engine studio/modals/blocks/_leveling_activity, leveling: false) — a plain "Join the Newsletter" action: input + Subscribe, then Subscribed. No quest pill, no seeds. Open with $store.dsModals.open('quest-activity-plain')),
|
|
993
|
+
open_expr: "$store.dsModals.open('quest-activity-plain')",
|
|
994
|
+
glow_when: ds_glow.call("quest-activity-plain") } do %>
|
|
995
|
+
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
|
|
996
|
+
<span class="text-lg leading-none">📬</span>
|
|
997
|
+
<span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
|
|
998
|
+
<span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
|
|
999
|
+
</div>
|
|
1000
|
+
<% end %>
|
|
1001
|
+
</div>
|
|
1002
|
+
</section>
|
|
862
1003
|
</section>
|
|
@@ -259,6 +259,52 @@
|
|
|
259
259
|
</div>
|
|
260
260
|
</section>
|
|
261
261
|
|
|
262
|
+
<!-- Confetti & pulse -->
|
|
263
|
+
<section class="space-y-5">
|
|
264
|
+
<div class="space-y-1">
|
|
265
|
+
<h3 class="text-xl font-bold text-heading">Confetti & pulse</h3>
|
|
266
|
+
<p class="text-muted text-sm max-w-2xl">
|
|
267
|
+
Celebration effects lifted from Turf Monster into the engine. Confetti is
|
|
268
|
+
<code class="font-mono text-2xs">window.studioConfetti</code> - a zero-dependency,
|
|
269
|
+
no-CDN effect (canvas-confetti is vendored into the engine and shipped through the
|
|
270
|
+
asset pipeline). Two callable variants, both honoring
|
|
271
|
+
<code class="font-mono text-2xs">prefers-reduced-motion</code>. The pulse is the
|
|
272
|
+
<code class="font-mono text-2xs">.pulse-cta</code> attention beat. Fire the confetti
|
|
273
|
+
with the buttons below.
|
|
274
|
+
</p>
|
|
275
|
+
</div>
|
|
276
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
277
|
+
<%= render layout: "style/specimen",
|
|
278
|
+
locals: { klass: "window.studioConfetti.burst(el)",
|
|
279
|
+
usage: %(window.studioConfetti.burst(cardEl)\n// radial burst from behind a card/modal, from its center) } do %>
|
|
280
|
+
<div x-data="{}" class="flex flex-col items-center gap-3 py-2">
|
|
281
|
+
<div x-ref="burstCard" class="card px-5 py-4 text-sm font-semibold text-heading text-center">Success!</div>
|
|
282
|
+
<button type="button" class="btn btn-primary btn-sm"
|
|
283
|
+
@click="window.studioConfetti && window.studioConfetti.burst($refs.burstCard)">Fire burst</button>
|
|
284
|
+
</div>
|
|
285
|
+
<% end %>
|
|
286
|
+
|
|
287
|
+
<%= render layout: "style/specimen",
|
|
288
|
+
locals: { klass: "window.studioConfetti.cannons()",
|
|
289
|
+
usage: %(window.studioConfetti.cannons()\n// left + right side-cannons, full-screen celebration) } do %>
|
|
290
|
+
<div x-data="{}" class="flex flex-col items-center gap-3 py-2">
|
|
291
|
+
<p class="text-sm font-semibold text-heading text-center">You joined a contest!</p>
|
|
292
|
+
<button type="button" class="btn btn-success btn-sm"
|
|
293
|
+
@click="window.studioConfetti && window.studioConfetti.cannons()">Fire cannons</button>
|
|
294
|
+
</div>
|
|
295
|
+
<% end %>
|
|
296
|
+
|
|
297
|
+
<%= render layout: "style/specimen",
|
|
298
|
+
locals: { klass: ".pulse-cta (knobs: --pulse-cta-color / -speed / -scale)",
|
|
299
|
+
usage: %(<button class="btn btn-primary pulse-cta">Change Username</button>\n<button class="btn btn-success pulse-cta" style="--pulse-cta-color: var(--color-success)">Go</button>) } do %>
|
|
300
|
+
<div class="flex flex-wrap items-center justify-center gap-4">
|
|
301
|
+
<button type="button" class="btn btn-primary pulse-cta">Change Username</button>
|
|
302
|
+
<button type="button" class="btn btn-success pulse-cta" style="--pulse-cta-color: var(--color-success)">Go</button>
|
|
303
|
+
</div>
|
|
304
|
+
<% end %>
|
|
305
|
+
</div>
|
|
306
|
+
</section>
|
|
307
|
+
|
|
262
308
|
<!-- Leveling (capability-gated: Studio.feature?(:leveling)) -->
|
|
263
309
|
<section class="space-y-5">
|
|
264
310
|
<div class="space-y-1">
|
data/lib/studio/engine.rb
CHANGED
data/lib/studio/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.22.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -170,7 +170,9 @@ files:
|
|
|
170
170
|
- README.md
|
|
171
171
|
- app/assets/images/resend-favicon.png
|
|
172
172
|
- app/assets/images/ses-favicon.png
|
|
173
|
+
- app/assets/javascripts/studio/canvas_confetti.js
|
|
173
174
|
- app/assets/javascripts/studio/sticky_table_header.js
|
|
175
|
+
- app/assets/javascripts/studio/studio_confetti.js
|
|
174
176
|
- app/assets/stylesheets/studio/sticky_table_header.css
|
|
175
177
|
- app/assets/tailwind/studio_engine/engine-motion.css
|
|
176
178
|
- app/assets/tailwind/studio_engine/engine.css
|
|
@@ -246,6 +248,7 @@ files:
|
|
|
246
248
|
- app/views/studio/_age_verify_assets.html.erb
|
|
247
249
|
- app/views/studio/_confirm_interstitial.html.erb
|
|
248
250
|
- app/views/studio/_cropper_assets.html.erb
|
|
251
|
+
- app/views/studio/_leveling_activity_assets.html.erb
|
|
249
252
|
- app/views/studio/admin_models/_arenas_table.html.erb
|
|
250
253
|
- app/views/studio/admin_models/_teams_table.html.erb
|
|
251
254
|
- app/views/studio/admin_models/index.html.erb
|
|
@@ -266,11 +269,13 @@ files:
|
|
|
266
269
|
- app/views/studio/modals/auth/_resend_footer.html.erb
|
|
267
270
|
- app/views/studio/modals/blocks/_age_verify.html.erb
|
|
268
271
|
- app/views/studio/modals/blocks/_card_header.html.erb
|
|
272
|
+
- app/views/studio/modals/blocks/_change_username.html.erb
|
|
269
273
|
- app/views/studio/modals/blocks/_cta_redirect.html.erb
|
|
270
274
|
- app/views/studio/modals/blocks/_digit_reel.html.erb
|
|
271
275
|
- app/views/studio/modals/blocks/_entry_confirmed.html.erb
|
|
272
276
|
- app/views/studio/modals/blocks/_error_card.html.erb
|
|
273
277
|
- app/views/studio/modals/blocks/_free_entry_earned.html.erb
|
|
278
|
+
- app/views/studio/modals/blocks/_leveling_activity.html.erb
|
|
274
279
|
- app/views/studio/modals/blocks/_onchain_success.html.erb
|
|
275
280
|
- app/views/studio/modals/blocks/_processing_card.html.erb
|
|
276
281
|
- app/views/studio/modals/blocks/_progress_countdown.html.erb
|