studio-engine 0.24.0 → 0.25.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: 2aed8dd0e8d45e41602035462f0b60d01414ac3ea2f41484cbff9d4a40384cb8
4
- data.tar.gz: e7aa1bfef279150c47cf3bd709de81b5b174f9027252a177a58a19e4609cf85c
3
+ metadata.gz: ecc09d648e749381eed240dbdf6290e6899bfff776a08a8cf76e8cf6fde5da35
4
+ data.tar.gz: bfa162a9a1078281ce421fcc9dcb4b3741fc30ab8bc9147b8eedbd7cee9a825b
5
5
  SHA512:
6
- metadata.gz: 2b0ed377abc9501ce2a0fa19cc17d547e02498673ac15265ceac38555bf1fa241996e52c7dc5af337677a3436cfeb2209dd47f75fe70757bea96cebca21194e1
7
- data.tar.gz: 1bd498dc182950382bfe3dca7fb5056bf6e2ff5760161ee8edf5481ef3c444a35ca7d4c51fd682b5d62f8e6c30aec787297cf537daba0956b8aefcc810b6172c
6
+ metadata.gz: d1cc7ce640975c429a0f85ea08cdc66037506efa681995128ad4a3ab76bf0894d1e60bf4738c3d52bc85a2625ea6ee21b60a420b3eef98af56008f0de33f92d5
7
+ data.tar.gz: fd4465c53f2591ff8031fcebee0ce81dfa7acc1113a934b98983a761ad54a2a9a6d4dff8c8b6b1c41b7c8947166f36d69dbf4da182c7e8713b4eb986c79ee6f3
data/CHANGELOG.md CHANGED
@@ -2,6 +2,83 @@
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.0 — 2026-07-28
6
+
7
+ **Profile Leveling** — the `/admin/style` "Leveling activities" section is rebuilt
8
+ into a single toggle-driven "Profile Leveling" flow, and the with-leveling /
9
+ without-leveling FORK is killed. Previously each activity shipped as TWO modal
10
+ ids (`change-username` + `change-username-plain`, `quest-activity` +
11
+ `quest-activity-plain`), because the primitive read `leveling` from a Ruby local
12
+ at render time. Now the primitive reads `leveling` at RUNTIME from the modal
13
+ store's `props.leveling`, so ONE id per activity flips the Turf Monster shape
14
+ (the seeds celebration + Free Entry Token progress) ↔ the McRitchie Studio shape
15
+ (plain input + Save, then a simple confirmation) live as its per-card leveling
16
+ toggle moves.
17
+
18
+ ### Changed
19
+
20
+ - **`studio/modals/blocks/_leveling_activity`** — three views now, all ALWAYS
21
+ rendered and gated by the reactive `leveling` getter instead of dropped at ERB
22
+ time: the **form** (`!celebrate`), the **seeds celebration**
23
+ (`celebrate && leveling`, TM), and a **plain confirmation**
24
+ (`celebrate && !leveling`, MS). The `leveling` local becomes the render-time
25
+ fallback only. **The "Quest N of N" counter pill is removed** — the profile
26
+ modals do not carry one. **The "Level N" pill is also dropped** from the
27
+ celebration (no consumer used it); the seeds bar stays. All existing seams
28
+ are preserved unchanged (`submit_url`, `finalize_hook`, `saved_event`,
29
+ `modal_store`, `demo`, seeds/level props), so Turf Monster's live usage and the
30
+ opaque save-callback contract are unaffected (TM ships no `props.leveling` and
31
+ keeps its `:leveling`-capability default).
32
+ - **`studio/_leveling_activity_assets`** (factory) — `leveling` is a runtime getter
33
+ reading `props.leveling` off the modal store (falling back to `_levelingDefault`);
34
+ an `init` hook opens the modal directly at the updated state via `props.celebrate`
35
+ (the "updated" cards open the same id pre-advanced, like the Auth step cards);
36
+ `_finishSaved` always advances to the updated state (the view gate picks
37
+ celebration vs plain confirmation) AND mirrors `celebrate` onto the live store
38
+ `props.celebrate`, so the active-card **glow follows the flow** — it transfers
39
+ from the input card to the updated card as the modal advances, exactly like the
40
+ Auth glow follows `props.step`. In **demo** mode the app-facing saved event is
41
+ suppressed, so a host app's follow-on handler (e.g. Turf Monster's
42
+ `quest-success` on `studio:username-saved`) can't stack a second modal over the
43
+ demo's own celebrate view.
44
+ - **`/admin/style`** — "Leveling activities" → **"Profile Leveling"**: **each
45
+ specimen card carries its OWN `Leveling` toggle** (like the Auth card's method
46
+ checkboxes), flipping THAT card between the Turf Monster and McRitchie Studio
47
+ shape at open — so you can preview Change Username leveling-on beside Great
48
+ Username leveling-off. The section walks **four** cards — **Change Username →
49
+ Great Username**, then **Join the Newsletter → Subscribed!** — each "updated"
50
+ card opening its modal straight at the success state, mirroring Turf Monster's
51
+ real copy, seeds, and Free Entry Token progress. No quest counter, no Level pill.
52
+
53
+ ### Added
54
+
55
+ - **`_leveling_activity` locals** (all optional, additive): `consent_label` (a
56
+ consent checkbox that gates the action — the newsletter join), `confirm_subtitle`
57
+ (subtext on the MS-shape plain confirmation), `next_label` / `next_open` (a
58
+ "Next Quest" button that walks to the next activity), and `demo_seeds_earned` /
59
+ `demo_seeds_total` (style-guide-only seed payload tuning).
60
+
61
+ ## 0.24.1 — 2026-07-28
62
+
63
+ Bug fix — the **`/admin/style` specimen glow failed OPEN**. Every glow-capable
64
+ specimen card (`style/_modal_specimen`) carries the `.studio-team-glow` class
65
+ statically, and the "only the active card glows" behavior rode entirely on a
66
+ reactive Alpine `:style` overriding the primitive's CSS default of
67
+ `--studio-team-glow-opacity: 1` (visible) down to `0`. Before Alpine hydrates —
68
+ or if it never loads — all 13 reactive-glow cards (Auth, Eligibility, Profile,
69
+ Leveling) painted their ring at once. The steady state was correct (the glow
70
+ follows the step machine), so this surfaced as a flash-of-all-glow on load.
71
+
72
+ ### Fixed
73
+
74
+ - **`style/_modal_specimen`** now renders a **static inline
75
+ `style="--studio-team-glow-opacity: 0"`** on the glow wrapper beside the
76
+ reactive `:style`, so the ring is OFF at first paint (fail-closed) regardless
77
+ of Alpine timing. The reactive binding still drives `0 ↔ 0.95` with the 0.4s
78
+ cross-fade, so the active-card glow and its slide between step cards are
79
+ unchanged. The `engine-motion.css` `.studio-team-glow` default is untouched
80
+ (the always-on Tricks demos depend on it).
81
+
5
82
  ## 0.24.0 — 2026-07-28
6
83
 
7
84
  The **smooth-load convention**, opt-in per app. Pages materialize behind the
@@ -38,9 +38,20 @@
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
40
  store Alpine modal store name backing close(). Default "modals".
41
- leveling true shows the seeds celebration on success (a UI flourish).
42
- demo style-guide preview: resolve locally, skip the network POST.
41
+ leveling the render-time DEFAULT for the seeds celebration. The live
42
+ value is read at RUNTIME from the modal store's current
43
+ props.leveling (this.leveling getter), so one modal id flips
44
+ TM (celebration) <-> MS (plain save) as a toggle moves; when
45
+ props omits it (TM's live path) the default stands.
46
+ hasConsent true renders a consent checkbox that gates the action until
47
+ ticked (the newsletter join).
48
+ demo style-guide preview: resolve locally, skip the network POST, and
49
+ suppress the app-facing saved event (so a host app's follow-on
50
+ handler can't stack a second modal over the demo's own state).
43
51
  seedsPerLevel seeds per level for the celebration bar. Default 100.
52
+ demoSeedsEarned / demoSeedsTotal demo-only seed payload (defaults to a
53
+ level-crossing 30 / seedsPerLevel) so each activity's
54
+ celebration can showcase a chosen state.
44
55
  %>
45
56
  <script>
46
57
  (function () {
@@ -57,9 +68,17 @@
57
68
  finalizeHook: opts.finalizeHook || "",
58
69
  savedEvent: opts.savedEvent || "studio:activity-saved",
59
70
  store: opts.store || "modals",
60
- leveling: !!opts.leveling,
71
+ // leveling is a RUNTIME getter (below) reading props.leveling off the modal
72
+ // store, so ONE modal id flips TM <-> MS shape live as a section toggle
73
+ // moves. _levelingDefault is the render-time fallback (TM ships no
74
+ // props.leveling and relies on its :leveling capability default).
75
+ _levelingDefault: !!opts.leveling,
76
+ hasConsent: !!opts.hasConsent,
77
+ consent: false,
61
78
  demo: !!opts.demo,
62
79
  seedsPerLevel: parseInt(opts.seedsPerLevel, 10) || 100,
80
+ demoSeedsEarned: parseInt(opts.demoSeedsEarned, 10),
81
+ demoSeedsTotal: parseInt(opts.demoSeedsTotal, 10),
63
82
  saving: false,
64
83
  error: "",
65
84
  saved: false,
@@ -68,9 +87,42 @@
68
87
  seedsEarned: 0,
69
88
  seedsTotal: 0,
70
89
 
90
+ // Open directly AT the updated/celebrate state — the specimen's "updated"
91
+ // cards (Great Username / Subscribed!) open the same modal id pre-advanced,
92
+ // exactly like the Auth cards open a given step. props.celebrate mounts the
93
+ // modal already saved, with the seed payload the celebration animates from.
94
+ // leveling (a getter) then decides at runtime whether that state renders the
95
+ // seeds celebration (TM) or the plain confirmation (MS).
96
+ init: function () {
97
+ var s = (window.Alpine && Alpine.store) ? Alpine.store(this.store) : null;
98
+ var cur = (s && typeof s.current === "function") ? s.current() : null;
99
+ var p = (cur && cur.props) || {};
100
+ if (p.celebrate) {
101
+ this.seedsEarned = parseInt(p.seedsEarned, 10) || 0;
102
+ this.seedsTotal = parseInt(p.seedsTotal, 10) || 0;
103
+ this.saved = true;
104
+ this.celebrate = true;
105
+ }
106
+ },
107
+
108
+ // leveling — resolved at RUNTIME from the modal store's current props, so a
109
+ // single modal id flips TM (seeds/level/quest) <-> MS (plain save) live as
110
+ // the section's leveling toggle moves. Falls back to the render-time default
111
+ // when props does not set it (TM's live path), so existing callers are
112
+ // unaffected.
113
+ get leveling() {
114
+ var s = (window.Alpine && Alpine.store) ? Alpine.store(this.store) : null;
115
+ var cur = (s && typeof s.current === "function") ? s.current() : null;
116
+ var p = cur && cur.props;
117
+ if (p && typeof p.leveling === "boolean") return p.leveling;
118
+ return this._levelingDefault;
119
+ },
120
+
71
121
  // Actionable while there is a real change to save (input activities) or
72
- // until the one-shot action has fired (no-field activities).
122
+ // until the one-shot action has fired (no-field activities). A consent
123
+ // checkbox (the newsletter join) additionally gates the action until ticked.
73
124
  get changed() {
125
+ if (this.hasConsent && !this.consent) return false;
74
126
  if (!this.hasInput) return !this.saved;
75
127
  var v = (this.value || "").trim();
76
128
  if (v.length < this.minLength) return false;
@@ -78,6 +130,13 @@
78
130
  },
79
131
 
80
132
  _dispatch: function (payload) {
133
+ // In demo mode (the /admin/style preview) do NOT fire the app-facing
134
+ // saved event: the host app may listen for it and open its OWN follow-on
135
+ // modal (e.g. Turf Monster swaps to 'quest-success' on
136
+ // 'studio:username-saved'), which would stack a SECOND modal over the
137
+ // demo's own celebrate view — the reported duplicate. The demo resolves
138
+ // locally and is self-contained, so it must not drive real app follow-ons.
139
+ if (this.demo) return;
81
140
  try {
82
141
  window.dispatchEvent(new CustomEvent(this.savedEvent, { detail: payload || {} }));
83
142
  } catch (e) {}
@@ -90,11 +149,26 @@
90
149
  this.progressLabel = "";
91
150
  if (this.hasInput) this.original = (this.value || "").trim();
92
151
  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
- }
152
+ // Always advance to the updated state; the view gate (celebrate &&
153
+ // leveling vs celebrate && !leveling) picks the seeds celebration (TM)
154
+ // or the plain confirmation (MS). Seeds are set either way — only the
155
+ // leveling view reads them.
156
+ this.seedsEarned = parseInt(payload.seeds_earned, 10) || 0;
157
+ this.seedsTotal = parseInt(payload.seeds_total, 10) || 0;
158
+ this.celebrate = true;
159
+ // Reflect the advance onto the store's LIVE current props so the specimen
160
+ // glow (which reads props.celebrate) transfers from the input card to the
161
+ // updated card — exactly like the Auth glow follows props.step as its
162
+ // machine advances. Without this, the glow stays on the input card.
163
+ this._syncCelebrate(true);
164
+ },
165
+
166
+ // Mirror the component's celebrate onto the current modal store entry's
167
+ // props, the reactive source the specimen glow_when reads.
168
+ _syncCelebrate: function (value) {
169
+ var s = (window.Alpine && Alpine.store) ? Alpine.store(this.store) : null;
170
+ var cur = (s && typeof s.current === "function") ? s.current() : null;
171
+ if (cur && cur.props) cur.props.celebrate = !!value;
98
172
  },
99
173
 
100
174
  _post: function (url, body) {
@@ -115,10 +189,14 @@
115
189
  this.error = "";
116
190
  var self = this;
117
191
  if (this.demo) {
118
- // Style-guide preview: no backend. Resolve to a level-crossing payload
119
- // so the leveling celebration demonstrates its full flourish.
192
+ // Style-guide preview: no backend. Resolve to a per-activity seed
193
+ // payload (defaulting to a level-crossing one) so the celebration
194
+ // demonstrates its flourish — Username stays at Level 1 (+25), the
195
+ // Newsletter crosses a level to reveal the Free Entry Token line.
196
+ var de = isNaN(self.demoSeedsEarned) ? 30 : self.demoSeedsEarned;
197
+ var dt = isNaN(self.demoSeedsTotal) ? self.seedsPerLevel : self.demoSeedsTotal;
120
198
  setTimeout(function () {
121
- self._finishSaved({ seeds_earned: 30, seeds_total: self.seedsPerLevel });
199
+ self._finishSaved({ seeds_earned: de, seeds_total: dt });
122
200
  }, 600);
123
201
  return;
124
202
  }
@@ -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.0"
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.0
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