studio-engine 0.24.0 → 0.25.1

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: 2aed8dd0e8d45e41602035462f0b60d01414ac3ea2f41484cbff9d4a40384cb8
4
- data.tar.gz: e7aa1bfef279150c47cf3bd709de81b5b174f9027252a177a58a19e4609cf85c
3
+ metadata.gz: d87b5711dd0833ea78e20b6f3174dd57e5cc3b0311e56f962e8954a408943963
4
+ data.tar.gz: 81708f5604bdebaa3f08504169e4bf1a55ba8ea9d8856f04594dbe49295d553c
5
5
  SHA512:
6
- metadata.gz: 2b0ed377abc9501ce2a0fa19cc17d547e02498673ac15265ceac38555bf1fa241996e52c7dc5af337677a3436cfeb2209dd47f75fe70757bea96cebca21194e1
7
- data.tar.gz: 1bd498dc182950382bfe3dca7fb5056bf6e2ff5760161ee8edf5481ef3c444a35ca7d4c51fd682b5d62f8e6c30aec787297cf537daba0956b8aefcc810b6172c
6
+ metadata.gz: b3ffce4a4ba6b91444902123ee106f9f88fec2be0606d69f310ff464bfe336ca444de94f85c2c81873feb335080e2a9fc01390ff48df7f87977e645e0c3397ac
7
+ data.tar.gz: 6584381c67a206525171c7195ded0999f8ce20ad896196aa8259b116e450c7db2a23ed95193dcb8281e49f57f09c5010817ce67e127bcc3d10c517ca4a67ca16
data/CHANGELOG.md CHANGED
@@ -2,6 +2,114 @@
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.25.1 — 2026-07-28
6
+
7
+ **Fix the Profile Leveling save-close regression 0.25.0 introduced.** The 0.25.0
8
+ rebuild made `_finishSaved` ALWAYS advance to the engine's own updated view (the
9
+ seeds celebration or the plain confirmation), on the belief that Turf Monster's
10
+ live usage was "unaffected." It was not: TM's change-username modal
11
+ (`leveling: false`, `saved_event: "studio:username-saved"`) drives its OWN
12
+ post-save follow-on — its listener runs `completeQuest` and closes/swaps. With
13
+ the engine now also advancing to its own confirm view, the next quest step
14
+ ("Send Your First Message") rendered TWICE at once — once in the post-save modal
15
+ dialog, once in the inline quest card behind it — which TM's Playwright e2e
16
+ caught as a strict-mode double-render (`quest_ladder_web2`, `quest_ladder_web3`,
17
+ and the web3 step-dedup contract). In 0.24 a `leveling: false` modal rendered no
18
+ celebrate view and let the app close it, so the step rendered once.
19
+
20
+ ### Fixed
21
+
22
+ - **`studio/_leveling_activity_assets`** (factory) — `_finishSaved` now CLOSES on
23
+ save (fires the `saved_event`, then stops — the 0.24 contract) instead of
24
+ advancing to the engine's own confirm/celebrate WHEN the consuming app drives
25
+ its own follow-on. A new `appDrivenFollowOn` getter is that signal: a **non-demo**
26
+ caller that wired its OWN (non-default) `saved_event` owns the after-save UI, so
27
+ the engine yields (it never force-closes — the app's listener owns close-vs-swap,
28
+ which TM relies on: close on the contest page, swap to `quest-success` on
29
+ `/account`). **Demo** previews (`/admin/style`) and **standalone** callers on the
30
+ DEFAULT `saved_event` are unchanged — they still advance to the seeds celebration
31
+ (TM shape) or the plain confirmation (MS shape), and the "Great Username" /
32
+ "Subscribed!" specimen cards still open straight at their celebrate state via
33
+ `props.celebrate`. No consumer change is required; TM's existing wiring restores
34
+ its own contract.
35
+
36
+
37
+
38
+ **Profile Leveling** — the `/admin/style` "Leveling activities" section is rebuilt
39
+ into a single toggle-driven "Profile Leveling" flow, and the with-leveling /
40
+ without-leveling FORK is killed. Previously each activity shipped as TWO modal
41
+ ids (`change-username` + `change-username-plain`, `quest-activity` +
42
+ `quest-activity-plain`), because the primitive read `leveling` from a Ruby local
43
+ at render time. Now the primitive reads `leveling` at RUNTIME from the modal
44
+ store's `props.leveling`, so ONE id per activity flips the Turf Monster shape
45
+ (the seeds celebration + Free Entry Token progress) ↔ the McRitchie Studio shape
46
+ (plain input + Save, then a simple confirmation) live as its per-card leveling
47
+ toggle moves.
48
+
49
+ ### Changed
50
+
51
+ - **`studio/modals/blocks/_leveling_activity`** — three views now, all ALWAYS
52
+ rendered and gated by the reactive `leveling` getter instead of dropped at ERB
53
+ time: the **form** (`!celebrate`), the **seeds celebration**
54
+ (`celebrate && leveling`, TM), and a **plain confirmation**
55
+ (`celebrate && !leveling`, MS). The `leveling` local becomes the render-time
56
+ fallback only. **The "Quest N of N" counter pill is removed** — the profile
57
+ modals do not carry one. **The "Level N" pill is also dropped** from the
58
+ celebration (no consumer used it); the seeds bar stays. All existing seams
59
+ are preserved unchanged (`submit_url`, `finalize_hook`, `saved_event`,
60
+ `modal_store`, `demo`, seeds/level props), so Turf Monster's live usage and the
61
+ opaque save-callback contract are unaffected (TM ships no `props.leveling` and
62
+ keeps its `:leveling`-capability default).
63
+ - **`studio/_leveling_activity_assets`** (factory) — `leveling` is a runtime getter
64
+ reading `props.leveling` off the modal store (falling back to `_levelingDefault`);
65
+ an `init` hook opens the modal directly at the updated state via `props.celebrate`
66
+ (the "updated" cards open the same id pre-advanced, like the Auth step cards);
67
+ `_finishSaved` always advances to the updated state (the view gate picks
68
+ celebration vs plain confirmation) AND mirrors `celebrate` onto the live store
69
+ `props.celebrate`, so the active-card **glow follows the flow** — it transfers
70
+ from the input card to the updated card as the modal advances, exactly like the
71
+ Auth glow follows `props.step`. In **demo** mode the app-facing saved event is
72
+ suppressed, so a host app's follow-on handler (e.g. Turf Monster's
73
+ `quest-success` on `studio:username-saved`) can't stack a second modal over the
74
+ demo's own celebrate view.
75
+ - **`/admin/style`** — "Leveling activities" → **"Profile Leveling"**: **each
76
+ specimen card carries its OWN `Leveling` toggle** (like the Auth card's method
77
+ checkboxes), flipping THAT card between the Turf Monster and McRitchie Studio
78
+ shape at open — so you can preview Change Username leveling-on beside Great
79
+ Username leveling-off. The section walks **four** cards — **Change Username →
80
+ Great Username**, then **Join the Newsletter → Subscribed!** — each "updated"
81
+ card opening its modal straight at the success state, mirroring Turf Monster's
82
+ real copy, seeds, and Free Entry Token progress. No quest counter, no Level pill.
83
+
84
+ ### Added
85
+
86
+ - **`_leveling_activity` locals** (all optional, additive): `consent_label` (a
87
+ consent checkbox that gates the action — the newsletter join), `confirm_subtitle`
88
+ (subtext on the MS-shape plain confirmation), `next_label` / `next_open` (a
89
+ "Next Quest" button that walks to the next activity), and `demo_seeds_earned` /
90
+ `demo_seeds_total` (style-guide-only seed payload tuning).
91
+
92
+ ## 0.24.1 — 2026-07-28
93
+
94
+ Bug fix — the **`/admin/style` specimen glow failed OPEN**. Every glow-capable
95
+ specimen card (`style/_modal_specimen`) carries the `.studio-team-glow` class
96
+ statically, and the "only the active card glows" behavior rode entirely on a
97
+ reactive Alpine `:style` overriding the primitive's CSS default of
98
+ `--studio-team-glow-opacity: 1` (visible) down to `0`. Before Alpine hydrates —
99
+ or if it never loads — all 13 reactive-glow cards (Auth, Eligibility, Profile,
100
+ Leveling) painted their ring at once. The steady state was correct (the glow
101
+ follows the step machine), so this surfaced as a flash-of-all-glow on load.
102
+
103
+ ### Fixed
104
+
105
+ - **`style/_modal_specimen`** now renders a **static inline
106
+ `style="--studio-team-glow-opacity: 0"`** on the glow wrapper beside the
107
+ reactive `:style`, so the ring is OFF at first paint (fail-closed) regardless
108
+ of Alpine timing. The reactive binding still drives `0 ↔ 0.95` with the 0.4s
109
+ cross-fade, so the active-card glow and its slide between step cards are
110
+ unchanged. The `engine-motion.css` `.studio-team-glow` default is untouched
111
+ (the always-on Tricks demos depend on it).
112
+
5
113
  ## 0.24.0 — 2026-07-28
