studio-engine 0.115.0 → 0.115.1.rc1

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: 6c4ec89a15db3597c3fbb5ec688fc5f635f20d16ea4a7354fbf71b6d30265442
4
- data.tar.gz: 648bd9c73a07a9868de36cb0188ce0460f912fa24e82f239b269f6fc4e210d5c
3
+ metadata.gz: fabe1d2bfd93ef94f291f09e43bc3c6116cbd80023cfffb91d77af00d9bbfbc6
4
+ data.tar.gz: ec6b825a90b3bbbf1ef0c4183ffe1f0b2555a34429df2d6877c8f7c4c94f838f
5
5
  SHA512:
6
- metadata.gz: 86d05be2c55f6e60dc5b896586ea4b8bb0a6aa178c5a52787bd2f3da51ed2a84ceb40b4e83b73f78b3fd641c46e3f94e066ea22c39ed70efeac2936b5f7b7a93
7
- data.tar.gz: 3993e767f3bc3b907a0af88e7d209ba94b6ad64d4c1e252fd76adbcb1058a69a2ab29bba012443a1cc7e773168855a06754e4279e4f40ed6ab081821d19cb232
6
+ metadata.gz: 8ac4e8a7fc5a8510ec63eb126ed44110a3205e072d2adf4861dfefcae0584de24ac2eb3624958c15c0a15e11a986dc1608116b51013d76879fb8cc26b1129516
7
+ data.tar.gz: d358e292cf4c84be548bf75235ec4613d9d9a3b9c9ddec008879976b696198b7ff1608ca3b46a82bd20d2e419f39f97b12aa7df08556ab808faca510a853d84c
data/CHANGELOG.md CHANGED
@@ -4,6 +4,65 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.115.1 — 2026-10-27
8
+
9
+ ### Changed
10
+
11
+ - **Five of the engine's six classic scripts are ES modules.** No host change
12
+ is needed: every host renders `layouts/studio/head`, which now loads them as
13
+ modules, and every global a host calls is kept.
14
+ - `studio/sticky_table_header` and `studio/studio_confetti` moved from
15
+ `app/assets/javascripts/studio/` to `app/javascript/studio/`, so the
16
+ importmap pins them as `studio/<name>` like every engine module. Each has a
17
+ default export (the sticky-header API, `{ burst, cannons }`);
18
+ `studio/studio_confetti` also exports `burst`, `cannons` and `confetti`.
19
+ - Alpine and canvas-confetti stay vendored, unedited, in
20
+ `app/assets/javascripts/studio/`, which now holds only those builds and the
21
+ classic `studio/session.js`.
22
+ Alpine loads by a `type="module"` tag; canvas-confetti is pinned as
23
+ `"canvas-confetti"` and imported by `studio/studio_confetti`, so the head no
24
+ longer has its own tag for it.
25
+ - Leaflet is imported by `studio/footer_map` with `import()` of the URL on the
26
+ map element, not a `<script>` element injected into the head. A failed fetch
27
+ is retried under `?retry=n`, because a browser never refetches a module whose
28
+ fetch failed.
29
+ - Every head script with a `src`, but the session store, is a `type="module"`
30
+ tag below the import map (a module tag above it makes browsers without
31
+ dynamic import maps ignore the map). The head adds no inline script, so the
32
+ strict-nonce count is unchanged.
33
+ - **Held: `studio/session.js` stays a synchronous classic script**, above the
34
+ import map, unchanged. Host code registers identity sources with
35
+ `window.StudioSession` as its own modules evaluate (turf-monster's
36
+ `wallet_signal.js` calls solana-studio's `walletIdentity.register()` at
37
+ import), and a host's entry point is the module tag
38
+ `javascript_importmap_tags` renders, which an engine module tag below the
39
+ map would follow: the store would not exist yet and the registration would
40
+ silently be null. It becomes a module once those consumers wait for it.
41
+ - **Timing.** `window.confetti` and `window.studioConfetti` used to exist
42
+ while the body parsed (synchronous classic tags); they now exist once the
43
+ head's modules run, which is still before Alpine starts and before
44
+ `DOMContentLoaded`. A host's inline body
45
+ script that calls one of them at parse time, rather than from a handler,
46
+ would now find it missing. No host on `origin/accepted` does: turf-monster's
47
+ `confetti()` calls in `solana_utils.js` and `shared/_alpine_factories` run
48
+ from functions its handlers call, and each checks `typeof confetti` first.
49
+ - `host.css` no longer scans `app/assets/javascripts` (its `@source` line and
50
+ the vendored-file exclusion are gone): it holds the vendored builds and
51
+ `studio/session.js`, which puts no class on a page, and a test measures
52
+ that. The moved scripts are scanned under `app/javascript`.
53
+
54
+ ### Deprecated
55
+
56
+ - **Compatibility globals for the converted scripts.** Each stays for classic
57
+ callers; new code imports the module instead:
58
+ - `window.studioConfetti`: `import { burst, cannons } from "studio/studio_confetti"`.
59
+ - `window.confetti` (the canvas-confetti library global) as a host call:
60
+ `import { confetti } from "studio/studio_confetti"`. turf-monster calls it
61
+ today; the engine's own `fireSuccessConfetti` still reads it.
62
+ - `window.StudioStickyTableHeaders`: `import StickyTableHeaders from "studio/sticky_table_header"`.
63
+ `window.Alpine` and `window.L` are not deprecated: Alpine and Leaflet read
64
+ their own globals.
65
+
7
66
  ## 0.115.0 — 2026-10-26
8
67
 
9
68
  ### Breaking
@@ -28,13 +28,15 @@
28
28
  files put a utility class on the page is scanned here, relative to this
29
29
  file, in every host that imports it, with no host edit: views and
30
30
  partials, the ViewComponents (their colour classes live in Ruby), the
31
- helpers that build markup (admin_model_table), and both JavaScript trees
32
- (classes added at runtime).
33
-
34
- The exclusions are NARROW on purpose: the vendored, minified third-party
35
- bundles (Stimulus, Alpine, Leaflet, Sortable, canvas-confetti) are not
36
- engine markup, and scanning them mints junk utilities from their
37
- identifiers. Never exclude a whole directory: the hub's
31
+ helpers that build markup (admin_model_table), and the engine's ES modules
32
+ (app/javascript: classes added at runtime). app/assets/javascripts is not
33
+ scanned: since the engine's scripts became modules it holds the vendored,
34
+ minified third-party builds (Alpine, Leaflet, Sortable, canvas-confetti) and
35
+ studio/session.js, which puts no class on a page (held by
36
+ test/integration/component_tailwind_source_test.rb).
37
+
38
+ The exclusions are NARROW on purpose: the vendored bundles are not engine
39
+ markup, and scanning them mints junk utilities from their identifiers. Never exclude a whole directory: the hub's
38
40
  `@source not "../../../vendor/bundle"` once dropped every one of these
39
41
  lines in CI, because the gem itself lives under vendor/bundle there.
40
42
 
@@ -47,8 +49,6 @@
47
49
  @source "../../../helpers";
48
50
  @source "../../../javascript";
49
51
  @source not "../../../javascript/studio/vendor";
50
- @source "../../javascripts";
51
- @source not "../../javascripts/studio/{alpine,canvas_confetti,leaflet,sortable}.js";
52
52
 
53
53
  /* -- Motion / effect / leveling layer ---------------------------------------
54
54
  Last, so the theme and component layer it reads are in scope, as when hosts
@@ -18,8 +18,16 @@
18
18
  // fail on its elements: that app's map carries no data-leaflet-js, so there is
19
19
  // nothing here to fetch.
20
20
  //
21
- // It imports nothing; test/javascript/footer_map.test.mjs loads it as a data:
22
- // module. Started by the footer-map controller.
21
+ // LEAFLET IS A MODULE IMPORT. The vendored build (studio/leaflet.js) is
22
+ // imported by the URL the element carries, as a dynamic import(), not a
23
+ // <script> element injected into the head: it assigns window.L when it is
24
+ // evaluated, and an import is governed by the page's script-src exactly as the
25
+ // module that asks for it is. A browser never refetches a module whose fetch
26
+ // failed, so a retry asks for the same file under a fresh query string.
27
+ //
28
+ // It imports nothing statically; test/javascript/footer_map.test.mjs loads it as
29
+ // a data: module and hands installFooterMaps its own importer. Started by the
30
+ // footer-map controller.
23
31
 
24
32
  export const MAPS = "[data-footer-map][data-leaflet-js]"
25
33
  export const TILES = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
@@ -33,15 +41,25 @@ export function mapOptions(dataset) {
33
41
  return { at, zoom: parseInt(dataset.zoom, 10) || 15, zoomControl: dataset.controls !== "false" }
34
42
  }
35
43
 
44
+ // The URL of the nth attempt at one file: the file itself first, then the same
45
+ // file under ?retry=n, because the module map remembers a failed fetch.
46
+ export function attemptUrl(src, attempt) {
47
+ if (!attempt) return src
48
+ return `${src}${src.includes("?") ? "&" : "?"}retry=${attempt}`
49
+ }
50
+
51
+ const importModule = (url) => import(url)
52
+
36
53
  // Installs the footer maps, once per document. Answers the function that arms
37
54
  // the maps now on the page (after `load`), whether this call installed it or an
38
- // earlier one did.
39
- export function installFooterMaps(win, doc) {
55
+ // earlier one did. `load` is how a module is imported (a test passes its own).
56
+ export function installFooterMaps(win, doc, { load = importModule } = {}) {
40
57
  if (win.__studioFooterMapsArmed) return win.__studioFooterMapsArm || (() => {})
41
58
  win.__studioFooterMapsArmed = true
42
59
 
43
60
  let maps = []
44
61
  let loading = null
62
+ let attempts = 0
45
63
 
46
64
  // Checked on every mount, not once: a page visit that rebuilds the head must
47
65
  // not leave a mounted map without its stylesheet.
@@ -57,14 +75,12 @@ export function installFooterMaps(win, doc) {
57
75
  function loadLeaflet(src) {
58
76
  if (win.L) return Promise.resolve()
59
77
  if (loading) return loading
60
- loading = new Promise((resolve, reject) => {
61
- const s = doc.createElement("script")
62
- s.src = src
63
- s.onload = resolve
78
+ const url = attemptUrl(src, attempts)
79
+ attempts += 1
80
+ loading = new Promise((resolve) => resolve(load(url)))
81
+ .then(() => { if (!win.L) throw new Error("leaflet loaded without window.L") })
64
82
  // A failed fetch must not be remembered: the next visit tries again.
65
- s.onerror = () => { loading = null; s.remove(); reject(new Error("leaflet failed to load")) }
66
- doc.head.appendChild(s)
67
- })
83
+ .catch((error) => { loading = null; throw error })
68
84
  return loading
69
85
  }
70
86
 
@@ -1,3 +1,12 @@
1
+ // studio/sticky_table_header: pins a cloned header row over a long table as it
2
+ // scrolls under the navbar (docs/NAVBAR_SETUP.md). An ES module that installs on
3
+ // import: the head loads it with a type="module" tag when
4
+ // Studio.sticky_table_headers is on (or the head's sticky_table_headers local
5
+ // says so). Its default export is window.StudioStickyTableHeaders
6
+ // ({ loaded, pinnedEdge, placement }), which stays as the compatibility global
7
+ // (CHANGELOG, "Deprecated"). The body stays one function with no import, so
8
+ // test/javascript/sticky_table_header.test.mjs still runs it in a node:vm
9
+ // context per test (cutting the single export line).
1
10
  (() => {
2
11
  if (window.StudioStickyTableHeaders?.loaded) return;
3
12
 
@@ -259,3 +268,5 @@
259
268
  window.addEventListener("scroll", scheduleUpdate, { passive: true });
260
269
  window.addEventListener("resize", scheduleUpdate, { passive: true });
261
270
  })();
271
+
272
+ export default window.StudioStickyTableHeaders
@@ -0,0 +1,117 @@
1
+ /*
2
+ studio/studio_confetti — two zero-dependency celebration effects built on the
3
+ vendored canvas-confetti (app/assets/javascripts/studio/canvas_confetti.js,
4
+ pinned as "canvas-confetti", no CDN).
5
+
6
+ An ES module (the head loads it with a type="module" tag after the import
7
+ map). It imports canvas-confetti for its side effect, which defines the
8
+ library's global `confetti`, and exports:
9
+
10
+ import { burst, cannons, confetti } from "studio/studio_confetti"
11
+ burst(cardEl) // radial burst behind a card/modal
12
+ cannons() // side-cannons full-screen celebration
13
+ confetti(opts) // the canvas-confetti call itself, a no-op if it failed to load
14
+
15
+ Ported faithfully from turf-monster:
16
+ - burst(target) → the radial card-burst that explodes from BEHIND a card or
17
+ modal, emanating from its center (TM solana_utils.js fireConfettiFromModal /
18
+ shared/_alpine_factories fireQuestBadgeConfetti). Pass the DOM element (or a
19
+ selector, or an {x,y} origin in 0..1 viewport space) to aim it; defaults to
20
+ screen center.
21
+ - cannons(opts) → the full-screen "you joined a contest" celebration: a center
22
+ burst plus left + right side-cannons firing inward and a top shell (the same
23
+ choreography as the engine head's window.fireSuccessConfetti).
24
+
25
+ Both honor prefers-reduced-motion: reduce (no-op, and disableForReducedMotion is
26
+ also passed through to canvas-confetti as a second belt). Colors come from
27
+ window.CONFETTI_COLORS when a host sets it, else a neutral festive default.
28
+
29
+ DEPRECATED COMPATIBILITY GLOBALS (CHANGELOG, "Deprecated"): window.studioConfetti
30
+ ({ burst, cannons }) and the library's own window.confetti stay, because hosts
31
+ call them from classic code (turf-monster's solana_utils and _alpine_factories).
32
+ New code imports from this module. Both now exist once the head's modules have
33
+ run (deferred, before Alpine starts), no longer during the body's parse.
34
+ */
35
+ import "canvas-confetti"
36
+
37
+ const DEFAULT_COLORS = [
38
+ "#4BAF50", "#8E82FE", "#06D6A0", "#FF7C47",
39
+ "#FFD700", "#00BFFF", "#FF6B9D", "#C084FC"
40
+ ]
41
+
42
+ function colors(opts) {
43
+ return (opts && opts.colors) || window.CONFETTI_COLORS || DEFAULT_COLORS
44
+ }
45
+
46
+ function reducedMotion() {
47
+ return !!(window.matchMedia &&
48
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches)
49
+ }
50
+
51
+ // The canvas-confetti call. Read off the window on every call, not captured at
52
+ // import: the vendored build assigns the global when it is evaluated, and a
53
+ // failed load leaves it undefined, which makes every effect a no-op.
54
+ export function confetti(opts) {
55
+ if (typeof window.confetti !== "function") return null
56
+ return window.confetti(opts)
57
+ }
58
+
59
+ function available() {
60
+ return typeof window.confetti === "function" && !reducedMotion()
61
+ }
62
+
63
+ // Resolve a burst origin ({x,y} in 0..1 viewport space) from an element, a
64
+ // selector, or a literal {x,y}. Falls back to screen center when the target is
65
+ // missing or not laid out (e.g. a card that just unmounted).
66
+ function originFor(target) {
67
+ const el = (typeof target === "string") ? document.querySelector(target) : target
68
+ if (el && el.nodeType === 1) {
69
+ const r = el.getBoundingClientRect()
70
+ if (r.width || r.height) {
71
+ return {
72
+ x: (r.left + r.width / 2) / window.innerWidth,
73
+ y: (r.top + r.height / 2) / window.innerHeight
74
+ }
75
+ }
76
+ }
77
+ if (target && typeof target.x === "number" && typeof target.y === "number") {
78
+ return { x: target.x, y: target.y }
79
+ }
80
+ return { x: 0.5, y: 0.5 }
81
+ }
82
+
83
+ // Radial burst from behind a card/modal — spread:360 fires in every direction
84
+ // from the card center, low velocity + gravity keep it clustered around the
85
+ // card rather than blasting off-screen. A smaller follow-shell layers texture,
86
+ // and two bottom-corner sweeps give the moment theatre.
87
+ export function burst(target, opts) {
88
+ if (!available()) return
89
+ opts = opts || {}
90
+ const origin = originFor(target)
91
+ const c = colors(opts)
92
+ const z = opts.zIndex || 9999
93
+ confetti({ particleCount: 140, angle: 90, spread: 360, origin: origin, colors: c, zIndex: z, startVelocity: 30, gravity: 0.7, ticks: 220, scalar: 1.0, disableForReducedMotion: true })
94
+ setTimeout(() => {
95
+ confetti({ particleCount: 60, angle: 60, spread: 70, origin: { x: Math.max(0, origin.x - 0.18), y: Math.min(1, origin.y + 0.05) }, colors: c, zIndex: z, startVelocity: 40, gravity: 0.9, ticks: 200, scalar: 0.9, disableForReducedMotion: true })
96
+ confetti({ particleCount: 60, angle: 120, spread: 70, origin: { x: Math.min(1, origin.x + 0.18), y: Math.min(1, origin.y + 0.05) }, colors: c, zIndex: z, startVelocity: 40, gravity: 0.9, ticks: 200, scalar: 0.9, disableForReducedMotion: true })
97
+ }, 180)
98
+ }
99
+
100
+ // Full-screen side-cannon celebration: center burst, then left + right cannons
101
+ // firing inward, then a wide top shell. Mirrors window.fireSuccessConfetti.
102
+ export function cannons(opts) {
103
+ if (!available()) return
104
+ opts = opts || {}
105
+ const c = colors(opts)
106
+ const z = opts.zIndex || 9999
107
+ confetti({ particleCount: 150, spread: 100, origin: { x: 0.5, y: 0.5 }, colors: c, zIndex: z, startVelocity: 45, gravity: 0.8, ticks: 300, scalar: 1.2, disableForReducedMotion: true })
108
+ setTimeout(() => { confetti({ particleCount: 80, angle: 60, spread: 60, origin: { x: 0, y: 0.6 }, colors: c, zIndex: z, startVelocity: 55, gravity: 1, ticks: 250, disableForReducedMotion: true }) }, 150)
109
+ setTimeout(() => { confetti({ particleCount: 80, angle: 120, spread: 60, origin: { x: 1, y: 0.6 }, colors: c, zIndex: z, startVelocity: 55, gravity: 1, ticks: 250, disableForReducedMotion: true }) }, 150)
110
+ setTimeout(() => { confetti({ particleCount: 100, spread: 160, origin: { x: 0.5, y: 0.3 }, colors: c, zIndex: z, startVelocity: 30, gravity: 1.2, ticks: 200, scalar: 0.8, disableForReducedMotion: true }) }, 400)
111
+ }
112
+
113
+ const studioConfetti = { burst, cannons }
114
+ export default studioConfetti
115
+
116
+ // DEPRECATED: the compatibility global for classic callers. Import the module.
117
+ window.studioConfetti = studioConfetti
@@ -28,7 +28,15 @@
28
28
  <%= csrf_meta_tags %>
