studio-engine 0.92.2 → 0.94.0
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 +83 -0
- data/README.md +1 -1
- data/app/controllers/concerns/studio/error_handling.rb +15 -0
- data/app/controllers/studio/onboarding_controller.rb +4 -6
- data/app/javascript/studio/alpine_shims.js +45 -0
- data/app/javascript/studio/alpine_stores.js +82 -0
- data/app/javascript/studio/application.js +37 -0
- data/app/javascript/studio/controllers/nav_collapse_controller.js +28 -0
- data/app/javascript/studio/head_chrome.js +79 -0
- data/app/javascript/studio/nav_collapse.js +238 -0
- data/app/javascript/studio/pinned_stack.js +306 -0
- data/app/javascript/studio/vendor/stimulus.js +5 -0
- data/app/models/concerns/sluggable.rb +152 -2
- data/app/views/layouts/_navbar.html.erb +14 -6
- data/app/views/layouts/studio/_head.html.erb +59 -567
- data/config/importmap.rb +14 -3
- data/lib/studio/engine.rb +26 -0
- data/lib/studio/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 32e6d36ae904992ea14289647084091b399296ebc27daf777e45620ad126b772
|
|
4
|
+
data.tar.gz: 2c026cd62fca795d60fe39976ca9b47313103afdee12bff1ce590961a5d9ff6f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ac374e2ec4e47bce46d2c17367c776dc33388ed8f6559ec1c6c8646eb336530d6c00d91047ebcf9f52afe89f06f7618db7693229b1e78bc47afba8fc3a6c7383
|
|
7
|
+
data.tar.gz: b2f4a82578a5f3e2dac0a9a649c992103921dc376ce439364a3f944e01f7880476a72a49dce125b24860214ff6180802cbf9ea11b3585f4c30d1dfee0edeecc7
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,69 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.94.0 — 2026-10-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **The engine boots its own Stimulus application** (`studio/application`),
|
|
12
|
+
imported on every page by `layouts/studio/_head` through
|
|
13
|
+
`javascript_import_module_tag`, which carries the request's CSP nonce. Engine
|
|
14
|
+
application reads its own attributes (`data-studio-controller`,
|
|
15
|
+
`data-studio-action`, `data-studio-target`), so a host's Stimulus application
|
|
16
|
+
and its lazy loader never see an engine controller. Stimulus 3.2.2 is vendored
|
|
17
|
+
(`studio/vendor/stimulus.js`) and pinned as `@hotwired/stimulus`; a host that
|
|
18
|
+
pins its own wins. The boot's module graph is preloaded
|
|
19
|
+
(`Studio::Engine.javascript_boot_graph`); every other engine pin is still
|
|
20
|
+
fetched only when imported.
|
|
21
|
+
- **`nav-collapse`**, the navbar collapse as a Stimulus controller
|
|
22
|
+
(`data-studio-controller="nav-collapse"`). The engine navbar uses it;
|
|
23
|
+
`data-nav-collapse-scrolled-class` names the
|
|
24
|
+
classes it toggles when the shadow's hysteresis flips.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **The head's behaviour moves from inline scripts to ES modules.**
|
|
29
|
+
`layouts/studio/_head` goes from 714 lines and seven inline scripts to 197
|
|
30
|
+
lines and one: the pre-paint theme script, which now carries the request's
|
|
31
|
+
CSP nonce. The navbar collapse is `studio/nav_collapse`, the pinned stack is
|
|
32
|
+
`studio/pinned_stack`, and the theme and devMode stores, nav spinner and
|
|
33
|
+
success confetti are `studio/head_chrome`, each with `node:test` unit tests.
|
|
34
|
+
`window.navCollapse`, `window.showNavSpinner`, `window.hideNavSpinner`,
|
|
35
|
+
`window.fireSuccessConfetti`, `$store.theme` and `$store.devMode` keep their
|
|
36
|
+
names as thin shims (`studio/alpine_shims`); a host that defines its own
|
|
37
|
+
`window.navCollapse` keeps it.
|
|
38
|
+
- **Alpine loads after the module tags.** So the shims exist before Alpine
|
|
39
|
+
starts, the head now loads Alpine after `javascript_importmap_tags`. A host
|
|
40
|
+
module therefore evaluates before `window.Alpine` exists: register Alpine
|
|
41
|
+
data and stores in an `alpine:init` listener (which now fires for host
|
|
42
|
+
modules), not at module evaluation. Inline scripts in the page are unaffected.
|
|
43
|
+
- **The engine navbar's header binds `data-studio-controller="nav-collapse"`**
|
|
44
|
+
in place of `x-data="navCollapse()"`, and keeps a bare `x-data` as the Alpine
|
|
45
|
+
scope its descendants bind through. A forked header may keep
|
|
46
|
+
`x-data="navCollapse()"`.
|
|
47
|
+
- Studio.nav_spinner_min_ms reaches the browser as
|
|
48
|
+
`<meta name="studio-nav-spinner-min-ms">`.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **The theme and devMode Alpine stores survive a boot that fails to load.**
|
|
53
|
+
They move to `studio/alpine_stores`, a module that imports nothing, and
|
|
54
|
+
`layouts/studio/_head` imports it by its own `javascript_import_module_tag`
|
|
55
|
+
(nonced, before Alpine) as well as through the boot's graph. So when
|
|
56
|
+
`studio/application` fails to load, a host's
|
|
57
|
+
`:class="{ 'dev-mode': $store.devMode }"` body binding and the theme toggle
|
|
58
|
+
still find their stores instead of throwing. A store registers only where
|
|
59
|
+
Alpine has none of that name, so the two paths never register twice and a
|
|
60
|
+
host's own store wins. `storedTheme` and `toggleTheme` move from
|
|
61
|
+
`studio/head_chrome` to `studio/alpine_stores`.
|
|
62
|
+
|
|
63
|
+
### Breaking
|
|
64
|
+
|
|
65
|
+
- **`window._navSpinnerShownAt` and `window._navSpinnerMinMs` are removed.**
|
|
66
|
+
The spinner's state is private to `studio/head_chrome`; no consumer reads them.
|
|
67
|
+
|
|
68
|
+
## 0.93.0 — 2026-10-07
|
|
69
|
+
|
|
7
70
|
## 0.92.2 — 2026-10-07
|
|
8
71
|
|
|
9
72
|
## 0.92.1 — 2026-10-07
|
|
@@ -22,6 +85,26 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
22
85
|
raises `Studio::HostSchemaError` in development and test, and logs (and reports
|
|
23
86
|
to Sentry) in production; `Studio.host_schema_check = :raise | :log | false`
|
|
24
87
|
overrides. Extra host columns are never reported, and rake tasks skip it.
|
|
88
|
+
- **`Sluggable#rename_slug!` changes a slug on purpose and cascades.** It updates
|
|
89
|
+
the row and every child column that holds the old slug in one transaction:
|
|
90
|
+
each `has_many`/`has_one` declared with `primary_key: :slug`, plus the pairs a
|
|
91
|
+
model declares with `has_slug_children "table" => :column`. A blank, badly
|
|
92
|
+
formed (`slug_format`, parameterize-shaped by default) or taken slug raises
|
|
93
|
+
`Sluggable::SlugRefused`, a `RecordInvalid` with the reason on
|
|
94
|
+
`errors[:slug]`; `rename_slug` returns false instead.
|
|
95
|
+
- **`Studio::ErrorHandling` answers a refused slug rename with 422** and the
|
|
96
|
+
reason (JSON and other formats), or a redirect back with the reason as the
|
|
97
|
+
alert (HTML), never the 500 catch-all and no ErrorLog row.
|
|
98
|
+
|
|
99
|
+
### Changed
|
|
100
|
+
|
|
101
|
+
- **A slug is written once, at create.** `Sluggable` no longer recomputes
|
|
102
|
+
`slug` from `name_slug` on every save, so a name edit stops renaming the row
|
|
103
|
+
and orphaning the child rows that point at it. A persisted row with a blank
|
|
104
|
+
slug still gets one on its next save, and a `name_slug` that reads the id is
|
|
105
|
+
settled inside the create, so `user-<id>` no longer waits for a second save.
|
|
106
|
+
Code that relied on a save moving the slug calls `rename_slug!(name_slug)`
|
|
107
|
+
instead.
|
|
25
108
|
|
|
26
109
|
### Fixed
|
|
27
110
|
|
data/README.md
CHANGED
|
@@ -35,7 +35,7 @@ resolved.
|
|
|
35
35
|
- **Theme system**: Dynamic CSS custom properties generated from 7 role colors (primary, dark, light, success, accent, warning, danger). Dark/light mode toggle. Admin theme editor at `/admin/theme`.
|
|
36
36
|
- **UI primitives**: Shared component partials and CSS primitives such as `components/emoji_swap` for nav/sidebar emoji hover transitions.
|
|
37
37
|
- **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls and `studio/banners/impersonation`.
|
|
38
|
-
- **Sluggable concern**:
|
|
38
|
+
- **Sluggable concern**: a human-readable slug written once at create (`to_param` returns it); `rename_slug!` changes it and cascades to every child column in one transaction, and a refused rename answers 422
|
|
39
39
|
- **ThemeSetting model**: Per-app DB overrides with fallback to config defaults
|
|
40
40
|
- **Geo**: `Studio::GeoDetection` places every visitor (IP → country + subdivision, session-cached), `Studio::GeoSetting` stores the operator's blocked countries and regions, `require_geo_allowed` locks whichever surfaces an app chooses, and the shared badge + `/admin/geo` manager ship with it. See [`docs/GEO.md`](docs/GEO.md).
|
|
41
41
|
- **Site identity and link previews**: `Studio::SiteIdentity` holds the app's title, description and image, edited at `/admin/link_preview` beside a live unfurl card and read anywhere through `Studio.site_identity`. Every page unfurls with it unless it calls `link_preview image:, title:, description:`, and `Studio::LinkPreviewBots` serves preview fetchers a slim page under iMessage's 1 MiB limit. Adopt with `bin/rails g studio:site_identity`. See [`docs/LINK_PREVIEW.md`](docs/LINK_PREVIEW.md).
|
|
@@ -17,6 +17,8 @@ module Studio
|
|
|
17
17
|
# handlers BELOW this line, never above it.
|
|
18
18
|
rescue_from StandardError, with: :handle_unexpected_error
|
|
19
19
|
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
|
|
20
|
+
# A string, so including this concern does not load the model concern.
|
|
21
|
+
rescue_from "Sluggable::SlugRefused", with: :handle_slug_refused
|
|
20
22
|
|
|
21
23
|
before_action :require_authentication
|
|
22
24
|
|
|
@@ -243,6 +245,19 @@ module Studio
|
|
|
243
245
|
raise exception
|
|
244
246
|
end
|
|
245
247
|
|
|
248
|
+
# Layer 1: a refused slug rename (Sluggable#rename_slug!) is the caller's
|
|
249
|
+
# input, not a fault: 422 with the reason for JSON and other formats, and a
|
|
250
|
+
# redirect back with the reason as the alert for HTML. No ErrorLog row.
|
|
251
|
+
def handle_slug_refused(exception)
|
|
252
|
+
reason = exception.record.errors.full_messages_for(:slug).to_sentence.presence || exception.message
|
|
253
|
+
|
|
254
|
+
respond_to do |format|
|
|
255
|
+
format.html { redirect_back_or_to root_path, alert: reason }
|
|
256
|
+
format.json { render json: { error: reason }, status: :unprocessable_entity }
|
|
257
|
+
format.any { render plain: reason, status: :unprocessable_entity }
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
246
261
|
# Layer 1: Catch-all for unexpected errors — log + friendly response.
|
|
247
262
|
# Skips logging if rescue_and_log already captured it.
|
|
248
263
|
def handle_unexpected_error(exception)
|
|
@@ -79,12 +79,10 @@ module Studio
|
|
|
79
79
|
# 2. It steps around any host before_save that DERIVES first_name FROM
|
|
80
80
|
# name — set_name_parts does exactly that — which would discard the
|
|
81
81
|
# value we were just handed.
|
|
82
|
-
# 3.
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
# a unique index, for every signup. That is the constraint this
|
|
87
|
-
# endpoint has always been protecting; it is not a shortcut.
|
|
82
|
+
# 3. A host save can carry other callbacks that react to a name
|
|
83
|
+
# write; this endpoint writes only the name columns. (Sluggable
|
|
84
|
+
# writes the slug once, at create, so the slug stays put either
|
|
85
|
+
# way.)
|
|
88
86
|
#
|
|
89
87
|
# WHAT SKIPPING CALLBACKS USED TO COST. Because set_name_parts never
|
|
90
88
|
# ran, `first_name` got the WHOLE typed value: someone answering "Ada
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// studio/alpine_shims: the Alpine names and window globals consumers still
|
|
2
|
+
// bind to, each a thin delegate to the module that now owns the behaviour.
|
|
3
|
+
//
|
|
4
|
+
// They exist while a consumer binds to them, and go with the consumer phase of
|
|
5
|
+
// the Stimulus migration. Every binding site is listed on the task
|
|
6
|
+
// engine-behaviour-moves-to-stimulus.
|
|
7
|
+
//
|
|
8
|
+
// WHY THIS WORKS BEFORE ALPINE STARTS. layouts/studio/_head loads Alpine AFTER
|
|
9
|
+
// the module tags, and deferred classic scripts and module scripts execute in
|
|
10
|
+
// document order. So this module has run, and its `alpine:init` listener is
|
|
11
|
+
// registered, before Alpine evaluates its first x-data.
|
|
12
|
+
//
|
|
13
|
+
// THE STORES ARE NOT HERE. $store.theme and $store.devMode are
|
|
14
|
+
// studio/alpine_stores, which the head also loads by its own module tag, so a
|
|
15
|
+
// host's body binding survives a boot that failed to load. Importing it here
|
|
16
|
+
// keeps it in the boot graph, preloaded; it installs itself on evaluation.
|
|
17
|
+
import "studio/alpine_stores"
|
|
18
|
+
import { NavCollapse } from "studio/nav_collapse"
|
|
19
|
+
import {
|
|
20
|
+
showNavSpinner, hideNavSpinner, installSpinnerReset, fireSuccessConfetti
|
|
21
|
+
} from "studio/head_chrome"
|
|
22
|
+
|
|
23
|
+
// x-data="navCollapse()": the hub's own header. A host that defines its own
|
|
24
|
+
// window.navCollapse (turf-monster does, inline, before this runs) keeps it.
|
|
25
|
+
export function navCollapse() {
|
|
26
|
+
return {
|
|
27
|
+
scrolled: false,
|
|
28
|
+
init: function () {
|
|
29
|
+
var self = this
|
|
30
|
+
this._collapse = new NavCollapse(this.$el, function (lit) { self.scrolled = lit })
|
|
31
|
+
this._collapse.start()
|
|
32
|
+
},
|
|
33
|
+
destroy: function () {
|
|
34
|
+
if (this._collapse) this._collapse.stop()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function installAlpineShims() {
|
|
40
|
+
if (!window.navCollapse) window.navCollapse = navCollapse
|
|
41
|
+
if (!window.showNavSpinner) window.showNavSpinner = showNavSpinner
|
|
42
|
+
if (!window.hideNavSpinner) window.hideNavSpinner = hideNavSpinner
|
|
43
|
+
if (!window.fireSuccessConfetti) window.fireSuccessConfetti = fireSuccessConfetti
|
|
44
|
+
installSpinnerReset()
|
|
45
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// studio/alpine_stores: the Alpine stores every host body binds to,
|
|
2
|
+
// $store.theme and $store.devMode, and the theme value and toggle behind them.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS IS ITS OWN ENTRY POINT. Every host's <body> says
|
|
5
|
+
// :class="{ 'dev-mode': $store.devMode }", and the engine's theme toggle reads
|
|
6
|
+
// $store.theme. When those stores lived only inside studio/application's graph,
|
|
7
|
+
// any failure to load that graph (a missing digest after a deploy, a network
|
|
8
|
+
// drop, a throw in Stimulus or a controller) left Alpine evaluating the body
|
|
9
|
+
// binding against an undefined store, and it threw on every page.
|
|
10
|
+
//
|
|
11
|
+
// So the stores load by two paths, and either one alone is enough:
|
|
12
|
+
//
|
|
13
|
+
// 1. layouts/studio/_head imports this module with its own
|
|
14
|
+
// javascript_import_module_tag, which carries the request's CSP nonce.
|
|
15
|
+
// A failed studio/application does not stop it.
|
|
16
|
+
// 2. studio/alpine_shims imports it, so the boot graph carries it too and the
|
|
17
|
+
// module is preloaded with the rest of the boot.
|
|
18
|
+
//
|
|
19
|
+
// Both resolve to one module instance, and the install below is idempotent
|
|
20
|
+
// regardless: one alpine:init listener per document, and a store is registered
|
|
21
|
+
// only when Alpine does not have one of that name (so a host's own wins).
|
|
22
|
+
//
|
|
23
|
+
// THIS MODULE IMPORTS NOTHING, and test/javascript/alpine_stores.test.mjs holds
|
|
24
|
+
// it to that: an import would put another module's failure back in its path.
|
|
25
|
+
//
|
|
26
|
+
// The pre-paint half of the theme (adding `dark` before first paint) stays an
|
|
27
|
+
// inline, nonced script in the head: it must run before the stylesheet paints,
|
|
28
|
+
// which no deferred module can.
|
|
29
|
+
|
|
30
|
+
// The stored theme, dark unless the reader chose light.
|
|
31
|
+
export function storedTheme(storage) {
|
|
32
|
+
return storage.getItem('theme') || 'dark'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Flips the root's `dark` class under a short transition class, stores the
|
|
36
|
+
// result, and answers it.
|
|
37
|
+
export function toggleTheme(root, storage, later) {
|
|
38
|
+
root.classList.add('theme-transition')
|
|
39
|
+
root.classList.toggle('dark')
|
|
40
|
+
var value = root.classList.contains('dark') ? 'dark' : 'light'
|
|
41
|
+
storage.setItem('theme', value)
|
|
42
|
+
later(function () { root.classList.remove('theme-transition') }, 300)
|
|
43
|
+
return value
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Registers each store Alpine does not already have. env carries what the
|
|
47
|
+
// stores read and write: { storage, root, later }.
|
|
48
|
+
export function registerStudioStores(Alpine, env) {
|
|
49
|
+
if (Alpine.store('devMode') === undefined) {
|
|
50
|
+
Alpine.store('devMode', env.storage.getItem('devMode') === 'true')
|
|
51
|
+
}
|
|
52
|
+
if (Alpine.store('theme') === undefined) {
|
|
53
|
+
Alpine.store('theme', {
|
|
54
|
+
value: storedTheme(env.storage),
|
|
55
|
+
get isDark() { return this.value === 'dark' },
|
|
56
|
+
toggle: function () {
|
|
57
|
+
this.value = toggleTheme(env.root, env.storage, env.later)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Adds the alpine:init listener that registers the stores, once per document.
|
|
64
|
+
// It must run before Alpine starts: the head loads Alpine after the module
|
|
65
|
+
// tags, and deferred and module scripts run in document order.
|
|
66
|
+
var installedOn = new WeakSet()
|
|
67
|
+
|
|
68
|
+
export function installStudioStores(doc, win) {
|
|
69
|
+
if (installedOn.has(doc)) return
|
|
70
|
+
installedOn.add(doc)
|
|
71
|
+
doc.addEventListener('alpine:init', function () {
|
|
72
|
+
registerStudioStores(win.Alpine, {
|
|
73
|
+
storage: win.localStorage,
|
|
74
|
+
root: doc.documentElement,
|
|
75
|
+
later: function (fn, ms) { win.setTimeout(fn, ms) }
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (typeof document !== 'undefined' && typeof window !== 'undefined') {
|
|
81
|
+
installStudioStores(document, window)
|
|
82
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// studio/application: the engine's browser boot, imported on every page by
|
|
2
|
+
// layouts/studio/_head (javascript_import_module_tag, which carries the
|
|
3
|
+
// request's CSP nonce).
|
|
4
|
+
//
|
|
5
|
+
// The engine runs its own Stimulus application on its OWN attributes:
|
|
6
|
+
//
|
|
7
|
+
// data-studio-controller="nav-collapse" data-studio-action data-studio-target
|
|
8
|
+
//
|
|
9
|
+
// so a host's Stimulus application never sees an engine controller. That is not
|
|
10
|
+
// tidiness: stimulus-loading's lazy loader (cyvasse) imports
|
|
11
|
+
// "controllers/<identifier>_controller" for every data-controller it meets, and
|
|
12
|
+
// would log "Failed to autoload controller" for each engine element. Values and
|
|
13
|
+
// classes keep Stimulus' own naming (data-nav-collapse-scrolled-class).
|
|
14
|
+
// "@hotwired/stimulus" resolves to the engine's vendored copy unless the host
|
|
15
|
+
// pins its own.
|
|
16
|
+
//
|
|
17
|
+
// Everything this file imports statically is preloaded on every page
|
|
18
|
+
// (Studio::Engine.javascript_boot_graph), so the boot costs one round trip.
|
|
19
|
+
import { Application, defaultSchema } from "@hotwired/stimulus"
|
|
20
|
+
import NavCollapseController from "studio/controllers/nav_collapse_controller"
|
|
21
|
+
import { startPinnedStack } from "studio/pinned_stack"
|
|
22
|
+
import { installAlpineShims } from "studio/alpine_shims"
|
|
23
|
+
|
|
24
|
+
installAlpineShims()
|
|
25
|
+
startPinnedStack()
|
|
26
|
+
|
|
27
|
+
export const schema = {
|
|
28
|
+
...defaultSchema,
|
|
29
|
+
controllerAttribute: "data-studio-controller",
|
|
30
|
+
actionAttribute: "data-studio-action",
|
|
31
|
+
targetAttribute: "data-studio-target"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const application = Application.start(document.documentElement, schema)
|
|
35
|
+
application.register("nav-collapse", NavCollapseController)
|
|
36
|
+
|
|
37
|
+
export { application }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// nav-collapse: the scroll-linked navbar collapse on a <header>, on the engine's
|
|
2
|
+
// Stimulus application (studio/application, which reads data-studio-controller).
|
|
3
|
+
//
|
|
4
|
+
// <header class="nav-shell ..." data-studio-controller="nav-collapse"
|
|
5
|
+
// data-nav-collapse-scrolled-class="shadow-lg is-scrolled">
|
|
6
|
+
//
|
|
7
|
+
// The behaviour is studio/nav_collapse; this controller binds it to the
|
|
8
|
+
// element's lifetime and toggles the `scrolled` classes when the shadow's
|
|
9
|
+
// hysteresis flips. Registered by studio/application.
|
|
10
|
+
import { Controller } from "@hotwired/stimulus"
|
|
11
|
+
import { NavCollapse } from "studio/nav_collapse"
|
|
12
|
+
|
|
13
|
+
export default class extends Controller {
|
|
14
|
+
static classes = ["scrolled"]
|
|
15
|
+
|
|
16
|
+
connect() {
|
|
17
|
+
this.collapse = new NavCollapse(this.element, (lit) => {
|
|
18
|
+
if (!this.hasScrolledClass) return
|
|
19
|
+
for (const name of this.scrolledClasses) this.element.classList.toggle(name, lit)
|
|
20
|
+
})
|
|
21
|
+
this.collapse.start()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
disconnect() {
|
|
25
|
+
if (this.collapse) this.collapse.stop()
|
|
26
|
+
this.collapse = null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// studio/head_chrome: the small behaviours every page's chrome carries. Each
|
|
2
|
+
// piece below used to be an inline <script> in layouts/studio/_head.
|
|
3
|
+
//
|
|
4
|
+
// nav spinner the scale morph between the theme toggle and a spinner
|
|
5
|
+
// confetti the success burst a completed flow fires
|
|
6
|
+
//
|
|
7
|
+
// The logic is exported for test/javascript/head_chrome.test.mjs. The window
|
|
8
|
+
// globals that consumers bind to are installed by studio/alpine_shims. The
|
|
9
|
+
// theme and its Alpine store are studio/alpine_stores, which loads even when
|
|
10
|
+
// this module does not.
|
|
11
|
+
|
|
12
|
+
// ---- NAV SPINNER ------------------------------------------------------------
|
|
13
|
+
//
|
|
14
|
+
// Minimum display time prevents quick flashes; per app via
|
|
15
|
+
// Studio.nav_spinner_min_ms, which the head publishes as
|
|
16
|
+
// <meta name="studio-nav-spinner-min-ms"> (smooth-load apps drop it to ~300).
|
|
17
|
+
|
|
18
|
+
// How long hide must still wait so the spinner shows for at least minMs.
|
|
19
|
+
export function spinnerHideDelay(shownAt, now, minMs) {
|
|
20
|
+
return Math.max(0, minMs - (now - shownAt))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function spinnerMinMs(doc) {
|
|
24
|
+
var meta = doc.querySelector('meta[name="studio-nav-spinner-min-ms"]')
|
|
25
|
+
var value = meta ? parseInt(meta.getAttribute('content'), 10) : 0
|
|
26
|
+
return value > 0 ? value : 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
var spinnerShownAt = 0
|
|
30
|
+
|
|
31
|
+
function showToggle(doc) {
|
|
32
|
+
doc.querySelectorAll('.nav-toggle-icon').forEach(function (e) { e.style.opacity = '1'; e.style.transform = 'scale(1) rotate(0deg)' })
|
|
33
|
+
doc.querySelectorAll('.nav-spinner-icon').forEach(function (e) { e.style.opacity = '0'; e.style.transform = 'scale(0) rotate(-90deg)' })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function showNavSpinner() {
|
|
37
|
+
spinnerShownAt = Date.now()
|
|
38
|
+
document.querySelectorAll('.nav-toggle-icon').forEach(function (e) { e.style.opacity = '0'; e.style.transform = 'scale(0) rotate(90deg)' })
|
|
39
|
+
document.querySelectorAll('.nav-spinner-icon').forEach(function (e) { e.style.opacity = '1'; e.style.transform = 'scale(1) rotate(0deg)' })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function hideNavSpinner() {
|
|
43
|
+
var wait = spinnerHideDelay(spinnerShownAt, Date.now(), spinnerMinMs(document))
|
|
44
|
+
setTimeout(function () { showToggle(document) }, wait)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Reset spinner state before Turbo caches the page. Installed once.
|
|
48
|
+
var spinnerResetInstalled = false
|
|
49
|
+
export function installSpinnerReset() {
|
|
50
|
+
if (spinnerResetInstalled) return
|
|
51
|
+
spinnerResetInstalled = true
|
|
52
|
+
document.addEventListener('turbo:before-cache', function () { showToggle(document) })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---- SUCCESS CONFETTI -------------------------------------------------------
|
|
56
|
+
//
|
|
57
|
+
// Rides the global `confetti` that studio/canvas_confetti defines; does
|
|
58
|
+
// nothing when it is absent. window.CONFETTI_COLORS overrides the palette.
|
|
59
|
+
|
|
60
|
+
export var SUCCESS_COLORS = ['#4BAF50', '#8E82FE', '#06D6A0', '#FF7C47', '#FFD700', '#00BFFF', '#FF6B9D', '#C084FC']
|
|
61
|
+
|
|
62
|
+
// The four bursts, each as [delay ms, confetti options].
|
|
63
|
+
export function successBursts(colors) {
|
|
64
|
+
return [
|
|
65
|
+
[0, { particleCount: 150, spread: 100, origin: { x: 0.5, y: 0.5 }, colors: colors, zIndex: 9999, startVelocity: 45, gravity: 0.8, ticks: 300, scalar: 1.2 }],
|
|
66
|
+
[150, { particleCount: 80, angle: 60, spread: 60, origin: { x: 0, y: 0.6 }, colors: colors, zIndex: 9999, startVelocity: 55, gravity: 1, ticks: 250 }],
|
|
67
|
+
[150, { particleCount: 80, angle: 120, spread: 60, origin: { x: 1, y: 0.6 }, colors: colors, zIndex: 9999, startVelocity: 55, gravity: 1, ticks: 250 }],
|
|
68
|
+
[400, { particleCount: 100, spread: 160, origin: { x: 0.5, y: 0.3 }, colors: colors, zIndex: 9999, startVelocity: 30, gravity: 1.2, ticks: 200, scalar: 0.8 }]
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function fireSuccessConfetti() {
|
|
73
|
+
if (typeof window.confetti === 'undefined') return
|
|
74
|
+
var fire = window.confetti
|
|
75
|
+
successBursts(window.CONFETTI_COLORS || SUCCESS_COLORS).forEach(function (burst) {
|
|
76
|
+
if (burst[0] === 0) fire(burst[1])
|
|
77
|
+
else setTimeout(function () { fire(burst[1]) }, burst[0])
|
|
78
|
+
})
|
|
79
|
+
}
|