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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e0b538d638180facefc1cff85cc4c5ccbea6aa49711d6ac48f1863a87e8d776
4
- data.tar.gz: c48c355f0e8e9e2164649383eb61c7f47eaece289dfd0007e1f4f2d849137023
3
+ metadata.gz: 32e6d36ae904992ea14289647084091b399296ebc27daf777e45620ad126b772
4
+ data.tar.gz: 2c026cd62fca795d60fe39976ca9b47313103afdee12bff1ce590961a5d9ff6f
5
5
  SHA512:
6
- metadata.gz: 9ac3c8a86695295f537aaa8355bfdc393a3b6a7ff5409308b7fe6ab402021e145089adc5a212d44aaced6921904563ccb16cf838ec5c46a33f7c9850196e2dda
7
- data.tar.gz: 6d0fbbc3746e31d7dfd570185740ce394f91fb5503e5c3ce6f0ad7d59c4a6ba4f302669d8fd96e4bd1e6c2ef509aa5703951b8417381411bfb5d276cc8607e1d
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**: `before_save :set_slug` with `to_param` for human-readable URLs
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. THE SLUG. Sluggable's `before_save :set_slug` is UNGATED, and
83
- # mcritchie-studio's User#name_slug is built from `name`. A full
84
- # save right after this writes `name` would therefore re-point the
85
- # slug the account answers on — a URL change, on a column carrying
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
+ }