studio-engine 0.115.0 → 0.115.1.rc2

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: e4a692edc7e883547004855e1fbd76ce5adf3da6053fdd3e704edd35727382be
4
+ data.tar.gz: cc943c4822bf3bdc6c4f27c02513bd625d8a4a9cb2e82fc10c73e8bd29c9c68c
5
5
  SHA512:
6
- metadata.gz: 86d05be2c55f6e60dc5b896586ea4b8bb0a6aa178c5a52787bd2f3da51ed2a84ceb40b4e83b73f78b3fd641c46e3f94e066ea22c39ed70efeac2936b5f7b7a93
7
- data.tar.gz: 3993e767f3bc3b907a0af88e7d209ba94b6ad64d4c1e252fd76adbcb1058a69a2ab29bba012443a1cc7e773168855a06754e4279e4f40ed6ab081821d19cb232
6
+ metadata.gz: f554a81ad3d547d3150d926e537895377a580c95cda43b490407ecc9d4f161f96767fd3fac23b7e1f62cd795df5371e93677bab6f0c04b9805d4321b14e53378
7
+ data.tar.gz: cb00d6b834827208724821269ce6adebaf0789e75156769040490a98eda2cef3eb35d0cc62cc899f10ff9cac46974f0e6b732251a5f1e74160bc2487c22e0ba2
data/CHANGELOG.md CHANGED
@@ -4,6 +4,99 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Added
8
+
9
+ - **One Node tooling package, shipped in the gem** (`lib/studio/node_tooling/`,
10
+ see its `README.md`). `versions.json` pins Node 22, `@playwright/test`
11
+ 1.62.1 and `axe-core` 4.11.0. `playwright_base.js` holds the browser lane's
12
+ shared defaults; `e2e_helpers.js` holds `watchPageErrors` and
13
+ `blockOffsiteRequests`; `node_test/register.mjs` lets `node --test` import a
14
+ module by its importmap name. `bin/rails studio:node:link` links it at
15
+ `node_modules/.studio-engine` (run it after `npm ci`), and
16
+ `bin/rails studio:node:check` exits 1, naming each pin a host drifts from. No
17
+ host change is needed until a host adopts it.
18
+
19
+ ### Changed
20
+
21
+ - **The engine runs Node 22.** `.tool-versions` reads `nodejs 22`, both
22
+ `engine-ci.yml` Node steps read it through `node-version-file`, and
23
+ `package.json` pins `@playwright/test` at exactly 1.62.1. The engine's
24
+ Playwright config and e2e helpers build on the shared package, and resolve to
25
+ the same config as before.
26
+
27
+ ### Fixed
28
+
29
+ - **`studio/studio_confetti` resolves `"canvas-confetti"` on every page.** In
30
+ 0.115.1.rc1 the head loaded it with `javascript_include_tag ..., type:
31
+ "module"`, which also sends a `Link: rel=modulepreload` response header. The
32
+ browser acts on that header before it parses the import map, so on a page
33
+ whose head parsed slowly it resolved the module's bare import against no map
34
+ and threw `TypeError: Failed to resolve module specifier "canvas-confetti"`
35
+ (11 hub e2e specs: the boards and `/dreams`). The head now imports it with
36
+ `javascript_import_module_tag` below the map, which sends no header. No host
37
+ change is needed. A module the head loads by URL may import no bare specifier
38
+ (`test/views/head_url_modules_import_no_bare_specifier_test.rb`), and the e2e
39
+ lab now sends the Link header Rails sends, which its stub had dropped.
40
+
41
+ ## 0.115.1 — 2026-10-27
42
+
43
+ ### Changed
44
+
45
+ - **Five of the engine's six classic scripts are ES modules.** No host change
46
+ is needed: every host renders `layouts/studio/head`, which now loads them as
47
+ modules, and every global a host calls is kept.
48
+ - `studio/sticky_table_header` and `studio/studio_confetti` moved from
49
+ `app/assets/javascripts/studio/` to `app/javascript/studio/`, so the
50
+ importmap pins them as `studio/<name>` like every engine module. Each has a
51
+ default export (the sticky-header API, `{ burst, cannons }`);
52
+ `studio/studio_confetti` also exports `burst`, `cannons` and `confetti`.
53
+ - Alpine and canvas-confetti stay vendored, unedited, in
54
+ `app/assets/javascripts/studio/`, which now holds only those builds and the
55
+ classic `studio/session.js`.
56
+ Alpine loads by a `type="module"` tag; canvas-confetti is pinned as
57
+ `"canvas-confetti"` and imported by `studio/studio_confetti`, so the head no
58
+ longer has its own tag for it.
59
+ - Leaflet is imported by `studio/footer_map` with `import()` of the URL on the
60
+ map element, not a `<script>` element injected into the head. A failed fetch
61
+ is retried under `?retry=n`, because a browser never refetches a module whose
62
+ fetch failed.
63
+ - Every head script with a `src`, but the session store, is a `type="module"`
64
+ tag below the import map (a module tag above it makes browsers without
65
+ dynamic import maps ignore the map). The head adds no inline script, so the
66
+ strict-nonce count is unchanged.
67
+ - **Held: `studio/session.js` stays a synchronous classic script**, above the
68
+ import map, unchanged. Host code registers identity sources with
69
+ `window.StudioSession` as its own modules evaluate (turf-monster's
70
+ `wallet_signal.js` calls solana-studio's `walletIdentity.register()` at
71
+ import), and a host's entry point is the module tag
72
+ `javascript_importmap_tags` renders, which an engine module tag below the
73
+ map would follow: the store would not exist yet and the registration would
74
+ silently be null. It becomes a module once those consumers wait for it.
75
+ - **Timing.** `window.confetti` and `window.studioConfetti` used to exist
76
+ while the body parsed (synchronous classic tags); they now exist once the
77
+ head's modules run, which is still before Alpine starts and before
78
+ `DOMContentLoaded`. A host's inline body
79
+ script that calls one of them at parse time, rather than from a handler,
80
+ would now find it missing. No host on `origin/accepted` does: turf-monster's
81
+ `confetti()` calls in `solana_utils.js` and `shared/_alpine_factories` run
82
+ from functions its handlers call, and each checks `typeof confetti` first.
83
+ - `host.css` no longer scans `app/assets/javascripts` (its `@source` line and
84
+ the vendored-file exclusion are gone): it holds the vendored builds and
85
+ `studio/session.js`, which puts no class on a page, and a test measures
86
+ that. The moved scripts are scanned under `app/javascript`.
87
+
88
+ ### Deprecated
89
+
90
+ - **Compatibility globals for the converted scripts.** Each stays for classic
91
+ callers; new code imports the module instead:
92
+ - `window.studioConfetti`: `import { burst, cannons } from "studio/studio_confetti"`.
93
+ - `window.confetti` (the canvas-confetti library global) as a host call:
94
+ `import { confetti } from "studio/studio_confetti"`. turf-monster calls it
95
+ today; the engine's own `fireSuccessConfetti` still reads it.
96
+ - `window.StudioStickyTableHeaders`: `import StickyTableHeaders from "studio/sticky_table_header"`.
97
+ `window.Alpine` and `window.L` are not deprecated: Alpine and Leaflet read
98
+ their own globals.
99
+
7
100
  ## 0.115.0 — 2026-10-26
