studio-engine 0.46.0 → 0.47.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: 026c018bce499134d15e23ca2f9101e05da752380be210e5c6136babbac97a5a
4
- data.tar.gz: ff33bee4cbe01664a9f2385716107d0a7319e0b267cc9a1f6c4a47e0555c4c5a
3
+ metadata.gz: 90659c9a1e1cd62a432e47529501574cca5f7a217f48b6f0eaf0990198150636
4
+ data.tar.gz: 23435ace6999ecb78b5ad57c2a7c90cf8da64cee0415feb1d5a2fd82f65d4470
5
5
  SHA512:
6
- metadata.gz: 25dedda1d9c8f5c7cc2f485cfcea80e94ce3446952029015d5e53ab1dd41fdbfd014dc1c1251f9c637ab7fbd3605b7aa50e0593e4d4e85b1a81c6349536dd7ea
7
- data.tar.gz: 8f71ee45ddf77e12851991857aff2a72bfe3082719b7505c16b8fc7cd08d17c5bf502c07af2393aa315c22146067ef63af1d275d9566d327193d9fa935a94a72
6
+ metadata.gz: 5c964c5081845ae99315fe62fe98fde0969255d779537524aeb0abbde5b8ceb5fab3ee98c5771471c992b3628021e35e6b42a5f91dc5909bbb3601fd9dabbe1d
7
+ data.tar.gz: 8c1e90e63006085ef8745ba6a5d1e06b004e09e4c74c523780be2ae9f7e024b3b426aa228dc22eeea683459820a3a9a079720d27e8c8905e90c4796999595d2d
data/CHANGELOG.md CHANGED
@@ -35,6 +35,60 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
35
35
 
36
36
  ### Added
37
37
 
38
+ - **`$store.modals.isLive(id)` — "is this card up?", the question `isOpen` cannot
39
+ answer.** `close()` flips `_closing` immediately and splices the entry only
40
+ after the exit animation, so for that whole ~220ms window `isOpen(id)` reports
41
+ a card that is already leaving the screen as open. Every consumer guard that
42
+ means *don't open this twice* wants the opposite answer, and turf-monster had
43
+ hand-rolled the same `_closing`-aware `.some()` over `.stack` to get it.
44
+
45
+ `isLive(id)` returns true when a card with that id is on the stack **and not on
46
+ its way out**. It also accepts an array — `isLive(['onboarding', 'age-verify',
47
+ 'wallet-setup'])` — so a caller can ask "is any card of this flow still up?"
48
+ without reaching into `.stack` and re-deriving the lifecycle rule. Shipped on
49
+ both the shared host and the page-scoped host.
50
+
51
+ **`isOpen(id)` is unchanged.** It still answers "on the stack, in any state",
52
+ which is what a caller asking "is the DOM node still mounted" depends on. This
53
+ is additive; no existing call site changes behavior. The proposal on the task
54
+ was `isOpen(id, { ignoreClosing: true })`, rejected because it reads as a double
55
+ negative at the call site (*is it open, ignoring that it is closing*) and
56
+ because two genuinely different questions are clearer as two names than as one
57
+ name with a mode flag.
58
+
59
+ The asymmetry to know, and the reason this is not simply "not animating":
60
+
61
+ | Transition | Flags on the entry | `isOpen` | `isLive` |
62
+ |---|---|---|---|
63
+ | `open()` | — | true | **true** |
64
+ | `close()` | `_closing` | true | **false** |
65
+ | `swap()`, leaving entry | `_swappingOut` + `_closing` | true | **false** |
66
+ | `advance()` | `_swappingOut` only | true | **true** |
67
+ | spliced / never opened | — | false | **false** |
68
+
69
+ `advance()` slides the SAME card between steps of its own flow, so it sets
70
+ `_swappingOut` and nothing else. `isLive` therefore tests `_closing` alone and
71
+ never consults `_swappingOut`: conflating the two reports a card as gone in the
72
+ middle of a step transition, which is how an in-flow hand-off starts looking to
73
+ a caller like a dismissal. Both halves are pinned by tests that execute the
74
+ store under node (`test/views/modal_host_store_behavior_test.rb`) — dropping the
75
+ `_closing` check and conflating it with `_swappingOut` redden *different*
76
+ assertions.
77
+
78
+ **Consumers that fork the host do not get this from a version bump.**
79
+ `mcritchie-studio` and `turf-monster` both ship their own
80
+ `app/views/studio/modals/_host.html.erb`, and a non-isolated engine lets the app
81
+ copy shadow the gem's. Those apps pick `isLive` up only by porting it into their
82
+ fork or by retiring the fork; `studio/modals/scoped_host` is unforked everywhere
83
+ and carries it today. turf-monster's two hand-rolled scans — the onboarding
84
+ chain driver's `open()` guard (`layouts/application.html.erb`) and
85
+ `selectionBoard#showWalletSetupModal` (`contests/_turf_totals_board.html.erb`)
86
+ — can then collapse onto this primitive, as can its two remaining bare
87
+ `isOpen()` idempotence guards (`app/javascript/solana_stores.js` for
88
+ `wallet-changed`, `cdp/returns/show.html.erb` for `cdp-ramp`), which are the
89
+ exact defect `isLive` exists to prevent. A turf-side follow-up, not part of
90
+ this change.
91
+
38
92
  - **The standard user profile columns — and the engine's first migration against
39
93
  a host-owned table.** Every other engine migration creates a `studio_*` table
40
94
  the engine owns outright; `users` belongs to the host. That boundary is crossed
data/README.md CHANGED
@@ -258,6 +258,14 @@ The scoped host takes its animations from `engine-motion.css` rather than an
258
258
  inline copy, so a consumer bundling that layer gets the same spring as the shared
259
259
  host.
260
260
 
261
+ It carries the store API below with three deliberate differences: there is no
262
+ `advance()`; `swap()` replaces the top entry immediately rather than running the
263
+ shared host's directional slide — so no entry is ever left mid-transition; and it
264
+ does not read the `window.ModalAnimations` registry, so the `enterAnim` /
265
+ `exitAnim` props documented under the table are ignored and the exit always plays
266
+ the 220ms unmount from `engine-motion.css`. `isOpen` and `isLive` behave
267
+ identically on both.
268
+
261
269
  Store API (`Alpine.store('modals')`):
262
270
 
263
271
  | Call | Behavior |
@@ -268,7 +276,17 @@ Store API (`Alpine.store('modals')`):
268
276
  | `close()` | Animated close of the current modal (no-op if already closing). |
269
277
  | `closeAll()` | Instant, unanimated clear (used by navigation cleanup). |
270
278
  | `closeAllDismissible()` | Clears all modals except those opened with `dismissible: false`. |
271
- | `isOpen(id)` / `current()` | Introspection. |
279
+ | `isOpen(id)` / `current()` | Introspection. `isOpen` is true while a card is ON THE STACK — including the whole exit animation, because `close()` flips `_closing` at once and splices only after it. |
280
+ | `isLive(id)` / `isLive([id, …])` | True when a card with that id is up and **not on its way out**. Pass an array to ask about a set of ids ("is any card of this flow still up?"). |
281
+
282
+ **`isOpen` or `isLive`?** A guard that means "don't open this twice" almost
283
+ always wants `isLive`. `isOpen` stays true for the ~220ms exit window, so an
284
+ idempotence check built on it refuses to reopen a card the user just dismissed.
285
+ The one asymmetry worth knowing: `advance()` slides the *same* card between
286
+ steps of its own flow and sets only `_swappingOut`, so a mid-advance card is
287
+ still **live** — only `close()` and the outgoing half of a `swap()` set
288
+ `_closing`. `isLive` tests `_closing` alone, deliberately; conflating it with
289
+ `_swappingOut` makes an in-flow hand-off look like a dismissal.
272
290
 
273
291
  Recognized props: `dismissible: false` disables escape/click-outside dismissal
274
292
  (e.g. an in-flight transaction); `enterAnim` / `exitAnim` pick a named
@@ -390,12 +390,51 @@
390
390
  });
391
391
  this._sync();
392
392
  },
393
+ // isOpen(id) — is a card with this id ON THE STACK, in any
394
+ // lifecycle state? A card mid-close still counts: close() flips
395
+ // _closing at once and splices only after the exit animation, so
396
+ // this answers TRUE for that whole exit window. Unchanged — callers
397
+ // asking "is the DOM node still mounted" depend on exactly this.
398
+ // If you mean "is it still up", you want isLive() below.
393
399
  isOpen: function(id) {
394
400
  for (var i = 0; i < this.stack.length; i++) {
395
401
  if (this.stack[i].id === id) return true;
396
402
  }
397
403
  return false;
398
404
  },
405
+ // isLive(id) / isLive([id, ...]) — is a card with this id on the
406
+ // stack and NOT on its way out? This is what a "don't open it
407
+ // twice" guard actually means, and it is the question isOpen
408
+ // cannot answer.
409
+ //
410
+ // The difference is a TIMING one, and the asymmetry is load-bearing:
411
+ //
412
+ // close() entry gets _closing, and is spliced
413
+ // CLOSE_ANIM_MS later. isOpen stays TRUE for
414
+ // that window; isLive is FALSE immediately.
415
+ // swap() / open(..., the LEAVING entry gets _swappingOut AND
416
+ // { replace: true }) _closing — a different card is taking the
417
+ // screen, so it is not live either.
418
+ // advance() the entry gets _swappingOut and NOTHING
419
+ // else. Same card, same id, sliding between
420
+ // steps of its own flow. It stays LIVE.
421
+ //
422
+ // So this tests _closing ALONE and deliberately never consults
423
+ // _swappingOut. Conflating the two reports a card as gone in the
424
+ // middle of a step transition, which is how an in-flow hand-off
425
+ // starts looking to a caller like a dismissal.
426
+ //
427
+ // Accepts an array so a caller can ask about a SET of ids ("is any
428
+ // card belonging to this flow still up?") without reaching into
429
+ // .stack and re-deriving the lifecycle rule for itself.
430
+ isLive: function(id) {
431
+ var ids = Array.isArray(id) ? id : [id];
432
+ for (var i = 0; i < this.stack.length; i++) {
433
+ var entry = this.stack[i];
434
+ if (entry && !entry._closing && ids.indexOf(entry.id) !== -1) return true;
435
+ }
436
+ return false;
437
+ },
399
438
  current: function() {
400
439
  return this.stack.length ? this.stack[this.stack.length - 1] : null;
401
440
  },
@@ -138,10 +138,26 @@
138
138
  this._sync();
139
139
  },
140
140
 
141
+ // isOpen(id) — on the stack in ANY lifecycle state, including a card
142
+ // mid-close (close() flips _closing at once and splices only after
143
+ // CLOSE_ANIM_MS). isLive() below is the "still up" question.
141
144
  isOpen: function(id) {
142
145
  return this.stack.some(function(entry) { return entry.id === id; });
143
146
  },
144
147
 
148
+ // isLive(id) / isLive([id, ...]) — on the stack and NOT on its way
149
+ // out. Mirrors the shared host's primitive so a page-scoped store
150
+ // answers the same question the same way; see studio/modals/_host
151
+ // for the full lifecycle rationale. Tests _closing ALONE: a leaving
152
+ // swap() entry carries _swappingOut AND _closing, so _swappingOut on
153
+ // its own must never count as leaving.
154
+ isLive: function(id) {
155
+ var ids = Array.isArray(id) ? id : [id];
156
+ return this.stack.some(function(entry) {
157
+ return entry && !entry._closing && ids.indexOf(entry.id) !== -1;
158
+ });
159
+ },
160
+
145
161
  current: function() {
146
162
  return this.stack.length ? this.stack[this.stack.length - 1] : null;
147
163
  },
@@ -34,8 +34,37 @@
34
34
  # belong to Studio::IpLocations — the column is just where it lands. It holds
35
35
  # IP-derived location data, which is personal data in most jurisdictions; the
36
36
  # 50-entry cap there bounds it, and no app writes to it until it opts in.
37
+ # ON THE DOWN: this migration REFUSES to reverse, deliberately.
38
+ #
39
+ # `up` is idempotent by design — every add is `if_not_exists`, precisely so it can
40
+ # run against apps that already disagree. That guard is what makes it safe going
41
+ # in, and it is exactly what makes an honest `down` impossible: the migration
42
+ # records NOTHING about which columns it actually created on this host.
43
+ #
44
+ # So a `down` cannot tell "I added this" from "the host has had it since 2024":
45
+ #
46
+ # - mcritchie-studio owned `users.first_name` BEFORE this ran.
47
+ # - turf-monster owned `users.first_name` AND `users.birth_year`.
48
+ # - mcritchie-industries owned none of the five.
49
+ #
50
+ # The two apps the `up` was careful not to touch are the two a `DROP COLUMN`
51
+ # would rob. Rails' auto-inverse does precisely that: `INVERT_METHODS` maps
52
+ # `add_column` to `remove_column` and passes the same options through, and
53
+ # `remove_column` honours only `if_exists`. A stray `if_not_exists:` there is
54
+ # silently DISCARDED rather than raising.
55
+ #
56
+ # `if_exists` is NOT the fix, and it is worth saying why since it is the reflex:
57
+ # it asks "does this column exist?" — and on those two apps it does. That is the
58
+ # whole hazard. `if_exists` only protects a SECOND rollback, after the data is
59
+ # already gone.
60
+ #
61
+ # Refusing turns silent data loss into a loud, actionable error. An operator who
62
+ # genuinely wants these columns gone can drop the ones they know they own, by
63
+ # hand, with the schema in front of them.
37
64
  class AddStandardUserProfileColumns < ActiveRecord::Migration[7.2]
38
- def change
65
+ COLUMNS = %i[first_name birth_day birth_month birth_year ip_locations].freeze
66
+
67
+ def up
39
68
  # An app that has no users table (the engine's dummy, and any future
40
69
  # consumer that names its accounts something else) is simply skipped.
41
70
  return unless table_exists?(:users)
@@ -48,4 +77,22 @@ class AddStandardUserProfileColumns < ActiveRecord::Migration[7.2]
48
77
 
49
78
  add_column :users, :ip_locations, :jsonb, default: [], null: false, if_not_exists: true
50
79
  end
80
+
81
+ def down
82
+ # Nothing was added, so nothing is owed — an app without `users` never ran
83
+ # the `up` body either, and reversing that is genuinely a no-op.
84
+ return unless table_exists?(:users)
85
+
86
+ raise ActiveRecord::IrreversibleMigration, <<~MSG
87
+ AddStandardUserProfileColumns cannot be reversed safely.
88
+
89
+ Its `up` adds #{COLUMNS.join(", ")} with `if_not_exists`, so it does not
90
+ know which of them it created on this host and which the host already
91
+ owned. Dropping them all would destroy host-owned data (mcritchie-studio
92
+ owned first_name; turf-monster owned first_name and birth_year).
93
+
94
+ If you truly want a column gone, drop the ones you know this app did not
95
+ own before the migration, by hand, in their own migration.
96
+ MSG
97
+ end
51
98
  end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.46.0"
2
+ VERSION = "0.47.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.46.0
4
+ version: 0.47.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-08-13 00:00:00.000000000 Z
11
+ date: 2026-08-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails