studio-engine 0.20.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3b84a5d11a22ad89cdbf077ba91c6d35374ba8a956c2fdc1207eb7033ec15c5
4
- data.tar.gz: 510f09d35fb5dd3562c9435c1df1f5cf8001b9a34dd12678eb32822f91ded766
3
+ metadata.gz: 15b4c45fed9c5a2fd6056fca154236cbc12b778c483af5922486e47fcec6baac
4
+ data.tar.gz: d1efa67d91b858e9ba81f9fea1cb63d4b73c1e76a97c08dd0a8ca2afcd0b4522
5
5
  SHA512:
6
- metadata.gz: 4f6da2882ad34a600e5855c659ba525bda3d191737389d1b8348e0eca8eb01b1da2a48d7df55dec5b5072879acf6ba236439c1ac99fac2c82216ac68b8df6af3
7
- data.tar.gz: dbe6979e76badd522119308b4c459b1ebd5a246fa549364b2b78d7ca86e8b828c93523899aaa0813f874d1bb95be37e4c6cd32dab492c60988dbae84051d9ceb
6
+ metadata.gz: ec7d708e3665fbccf82ee641f82110cc8adfda8dd19528b3748807697ae6127db02c9579e1ef414051e541ea28005cc6b525b554e46ab1d1abd11cc959f48e74
7
+ data.tar.gz: dbc43339aeaad12be683b260e4cfcac8aa70f9fbf3a23be136b161790ce4468c8711f1e45c2fd608257df4bf867427e7cf3f8b8e52b0100e218c6de6a85dce1c
data/CHANGELOG.md CHANGED
@@ -2,6 +2,151 @@
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
+
93
+ ## 0.21.0 — 2026-07-27
94
+
95
+ Phase 2 of the studio-engine modal convergence: homes the entry-time age-gate DOB
96
+ modal as a policy-free engine primitive, and documents the app-specific entry-token
97
+ purchase flow as a living-style-guide specimen.
98
+
99
+ ### Added
100
+
101
+ - **Age-gate DOB modal — `studio/modals/blocks/_age_verify`.** The entry-time
102
+ date-of-birth gate (Month / Day / Year + a live "too young" hint + submit),
103
+ homed in the engine as the heavier sibling of the existing
104
+ `studio/modals/shared/_age_attestation` checkbox. **Run one or the other:** the
105
+ attestation is a signup-time "I confirm I'm of legal age" checkbox; this is an
106
+ entry-time DOB gate a server can recompute and stamp.
107
+
108
+ **The engine hardcodes NO legal policy** — the whole seam is app-supplied:
109
+ `min_age` (REQUIRED, with **no engine default** — 18 is itself a policy value),
110
+ `submit_url` (the app owns the authoritative recompute + DOB persistence),
111
+ `state` (an optional PASSIVE jurisdiction label — the modal never detects
112
+ geography and never offers an editable state field, so a spoofed client state
113
+ can't lower the bar), and `fine_print` (the app's per-jurisdiction legal copy;
114
+ the engine ships only a neutral, policy-free default). The per-state age table
115
+ that the app's policy encodes never enters the engine.
116
+
117
+ - **`ageVerifyModal` factory — `studio/_age_verify_assets`.** The Alpine data for
118
+ the DOB modal, shipped at page level (a `<script>` cloned out of a modal-host
119
+ template never runs). A consumer renders it once in its layout, like the shared
120
+ alpine factories; mirrors how `studio/_cropper_assets` homes `cropPhotoModal`.
121
+ The factory carries zero policy — no age default, no state table; the client
122
+ age math is UX only, the server recompute stays authoritative.
123
+
124
+ - **Living style guide — new "Eligibility & entry" group.** Adds two specimens to
125
+ the `/admin/style` Modals section:
126
+ - The **age-gate** specimen, opened with a clearly-labelled DEMO policy (21+ in
127
+ CA) that documents the app-supplied seam, gated by
128
+ `Studio.feature?(:age_gate)` — disabled-but-present-yet-openable when off,
129
+ like the web3 specimens.
130
+ - The **entry-tokens** specimen — the app-specific purchase flow
131
+ (picker → confirming → minted) documented by composing engine chrome
132
+ (`shell`, `card_header`, `progress_pill`) with illustrative demo packs. The
133
+ engine does NOT own the packs, pricing, rails, or the on-chain mint. The copy
134
+ reflects the real on-chain model: an entry token is a **prepaid entry credit
135
+ recorded on-chain** (not a wallet-held SPL token), **minted server-side** once
136
+ payment clears and **consumed** when the app's hold-to-confirm creates the
137
+ entry.
138
+
139
+ ### Capability gating
140
+
141
+ - **`:age_gate`** — a new, independent capability flag gating the age-gate
142
+ specimen (and the intended consumer mount). Off by default; a consumer opts in
143
+ via `config.features`.
144
+
145
+ ### Unchanged
146
+
147
+ - Backward compatible: every addition is additive/optional. Nothing existing
148
+ changes behaviour, and the lighter `_age_attestation` checkbox is untouched.
149
+
5
150
  ## 0.20.0 — 2026-07-27
6
151
 
7
152
  Phase 1 of the studio-engine modal convergence: the engine now OWNS the wallet
@@ -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
- <script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js"></script>
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,120 @@
1
+ <%#
2
+ ageVerifyModal factory — the Alpine data for the engine age-gate DOB modal
3
+ (studio/modals/blocks/_age_verify). Ships here, at PAGE level, on purpose: the
4
+ modal partial mounts inside a modal host's single-root template, and a script
5
+ cloned out of a template never runs. So a consumer renders THIS once in its
6
+ layout (like the shared alpine factories), and the modal partial anywhere below
7
+ just references window.ageVerifyModal. Mirrors how studio/_cropper_assets homes
8
+ the cropPhotoModal factory for the crop modal.
9
+
10
+ The factory holds ZERO legal policy — no age default, no state table. minAge is
11
+ whatever the app hands it (a required prop upstream); the client "too young"
12
+ hint is UX only, and the server recompute at submit_url stays authoritative.
13
+
14
+ opts: { minAge, state, url, store, demo }.
15
+ minAge app-supplied minimum age (no engine fallback — 0 means unsupplied).
16
+ state passive jurisdiction label (server-detected upstream; never editable).
17
+ url app endpoint the DOB POSTs to (the app owns the authoritative check).
18
+ store Alpine modal store name backing close() (default "modals").
19
+ demo style-guide preview: resolve locally, skip the network POST.
20
+ %>
21
+ <script>
22
+ (function () {
23
+ if (window.ageVerifyModal) return;
24
+ window.ageVerifyModal = function (opts) {
25
+ opts = opts || {};
26
+ var now = new Date();
27
+ return {
28
+ month: "", day: "", year: "",
29
+ submitting: false,
30
+ error: "",
31
+ // No engine age default: 18 is itself a policy value. The app supplies it.
32
+ minAge: parseInt(opts.minAge, 10) || 0,
33
+ stateCode: opts.state || "",
34
+ url: opts.url || "",
35
+ store: opts.store || "modals",
36
+ demo: !!opts.demo,
37
+ months: [
38
+ { n: 1, label: "January" }, { n: 2, label: "February" }, { n: 3, label: "March" },
39
+ { n: 4, label: "April" }, { n: 5, label: "May" }, { n: 6, label: "June" },
40
+ { n: 7, label: "July" }, { n: 8, label: "August" }, { n: 9, label: "September" },
41
+ { n: 10, label: "October" }, { n: 11, label: "November" }, { n: 12, label: "December" }
42
+ ],
43
+ // Year range: 13 years ago (youngest plausible) back 100 years. This is a
44
+ // sane input range, not an age bar — the app's minAge decides eligibility.
45
+ years: (function () {
46
+ var ys = [], top = now.getFullYear() - 13, bottom = now.getFullYear() - 100;
47
+ for (var y = top; y >= bottom; y--) ys.push(y);
48
+ return ys;
49
+ })(),
50
+ // Days available for the chosen month/year (handles 28/29/30/31).
51
+ get dayOptions() {
52
+ var m = parseInt(this.month, 10), y = parseInt(this.year, 10);
53
+ var max = 31;
54
+ if (m) max = new Date(y || 2000, m, 0).getDate();
55
+ var ds = [];
56
+ for (var d = 1; d <= max; d++) ds.push(d);
57
+ return ds;
58
+ },
59
+ get complete() {
60
+ return !!(this.month && this.day && this.year);
61
+ },
62
+ get computedAge() {
63
+ if (!this.complete) return null;
64
+ var dob = new Date(parseInt(this.year, 10), parseInt(this.month, 10) - 1, parseInt(this.day, 10));
65
+ var age = now.getFullYear() - dob.getFullYear();
66
+ var hadBirthday = (now.getMonth() > dob.getMonth()) ||
67
+ (now.getMonth() === dob.getMonth() && now.getDate() >= dob.getDate());
68
+ if (!hadBirthday) age -= 1;
69
+ return age;
70
+ },
71
+ // Clamp an out-of-range day when the month changes (e.g. Feb after 31).
72
+ onMonthChange() {
73
+ if (this.day && parseInt(this.day, 10) > this.dayOptions.length) this.day = "";
74
+ this.error = "";
75
+ },
76
+ // Success path: flip a session flag if present, close via the app's store,
77
+ // and let the app resume its own flow (the entry gate listens for this).
78
+ _finish() {
79
+ try {
80
+ var sess = window.Alpine && Alpine.store && Alpine.store("session");
81
+ if (sess) sess.ageVerified = true;
82
+ } catch (e) {}
83
+ try { Alpine.store(this.store).close(); } catch (e) {}
84
+ window.dispatchEvent(new CustomEvent("age-verified"));
85
+ },
86
+ submit() {
87
+ if (!this.complete || this.submitting) return;
88
+ this.error = "";
89
+ // Client courtesy gate only — the server recompute is authoritative.
90
+ if (this.computedAge !== null && this.minAge && this.computedAge < this.minAge) return;
91
+ this.submitting = true;
92
+ var self = this;
93
+ if (this.demo) {
94
+ // Style-guide preview: no backend, resolve locally so the modal
95
+ // demonstrates the success handoff without an endpoint.
96
+ setTimeout(function () { self.submitting = false; self._finish(); }, 450);
97
+ return;
98
+ }
99
+ var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
100
+ fetch(this.url, {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
103
+ body: JSON.stringify({ year: this.year, month: this.month, day: this.day })
104
+ })
105
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
106
+ .then(function (res) {
107
+ self.submitting = false;
108
+ if (res.ok && res.body && res.body.verified) { self._finish(); return; }
109
+ self.error = (res.body && res.body.error) ||
110
+ "We couldn't verify your age. Please check the date and try again.";
111
+ })
112
+ .catch(function () {
113
+ self.submitting = false;
114
+ self.error = "Network error — please try again.";
115
+ });
116
+ }
117
+ };
118
+ };
119
+ })();
120
+ </script>