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 +4 -4
- data/CHANGELOG.md +59 -0
- data/app/assets/tailwind/studio_engine/host.css +9 -9
- data/app/javascript/studio/footer_map.js +27 -11
- data/app/{assets/javascripts → javascript}/studio/sticky_table_header.js +11 -0
- data/app/javascript/studio/studio_confetti.js +117 -0
- data/app/views/layouts/studio/_head.html.erb +35 -13
- data/config/importmap.rb +6 -0
- data/docs/SITE_FOOTER.md +3 -1
- data/lib/studio/engine.rb +9 -5
- data/lib/studio/version.rb +1 -1
- metadata +3 -3
- data/app/assets/javascripts/studio/studio_confetti.js +0 -93
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fabe1d2bfd93ef94f291f09e43bc3c6116cbd80023cfffb91d77af00d9bbfbc6
|
|
4
|
+
data.tar.gz: ec6b825a90b3bbbf1ef0c4183ffe1f0b2555a34429df2d6877c8f7c4c94f838f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
32
|
-
(classes added at runtime).
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
//
|
|
22
|
-
//
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
<%#
|
|
120
|
-
|
|
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
|
-
<%#
|
|
233
|
-
|
|
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
|
-
|
|
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",
|
|
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.
|
|
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
|
|
134
|
-
#
|
|
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)
|
data/lib/studio/version.rb
CHANGED
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.
|
|
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
|
-
})();
|