6
114
 
7
115
  The **smooth-load convention**, opt-in per app. Pages materialize behind the
@@ -37,14 +37,34 @@
37
37
  if none); it owns EVERY app-side detail of that step.
38
38
  savedEvent DOM event dispatched on success, carrying the app payload, so
39
39
  the app can run its own follow-on. Default "studio:activity-saved".
40
+ A non-demo caller that supplies its OWN (non-default) saved_event
41
+ is declaring it drives that follow-on: the engine then CLOSES on
42
+ save (fires the event, stops) rather than rendering its own
43
+ confirm/celebrate, so the app's follow-on never renders twice.
40
44
  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.
45
+ leveling the render-time DEFAULT for the seeds celebration. The live
46
+ value is read at RUNTIME from the modal store's current
47
+ props.leveling (this.leveling getter), so one modal id flips
48
+ TM (celebration) <-> MS (plain save) as a toggle moves; when
49
+ props omits it (TM's live path) the default stands.
50
+ hasConsent true renders a consent checkbox that gates the action until
51
+ ticked (the newsletter join).
52
+ demo style-guide preview: resolve locally, skip the network POST, and
53
+ suppress the app-facing saved event (so a host app's follow-on
54
+ handler can't stack a second modal over the demo's own state).
43
55
  seedsPerLevel seeds per level for the celebration bar. Default 100.
56
+ demoSeedsEarned / demoSeedsTotal demo-only seed payload (defaults to a
57
+ level-crossing 30 / seedsPerLevel) so each activity's
58
+ celebration can showcase a chosen state.
44
59
  %>
45
60
  <script>
46
61
  (function () {
47
62
  if (window.levelingActionModal) return;
63
+ // The default saved event. A non-demo caller that wires its OWN (non-default)
64
+ // saved_event is declaring it drives the post-save follow-on itself — see the
65
+ // appDrivenFollowOn getter, which then makes the engine close on save instead of
66
+ // rendering its own confirm/celebrate.
67
+ var DEFAULT_SAVED_EVENT = "studio:activity-saved";
48
68
  window.levelingActionModal = function (opts) {
49
69
  opts = opts || {};
50
70
  return {
@@ -55,11 +75,19 @@
55
75
  submitUrl: opts.submitUrl || "",
56
76
  finalizeUrl: opts.finalizeUrl || "",
57
77
  finalizeHook: opts.finalizeHook || "",
58
- savedEvent: opts.savedEvent || "studio:activity-saved",
78
+ savedEvent: opts.savedEvent || DEFAULT_SAVED_EVENT,
59
79
  store: opts.store || "modals",
60
- leveling: !!opts.leveling,
80
+ // leveling is a RUNTIME getter (below) reading props.leveling off the modal
81
+ // store, so ONE modal id flips TM <-> MS shape live as a section toggle
82
+ // moves. _levelingDefault is the render-time fallback (TM ships no
83
+ // props.leveling and relies on its :leveling capability default).
84
+ _levelingDefault: !!opts.leveling,
85
+ hasConsent: !!opts.hasConsent,
86
+ consent: false,
61
87
  demo: !!opts.demo,
62
88
  seedsPerLevel: parseInt(opts.seedsPerLevel, 10) || 100,
89
+ demoSeedsEarned: parseInt(opts.demoSeedsEarned, 10),
90
+ demoSeedsTotal: parseInt(opts.demoSeedsTotal, 10),
63
91
  saving: false,
64
92
  error: "",
65
93
  saved: false,
@@ -68,16 +96,71 @@
68
96
  seedsEarned: 0,
69
97
  seedsTotal: 0,
70
98
 
99
+ // Open directly AT the updated/celebrate state — the specimen's "updated"
100
+ // cards (Great Username / Subscribed!) open the same modal id pre-advanced,
101
+ // exactly like the Auth cards open a given step. props.celebrate mounts the
102
+ // modal already saved, with the seed payload the celebration animates from.
103
+ // leveling (a getter) then decides at runtime whether that state renders the
104
+ // seeds celebration (TM) or the plain confirmation (MS).
105
+ init: function () {
106
+ var s = (window.Alpine && Alpine.store) ? Alpine.store(this.store) : null;
107
+ var cur = (s && typeof s.current === "function") ? s.current() : null;
108
+ var p = (cur && cur.props) || {};
109
+ if (p.celebrate) {
110
+ this.seedsEarned = parseInt(p.seedsEarned, 10) || 0;
111
+ this.seedsTotal = parseInt(p.seedsTotal, 10) || 0;
112
+ this.saved = true;
113
+ this.celebrate = true;
114
+ }
115
+ },
116
+
117
+ // leveling — resolved at RUNTIME from the modal store's current props, so a
118
+ // single modal id flips TM (seeds/level/quest) <-> MS (plain save) live as
119
+ // the section's leveling toggle moves. Falls back to the render-time default
120
+ // when props does not set it (TM's live path), so existing callers are
121
+ // unaffected.
122
+ get leveling() {
123
+ var s = (window.Alpine && Alpine.store) ? Alpine.store(this.store) : null;
124
+ var cur = (s && typeof s.current === "function") ? s.current() : null;
125
+ var p = cur && cur.props;
126
+ if (p && typeof p.leveling === "boolean") return p.leveling;
127
+ return this._levelingDefault;
128
+ },
129
+
71
130
  // Actionable while there is a real change to save (input activities) or
72
- // until the one-shot action has fired (no-field activities).
131
+ // until the one-shot action has fired (no-field activities). A consent
132
+ // checkbox (the newsletter join) additionally gates the action until ticked.
73
133
  get changed() {
134
+ if (this.hasConsent && !this.consent) return false;
74
135
  if (!this.hasInput) return !this.saved;
75
136
  var v = (this.value || "").trim();
76
137
  if (v.length < this.minLength) return false;
77
138
  return v !== (this.original || "").trim();
78
139
  },
79
140
 
141
+ // appDrivenFollowOn — a real (non-demo) consumer that wired its OWN saved_event
142
+ // owns the after-save UI: its listener runs the follow-on (e.g. Turf Monster's
143
+ // completeQuest / quest-success on 'studio:username-saved'), including whether
144
+ // to close, swap, or advance. For those the engine must NOT advance to its own
145
+ // confirm/celebrate on save — that double-renders the app's follow-on step
146
+ // (once in this dialog, once in the app's own inline card: the strict-mode
147
+ // double-render TM's e2e caught). The engine yields: it fires the saved event,
148
+ // then STOPS (the 0.24 contract), letting the app drive. Demo previews (self-
149
+ // contained) and standalone callers on the DEFAULT event keep the engine's own
150
+ // confirm/celebrate.
151
+ get appDrivenFollowOn() {
152
+ if (this.demo) return false;
153
+ return this.savedEvent !== DEFAULT_SAVED_EVENT;
154
+ },
155
+
80
156
  _dispatch: function (payload) {
157
+ // In demo mode (the /admin/style preview) do NOT fire the app-facing
158
+ // saved event: the host app may listen for it and open its OWN follow-on
159
+ // modal (e.g. Turf Monster swaps to 'quest-success' on
160
+ // 'studio:username-saved'), which would stack a SECOND modal over the
161
+ // demo's own celebrate view — the reported duplicate. The demo resolves
162
+ // locally and is self-contained, so it must not drive real app follow-ons.
163
+ if (this.demo) return;
81
164
  try {
82
165
  window.dispatchEvent(new CustomEvent(this.savedEvent, { detail: payload || {} }));
83
166
  } catch (e) {}
@@ -90,11 +173,35 @@
90
173
  this.progressLabel = "";
91
174
  if (this.hasInput) this.original = (this.value || "").trim();
92
175
  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
- }
176
+ // App-driven follow-on: the consumer wired its own saved_event and owns
177
+ // what comes next (its listener advances/closes/swaps). Dispatch fired
178
+ // above; STOP here — the 0.24 contract instead of advancing to the
179
+ // engine's own confirm/celebrate, which would render the app's follow-on
180
+ // step twice. The engine never force-closes here: the app's listener owns
181
+ // close-vs-swap (TM closes on the contest page, swaps to quest-success on
182
+ // /account), so a force-close would clobber that swap.
183
+ if (this.appDrivenFollowOn) return;
184
+ // Otherwise (a demo preview, or a standalone caller on the DEFAULT event
185
+ // with no app follow-on): advance to the updated state; the view gate
186
+ // (celebrate && leveling vs celebrate && !leveling) picks the seeds
187
+ // celebration (TM shape) or the plain confirmation (MS shape). Seeds are
188
+ // set either way — only the leveling view reads them.
189
+ this.seedsEarned = parseInt(payload.seeds_earned, 10) || 0;
190
+ this.seedsTotal = parseInt(payload.seeds_total, 10) || 0;
191
+ this.celebrate = true;
192
+ // Reflect the advance onto the store's LIVE current props so the specimen
193
+ // glow (which reads props.celebrate) transfers from the input card to the
194
+ // updated card — exactly like the Auth glow follows props.step as its
195
+ // machine advances. Without this, the glow stays on the input card.
196
+ this._syncCelebrate(true);
197
+ },
198
+
199
+ // Mirror the component's celebrate onto the current modal store entry's
200
+ // props, the reactive source the specimen glow_when reads.
201
+ _syncCelebrate: function (value) {
202
+ var s = (window.Alpine && Alpine.store) ? Alpine.store(this.store) : null;
203
+ var cur = (s && typeof s.current === "function") ? s.current() : null;
204
+ if (cur && cur.props) cur.props.celebrate = !!value;
98
205
  },
99
206
 
100
207
  _post: function (url, body) {
@@ -115,10 +222,14 @@
115
222
  this.error = "";
116
223
  var self = this;
117
224
  if (this.demo) {
118
- // Style-guide preview: no backend. Resolve to a level-crossing payload
119
- // so the leveling celebration demonstrates its full flourish.
225
+ // Style-guide preview: no backend. Resolve to a per-activity seed
226
+ // payload (defaulting to a level-crossing one) so the celebration
227
+ // demonstrates its flourish — Username stays at Level 1 (+25), the
228
+ // Newsletter crosses a level to reveal the Free Entry Token line.
229
+ var de = isNaN(self.demoSeedsEarned) ? 30 : self.demoSeedsEarned;
230
+ var dt = isNaN(self.demoSeedsTotal) ? self.seedsPerLevel : self.demoSeedsTotal;
120
231
  setTimeout(function () {
121
- self._finishSaved({ seeds_earned: 30, seeds_total: self.seedsPerLevel });
232
+ self._finishSaved({ seeds_earned: de, seeds_total: dt });
122
233
  }, 600);
123
234
  return;
124
235
  }
@@ -40,7 +40,7 @@
40
40
  saved_event: "studio:username-saved",
41
41
  icon_emoji: "🌱",
42
42
  celebrate_title: "Great Username",
43
- celebrate_subtitle: "Nice — you earned seeds for updating your profile. Keep completing quests to earn a Free Entry."
43
+ celebrate_subtitle: "Nice — you earned 25 seeds by completing a quest. Complete more quests to earn a Free Entry Token."
44
44
  )
45
45
  %>
46
46
  <%= render "studio/modals/blocks/leveling_activity", username_locals %>
@@ -1,16 +1,23 @@
1
1
  <%#
2
- Leveling-activity modal — the engine primitive behind TM's quest/leveling
2
+ Leveling-activity modal — the engine primitive behind TM's profile-leveling
3
3
  activities (change username, join newsletter, and the rest). ONE primitive,
4
- TWO modes, decided by the app's :leveling capability:
4
+ three views (form / seeds celebration / plain confirm), TWO modes decided by the
5
+ app's :leveling capability:
5
6
 
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.)
7
+ leveling ON — the action, then on success the seeds celebration: a "Level N"
8
+ pill + the seeds progress bar (level-up + Free Entry Token).
9
+ leveling OFF — the plain action, then a simple confirmation on success (a
10
+ "Saved" / "Subscribed" card). No Level pill, no seeds. (This is
11
+ the McRitchie-Studio shape: MS has no leveling, TM does.)
11
12
 
12
- Same primitive; the flag decides. The leveling chrome (quest pill, seeds bar) is
13
- engine-owned UI — it is NOT on-chain.
13
+ There is NO "Quest N of N" counter the profile modals do not carry one.
14
+
15
+ Same primitive; the flag decides — and it decides at RUNTIME. The leveling chrome
16
+ (Level pill, seeds celebration) is ALWAYS rendered and gated by the reactive
17
+ leveling getter (which reads props.leveling off the modal store, falling back to
18
+ this local), so ONE modal id flips TM <-> MS live as a toggle moves — no more
19
+ `-plain` duplicate ids. The leveling chrome is engine-owned UI — it is NOT
20
+ on-chain.
14
21
 
15
22
  CRITICAL BOUNDARY — UI ONLY. There is NO wallet, signing, key, or on-chain write
16
23
  anywhere in this primitive. The actual save is an APP-SUPPLIED callback: the
@@ -36,17 +43,27 @@
36
43
  finalize_hook (String) — window async fn name for the step (omit if none).
37
44
  saved_event (String) — DOM event dispatched on success. Default
38
45
  "studio:activity-saved".
39
- quest_label (String) — the "Quest N of N" pill text (leveling only).
40
46
  icon_emoji (String) — celebration glyph. Default "🌱".
41
47
  celebrate_title (String) — celebration headline. Default "Activity complete".
42
48
  celebrate_subtitle (String) — celebration subtext.
43
49
  free_entry_label (String) — level-up reward line. Default "Free Entry Earned 🎟️".
44
50
  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.
51
+ leveling (Boolean) — the RENDER-TIME DEFAULT for the :leveling gate
52
+ (defaults to Studio.feature?(:leveling)). The LIVE value is read
53
+ at runtime from the modal store's props.leveling, so a single
54
+ modal id flips TM <-> MS as a toggle moves.
55
+ consent_label (String) — render a consent checkbox that gates the action until
56
+ ticked (the newsletter join). Omit for no checkbox.
57
+ confirm_subtitle (String) — subtext on the MS-shape (leveling off) plain
58
+ confirmation. Omit = none.
59
+ next_label / next_open — an optional "Next Quest" button on the celebration;
60
+ next_open is a developer-authored Alpine expression (e.g. swap to
61
+ the next activity). Both required to render the button.
62
+ demo_seeds_earned / demo_seeds_total — style-guide-only seed payload for the
63
+ demo save (defaults to a level-crossing 30 / seeds_per_level).
48
64
  modal_store (String) — Alpine store name backing close(). Default "modals".
49
- demo (Boolean) — style-guide preview: resolve locally, no POST.
65
+ demo (Boolean) — style-guide preview: resolve locally, no POST, and
66
+ no app-facing saved event (no host follow-on can stack a modal).
50
67
 
51
68
  Single root element (the modal host mounts this through its own template x-if).
52
69
  %>
@@ -68,7 +85,6 @@
68
85
  finalize_url = local_assigns.fetch(:finalize_url, "")
69
86
  finalize_hook = local_assigns.fetch(:finalize_hook, "")
70
87
  saved_event = local_assigns.fetch(:saved_event, "studio:activity-saved")
71
- quest_label = local_assigns.fetch(:quest_label, nil)
72
88
  icon_emoji = local_assigns.fetch(:icon_emoji, "🌱")
73
89
  celebrate_title = local_assigns.fetch(:celebrate_title, "Activity complete")
74
90
  celebrate_subtitle = local_assigns.fetch(:celebrate_subtitle, "Nice — you earned seeds. Keep completing quests to earn a Free Entry.")
@@ -77,6 +93,17 @@
77
93
  leveling = local_assigns.fetch(:leveling, Studio.feature?(:leveling))
78
94
  modal_store = local_assigns.fetch(:modal_store, "modals")
79
95
  demo = local_assigns.fetch(:demo, false)
96
+ # consent_label — render a consent checkbox that gates the action (newsletter
97
+ # join). next_label / next_open — an optional "Next Quest" button in the
98
+ # celebration (next_open is a developer-authored Alpine expression). demo_seeds_*
99
+ # — style-guide-only tuning.
100
+ consent_label = local_assigns.fetch(:consent_label, nil)
101
+ next_label = local_assigns.fetch(:next_label, nil)
102
+ next_open = local_assigns[:next_open].presence&.to_s&.html_safe
103
+ # confirm_subtitle — the MS-shape (leveling off) plain-confirmation subtext.
104
+ confirm_subtitle = local_assigns.fetch(:confirm_subtitle, nil)
105
+ demo_seeds_earned = local_assigns.fetch(:demo_seeds_earned, nil)
106
+ demo_seeds_total = local_assigns.fetch(:demo_seeds_total, nil)
80
107
  %>
81
108
  <div x-data="levelingActionModal({
82
109
  hasInput: <%= input ? 'true' : 'false' %>,
@@ -88,20 +115,17 @@
88
115
  savedEvent: '<%= j saved_event %>',
89
116
  store: '<%= j modal_store %>',
90
117
  leveling: <%= leveling ? 'true' : 'false' %>,
118
+ hasConsent: <%= consent_label.present? ? 'true' : 'false' %>,
91
119
  demo: <%= demo ? 'true' : 'false' %>,
92
- seedsPerLevel: <%= seeds_per_level.to_i %>
120
+ seedsPerLevel: <%= seeds_per_level.to_i %>,
121
+ demoSeedsEarned: <%= demo_seeds_earned.present? ? demo_seeds_earned.to_i : 'null' %>,
122
+ demoSeedsTotal: <%= demo_seeds_total.present? ? demo_seeds_total.to_i : 'null' %>
93
123
  })">
94
124
 
95
125
  <%# FORM VIEW — the action. Single root inside the template. %>
96
126
  <template x-if="!celebrate">
97
127
  <div>
98
128
  <%= 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
129
  <% if description.present? %>
106
130
  <p class="text-sm text-muted mb-4"><%= description %></p>
107
131
  <% end %>
@@ -120,6 +144,12 @@
120
144
  <%= "title=\"#{pattern_title}\"".html_safe if pattern_title.present? %>
121
145
  autofocus>
122
146
  <% end %>
147
+ <% if consent_label.present? %>
148
+ <label class="flex items-center gap-2 text-xs text-secondary select-none cursor-pointer">
149
+ <input type="checkbox" x-model="consent" class="rounded border-subtle text-primary cursor-pointer">
150
+ <span><%= consent_label %></span>
151
+ </label>
152
+ <% end %>
123
153
  <button type="submit" class="btn btn-primary btn-lg w-full" :disabled="saving || !changed">
124
154
  <span x-show="!saving" x-text="changed ? '<%= j cta_label %>' : '<%= j saved_label %>'"><%= cta_label %></span>
125
155
  <span x-show="saving" class="inline-flex items-center justify-center gap-2" style="display: none;">
@@ -132,28 +162,48 @@
132
162
  </div>
133
163
  </template>
134
164
 
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 %>
165
+ <%# CELEBRATION VIEW — leveling-only chrome. ALWAYS rendered now (so a runtime
166
+ leveling toggle can reveal it); the x-if gates on BOTH celebrate AND the
167
+ reactive leveling getter, so an MS-shape modal (leveling off) shows the plain
168
+ saved state and never this. Mounts once celebrate flips true so the seeds bar
169
+ animates from the freshly-saved payload. Single root. %>
170
+ <template x-if="celebrate && leveling">
171
+ <div>
172
+ <%= render "studio/modals/blocks/card_header",
173
+ size: :lg, icon_emoji: icon_emoji,
174
+ title: celebrate_title, subtitle: celebrate_subtitle %>
145
175
 
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>
176
+ <div class="mb-5">
177
+ <%= render "studio/modals/blocks/seeds_bar",
178
+ seeds_earned_key: "seedsEarned", seeds_total_key: "seedsTotal",
179
+ seeds_per_level: seeds_per_level, free_entry_label: free_entry_label %>
180
+ </div>
151
181
 
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
182
+ <% if next_label.present? && next_open.present? %>
183
+ <button type="button" @click="<%= next_open %>" class="btn btn-primary btn-lg w-full">
184
+ <%= next_label %>
155
185
  </button>
156
- </div>
157
- </template>
158
- <% end %>
186
+ <% end %>
187
+ <button type="button" @click="$store.<%= modal_store %>.close()"
188
+ class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
189
+ Close
190
+ </button>
191
+ </div>
192
+ </template>
193
+
194
+ <%# PLAIN CONFIRM VIEW — the MS shape (leveling off) after an update: a simple
195
+ confirmation, NO seeds / Level / celebration chrome. Gated celebrate &&
196
+ !leveling, so the single leveling toggle flips this <-> the seeds celebration
197
+ above, live. Single root. %>
198
+ <template x-if="celebrate && !leveling">
199
+ <div>
200
+ <%= render "studio/modals/blocks/card_header",
201
+ size: :lg, icon_color: "success",
202
+ title: saved_label, subtitle: confirm_subtitle %>
203
+ <button type="button" @click="$store.<%= modal_store %>.close()"
204
+ class="btn btn-primary btn-lg w-full">
205
+ Done
206
+ </button>
207
+ </div>
208
+ </template>
159
209
  </div>
@@ -55,7 +55,12 @@
55
55
  height via the grid's default align-items: stretch). %>
56
56
  <div class="relative h-full<%= " studio-team-glow rounded-xl" if glow_when %>"
57
57
  x-data="<%= card_x_data %>"
58
- <% if glow_when %>:style="{ '--studio-team-glow-opacity': (<%= glow_when %>) ? '0.95' : '0' }"<% end %>>
58
+ <%# Fail-closed default: a STATIC inline 0 out-specifies the primitive's CSS
59
+ default (--studio-team-glow-opacity of 1) so the ring is OFF at first
60
+ paint, BEFORE Alpine hydrates the reactive :style below. Without it every
61
+ glow card shows its ring until Alpine boots (the fail-open flash). The
62
+ :style then drives 0 to 0.95 with the 0.4s cross-fade. Keep BOTH. %>
63
+ <% if glow_when %>style="--studio-team-glow-opacity: 0" :style="{ '--studio-team-glow-opacity': (<%= glow_when %>) ? '0.95' : '0' }"<% end %>>
59
64
  <article
60
65
  class="card overflow-hidden flex flex-col h-full<%= " opacity-95" if disabled %><%= " cursor-pointer" if clickable %>"
61
66
  <%= 'aria-disabled="true"'.html_safe if disabled %>
@@ -47,11 +47,14 @@
47
47
  # variants (:picker vs :crop). The crop sub-state rides props.cropReady — the
48
48
  # crop factory sets it true when the cropper mounts (on a direct image OR after
49
49
  # an in-modal pick), so the glow moves picker -> cropper as the flow advances.
50
- ds_glow = lambda do |id, step: nil, crop: nil|
50
+ ds_glow = lambda do |id, step: nil, crop: nil, celebrate: nil|
51
51
  cur = "$store.dsModals.current()"
52
52
  expr = "#{cur} && #{cur}.id === '#{id}'"
53
53
  expr += " && (#{cur}.props.step || 'credentials') === '#{step}'" if step
54
54
  expr += (crop == :crop ? " && !!#{cur}.props.cropReady" : " && !#{cur}.props.cropReady") unless crop.nil?
55
+ # celebrate discriminates the input specimen (form) from the "updated" specimen
56
+ # (celebrate) of the SAME leveling modal id, mirroring the step/crop sub-states.
57
+ expr += (celebrate ? " && !!#{cur}.props.celebrate" : " && !#{cur}.props.celebrate") unless celebrate.nil?
55
58
  expr
56
59
  end
57
60
  %>
@@ -256,7 +259,7 @@
256
259
  <%= render "studio/age_verify_assets" %>
257
260
 
258
261
  <%# levelingActionModal factory — page level (never inside the overlay template,
259
- where a cloned script would not run) so the change-username + quest-activity
262
+ where a cloned script would not run) so the change-username + join-newsletter
260
263
  specimens open live. A consumer app renders this in its layout the same way. %>
261
264
  <%= render "studio/leveling_activity_assets" %>
262
265
 
@@ -381,47 +384,46 @@
381
384
  subtitle: "You leveled up! A Free Entry lands in your account soon. Keep leveling for more." %></div>
382
385
  </template>
383
386
 
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). %>
387
+ <%# --- Profile Leveling: change username + join newsletter --- %>
388
+ <%# ONE id per activity — opened at the form OR (with props.celebrate) at the
389
+ "updated" state, exactly like the Auth cards open a given step. The
390
+ primitive reads props.leveling at RUNTIME, so each card's own Leveling
391
+ toggle flips TM (the seeds celebration) <-> MS (plain confirmation) at
392
+ open no more `-plain` twins, and no "Quest N of N" counter. All
393
+ demo:true (the save resolves locally); the on-chain-accurate copy is an
394
+ app-authored description (the engine default stays neutral). The two
395
+ activities walk in sequence via the celebration's "Next Quest"
396
+ (Username -> Newsletter -> close). %>
390
397
  <template x-if="$store.dsModals.current().id === 'change-username'">
391
398
  <div><%= render "studio/modals/blocks/change_username",
392
399
  modal_store: "dsModals", demo: true, leveling: true,
393
400
  current_username: "turfmonster",
394
401
  submit_url: "#demo-change-username",
395
402
  description: "Your username is stored on-chain. Changes are saved via your managed wallet.",
396
- quest_label: "Quest 1 of 4" %></div>
403
+ cta_label: "Save", saved_label: "Saved",
404
+ demo_seeds_earned: 25, demo_seeds_total: 25,
405
+ free_entry_label: "Free Entry Token 🎟️",
406
+ confirm_subtitle: "Your username has been updated.",
407
+ next_label: "Next Quest",
408
+ next_open: "$store.dsModals.swap('join-newsletter', { demo: true, leveling: true })" %></div>
397
409
  </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'">
410
+ <template x-if="$store.dsModals.current().id === 'join-newsletter'">
406
411
  <div><%= render "studio/modals/blocks/leveling_activity",
407
412
  modal_store: "dsModals", demo: true, leveling: true,
408
- submit_url: "#demo-quest",
413
+ submit_url: "#demo-newsletter",
409
414
  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",
415
+ description: "Sports news and contest updates — and 25 seeds for joining.",
416
+ input: false,
417
+ consent_label: "Email me sports news and contest updates.",
412
418
  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>