8
101
 
9
102
  ### Breaking
data/README.md CHANGED
@@ -127,6 +127,23 @@ A migrating host also moves every other reader of the preset, not only the
127
127
  build: any production code or test that `require`s or reads the preset's text
128
128
  reads `theme.css` instead.
129
129
 
130
+ ## Node tooling
131
+
132
+ The gem ships the one Node tooling package every app shares, in
133
+ `lib/studio/node_tooling/` (its `README.md` lists the files): the pins
134
+ (`versions.json`: Node 22, `@playwright/test` 1.62.1, `axe-core` 4.11.0), a
135
+ Playwright config base, the shared e2e helpers, and the `node --test` importmap
136
+ hooks.
137
+
138
+ ```bash
139
+ bin/rails studio:node:link # node_modules/.studio-engine -> the package; run after npm ci
140
+ bin/rails studio:node:check # exits 1, naming each pin this app drifts from
141
+ ```
142
+
143
+ An app pins Node in `.tool-versions` (`nodejs 22`), the file mise and
144
+ `actions/setup-node`'s `node-version-file` both read.
145
+ `docs/FRONT_END_STANDARD.md`, "Node tooling", has the rest.
146
+
130
147
  ## Configuration
131
148
 
132
149
  Each consuming app configures the engine in `config/initializers/studio.rb`:
@@ -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,118 @@
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 imports it with an inline module tag after the import
7
+ map, never by URL: a URL tag's Link modulepreload header would resolve the
8
+ bare import below before the map exists). It imports canvas-confetti for its
9
+ side effect, which defines the library's global `confetti`, and exports:
10
+
11
+ import { burst, cannons, confetti } from "studio/studio_confetti"
12
+ burst(cardEl) // radial burst behind a card/modal
13
+ cannons() // side-cannons full-screen celebration
14
+ confetti(opts) // the canvas-confetti call itself, a no-op if it failed to load
15
+
16
+ Ported faithfully from turf-monster:
17
+ - burst(target) → the radial card-burst that explodes from BEHIND a card or
18
+ modal, emanating from its center (TM solana_utils.js fireConfettiFromModal /
19
+ shared/_alpine_factories fireQuestBadgeConfetti). Pass the DOM element (or a
20
+ selector, or an {x,y} origin in 0..1 viewport space) to aim it; defaults to
21
+ screen center.
22
+ - cannons(opts) → the full-screen "you joined a contest" celebration: a center
23
+ burst plus left + right side-cannons firing inward and a top shell (the same
24
+ choreography as the engine head's window.fireSuccessConfetti).
25
+
26
+ Both honor prefers-reduced-motion: reduce (no-op, and disableForReducedMotion is
27
+ also passed through to canvas-confetti as a second belt). Colors come from
28
+ window.CONFETTI_COLORS when a host sets it, else a neutral festive default.
29
+
30
+ DEPRECATED COMPATIBILITY GLOBALS (CHANGELOG, "Deprecated"): window.studioConfetti
31
+ ({ burst, cannons }) and the library's own window.confetti stay, because hosts
32
+ call them from classic code (turf-monster's solana_utils and _alpine_factories).
33
+ New code imports from this module. Both now exist once the head's modules have
34
+ run (deferred, before Alpine starts), no longer during the body's parse.
35
+ */
36
+ import "canvas-confetti"
37
+
38
+ const DEFAULT_COLORS = [
39
+ "#4BAF50", "#8E82FE", "#06D6A0", "#FF7C47",
40
+ "#FFD700", "#00BFFF", "#FF6B9D", "#C084FC"
41
+ ]
42
+
43
+ function colors(opts) {
44
+ return (opts && opts.colors) || window.CONFETTI_COLORS || DEFAULT_COLORS
45
+ }
46
+
47
+ function reducedMotion() {
48
+ return !!(window.matchMedia &&
49
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches)
50
+ }
51
+
52
+ // The canvas-confetti call. Read off the window on every call, not captured at
53
+ // import: the vendored build assigns the global when it is evaluated, and a
54
+ // failed load leaves it undefined, which makes every effect a no-op.
55
+ export function confetti(opts) {
56
+ if (typeof window.confetti !== "function") return null
57
+ return window.confetti(opts)
58
+ }
59
+
60
+ function available() {
61
+ return typeof window.confetti === "function" && !reducedMotion()
62
+ }
63
+
64
+ // Resolve a burst origin ({x,y} in 0..1 viewport space) from an element, a
65
+ // selector, or a literal {x,y}. Falls back to screen center when the target is
66
+ // missing or not laid out (e.g. a card that just unmounted).
67
+ function originFor(target) {
68
+ const el = (typeof target === "string") ? document.querySelector(target) : target
69
+ if (el && el.nodeType === 1) {
70
+ const r = el.getBoundingClientRect()
71
+ if (r.width || r.height) {
72
+ return {
73
+ x: (r.left + r.width / 2) / window.innerWidth,
74
+ y: (r.top + r.height / 2) / window.innerHeight
75
+ }
76
+ }
77
+ }
78
+ if (target && typeof target.x === "number" && typeof target.y === "number") {
79
+ return { x: target.x, y: target.y }
80
+ }
81
+ return { x: 0.5, y: 0.5 }
82
+ }
83
+
84
+ // Radial burst from behind a card/modal — spread:360 fires in every direction
85
+ // from the card center, low velocity + gravity keep it clustered around the
86
+ // card rather than blasting off-screen. A smaller follow-shell layers texture,
87
+ // and two bottom-corner sweeps give the moment theatre.
88
+ export function burst(target, opts) {
89
+ if (!available()) return
90
+ opts = opts || {}
91
+ const origin = originFor(target)
92
+ const c = colors(opts)
93
+ const z = opts.zIndex || 9999
94
+ 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 })
95
+ setTimeout(() => {
96
+ 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 })
97
+ 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 })
98
+ }, 180)
99
+ }
100
+
101
+ // Full-screen side-cannon celebration: center burst, then left + right cannons
102
+ // firing inward, then a wide top shell. Mirrors window.fireSuccessConfetti.
103
+ export function cannons(opts) {
104
+ if (!available()) return
105
+ opts = opts || {}
106
+ const c = colors(opts)
107
+ const z = opts.zIndex || 9999
108
+ 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 })
109
+ 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)
110
+ 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)
111
+ 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)
112
+ }
113
+
114
+ const studioConfetti = { burst, cannons }
115
+ export default studioConfetti
116
+
117
+ // DEPRECATED: the compatibility global for classic callers. Import the module.
118
+ 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,19 @@
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.
199
+ A MODULE LOADED BY URL MUST IMPORT NO BARE SPECIFIER. javascript_include_tag
200
+ also sends a `Link: rel=modulepreload` response header, which the browser
201
+ acts on before it parses this map, so a bare import in that module can
202
+ resolve against no map at all and fail for good (0.115.1.rc1, task
203
+ confetti-resolves-on-hub). A module with bare imports takes a
204
+ javascript_import_module_tag, which sends no header. %>
190
205
  <%# THE ENGINE'S BROWSER BOOT (studio/application): its every-page Stimulus
191
206
  controllers, the pinned stack, and the Alpine shims. A module tag, so it
192
207
  carries the nonce. %>
@@ -229,8 +244,21 @@
229
244
  module that owns it. Its imports import nothing, and studio/alpine_shims
230
245
  imports it, so a failed boot leaves a page its uploads. %>
231
246
  <%= 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
247
+ <%# CONFETTI, BY ITS OWN DOOR, AND NOT BY URL. studio/studio_confetti imports the
248
+ vendored canvas-confetti by its bare pin ("canvas-confetti",
249
+ app/assets/javascripts/studio/canvas_confetti.js: no CDN, same-origin) and
250
+ exports burst, cannons and confetti. It also keeps the deprecated globals
251
+ window.studioConfetti and window.confetti, which studio/head_chrome's
252
+ fireSuccessConfetti and host classic code still call. Before Alpine, so both
253
+ exist by the time an x-init can fire one.
254
+ An inline module tag, never javascript_include_tag: that helper's Link
255
+ modulepreload header had the browser resolve "canvas-confetti" before the
256
+ map above existed, so on a page whose head parsed slowly (the hub's boards
257
+ and /dreams) the module failed with "Failed to resolve module specifier"
258
+ (e2e/confetti_resolves_under_a_slow_head.spec.js). %>
259
+ <%= javascript_import_module_tag "studio/studio_confetti" %>
260
+ <%# ALPINE LOADS AFTER THE MODULE TAGS, and the order is the contract. Module
261
+ scripts (and deferred classic ones) execute in document order, so by the time
234
262
  Alpine starts, studio/application has defined the shims it evaluates
235
263
  (x-data="navCollapse()"), studio/alpine_stores has registered the
236
264
  `alpine:init` listener that installs $store.theme and $store.devMode, and
@@ -255,7 +283,13 @@
255
283
  Investigated as a suspect in the board-filter flake (three PRs reddened in one
256
284
  day, 2026-08-15). This removes a real source of run-to-run variance; it does NOT
257
285
  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" %>
286
+ <%# A MODULE TAG, NOT A DEFERRED CLASSIC ONE (it was `defer: true` until the
287
+ engine's scripts became modules). A module is deferred by definition, so its
288
+ place in the document order is unchanged. The vendored build is unedited: it
289
+ assigns window.Alpine and starts Alpine in a microtask after it is evaluated,
290
+ which as a module is after every module above it. window.Alpine is not
291
+ deprecated; Alpine itself and every host read it. %>
292
+ <%= javascript_include_tag "studio/alpine", type: "module", "data-turbo-track": "reload" %>
259
293
  <% if sticky_table_headers %>
260
- <%= javascript_include_tag "studio/sticky_table_header", defer: true, "data-turbo-track": "reload" %>
294
+ <%= javascript_include_tag "studio/sticky_table_header", type: "module", "data-turbo-track": "reload" %>
261
295
  <% 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)
@@ -0,0 +1,27 @@
1
+ # studio-engine Node tooling
2
+
3
+ The one Node tooling package every host shares. It ships inside the gem, so a
4
+ host gets it with the engine it already bundles: no npm registry, no token.
5
+
6
+ - `versions.json`: the pin set. Node's major, and the exact version of every
7
+ shared npm package. A host that declares one of these packages declares it at
8
+ exactly this version.
9
+ - `playwright_base.js`: the browser lane's shared defaults (one chromium
10
+ project, one worker, a Rails `webServer` on `/up`).
11
+ - `e2e_helpers.js`: `watchPageErrors` and `blockOffsiteRequests`.
12
+ - `node_test/register.mjs` and `node_test/importmap_hooks.mjs`: let `node --test`
13
+ import a module by the bare name the importmap pins it under.
14
+
15
+ Every file here requires only `node:` builtins and its own siblings, because it
16
+ is loaded from the gem's directory, which has no `node_modules`.
17
+
18
+ A host links the package into its own tree and checks itself against the pins:
19
+
20
+ ```sh
21
+ bin/rails studio:node:link # node_modules/.studio-engine -> this directory
22
+ bin/rails studio:node:check # exits 1, naming each pin the host drifts from
23
+ ```
24
+
25
+ Run `link` after `npm ci`, because `npm ci` deletes `node_modules`. Then
26
+ `require("./node_modules/.studio-engine/playwright_base.js")`. The adoption
27
+ steps are in the engine repository's `docs/FRONT_END_STANDARD.md`, "Node tooling".
@@ -0,0 +1,74 @@
1
+ // studio-engine's shared browser-lane helpers. Requires only node: builtins, so it
2
+ // loads from the gem's directory (see README.md).
3
+
4
+ const fs = require("node:fs");
5
+
6
+ const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost"]);
7
+
8
+ function isOffOrigin(url) {
9
+ if (!url || !/^https?:/.test(url)) return false;
10
+ return !LOCAL_HOSTS.has(new URL(url).hostname);
11
+ }
12
+
13
+ // Collect everything the page THREW or logged as an error.
14
+ //
15
+ // `pageerror` is the cheapest detector there is for a script that never ran: the
16
+ // phantom-element defect threw a SyntaxError on every load while the response
17
+ // bytes were valid and the server-side guard passed.
18
+ //
19
+ // It hears every frame on the page, a third party's included: Playwright delivers a
20
+ // cross-origin frame's console to the page's `console` event. `ownOriginOnly: true`
21
+ // scopes that out BY ORIGIN, never by message text. A console error is dropped only
22
+ // when the script that logged it was served from another host, and a thrown error
23
+ // only when every frame of its stack was. An error with no location or no stack is
24
+ // kept, because it cannot be shown to be someone else's. The default counts
25
+ // everything.
26
+ function watchPageErrors(page, { ownOriginOnly = false } = {}) {
27
+ const errors = [];
28
+ page.on("pageerror", (error) => {
29
+ if (ownOriginOnly) {
30
+ const sources = (error.stack || "").match(/https?:\/\/[^\s)]+/g) || [];
31
+ if (sources.length > 0 && sources.every(isOffOrigin)) return;
32
+ }
33
+ errors.push(`pageerror: ${error.message}`);
34
+ });
35
+ page.on("console", (message) => {
36
+ if (message.type() !== "error") return;
37
+ if (ownOriginOnly && isOffOrigin(message.location().url)) return;
38
+ errors.push(`console.error: ${message.text()}`);
39
+ });
40
+ return errors;
41
+ }
42
+
43
+ // Answer every request that leaves this origin, so the lane cannot depend on the
44
+ // public internet.
45
+ //
46
+ // ANSWERED, NOT ABORTED. route.abort() makes Chromium log `Failed to load resource:
47
+ // net::ERR_FAILED`, which watchPageErrors counts. An empty 200 logs nothing, and
48
+ // the page falls back as it would offline.
49
+ //
50
+ // `pinned` maps an off-origin URL to `{ file, contentType }`: a file loaded with
51
+ // Subresource Integrity cannot be answered empty (the digest fails and Chromium
52
+ // logs it), so it is answered with a byte-exact local copy. The response carries
53
+ // `access-control-allow-origin: *`, because a `crossorigin` tag refuses a CORS
54
+ // response without it before checking the digest.
55
+ async function blockOffsiteRequests(page, { pinned = {} } = {}) {
56
+ await page.route("**/*", (route) => {
57
+ const url = new URL(route.request().url());
58
+ if (LOCAL_HOSTS.has(url.hostname)) return route.continue();
59
+
60
+ const copy = pinned[url.href];
61
+ if (copy) {
62
+ return route.fulfill({
63
+ status: 200,
64
+ contentType: copy.contentType,
65
+ headers: { "access-control-allow-origin": "*" },
66
+ body: fs.readFileSync(copy.file),
67
+ });
68
+ }
69
+
70
+ return route.fulfill({ status: 200, body: "" });
71
+ });
72
+ }
73
+
74
+ module.exports = { isOffOrigin, watchPageErrors, blockOffsiteRequests };
@@ -0,0 +1,48 @@
1
+ // Resolve importmap specifiers under `node --test` the way the browser does.
2
+ //
3
+ // In the browser, config/importmap.rb pins a directory under a prefix (`board`,
4
+ // `cyvasse`, `studio`), and the modules import each other as "board/colors".
5
+ // Node has no import map, so this hook maps each prefix onto the same files. A
6
+ // bare top-level name with a file of its own in `topLevelDir`
7
+ // (`import … from "chat_follow"`, pinned by name) maps the same way.
8
+ //
9
+ // A Rails app has no package.json "type", so Node has to guess each .js file's
10
+ // format. Files under `esmDirs` are ES modules (that is what the browser loads),
11
+ // so this hook says so instead.
12
+ //
13
+ // Configured by register.mjs through `initialize(data)`; it holds no app's names.
14
+ import { existsSync } from "node:fs";
15
+
16
+ let prefixes = [];
17
+ let topLevel = null;
18
+ let esmDirs = [];
19
+
20
+ const asDir = (url) => (url.endsWith("/") ? url : `${url}/`);
21
+
22
+ export function initialize({ root, prefixes: map = {}, topLevelDir = null, esmDirs: dirs = [] } = {}) {
23
+ const base = asDir(String(root));
24
+ prefixes = Object.entries(map).map(([prefix, dir]) => [prefix, asDir(new URL(dir, base).href)]);
25
+ topLevel = topLevelDir ? asDir(new URL(topLevelDir, base).href) : null;
26
+ esmDirs = dirs.map((dir) => asDir(new URL(dir, base).href));
27
+ }
28
+
29
+ function mapped(specifier) {
30
+ for (const [prefix, dir] of prefixes) {
31
+ const match = specifier.startsWith(`${prefix}/`) && /^[\w/-]+$/.test(specifier.slice(prefix.length + 1));
32
+ if (match) return new URL(`${specifier.slice(prefix.length + 1)}.js`, dir).href;
33
+ }
34
+ if (topLevel && /^[a-z_]+$/.test(specifier)) {
35
+ const file = new URL(`${specifier}.js`, topLevel);
36
+ if (existsSync(file)) return file.href;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ export async function resolve(specifier, context, nextResolve) {
42
+ const target = mapped(specifier);
43
+ const resolved = await nextResolve(target || specifier, context);
44
+ if (resolved.url.endsWith(".js") && esmDirs.some((dir) => resolved.url.startsWith(dir))) {
45
+ return { ...resolved, format: "module" };
46
+ }
47
+ return resolved;
48
+ }
@@ -0,0 +1,20 @@
1
+ // Installs importmap_hooks.mjs for `node --test`. A host's own register file
2
+ // (loaded with `node --import`) names its prefixes and calls this once:
3
+ //
4
+ // import { registerImportmapHooks } from "../../../node_modules/.studio-engine/node_test/register.mjs";
5
+ // registerImportmapHooks({
6
+ // root: new URL("../../../", import.meta.url),
7
+ // prefixes: { board: "app/javascript/board/" },
8
+ // topLevelDir: "app/javascript/",
9
+ // esmDirs: ["app/javascript/", "test/javascript/"],
10
+ // });
11
+ //
12
+ // Paths are relative to `root`, the host's Rails root.
13
+ import { register } from "node:module";
14
+
15
+ export function registerImportmapHooks({ root, prefixes = {}, topLevelDir = null, esmDirs = [] }) {
16
+ if (!root) throw new Error("registerImportmapHooks: root is required");
17
+ register("./importmap_hooks.mjs", import.meta.url, {
18
+ data: { root: String(root), prefixes, topLevelDir, esmDirs },
19
+ });
20
+ }
@@ -0,0 +1,63 @@
1
+ // studio-engine's shared Playwright defaults.
2
+ //
3
+ // A plain object, not a defineConfig() call: this file is loaded from the gem's
4
+ // directory, which has no node_modules, so it cannot require @playwright/test.
5
+ // The host requires its own copy and wraps the result:
6
+ //
7
+ // const { defineConfig } = require("@playwright/test");
8
+ // const { studioPlaywrightConfig } = require("./node_modules/.studio-engine/playwright_base.js");
9
+ // module.exports = defineConfig(studioPlaywrightConfig({
10
+ // defaultPort: "3000",
11
+ // webServer: { command: "bin/rails server -p $E2E_PORT" },
12
+ // }));
13
+ //
14
+ // Every key of `overrides` replaces the default of the same name; `use` merges
15
+ // one level deep, so a host can add `reducedMotion` and keep `baseURL`.
16
+
17
+ const DEFAULTS = Object.freeze({
18
+ testDir: "./e2e",
19
+ timeout: 30_000,
20
+ retries: 0,
21
+ workers: 1,
22
+ });
23
+
24
+ // The base URL, from the environment first: PW_BASE_URL (or BASE_URL) points the
25
+ // lane at a stack someone else booted, and then no webServer is started.
26
+ function studioBaseURL({ defaultPort, env = process.env } = {}) {
27
+ const port = env.E2E_PORT || String(defaultPort);
28
+ const external = env.PW_BASE_URL || env.BASE_URL || null;
29
+ return { port, external, baseURL: external || `http://127.0.0.1:${port}` };
30
+ }
31
+
32
+ // A Rails server Playwright boots and waits on. `/up` is the Rails health check
33
+ // every app in the ecosystem keeps.
34
+ function railsWebServer({ command, port, env = {}, timeout = 120_000, ci = !!process.env.CI }) {
35
+ if (!command) throw new Error("railsWebServer: a command is required");
36
+ return {
37
+ command,
38
+ url: `http://127.0.0.1:${port}/up`,
39
+ reuseExistingServer: !ci,
40
+ timeout,
41
+ stdout: "pipe",
42
+ stderr: "pipe",
43
+ env: { RAILS_ENV: "test", E2E_PORT: String(port), ...env },
44
+ };
45
+ }
46
+
47
+ function studioPlaywrightConfig({ defaultPort, webServer, env = process.env, use = {}, ...overrides } = {}) {
48
+ if (!defaultPort) throw new Error("studioPlaywrightConfig: defaultPort is required");
49
+ const { port, external, baseURL } = studioBaseURL({ defaultPort, env });
50
+ const config = {
51
+ ...DEFAULTS,
52
+ forbidOnly: !!env.CI,
53
+ projects: [{ name: "chromium", use: { browserName: "chromium" } }],
54
+ ...overrides,
55
+ use: { baseURL, headless: true, ...use },
56
+ };
57
+ if (!external && webServer) {
58
+ config.webServer = railsWebServer({ ci: !!env.CI, ...webServer, port });
59
+ }
60
+ return config;
61
+ }
62
+
63
+ module.exports = { DEFAULTS, studioBaseURL, railsWebServer, studioPlaywrightConfig };
@@ -0,0 +1,5 @@
1
+ {
2
+ "node": "22",
3
+ "@playwright/test": "1.62.1",
4
+ "axe-core": "4.11.0"
5
+ }
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "pathname"
6
+
7
+ module Studio
8
+ # The engine's Node tooling package (lib/studio/node_tooling/README.md): the
9
+ # one pin set, the shared Playwright base and helpers, and the node:test hooks.
10
+ #
11
+ # The gem's path is resolved here in Ruby, never by Node running bundler (the
12
+ # same rule Studio::TailwindEntry keeps). `link!` puts a symlink at a fixed
13
+ # path in the host, so a host's playwright.config.js or test register file
14
+ # requires `./node_modules/.studio-engine/...` with no shell-out.
15
+ #
16
+ # `drift` holds a host to the pins: Node's major in `.tool-versions` (what
17
+ # mise and actions/setup-node's `node-version-file` read) and package.json
18
+ # `engines`, and the exact version of every pinned package the host declares,
19
+ # in package.json and in package-lock.json. A pinned package the host does not
20
+ # declare is not drift: no host is made to install what it does not use.
21
+ module NodeTooling
22
+ ROOT = Pathname(__dir__).join("node_tooling").freeze
23
+ LINK = "node_modules/.studio-engine"
24
+
25
+ module_function
26
+
27
+ def root = ROOT
28
+
29
+ # The host the rake tasks act on. A method of its own so a test can point
30
+ # the tasks at a temporary host without defining a Rails constant.
31
+ def host_root = ::Rails.root
32
+
33
+ def versions = JSON.parse(ROOT.join("versions.json").read)
34
+
35
+ def link_path(app_root) = Pathname(app_root).join(LINK)
36
+
37
+ # Idempotent. Replaces a stale link (an older gem's directory); refuses a
38
+ # real directory or file at the path rather than deleting someone's work.
39
+ def link!(app_root)
40
+ path = link_path(app_root)
41
+ if path.symlink?
42
+ return path if path.readlink == ROOT
43
+
44
+ path.delete
45
+ elsif path.exist?
46
+ raise ArgumentError, "#{path} exists and is not a symlink; move it aside and link again"
47
+ end
48
+ FileUtils.mkdir_p(path.dirname)
49
+ File.symlink(ROOT, path)
50
+ path
51
+ end
52
+
53
+ # Every way the host at `app_root` differs from versions.json, one sentence
54
+ # each. Empty when it matches.
55
+ def drift(app_root)
56
+ app_root = Pathname(app_root)
57
+ pins = versions
58
+ major = pins.fetch("node")
59
+ problems = node_version_drift(app_root, major)
60
+
61
+ package = read_json(app_root.join("package.json"))
62
+ return problems unless package
63
+
64
+ engines = package.dig("engines", "node")
65
+ unless engines == "#{major}.x"
66
+ problems << "package.json engines.node is #{engines.inspect}; the pin is \"#{major}.x\""
67
+ end
68
+
69
+ lock = read_json(app_root.join("package-lock.json"))
70
+ pins.except("node").each do |name, version|
71
+ declared = package.dig("devDependencies", name) || package.dig("dependencies", name)
72
+ next unless declared
73
+
74
+ problems << "package.json declares #{name} #{declared.inspect}; the pin is \"#{version}\"" unless declared == version
75
+ locked = lock&.dig("packages", "node_modules/#{name}", "version")
76
+ problems << "package-lock.json locks #{name} at #{locked.inspect}; the pin is \"#{version}\"" unless locked == version
77
+ end
78
+ problems
79
+ end
80
+
81
+ def node_version_drift(app_root, major)
82
+ file = app_root.join(".tool-versions")
83
+ return ["no .tool-versions; it should read \"nodejs #{major}\""] unless file.file?
84
+
85
+ line = file.read.lines.map(&:split).find { |tool, *| %w[nodejs node].include?(tool) }
86
+ return [".tool-versions names no nodejs; it should read \"nodejs #{major}\""] unless line
87
+ return [] if line[1] == major
88
+
89
+ [".tool-versions reads \"#{line.join(" ")}\"; it should read \"nodejs #{major}\""]
90
+ end
91
+
92
+ def read_json(path)
93
+ path.file? ? JSON.parse(path.read) : nil
94
+ end
95
+ end
96
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.115.0"
2
+ VERSION = "0.115.1.rc2"
3
3
  end
data/lib/studio.rb CHANGED
@@ -22,6 +22,7 @@ require "studio/geo/lookup"
22
22
  require "studio/link_preview"
23
23
  require "studio/engine"
24
24
  require "studio/tailwind_entry"
25
+ require "studio/node_tooling"
25
26
  require "studio/color_scale"
26
27
  require "studio/environment_banner"
27
28
  require "studio/theme_resolver"
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The engine's Node tooling package (lib/studio/node_tooling/README.md). Neither
4
+ # task loads :environment; both need only Rails.root (Studio::NodeTooling.host_root).
5
+ namespace :studio do
6
+ namespace :node do
7
+ desc "Link studio-engine's Node tooling package at node_modules/.studio-engine (run after npm ci)"
8
+ task :link do
9
+ root = Studio::NodeTooling.host_root
10
+ path = Studio::NodeTooling.link!(root)
11
+ puts "studio-engine: #{path.relative_path_from(root)} -> #{Studio::NodeTooling.root}"
12
+ end
13
+
14
+ desc "Fail when this app's Node pins drift from studio-engine's versions.json"
15
+ task :check do
16
+ problems = Studio::NodeTooling.drift(Studio::NodeTooling.host_root)
17
+ if problems.empty?
18
+ puts "studio-engine: Node tooling matches the pins #{Studio::NodeTooling.versions.to_json}"
19
+ else
20
+ abort(["studio-engine: Node tooling drifts from the pins:", *problems.map { |p| " - #{p}" }].join("\n"))
21
+ end
22
+ end
23
+ end
24
+ 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.rc2
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
@@ -774,6 +774,13 @@ files:
774
774
  - lib/studio/navbar_slots.rb
775
775
  - lib/studio/navbar_wordmark.rb
776
776
  - lib/studio/newsletter.rb
777
+ - lib/studio/node_tooling.rb
778
+ - lib/studio/node_tooling/README.md
779
+ - lib/studio/node_tooling/e2e_helpers.js
780
+ - lib/studio/node_tooling/node_test/importmap_hooks.mjs
781
+ - lib/studio/node_tooling/node_test/register.mjs
782
+ - lib/studio/node_tooling/playwright_base.js
783
+ - lib/studio/node_tooling/versions.json
777
784
  - lib/studio/oauth_identity.rb
778
785
  - lib/studio/partial_block.rb
779
786
  - lib/studio/profile_image.rb
@@ -799,6 +806,7 @@ files:
799
806
  - lib/studio/version.rb
800
807
  - lib/tasks/studio_email.rake
801
808
  - lib/tasks/studio_knowledge.rake
809
+ - lib/tasks/studio_node.rake
802
810
  - lib/tasks/studio_trash.rake
803
811
  - studio-engine.gemspec
804
812
  homepage: https://github.com/McRitchie-Studio/studio-engine
@@ -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
- })();