29
29
  <%= csp_meta_tag %>
30
30
  <%# The session-drift stamp + browser store. Renders nothing unless the controller
31
- includes Studio::ErrorHandling. See studio/_session_stamp. %>
31
+ includes Studio::ErrorHandling. See studio/_session_stamp.
32
+
33
+ THE ONE ENGINE SCRIPT THAT STAYS CLASSIC, AND SYNCHRONOUS. Host code
34
+ registers identity sources with window.StudioSession as its own modules
35
+ EVALUATE (turf-monster registers its identity source from a module at
36
+ import; CHANGELOG, "Held"), and the host's entry point is the module tag
37
+ javascript_importmap_tags renders, which any engine module tag below the map
38
+ would follow. A module store would not exist yet, and the registration would
39
+ silently be null. It becomes a module when those consumers wait for it. %>
32
40
  <%= render "studio/session_stamp" %>
33
41
  <%= render "layouts/studio/smooth_load" %>
34
42
  <%# The favicon's URL carries its content version (Studio::PublicFileVersion),
@@ -116,14 +124,8 @@
116
124
  }
117
125
  </style>
118
126
 
119
- <%# canvas-confetti is VENDORED into the engine (studio/canvas_confetti.js) and
120
- shipped through the asset pipeline — no CDN, CSP-safe (same-origin :self),
121
- zero-per-app dependency. Defines the global `confetti`; studio_confetti.js
122
- then builds window.studioConfetti (burst/cannons). fireSuccessConfetti
123
- (studio/head_chrome) rides the same global. %>
124
- <%= javascript_include_tag "studio/canvas_confetti", "data-turbo-track": "reload" %>
125
- <%= javascript_include_tag "studio/studio_confetti", "data-turbo-track": "reload" %>
126
- <%# The theme and devMode stores are studio/alpine_stores (its module tag is below).
127
+ <%# Confetti is a module now: studio/studio_confetti, loaded below the import map.
128
+ The theme and devMode stores are studio/alpine_stores (its module tag is below).
127
129
  showNavSpinner / hideNavSpinner and fireSuccessConfetti are studio/head_chrome,
128
130
  installed by studio/alpine_shims. %>
129
131
 
@@ -187,6 +189,13 @@
187
189
  <%= stylesheet_link_tag "studio/sticky_table_header", "data-turbo-track": "reload" %>
188
190
  <% end %>
189
191
  <%= javascript_importmap_tags %>
192
+ <%# EVERY ENGINE SCRIPT BELOW IS A MODULE, AND EVERY MODULE TAG COMES AFTER THE
193
+ IMPORT MAP. A browser without dynamic import maps ignores an import map that
194
+ arrives after its first module script, so a module tag above this line would
195
+ take every bare specifier on the page down with it. The ones below that load by
196
+ URL (type="module" with a src) are external files, not inline scripts: they
197
+ carry no body and need no nonce under a policy that allows 'self'. The session
198
+ store (studio/_session_stamp, above) is the one classic script, and says why. %>
190
199
  <%# THE ENGINE'S BROWSER BOOT (studio/application): its every-page Stimulus
191
200
  controllers, the pinned stack, and the Alpine shims. A module tag, so it
192
201
  carries the nonce. %>
@@ -229,8 +238,15 @@
229
238
  module that owns it. Its imports import nothing, and studio/alpine_shims
230
239
  imports it, so a failed boot leaves a page its uploads. %>
231
240
  <%= javascript_import_module_tag "studio/alpine_scopes" %>
232
- <%# ALPINE LOADS AFTER THE MODULE TAGS, and the order is the contract. Deferred
233
- classic scripts and module scripts execute in document order, so by the time
241
+ <%# CONFETTI, BY URL. studio/studio_confetti imports the vendored canvas-confetti
242
+ (pinned "canvas-confetti", app/assets/javascripts/studio/canvas_confetti.js:
243
+ no CDN, same-origin) and exports burst, cannons and confetti. It also keeps
244
+ the deprecated globals window.studioConfetti and window.confetti, which
245
+ studio/head_chrome's fireSuccessConfetti and host classic code still call.
246
+ Before Alpine, so both exist by the time an x-init can fire one. %>
247
+ <%= javascript_include_tag "studio/studio_confetti", type: "module", "data-turbo-track": "reload" %>
248
+ <%# ALPINE LOADS AFTER THE MODULE TAGS, and the order is the contract. Module
249
+ scripts (and deferred classic ones) execute in document order, so by the time
234
250
  Alpine starts, studio/application has defined the shims it evaluates
235
251
  (x-data="navCollapse()"), studio/alpine_stores has registered the
236
252
  `alpine:init` listener that installs $store.theme and $store.devMode, and
@@ -255,7 +271,13 @@
255
271
  Investigated as a suspect in the board-filter flake (three PRs reddened in one
256
272
  day, 2026-08-15). This removes a real source of run-to-run variance; it does NOT
257
273
  claim to be that flake's cause, and the remaining suspect there is untouched. %>
258
- <%= javascript_include_tag "studio/alpine", defer: true, "data-turbo-track": "reload" %>
274
+ <%# A MODULE TAG, NOT A DEFERRED CLASSIC ONE (it was `defer: true` until the
275
+ engine's scripts became modules). A module is deferred by definition, so its
276
+ place in the document order is unchanged. The vendored build is unedited: it
277
+ assigns window.Alpine and starts Alpine in a microtask after it is evaluated,
278
+ which as a module is after every module above it. window.Alpine is not
279
+ deprecated; Alpine itself and every host read it. %>
280
+ <%= javascript_include_tag "studio/alpine", type: "module", "data-turbo-track": "reload" %>
259
281
  <% if sticky_table_headers %>
260
- <%= javascript_include_tag "studio/sticky_table_header", defer: true, "data-turbo-track": "reload" %>
282
+ <%= javascript_include_tag "studio/sticky_table_header", type: "module", "data-turbo-track": "reload" %>
261
283
  <% end %>
data/config/importmap.rb CHANGED
@@ -15,6 +15,11 @@
15
15
  # is the classic build (app/assets/javascripts/studio/sortable.js), which assigns
16
16
  # window.Sortable when it is imported.
17
17
  #
18
+ # The vendored canvas-confetti is pinned the same way, as "canvas-confetti", and
19
+ # not preloaded: studio/studio_confetti imports it, and the head loads that
20
+ # module by its own tag. It too is the classic build, which assigns
21
+ # window.confetti when it is imported.
22
+ #
18
23
  # `pin`, not `pin_all_from`, so a host can override one: importmap-rails expands
19
24
  # a pin_all_from directory AFTER every plain pin, so a directory pin would beat
20
25
  # the host's own `pin` of the same name. Plain pins are last-drawn-wins, and the
@@ -28,3 +33,4 @@ Studio::Engine.javascript_module_logical_paths.each do |logical_path|
28
33
  end
29
34
  pin "@hotwired/stimulus", to: "studio/vendor/stimulus.js", preload: true
30
35
  pin "sortablejs", to: "studio/sortable.js", preload: false
36
+ pin "canvas-confetti", to: "studio/canvas_confetti.js", preload: false
data/docs/SITE_FOOTER.md CHANGED
@@ -279,7 +279,9 @@ element, isolate that element too.
279
279
  Leaflet is served by the engine through the app's asset pipeline
280
280
  (`studio/leaflet.js`, `studio/leaflet.css`, both in the engine's precompile
281
281
  list), so it works on Heroku with no extra step in either a Sprockets or a
282
- Propshaft app. The engine's copy of the stylesheet drops Leaflet's default marker
282
+ Propshaft app. `studio/footer_map` imports the script as a module, with a
283
+ dynamic `import()` of the URL on the map element, and the vendored build
284
+ assigns `window.L` when it is evaluated. The engine's copy of the stylesheet drops Leaflet's default marker
283
285
  image and its layers-control image, which are not shipped: mark a point with
284
286
  `L.divIcon`, as the footer does.
285
287
 
data/lib/studio/engine.rb CHANGED
@@ -76,12 +76,16 @@ module Studio
76
76
  end
77
77
 
78
78
  initializer "studio.assets" do |app|
79
+ # app/assets/javascripts/studio: the vendored third-party builds, each
80
+ # loaded as an ES module (Alpine by the head's type="module" tag,
81
+ # canvas-confetti and SortableJS through their importmap pins, Leaflet by
82
+ # studio/footer_map's import()), and studio/session.js, the one classic
83
+ # script (layouts/studio/_head says why). The engine's own modules are
84
+ # under app/javascript, enumerated below.
79
85
  app.config.assets.precompile += %w[
80
86
  studio/sticky_table_header.css
81
- studio/sticky_table_header.js
82
87
  studio/alpine.js
83
88
  studio/canvas_confetti.js
84
- studio/studio_confetti.js
85
89
  studio/sortable.js
86
90
  studio/leaflet.js
87
91
  studio/leaflet.css
@@ -130,9 +134,9 @@ module Studio
130
134
  # The contract a module joins by being here:
131
135
  # - it lives under app/javascript/studio/ and is pinned as "studio/<name>";
132
136
  # - pins are NOT preloaded, so a page fetches only the modules it imports;
133
- # - its logical asset path shares the studio/ prefix with the classic
134
- # scripts in app/assets/javascripts/studio, so a module may not reuse one
135
- # of those names (test/integration/engine_importmap_pins_test.rb).
137
+ # - its logical asset path shares the studio/ prefix with the files in
138
+ # app/assets/javascripts/studio (vendored builds, studio/session.js), so
139
+ # a module may not reuse one of those names (test/integration/engine_importmap_pins_test.rb).
136
140
  # A host without importmap-rails (a footer-only consumer) skips this.
137
141
  initializer "studio.importmap", before: "importmap" do |app|
138
142
  next unless app.config.respond_to?(:importmap)
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.115.0"
2
+ VERSION = "0.115.1.rc1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.115.0
4
+ version: 0.115.1.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -365,8 +365,6 @@ files:
365
365
  - app/assets/javascripts/studio/leaflet.js
366
366
  - app/assets/javascripts/studio/session.js
367
367
  - app/assets/javascripts/studio/sortable.js
368
- - app/assets/javascripts/studio/sticky_table_header.js
369
- - app/assets/javascripts/studio/studio_confetti.js
370
368
  - app/assets/stylesheets/studio/leaflet.css
371
369
  - app/assets/stylesheets/studio/sticky_table_header.css
372
370
  - app/assets/tailwind/studio_engine/engine-motion.css
@@ -471,7 +469,9 @@ files:
471
469
  - app/javascript/studio/newsletter_modal.js
472
470
  - app/javascript/studio/pinned_stack.js
473
471
  - app/javascript/studio/profile_form.js
472
+ - app/javascript/studio/sticky_table_header.js
474
473
  - app/javascript/studio/stimulus.js
474
+ - app/javascript/studio/studio_confetti.js
475
475
  - app/javascript/studio/style_guide.js
476
476
  - app/javascript/studio/style_modals.js
477
477
  - app/javascript/studio/survey.js
@@ -1,93 +0,0 @@
1
- /*
2
- studio-engine — window.studioConfetti: two zero-dependency celebration effects
3
- built on the vendored canvas-confetti (studio/canvas_confetti.js, no CDN).
4
-
5
- Ported faithfully from turf-monster:
6
- - burst(target) → the radial card-burst that explodes from BEHIND a card or
7
- modal, emanating from its center (TM solana_utils.js fireConfettiFromModal /
8
- shared/_alpine_factories fireQuestBadgeConfetti). Pass the DOM element (or a
9
- selector, or an {x,y} origin in 0..1 viewport space) to aim it; defaults to
10
- screen center.
11
- - cannons(opts) → the full-screen "you joined a contest" celebration: a center
12
- burst plus left + right side-cannons firing inward and a top shell (the same
13
- choreography as the engine head's window.fireSuccessConfetti).
14
-
15
- Both honor prefers-reduced-motion: reduce (no-op, and disableForReducedMotion is
16
- also passed through to canvas-confetti as a second belt). Colors come from
17
- window.CONFETTI_COLORS when a host sets it, else a neutral festive default.
18
-
19
- Usage:
20
- window.studioConfetti.burst(cardEl); // radial burst behind a card/modal
21
- window.studioConfetti.cannons(); // side-cannons full-screen celebration
22
- */
23
- (function () {
24
- "use strict";
25
-
26
- var DEFAULT_COLORS = [
27
- "#4BAF50", "#8E82FE", "#06D6A0", "#FF7C47",
28
- "#FFD700", "#00BFFF", "#FF6B9D", "#C084FC"
29
- ];
30
-
31
- function colors(opts) {
32
- return (opts && opts.colors) || window.CONFETTI_COLORS || DEFAULT_COLORS;
33
- }
34
-
35
- function reducedMotion() {
36
- return !!(window.matchMedia &&
37
- window.matchMedia("(prefers-reduced-motion: reduce)").matches);
38
- }
39
-
40
- // Resolve a burst origin ({x,y} in 0..1 viewport space) from an element, a
41
- // selector, or a literal {x,y}. Falls back to screen center when the target is
42
- // missing or not laid out (e.g. a card that just unmounted).
43
- function originFor(target) {
44
- var el = (typeof target === "string") ? document.querySelector(target) : target;
45
- if (el && el.nodeType === 1) {
46
- var r = el.getBoundingClientRect();
47
- if (r.width || r.height) {
48
- return {
49
- x: (r.left + r.width / 2) / window.innerWidth,
50
- y: (r.top + r.height / 2) / window.innerHeight
51
- };
52
- }
53
- }
54
- if (target && typeof target.x === "number" && typeof target.y === "number") {
55
- return { x: target.x, y: target.y };
56
- }
57
- return { x: 0.5, y: 0.5 };
58
- }
59
-
60
- var studioConfetti = {
61
- // Radial burst from behind a card/modal — spread:360 fires in every direction
62
- // from the card center, low velocity + gravity keep it clustered around the
63
- // card rather than blasting off-screen. A smaller follow-shell layers texture,
64
- // and two bottom-corner sweeps give the moment theatre.
65
- burst: function (target, opts) {
66
- if (typeof confetti === "undefined" || reducedMotion()) return;
67
- opts = opts || {};
68
- var origin = originFor(target);
69
- var c = colors(opts);
70
- var z = opts.zIndex || 9999;
71
- confetti({ particleCount: 140, angle: 90, spread: 360, origin: origin, colors: c, zIndex: z, startVelocity: 30, gravity: 0.7, ticks: 220, scalar: 1.0, disableForReducedMotion: true });
72
- setTimeout(function () {
73
- confetti({ particleCount: 60, angle: 60, spread: 70, origin: { x: Math.max(0, origin.x - 0.18), y: Math.min(1, origin.y + 0.05) }, colors: c, zIndex: z, startVelocity: 40, gravity: 0.9, ticks: 200, scalar: 0.9, disableForReducedMotion: true });
74
- confetti({ particleCount: 60, angle: 120, spread: 70, origin: { x: Math.min(1, origin.x + 0.18), y: Math.min(1, origin.y + 0.05) }, colors: c, zIndex: z, startVelocity: 40, gravity: 0.9, ticks: 200, scalar: 0.9, disableForReducedMotion: true });
75
- }, 180);
76
- },
77
-
78
- // Full-screen side-cannon celebration: center burst, then left + right cannons
79
- // firing inward, then a wide top shell. Mirrors window.fireSuccessConfetti.
80
- cannons: function (opts) {
81
- if (typeof confetti === "undefined" || reducedMotion()) return;
82
- opts = opts || {};
83
- var c = colors(opts);
84
- var z = opts.zIndex || 9999;
85
- confetti({ particleCount: 150, spread: 100, origin: { x: 0.5, y: 0.5 }, colors: c, zIndex: z, startVelocity: 45, gravity: 0.8, ticks: 300, scalar: 1.2, disableForReducedMotion: true });
86
- setTimeout(function () { confetti({ particleCount: 80, angle: 60, spread: 60, origin: { x: 0, y: 0.6 }, colors: c, zIndex: z, startVelocity: 55, gravity: 1, ticks: 250, disableForReducedMotion: true }); }, 150);
87
- setTimeout(function () { confetti({ particleCount: 80, angle: 120, spread: 60, origin: { x: 1, y: 0.6 }, colors: c, zIndex: z, startVelocity: 55, gravity: 1, ticks: 250, disableForReducedMotion: true }); }, 150);
88
- setTimeout(function () { confetti({ particleCount: 100, spread: 160, origin: { x: 0.5, y: 0.3 }, colors: c, zIndex: z, startVelocity: 30, gravity: 1.2, ticks: 200, scalar: 0.8, disableForReducedMotion: true }); }, 400);
89
- }
90
- };
91
-
92
- window.studioConfetti = studioConfetti;
93
- })();