419
+ icon_emoji: "🌱",
420
+ celebrate_title: "Subscribed!",
421
+ celebrate_subtitle: "You're on the list. We'll keep the sports news and contest updates coming.",
422
+ demo_seeds_earned: 25, demo_seeds_total: 100,
423
+ free_entry_label: "Free Entry Token 🎟️",
424
+ confirm_subtitle: "You're on the list.",
425
+ next_label: "Next Quest",
426
+ next_open: "$store.dsModals.close()" %></div>
425
427
  </template>
426
428
 
427
429
  </div>
@@ -630,7 +632,126 @@
630
632
  </section>
631
633
 
632
634
  <%# ===================================================================== %>
633
- <%# 3. WEB3gated by Studio.feature?(:web3); off = disabled-but-present-yet-openable %>
635
+ <%# 3. PROFILE LEVELING change username + join newsletter (per-card toggles) %>
636
+ <%# ===================================================================== %>
637
+ <%# Each specimen card carries its OWN Leveling toggle (like the Auth "Sign in"
638
+ card's method checkboxes) — the card's opts.leveling flows into its open() as
639
+ props.leveling, so the card opens at the TM shape (checked, the default) or the
640
+ MS shape (unchecked). Independent per card: preview Change Username leveling-on
641
+ beside Great Username leveling-off. ONE primitive, ONE id per activity, no
642
+ "Quest N of N" counter — the with/without fork is gone. The two "updated"
643
+ cards open the same modal id straight at the celebrate state (props.celebrate),
644
+ exactly like the Auth cards open a given step. %>
645
+ <section class="space-y-5">
646
+ <div class="space-y-1">
647
+ <div class="flex flex-wrap items-center gap-3">
648
+ <h3 class="text-xl font-bold text-heading">Profile Leveling</h3>
649
+ <% if leveling_on %>
650
+ <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">leveling on</span>
651
+ <% else %>
652
+ <span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">leveling off</span>
653
+ <% end %>
654
+ </div>
655
+ <p class="text-muted text-sm">
656
+ The profile leveling flow, ported from Turf Monster as ONE primitive
657
+ (<code class="font-mono text-2xs">studio/modals/blocks/_leveling_activity</code>,
658
+ with <code class="font-mono text-2xs">_change_username</code> as its username
659
+ specialization). Walk all four states — <strong>Change Username &rarr; Great
660
+ Username</strong>, then <strong>Join the Newsletter &rarr; Subscribed!</strong>
661
+ — each "updated" card opens its modal straight at the success state, just like
662
+ the Auth cards open a given step. Each card carries its <strong>own Leveling
663
+ toggle</strong> (like the Auth card's method checkboxes): <strong>checked</strong>
664
+ is the Turf Monster shape (the seeds bar + Free Entry Token progress),
665
+ <strong>unchecked</strong> is the McRitchie Studio shape (plain input + Save,
666
+ then a simple confirmation). Flip a card's toggle, then open it — so you can
667
+ preview Change Username leveling-on beside Great Username leveling-off. Same
668
+ modal id either way — the primitive reads
669
+ <code class="font-mono text-2xs">leveling</code> at runtime, so there is no
670
+ with/without fork and <strong>no "Quest N of N" counter</strong>. The save is an
671
+ <strong>app-supplied callback</strong>
672
+ (<code class="font-mono text-2xs">submit_url</code> + an opaque
673
+ <code class="font-mono text-2xs">finalize_hook</code> for a wallet step) —
674
+ <strong>no wallet, signing, or on-chain write lives in the engine</strong>.
675
+ (These demos resolve locally.)
676
+ </p>
677
+ </div>
678
+ <%# The walk, left to right: Change Username -> Great Username -> Join Newsletter
679
+ -> Subscribed. The two "updated" cards open the same id at props.celebrate. %>
680
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
681
+ <%# 1. Change Username — the input (form) state. %>
682
+ <%= render layout: "style/modal_specimen", locals: {
683
+ label: "Change Username",
684
+ reference: %(the Profile Leveling "Change Username" activity (studio-engine studio/modals/blocks/_change_username) — the input state. Reads leveling at runtime; 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', { demo: true, leveling: true })),
685
+ card_data: "opts: { leveling: true }",
686
+ toggles: [{ model: "opts.leveling", label: "Leveling" }],
687
+ open_expr: "$store.dsModals.open('change-username', { demo: true, leveling: opts.leveling })",
688
+ glow_when: ds_glow.call("change-username", celebrate: false) } do %>
689
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
690
+ <span class="block h-2 w-16 rounded" style="background: var(--color-text); opacity: .18"></span>
691
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
692
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
693
+ </div>
694
+ <% end %>
695
+
696
+ <%# 2. Great Username — the updated (celebrate) state, opened directly. %>
697
+ <%= render layout: "style/modal_specimen", locals: {
698
+ label: "Great Username",
699
+ reference: %(the Profile Leveling "Great Username" updated state (studio/modals/blocks/_change_username opened at props.celebrate) — leveling ON shows the seeds celebration (plus 25, Free Entry Token) + Next Quest; leveling OFF shows the plain "Saved" confirmation. Toggle this card's Leveling checkbox to switch, then open. Open with $store.dsModals.open('change-username', { demo: true, leveling: true, celebrate: true, seedsEarned: 25, seedsTotal: 25 })),
700
+ card_data: "opts: { leveling: true }",
701
+ toggles: [{ model: "opts.leveling", label: "Leveling" }],
702
+ open_expr: "$store.dsModals.open('change-username', { demo: true, leveling: opts.leveling, celebrate: true, seedsEarned: 25, seedsTotal: 25 })",
703
+ glow_when: ds_glow.call("change-username", celebrate: true) } do %>
704
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
705
+ <div class="text-2xl leading-none">🌱</div>
706
+ <div x-show="opts.leveling" class="flex gap-1">
707
+ <span class="flex-1 h-2 rounded" style="background: var(--color-cta)"></span>
708
+ <span class="flex-1 h-2 rounded" style="background: var(--color-cta); opacity:.5"></span>
709
+ <span class="flex-1 h-2 rounded" style="background: var(--color-text); opacity:.12"></span>
710
+ </div>
711
+ <span class="block h-1.5 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
712
+ </div>
713
+ <% end %>
714
+
715
+ <%# 3. Join the Newsletter — the input state (consent checkbox + Subscribe). %>
716
+ <%= render layout: "style/modal_specimen", locals: {
717
+ label: "Join Newsletter",
718
+ reference: %(the Profile Leveling "Join the Newsletter" activity (studio-engine studio/modals/blocks/_leveling_activity) — the input state: a consent checkbox + Subscribe. Reads leveling at runtime. Open with $store.dsModals.open('join-newsletter', { demo: true, leveling: true })),
719
+ card_data: "opts: { leveling: true }",
720
+ toggles: [{ model: "opts.leveling", label: "Leveling" }],
721
+ open_expr: "$store.dsModals.open('join-newsletter', { demo: true, leveling: opts.leveling })",
722
+ glow_when: ds_glow.call("join-newsletter", celebrate: false) } do %>
723
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
724
+ <span class="text-lg leading-none">📬</span>
725
+ <span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
726
+ <span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
727
+ </div>
728
+ <% end %>
729
+
730
+ <%# 4. Subscribed! — the updated (celebrate) state, opened directly. %>
731
+ <%= render layout: "style/modal_specimen", locals: {
732
+ label: "Subscribed!",
733
+ reference: %(the Profile Leveling "Subscribed!" updated state (studio/modals/blocks/_leveling_activity opened at props.celebrate) — leveling ON shows the seeds celebration (Free Entry Token) + Next Quest; leveling OFF shows the plain "Subscribed" confirmation. Toggle this card's Leveling checkbox to switch, then open. Open with $store.dsModals.open('join-newsletter', { demo: true, leveling: true, celebrate: true, seedsEarned: 25, seedsTotal: 100 })),
734
+ card_data: "opts: { leveling: true }",
735
+ toggles: [{ model: "opts.leveling", label: "Leveling" }],
736
+ open_expr: "$store.dsModals.open('join-newsletter', { demo: true, leveling: opts.leveling, celebrate: true, seedsEarned: 25, seedsTotal: 100 })",
737
+ glow_when: ds_glow.call("join-newsletter", celebrate: true) } do %>
738
+ <div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 text-center space-y-2">
739
+ <div class="mx-auto w-7 h-7 rounded-full flex items-center justify-center" style="background: color-mix(in srgb, var(--color-success) 15%, transparent)">
740
+ <svg class="w-4 h-4" style="color: var(--color-success)" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
741
+ </div>
742
+ <div x-show="opts.leveling" class="flex gap-1">
743
+ <span class="flex-1 h-2 rounded" style="background: var(--color-cta)"></span>
744
+ <span class="flex-1 h-2 rounded" style="background: var(--color-cta); opacity:.6"></span>
745
+ <span class="flex-1 h-2 rounded" style="background: var(--color-cta); opacity:.3"></span>
746
+ </div>
747
+ <span class="block h-1.5 w-20 mx-auto rounded" style="background: var(--color-text); opacity: .18"></span>
748
+ </div>
749
+ <% end %>
750
+ </div>
751
+ </section>
752
+
753
+ <%# ===================================================================== %>
754
+ <%# 4. WEB3 — gated by Studio.feature?(:web3); off = disabled-but-present-yet-openable %>
634
755
  <%# ===================================================================== %>
635
756
  <section class="space-y-5">
636
757
  <div class="space-y-1">
@@ -735,7 +856,7 @@
735
856
  </section>
736
857
 
737
858
  <%# ===================================================================== %>
738
- <%# 4. SYSTEM AND STATUS (reusable card blocks) %>
859
+ <%# 5. SYSTEM AND STATUS (reusable card blocks) %>
739
860
  <%# ===================================================================== %>
740
861
  <section class="space-y-5">
741
862
  <div class="space-y-1">
@@ -797,7 +918,7 @@
797
918
  </section>
798
919
 
799
920
  <%# ===================================================================== %>
800
- <%# 5. TEMPLATES (copy-from archetypes) %>
921
+ <%# 6. TEMPLATES (copy-from archetypes) %>
801
922
  <%# ===================================================================== %>
802
923
  <section class="space-y-5">
803
924
  <div class="space-y-1">
@@ -831,7 +952,7 @@
831
952
  </section>
832
953
 
833
954
  <%# ===================================================================== %>
834
- <%# 6. REWARDS / leveling (gated by Studio.feature?(:leveling)) %>
955
+ <%# 7. REWARDS / leveling (gated by Studio.feature?(:leveling)) %>
835
956
  <%# ===================================================================== %>
836
957
  <section class="space-y-5">
837
958
  <div class="space-y-1">
@@ -908,96 +1029,4 @@
908
1029
  </div>
909
1030
  </section>
910
1031
 
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>
1003
1032
  </section>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.24.0"
2
+ VERSION = "0.25.1"
3
3
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.24.0
4
+ version: 0.25.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-07-28 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rails
@@ -349,6 +350,7 @@ metadata:
349
350
  source_code_uri: https://github.com/amcritchie/studio-engine/tree/main
350
351
  bug_tracker_uri: https://github.com/amcritchie/studio-engine/issues
351
352
  changelog_uri: https://github.com/amcritchie/studio-engine/blob/main/CHANGELOG.md
353
+ post_install_message:
352
354
  rdoc_options: []
353
355
  require_paths:
354
356
  - lib
@@ -363,7 +365,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
363
365
  - !ruby/object:Gem::Version
364
366
  version: '0'
365
367
  requirements: []
366
- rubygems_version: 4.0.9
368
+ rubygems_version: 3.5.22
369
+ signing_key:
367
370
  specification_version: 4
368
371
  summary: Shared Rails engine providing auth, SSO, error logging, theming, and S3-backed
369
372
  image caching