studio-engine 0.25.0 → 0.26.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: ecc09d648e749381eed240dbdf6290e6899bfff776a08a8cf76e8cf6fde5da35
4
- data.tar.gz: bfa162a9a1078281ce421fcc9dcb4b3741fc30ab8bc9147b8eedbd7cee9a825b
3
+ metadata.gz: 8d1e665b17a360de65ba411842a7c8368e8077160cf34e4a218ebbe1857d0988
4
+ data.tar.gz: 1b9ebc66583eeacb8c65d97f4ec1eb90b0451829cf7ed263d0a5cd8895b96b88
5
5
  SHA512:
6
- metadata.gz: d1cc7ce640975c429a0f85ea08cdc66037506efa681995128ad4a3ab76bf0894d1e60bf4738c3d52bc85a2625ea6ee21b60a420b3eef98af56008f0de33f92d5
7
- data.tar.gz: fd4465c53f2591ff8031fcebee0ce81dfa7acc1113a934b98983a761ad54a2a9a6d4dff8c8b6b1c41b7c8947166f36d69dbf4da182c7e8713b4eb986c79ee6f3
6
+ metadata.gz: 61d7d9f100144b0683bfa27a12a86ad4b0cac8cc3280c067c9f016a99edf98b0c28f73cf38e2f212d26183336f519d03f633970b15381ebcf165ba9ee143997b
7
+ data.tar.gz: 56b58a13c533dd060fd5d8313987735123e6ed1bd1f148d15ef51ea7306815f365dabf900770f0b8de84fc81b6588bf80e61545ba5a8440b49243a3db7883f52
data/CHANGELOG.md CHANGED
@@ -2,7 +2,120 @@
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
5
+ ## 0.26.1 — 2026-07-28
6
+
7
+ **Fix the `/admin/style` Design System page breaking when reached via Turbo Drive
8
+ navigation (the admin sidebar "Design System" link).** Reached by a direct page
9
+ load the page was fine; reached by an in-app Turbo visit its modals would not open
10
+ on a card click and EVERY specimen glow ring lit at once. Both symptoms were one
11
+ cause: the page-scoped `dsModals` (and `dsSolanaModal`) Alpine store was registered
12
+ ONLY inside a `document.addEventListener('alpine:init', …)` handler in the page
13
+ body. Alpine loads via a deferred CDN `<script>` in the engine head and fires
14
+ `alpine:init` exactly once, on the first full-document load; Turbo Drive advance
15
+ visits swap `<body>` without reloading that head script, so `alpine:init` never
16
+ fires again — and because the registration lives only in the `/admin/style` body,
17
+ it was absent during that one `alpine:init`. The specimen cards' `x-data` still
18
+ re-initialize on the Turbo body swap (Alpine's MutationObserver), so their
19
+ `@click="$store.dsModals.open(…)"`, `:style` glow bindings, and the host's
20
+ `<template x-if="$store.dsModals.current()…">` all evaluated against an undefined
21
+ store and threw `Cannot read properties of undefined (reading 'current')` — no
22
+ modal opened, and the throwing `:style` wiped each card's static inline
23
+ `--studio-team-glow-opacity: 0`, so the CSS default of `1` relit all rings.
24
+
25
+ ### Fixed
26
+
27
+ - **`style/_modals`** — the `dsModals` + `dsSolanaModal` registration is now a
28
+ named `registerDsModals()` invoked through the same dual-guard the engine
29
+ already uses for `studio/modals/_image_upload`'s `cropPhotoModal`:
30
+ `if (window.Alpine) registerDsModals(); else document.addEventListener('alpine:init', registerDsModals);`.
31
+ On a Turbo visit Alpine has already started, so the store registers immediately
32
+ (the body script re-runs on every Turbo render) and `$store.dsModals` is defined
33
+ before the cards' bindings evaluate; on a first load Alpine has not booted yet,
34
+ so it still defers to `alpine:init`. The `if (Alpine.store('dsModals')) return;`
35
+ idempotency guard is unchanged, so a later `alpine:init` is a no-op. No consumer
36
+ change is required — `/admin/style` is an engine page.
37
+ - **Docs** — `style/_modal_specimen`'s fail-closed comment now records that the
38
+ static inline `--studio-team-glow-opacity: 0` guards the ring only while
39
+ `$store.dsModals` is DEFINED (a throwing `:style` wipes the inline value) — the
40
+ refinement of 0.24.1's "regardless of Alpine timing" claim that this bug exposed.
41
+
42
+ ### Swept
43
+
44
+ - Audited every engine `Alpine.store(...)` / `Alpine.data(...)` registration for
45
+ the same Turbo-visit fragility. `dsModals`/`dsSolanaModal` (`style/_modals`) was
46
+ the only page-scoped registration missing the guard. `studio/modals/_image_upload`
47
+ (`cropPhotoModal`) already carries it; the shared modal host (`studio/modals/_host`
48
+ → `modals`) and the theme/devMode stores (`layouts/studio/_head`) are app-wide
49
+ bootstrap rendered on the first full load, so their single `alpine:init`
50
+ registration persists across Turbo visits (verified: `$store.modals` stays
51
+ defined after a Turbo nav).
52
+
53
+ ## 0.26.0 — 2026-07-28
54
+
55
+ **The engine navbar pins itself under smooth-load, and the smooth-load CSS is
56
+ fully engine-owned.** Since 0.24 the `vt-pinned-header` pin was opt-in but the
57
+ engine's own `layouts/_navbar` never carried it, so a host that wanted the
58
+ pinned-header transition had to SHADOW the whole partial just to add one class
59
+ (acquisition-studio did exactly that — the known override-drift trap). The
60
+ navbar now self-pins when `Studio.smooth_load` is on, and the engine also ships
61
+ the `studio-header` cross-fade suppression that mcritchie-studio and
62
+ turf-monster had been carrying app-side as a bridge. **Consumer cleanup this
63
+ version unlocks:** acquisition-studio deletes its entire
64
+ `app/views/layouts/_navbar.html.erb` override; mcritchie-studio and
65
+ turf-monster delete their app-side `::view-transition-old(studio-header)` /
66
+ `::view-transition-new(studio-header) { animation: none; }` bridge blocks.
67
+
68
+ ### Changed
69
+
70
+ - **`layouts/_navbar`** — the sticky header adds `vt-pinned-header` itself when
71
+ `Studio.smooth_load` is on, non-preview branch ONLY (preview renders can
72
+ repeat per page, and a duplicate `view-transition-name` silently disables
73
+ every transition). With the flag off nothing extra renders, so a plain gem
74
+ bump changes no opted-out app.
75
+ - **`engine.css`** — new `::view-transition-old(studio-header)` /
76
+ `::view-transition-new(studio-header) { animation: none; }` rule beside the
77
+ smooth-load block: it suppresses the UA-default ~250ms plus-lighter
78
+ cross-fade between the header's two snapshots, which double-drew the wordmark
79
+ and buttons on any navigation from a scrolled page (collapsed header →
80
+ expanded at top). Lifts the identical bridge rule the hub and turf-monster
81
+ carried app-side.
82
+ - **`engine.css`** — `.turbo-progress-bar` background becomes
83
+ `var(--color-cta, #0076ff)`: the fallback (Turbo's own default blue) keeps
84
+ the bar visible in a layout that never renders the runtime theme block
85
+ (`studio_theme_css_tag`), where `--color-cta` is unset.
86
+
87
+ ## 0.25.1 — 2026-07-28
88
+
89
+ **Fix the Profile Leveling save-close regression 0.25.0 introduced.** The 0.25.0
90
+ rebuild made `_finishSaved` ALWAYS advance to the engine's own updated view (the
91
+ seeds celebration or the plain confirmation), on the belief that Turf Monster's
92
+ live usage was "unaffected." It was not: TM's change-username modal
93
+ (`leveling: false`, `saved_event: "studio:username-saved"`) drives its OWN
94
+ post-save follow-on — its listener runs `completeQuest` and closes/swaps. With
95
+ the engine now also advancing to its own confirm view, the next quest step
96
+ ("Send Your First Message") rendered TWICE at once — once in the post-save modal
97
+ dialog, once in the inline quest card behind it — which TM's Playwright e2e
98
+ caught as a strict-mode double-render (`quest_ladder_web2`, `quest_ladder_web3`,
99
+ and the web3 step-dedup contract). In 0.24 a `leveling: false` modal rendered no
100
+ celebrate view and let the app close it, so the step rendered once.
101
+
102
+ ### Fixed
103
+
104
+ - **`studio/_leveling_activity_assets`** (factory) — `_finishSaved` now CLOSES on
105
+ save (fires the `saved_event`, then stops — the 0.24 contract) instead of
106
+ advancing to the engine's own confirm/celebrate WHEN the consuming app drives
107
+ its own follow-on. A new `appDrivenFollowOn` getter is that signal: a **non-demo**
108
+ caller that wired its OWN (non-default) `saved_event` owns the after-save UI, so
109
+ the engine yields (it never force-closes — the app's listener owns close-vs-swap,
110
+ which TM relies on: close on the contest page, swap to `quest-success` on
111
+ `/account`). **Demo** previews (`/admin/style`) and **standalone** callers on the
112
+ DEFAULT `saved_event` are unchanged — they still advance to the seeds celebration
113
+ (TM shape) or the plain confirmation (MS shape), and the "Great Username" /
114
+ "Subscribed!" specimen cards still open straight at their celebrate state via
115
+ `props.celebrate`. No consumer change is required; TM's existing wiring restores
116
+ its own contract.
117
+
118
+
6
119
 
7
120
  **Profile Leveling** — the `/admin/style` "Leveling activities" section is rebuilt
8
121
  into a single toggle-driven "Profile Leveling" flow, and the with-leveling /
@@ -79,6 +192,12 @@ follows the step machine), so this surfaced as a flash-of-all-glow on load.
79
192
  unchanged. The `engine-motion.css` `.studio-team-glow` default is untouched
80
193
  (the always-on Tricks demos depend on it).
81
194
 
195
+ > **Refined in 0.26.1:** "regardless of Alpine timing" holds only while
196
+ > `$store.dsModals` is defined. On a Turbo Drive visit the store went
197
+ > unregistered, the reactive `:style` threw, and Alpine wiped this inline
198
+ > default — relighting every ring. 0.26.1 dual-guards the store registration so
199
+ > it survives Turbo visits, restoring the fail-closed guarantee.
200
+
82
201
  ## 0.24.0 — 2026-07-28
83
202
 
84
203
  The **smooth-load convention**, opt-in per app. Pages materialize behind the
@@ -221,12 +221,28 @@
221
221
  view-transition-name: studio-header;
222
222
  }
223
223
 
224
+ /* Suppress the UA default on the pinned header's own group: without this the
225
+ browser runs its ~250ms plus-lighter cross-fade between the header's old and
226
+ new snapshots, and on any navigation from a scrolled page (collapsed header
227
+ → expanded at top) the wordmark and buttons draw TWICE, offset by the height
228
+ delta, until the blend settles. animation: none shows the incoming header
229
+ immediately while the group box still morphs its height. Engine-owned as of
230
+ 0.26.0 — this lifts the identical bridge rule mcritchie-studio and
231
+ turf-monster carried app-side; consumers delete their copies when adopting
232
+ this version. */
233
+ ::view-transition-old(studio-header),
234
+ ::view-transition-new(studio-header) {
235
+ animation: none;
236
+ }
237
+
224
238
  /* Turbo's progress bar is the slow-load feedback under no-preview (the old
225
239
  page holds until the fresh response). Theme it so every app's bar matches
226
- its brand instead of Turbo's default. */
240
+ its brand instead of Turbo's default. The fallback (#0076ff, Turbo's own
241
+ default blue) keeps the bar visible in a layout that never renders the
242
+ runtime theme block (studio_theme_css_tag), where --color-cta is unset. */
227
243
  .turbo-progress-bar {
228
244
  height: 3px;
229
- background: var(--color-cta);
245
+ background: var(--color-cta, #0076ff);
230
246
  }
231
247
 
232
248
  /* Reduced motion: keep the single-render swap, drop the choreography. Also
@@ -8,6 +8,14 @@
8
8
  %>
9
9
  <% show_user = local_assigns.fetch(:show_logged_in, logged_in?) %>
10
10
  <% is_preview = local_assigns.fetch(:preview, false) %>
11
+ <%# Smooth-load self-pin (engine 0.26): when the app opts into Studio.smooth_load,
12
+ the sticky header names itself as the shared view-transition group
13
+ (vt-pinned-header) so page content transitions beneath a navbar that stays
14
+ put — hosts no longer shadow this partial just to add the class. Non-preview
15
+ branch ONLY: preview renders can repeat per page, and a duplicate
16
+ view-transition-name silently disables every transition. With the flag off,
17
+ nothing extra renders. %>
18
+ <% pin_header = Studio.smooth_load && !is_preview %>
11
19
  <% balance_html = local_assigns.fetch(:balance_html, nil) %>
12
20
  <% extra_icons_html = local_assigns.fetch(:extra_icons_html, nil) %>
13
21
  <% show_logout_link = local_assigns.fetch(:show_logout_link, false) %>
@@ -19,7 +27,7 @@
19
27
  %>
20
28
 
21
29
  <header x-data="{ scrolled: false }" <%= '@scroll.window="scrolled = scrolled ? (window.scrollY > 5) : (window.scrollY > 60)"'.html_safe unless is_preview %>
22
- class="<%= is_preview ? 'bg-page' : 'sticky top-0 z-50 bg-page transition-shadow duration-300' %>"
30
+ class="<%= is_preview ? 'bg-page' : "#{'vt-pinned-header ' if pin_header}sticky top-0 z-50 bg-page transition-shadow duration-300" %>"
23
31
  :class="scrolled && 'shadow-lg border-b border-subtle is-scrolled'">
24
32
  <style>
25
33
  .user-nav-col { width: 14rem; }
@@ -37,6 +37,10 @@
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
45
  leveling the render-time DEFAULT for the seeds celebration. The live
42
46
  value is read at RUNTIME from the modal store's current
@@ -56,6 +60,11 @@
56
60
  <script>
57
61
  (function () {
58
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";
59
68
  window.levelingActionModal = function (opts) {
60
69
  opts = opts || {};
61
70
  return {
@@ -66,7 +75,7 @@
66
75
  submitUrl: opts.submitUrl || "",
67
76
  finalizeUrl: opts.finalizeUrl || "",
68
77
  finalizeHook: opts.finalizeHook || "",
69
- savedEvent: opts.savedEvent || "studio:activity-saved",
78
+ savedEvent: opts.savedEvent || DEFAULT_SAVED_EVENT,
70
79
  store: opts.store || "modals",
71
80
  // leveling is a RUNTIME getter (below) reading props.leveling off the modal
72
81
  // store, so ONE modal id flips TM <-> MS shape live as a section toggle
@@ -129,6 +138,21 @@
129
138
  return v !== (this.original || "").trim();
130
139
  },
131
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
+
132
156
  _dispatch: function (payload) {
133
157
  // In demo mode (the /admin/style preview) do NOT fire the app-facing
134
158
  // saved event: the host app may listen for it and open its OWN follow-on
@@ -149,10 +173,19 @@
149
173
  this.progressLabel = "";
150
174
  if (this.hasInput) this.original = (this.value || "").trim();
151
175
  this._dispatch(payload);
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.
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.
156
189
  this.seedsEarned = parseInt(payload.seeds_earned, 10) || 0;
157
190
  this.seedsTotal = parseInt(payload.seeds_total, 10) || 0;
158
191
  this.celebrate = true;
@@ -59,7 +59,14 @@
59
59
  default (--studio-team-glow-opacity of 1) so the ring is OFF at first
60
60
  paint, BEFORE Alpine hydrates the reactive :style below. Without it every
61
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. %>
62
+ :style then drives 0 to 0.95 with the 0.4s cross-fade. Keep BOTH.
63
+ CAVEAT: this fail-closed default holds only while $store.dsModals is
64
+ DEFINED. glow_when dereferences the store; if the store is missing the
65
+ reactive :style THROWS, and Alpine's style bind wipes this inline value
66
+ on the way, so the CSS default of 1 relights every ring. That is exactly
67
+ the Turbo-nav store gap fixed in 0.26.1 (style/_modals dual-guards the
68
+ dsModals registration so it survives Turbo visits, not just first load).
69
+ So the guarantee is: static inline 0 PLUS a registered dsModals. %>
63
70
  <% if glow_when %>style="--studio-team-glow-opacity: 0" :style="{ '--studio-team-glow-opacity': (<%= glow_when %>) ? '0.95' : '0' }"<% end %>>
64
71
  <article
65
72
  class="card overflow-hidden flex flex-col h-full<%= " opacity-95" if disabled %><%= " cursor-pointer" if clickable %>"
@@ -107,7 +107,10 @@
107
107
  isAvailable: function () { return false; }
108
108
  };
109
109
 
110
- document.addEventListener('alpine:init', function () {
110
+ // Register the page-scoped dsModals + dsSolanaModal stores. Named (not an
111
+ // inline alpine:init callback) so the dual-guard at the end can invoke it
112
+ // directly on a Turbo visit, when alpine:init will not fire again.
113
+ function registerDsModals() {
111
114
  if (Alpine.store('dsModals')) return;
112
115
 
113
116
  // Enter/exit animation registry — keys map to a CSS class (engine-
@@ -244,7 +247,19 @@
244
247
  get ctaLabel() { return this._read('ctaLabel', ''); },
245
248
  get ctaHref() { return this._read('ctaHref', ''); }
246
249
  });
247
- });
250
+ }
251
+
252
+ // Alpine's deferred CDN <script> fires alpine:init exactly ONCE, on the
253
+ // first full-document load. Turbo Drive advance visits swap the document
254
+ // body without reloading that head script, so alpine:init never fires
255
+ // again — and
256
+ // because this dsModals registration lives ONLY in the /admin/style body,
257
+ // it was absent during that one alpine:init. Registering here on the
258
+ // Turbo-visit path (Alpine already started) keeps $store.dsModals defined,
259
+ // so the specimen cards' @click / :style / <template x-if> don't throw.
260
+ // Mirrors studio/modals/_image_upload's cropPhotoModal guard.
261
+ if (window.Alpine) { registerDsModals(); }
262
+ else { document.addEventListener('alpine:init', registerDsModals); }
248
263
  })();
249
264
  </script>
250
265
 
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.25.0"
2
+ VERSION = "0.26.1"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.0
4
+ version: 0.26.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-28 00:00:00.000000000 Z
11
+ date: 2026-07-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails