studio-engine 0.96.2 → 0.97.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +183 -0
  3. data/app/assets/tailwind/studio_engine/engine-motion.css +57 -0
  4. data/app/assets/tailwind/studio_engine/engine.css +8 -0
  5. data/app/javascript/studio/alpine_shims.js +23 -0
  6. data/app/javascript/studio/application.js +9 -21
  7. data/app/javascript/studio/board.js +593 -0
  8. data/app/javascript/studio/booking.js +176 -0
  9. data/app/javascript/studio/controllers/board_controller.js +41 -0
  10. data/app/javascript/studio/controllers/booking_controller.js +24 -0
  11. data/app/javascript/studio/controllers/email_banner_scale_controller.js +22 -0
  12. data/app/javascript/studio/controllers/footer_map_controller.js +22 -0
  13. data/app/javascript/studio/controllers/geo_settings_controller.js +24 -0
  14. data/app/javascript/studio/controllers/hold_button_controller.js +64 -0
  15. data/app/javascript/studio/controllers/link_preview_card_controller.js +25 -0
  16. data/app/javascript/studio/controllers/link_sidebar_controller.js +18 -0
  17. data/app/javascript/studio/controllers/survey_controller.js +18 -0
  18. data/app/javascript/studio/controllers/toast_controller.js +26 -0
  19. data/app/javascript/studio/email_banner.js +277 -0
  20. data/app/javascript/studio/email_banner_scale.js +60 -0
  21. data/app/javascript/studio/footer_map.js +143 -0
  22. data/app/javascript/studio/geo_settings.js +105 -0
  23. data/app/javascript/studio/hold_button.js +416 -0
  24. data/app/javascript/studio/hold_button_hooks.js +162 -0
  25. data/app/javascript/studio/lazy_controllers.js +138 -0
  26. data/app/javascript/studio/leveling_activity.js +252 -0
  27. data/app/javascript/studio/link_preview_card.js +34 -0
  28. data/app/javascript/studio/link_sidebar.js +157 -0
  29. data/app/javascript/studio/stimulus.js +72 -0
  30. data/app/javascript/studio/survey.js +316 -0
  31. data/app/javascript/studio/theme_editor.js +197 -0
  32. data/app/javascript/studio/toast.js +270 -0
  33. data/app/views/components/_link_sidebar.html.erb +5 -100
  34. data/app/views/components/_link_sidebar_trigger.html.erb +2 -2
  35. data/app/views/layouts/studio/_flash.html.erb +13 -118
  36. data/app/views/layouts/studio/_head.html.erb +30 -14
  37. data/app/views/studio/_board_assets.html.erb +9 -452
  38. data/app/views/studio/_hold_button.html.erb +38 -445
  39. data/app/views/studio/_hold_button_guard.html.erb +172 -0
  40. data/app/views/studio/_leveling_activity_assets.html.erb +9 -283
  41. data/app/views/studio/board/_board.html.erb +16 -5
  42. data/app/views/studio/booking/_assets.html.erb +5 -139
  43. data/app/views/studio/booking/_frame.html.erb +3 -3
  44. data/app/views/studio/booking/_popup.html.erb +4 -4
  45. data/app/views/studio/emails/_banner_editor.html.erb +4 -152
  46. data/app/views/studio/emails/_banner_scale.html.erb +5 -56
  47. data/app/views/studio/emails/index.html.erb +0 -1
  48. data/app/views/studio/geo_settings/edit.html.erb +1 -97
  49. data/app/views/studio/site_footer/_map.html.erb +7 -5
  50. data/app/views/studio/site_footer/_map_assets.html.erb +5 -129
  51. data/app/views/studio/site_identities/edit.html.erb +1 -24
  52. data/app/views/studio/surveys/show.html.erb +4 -5
  53. data/app/views/style/_tasks.html.erb +3 -8
  54. data/app/views/style/_theme.html.erb +2 -52
  55. data/app/views/theme_settings/edit.html.erb +3 -82
  56. data/config/importmap.rb +6 -0
  57. data/docs/BOOKING.md +6 -5
  58. data/docs/SITE_FOOTER.md +16 -12
  59. data/lib/studio/version.rb +1 -1
  60. metadata +28 -3
  61. data/app/views/studio/emails/_recipient_repaint.html.erb +0 -122
  62. data/app/views/studio/surveys/_script.html.erb +0 -309
@@ -0,0 +1,270 @@
1
+ // studio/toast: the toast queue behind layouts/studio/_flash ($store.toasts).
2
+ //
3
+ // THE API IS THE `toast` WINDOW EVENT, from any page that renders the partial:
4
+ //
5
+ // window.dispatchEvent(new CustomEvent("toast", { detail: {
6
+ // type: "notice", "notice" or "alert"
7
+ // title: "Header", "Success" / "Error" when omitted
8
+ // message: "Subtext",
9
+ // image: "/avatar.jpg", an icon when omitted
10
+ // dismissible: true, the X button
11
+ // buttons: [{ label, style, onclick }],
12
+ // duration: 4000, ms; 0 stays; 0 by default when it has buttons
13
+ // blurShadow: false a frosted halo behind the card
14
+ // } }))
15
+ //
16
+ // The partial renders markup only. Its root carries
17
+ //
18
+ // data-studio-controller="toast"
19
+ // data-toast-initial-value='[{"type":"notice","message":"Saved"}]'
20
+ //
21
+ // and its Alpine bindings read $store.toasts. studio/controllers/toast_controller
22
+ // binds the queue to the element's lifetime.
23
+ //
24
+ // WHEN THE STORE REGISTERS. Alpine evaluates $store.toasts as it starts, before
25
+ // Stimulus connects a controller, so the store registers from this module on
26
+ // alpine:init, and only when Alpine has none (a host's own wins). The module
27
+ // also owns the `toast` listener and seeds each rendered root's flash messages
28
+ // (on alpine:initialized and on turbo:load), so a page whose Stimulus boot
29
+ // failed still shows its flash and its toasts. The controller seeds the same
30
+ // root on connect; an element is seeded once.
31
+ //
32
+ // WHY THIS IS ITS OWN ENTRY POINT. layouts/studio/_head imports this module by
33
+ // its own nonced module tag as well as through the boot graph, like
34
+ // studio/alpine_stores. It imports nothing, and test/javascript/toast.test.mjs
35
+ // holds it to that.
36
+
37
+ // The queue keeps this many toasts; the oldest leaves when one more arrives.
38
+ export const TOAST_CAP = 5
39
+ // A toast with no duration of its own, and no buttons, leaves after this long.
40
+ export const DEFAULT_DURATION_MS = 4000
41
+ // The leave transition's length (.toast-wrapper in layouts/studio/_flash): a
42
+ // dismissed toast is spliced out this long after it starts to shrink.
43
+ export const LEAVE_MS = 400
44
+ // Flash messages enter one after another: the first after SEED_DELAY_MS, each
45
+ // later one SEED_STAGGER_MS behind the last.
46
+ export const SEED_DELAY_MS = 50
47
+ export const SEED_STAGGER_MS = 100
48
+
49
+ export const TOAST_ROOT = '[data-studio-controller~="toast"]'
50
+ const INITIAL_ATTRIBUTE = "data-toast-initial-value"
51
+
52
+ // How long a toast stays. A toast with buttons waits for its reader.
53
+ export function toastDuration(detail) {
54
+ const buttons = (detail && detail.buttons) || []
55
+ if (detail && detail.duration != null) return detail.duration
56
+ return buttons.length ? 0 : DEFAULT_DURATION_MS
57
+ }
58
+
59
+ // The queue entry for a `toast` event's detail. It enters invisible; the queue
60
+ // flips `visible` a tick later so the enter transition runs.
61
+ export function buildToast(detail, id) {
62
+ const source = detail || {}
63
+ const type = source.type || "notice"
64
+ return {
65
+ id,
66
+ type,
67
+ title: source.title || (type === "notice" ? "Success" : "Error"),
68
+ message: source.message || "",
69
+ image: source.image || null,
70
+ dismissible: source.dismissible !== false,
71
+ blurShadow: source.blurShadow || false,
72
+ buttons: source.buttons || [],
73
+ visible: false
74
+ }
75
+ }
76
+
77
+ const visibleCount = (toasts) => toasts.filter((toast) => toast.visible).length
78
+
79
+ // The inline style of the toast at `index`: full, a collapsed peek strip under
80
+ // the newest, or hidden beyond the third.
81
+ export function toastStyle(toasts, index, expanded) {
82
+ const toast = toasts[index]
83
+ if (!toast || !toast.visible) {
84
+ return "opacity: 0; transform: scale(0.7); max-height: 0; margin-top: 0; padding: 0; overflow: hidden;"
85
+ }
86
+
87
+ const collapsed = !expanded && visibleCount(toasts) > 1
88
+ if (!collapsed || index === 0) {
89
+ return "opacity: 1; transform: scale(1) translateY(0); margin-top: " + (index > 0 ? "0.35rem" : "0") + "; max-height: 20rem;"
90
+ }
91
+
92
+ if (index <= 2) {
93
+ const scale = 1 - (index * 0.03)
94
+ const opacity = 0.7 - ((index - 1) * 0.15)
95
+ return "display: flex; flex-direction: column; justify-content: flex-end; transform: scale(" + scale +
96
+ ") translateY(0); max-height: 0.5rem; overflow: hidden; opacity: " + opacity +
97
+ "; margin-top: 0.15rem; pointer-events: auto; cursor: pointer;"
98
+ }
99
+
100
+ return "max-height: 0; overflow: hidden; opacity: 0; margin-top: 0; pointer-events: none;"
101
+ }
102
+
103
+ export function toastShadowClass(toasts, index) {
104
+ if (visibleCount(toasts) <= 1) return "toast-shadow-all"
105
+ return index === 0 ? "toast-shadow-top" : "toast-shadow-bottom"
106
+ }
107
+
108
+ // $store.toasts. Methods read `this`, so they run on Alpine's reactive proxy
109
+ // when Alpine calls them and on the plain object in a test.
110
+ export function createToastStore({ win } = {}) {
111
+ const host = win || globalThis
112
+ const later = (fn, ms) => host.setTimeout(fn, ms)
113
+ const nextTick = (fn) => {
114
+ const alpine = host.Alpine
115
+ if (alpine && typeof alpine.nextTick === "function") alpine.nextTick(fn)
116
+ else later(fn, 0)
117
+ }
118
+
119
+ return {
120
+ toasts: [],
121
+ expanded: false,
122
+ _nextId: 0,
123
+ _page: 0,
124
+
125
+ add(detail) {
126
+ const id = ++this._nextId
127
+ this.toasts.unshift(buildToast(detail, id))
128
+ while (this.toasts.length > TOAST_CAP) this.toasts.pop()
129
+ this.expanded = false
130
+
131
+ nextTick(() => {
132
+ const toast = this.toasts.find((entry) => entry.id === id)
133
+ if (toast) toast.visible = true
134
+ })
135
+
136
+ const duration = toastDuration(detail)
137
+ if (duration > 0) later(() => this.dismiss(id), duration)
138
+ return id
139
+ },
140
+
141
+ // Each message after the last, as the flash arrives. A message still
142
+ // waiting when its page leaves (clear) is dropped.
143
+ seed(messages) {
144
+ const page = this._page;
145
+ (messages || []).forEach((message, index) => {
146
+ later(() => { if (this._page === page) this.add(message) }, index * SEED_STAGGER_MS + SEED_DELAY_MS)
147
+ })
148
+ },
149
+
150
+ dismiss(id) {
151
+ const toast = this.toasts.find((entry) => entry.id === id)
152
+ if (!toast || !toast.visible) return
153
+ toast.visible = false
154
+ later(() => {
155
+ this.toasts = this.toasts.filter((entry) => entry.id !== id)
156
+ if (visibleCount(this.toasts) <= 1) this.expanded = false
157
+ }, LEAVE_MS)
158
+ },
159
+
160
+ // The queue leaves with its page. A timer that fires later finds no toast.
161
+ clear() {
162
+ this._page += 1
163
+ this.toasts = []
164
+ this.expanded = false
165
+ },
166
+
167
+ hasVisible() { return this.toasts.some((toast) => toast.visible) },
168
+
169
+ handlePeekClick(index) {
170
+ if (!this.expanded && index > 0 && this.toasts.length > 1) this.expanded = true
171
+ },
172
+
173
+ toastStyle(index) { return toastStyle(this.toasts, index, this.expanded) },
174
+ toastShadowClass(index) { return toastShadowClass(this.toasts, index) }
175
+ }
176
+ }
177
+
178
+ // The flash messages a rendered root carries. Anything unreadable is no flash.
179
+ export function initialToasts(element) {
180
+ const raw = element && element.getAttribute ? element.getAttribute(INITIAL_ATTRIBUTE) : null
181
+ if (!raw) return []
182
+ try {
183
+ const parsed = JSON.parse(raw)
184
+ return Array.isArray(parsed) ? parsed : []
185
+ } catch (_error) {
186
+ return []
187
+ }
188
+ }
189
+
190
+ // Registers $store.toasts unless Alpine already has one. Returns the store
191
+ // Alpine holds (its reactive proxy), or null before Alpine exists.
192
+ export function registerToastStore(Alpine, deps = {}) {
193
+ if (!Alpine || typeof Alpine.store !== "function") return null
194
+ if (!Alpine.store("toasts")) Alpine.store("toasts", createToastStore(deps))
195
+ return Alpine.store("toasts")
196
+ }
197
+
198
+ const seeded = new WeakSet()
199
+
200
+ // Queues a root's flash messages, once per element. False when there is no
201
+ // store yet, so a later path seeds it.
202
+ export function seedToasts(element, { win } = {}) {
203
+ const host = win || globalThis
204
+ if (!element || seeded.has(element)) return false
205
+ const store = registerToastStore(host.Alpine, { win: host })
206
+ if (!store) return false
207
+ seeded.add(element)
208
+ store.seed(initialToasts(element))
209
+ return true
210
+ }
211
+
212
+ // A cached Turbo snapshot keeps the root's flash attribute, and a restored page
213
+ // would replay it. Emptied here, before the snapshot is taken.
214
+ export function forgetInitialToasts(element) {
215
+ if (element && element.hasAttribute && element.hasAttribute(INITIAL_ATTRIBUTE)) {
216
+ element.setAttribute(INITIAL_ATTRIBUTE, "[]")
217
+ }
218
+ }
219
+
220
+ const toastRoots = (root) =>
221
+ (root && typeof root.querySelectorAll === "function" ? Array.from(root.querySelectorAll(TOAST_ROOT)) : [])
222
+
223
+ export function seedAllToasts({ win, doc } = {}) {
224
+ const host = win || globalThis
225
+ toastRoots(doc || host.document).forEach((element) => seedToasts(element, { win: host }))
226
+ }
227
+
228
+ // The page is leaving for Turbo's cache: its queue goes, and its roots forget
229
+ // their flash.
230
+ export function leavePage({ win, doc } = {}) {
231
+ const host = win || globalThis
232
+ const store = host.Alpine && typeof host.Alpine.store === "function" ? host.Alpine.store("toasts") : null
233
+ if (store && typeof store.clear === "function") store.clear()
234
+ toastRoots(doc || host.document).forEach(forgetInitialToasts)
235
+ }
236
+
237
+ // The `toast` event's handler. A toast raised before Alpine exists is dropped,
238
+ // as it was when the listener was an Alpine binding.
239
+ export function raiseToast(detail, { win } = {}) {
240
+ const host = win || globalThis
241
+ const store = registerToastStore(host.Alpine, { win: host })
242
+ if (!store) return null
243
+ return store.add(detail || {})
244
+ }
245
+
246
+ const installed = new WeakSet()
247
+
248
+ // Once per window: a second call adds no second listener.
249
+ export function installToast({ win, doc } = {}) {
250
+ const host = win || globalThis
251
+ const root = doc || host.document
252
+ if (!root || typeof root.addEventListener !== "function") return false
253
+ if (installed.has(host)) return false
254
+ installed.add(host)
255
+
256
+ root.addEventListener("alpine:init", () => registerToastStore(host.Alpine, { win: host }))
257
+ root.addEventListener("alpine:initialized", () => seedAllToasts({ win: host, doc: root }))
258
+ root.addEventListener("turbo:load", () => seedAllToasts({ win: host, doc: root }))
259
+ root.addEventListener("turbo:before-cache", () => leavePage({ win: host, doc: root }))
260
+ host.addEventListener("toast", (event) => raiseToast(event.detail, { win: host }))
261
+
262
+ // Alpine already started (a host that loads it ahead of the module tags).
263
+ if (host.Alpine && host.Alpine.version) {
264
+ registerToastStore(host.Alpine, { win: host })
265
+ seedAllToasts({ win: host, doc: root })
266
+ }
267
+ return true
268
+ }
269
+
270
+ if (typeof window !== "undefined") installToast({ win: window, doc: document })
@@ -1,7 +1,6 @@
1
1
  <%# The link sidebar — the engine's out-of-the-box navigation panel (lifted
2
2
  from the mcritchie-studio hub). Renders the host's Studio.sidebar_sections
3
- as a slide-out link tree in dual desktop/mobile panels, and carries its
4
- own store bridge so a single render is self-sufficient. Render it once,
3
+ as a slide-out link tree in dual desktop/mobile panels. Render it once,
5
4
  outside any sticky/backdrop-filter header (a fixed panel inside one
6
5
  positions against the header, not the viewport); the engine navbar does
7
6
  this automatically when sections are declared. %>
@@ -17,104 +16,10 @@
17
16
  html { overflow-x: clip; }
18
17
  </style>
19
18
 
20
- <script>
21
- // Store bridge (engine-owned): registers the Alpine `sidebars` store and
22
- // keeps it healthy across Turbo navigations and bfcache restores. The
23
- // panel's open/closed state is driven ENTIRELY by the store flag via
24
- // x-show + x-transition — this bridge only flips the flag, never `display`
25
- // directly, because an imperative display:none bakes an inline style that
26
- // makes Alpine skip its enter transition (the panel pops instead of
27
- // sliding). On turbo:before-cache / bfcache the flag drops so a restored
28
- // page is closed. The document-level capture handlers make the trigger
29
- // work even for clicks that land before Alpine initializes.
30
- (function () {
31
- // The panels this component renders — the scope for every handler below that
32
- // claims a click. Declared once so the selector cannot drift from the ids.
33
- var LINK_SIDEBAR_PANELS = '#studio-link-sidebar, #studio-link-sidebar-mobile';
34
-
35
- function ensureLinkSidebarStore() {
36
- if (!window.Alpine) return null;
37
-
38
- var sidebars = Alpine.store('sidebars');
39
- if (!sidebars) {
40
- Alpine.store('sidebars', { linkTreeOpen: false });
41
- return Alpine.store('sidebars');
42
- }
43
-
44
- if (typeof sidebars.linkTreeOpen === 'undefined') {
45
- sidebars.linkTreeOpen = false;
46
- }
47
-
48
- return sidebars;
49
- }
50
-
51
- function setLinkSidebarOpen(open) {
52
- var sidebars = ensureLinkSidebarStore();
53
- if (sidebars) sidebars.linkTreeOpen = open;
54
- }
55
-
56
- function closeLinkSidebar() {
57
- setLinkSidebarOpen(false);
58
- }
59
-
60
- function handleLinkSidebarClick(event) {
61
- // SCOPED TO THIS COMPONENT'S OWN PANELS, and that scope is the whole of it.
62
- // components/_sidebar_panel is SHARED: every panel built on it stamps its close
63
- // button data-link-sidebar-close, so an unscoped match here claimed the close
64
- // click of EVERY other panel on the page — stopImmediatePropagation in the
65
- // capture phase, before that panel's own @click could run — and closed this
66
- // sidebar instead. The other panel's × then did nothing at all: no error, no
67
- // console line. Measured 2026-09-18 on the hub's /deployments summary sidebars,
68
- // where the click never reached a document-level listener and the panel stayed
69
- // open (mcritchie-studio worked around it with a close button of its own).
70
- // The two ids are the panels this file renders, below.
71
- var closeTrigger = event.target.closest('[data-link-sidebar-close]');
72
- if (closeTrigger && closeTrigger.closest(LINK_SIDEBAR_PANELS)) {
73
- event.preventDefault();
74
- event.stopPropagation();
75
- event.stopImmediatePropagation();
76
- closeLinkSidebar();
77
- return;
78
- }
79
-
80
- var trigger = event.target.closest('[data-link-sidebar-trigger], [data-username-display], [data-profile-image-toggle]');
81
- if (trigger && trigger.matches('[aria-controls~="studio-link-sidebar"]')) {
82
- var sidebars = ensureLinkSidebarStore();
83
- if (!sidebars) return;
84
-
85
- event.preventDefault();
86
- event.stopPropagation();
87
- event.stopImmediatePropagation();
88
- sidebars.linkTreeOpen = !sidebars.linkTreeOpen;
89
- return;
90
- }
91
-
92
- var sidebars = ensureLinkSidebarStore();
93
- if (!sidebars || !sidebars.linkTreeOpen) return;
94
- if (event.target.closest(LINK_SIDEBAR_PANELS)) return;
95
-
96
- closeLinkSidebar();
97
- }
98
-
99
- function handleLinkSidebarKeydown(event) {
100
- if (event.key === 'Escape') closeLinkSidebar();
101
- }
102
-
103
- if (!window.__studioLinkSidebarBridge) {
104
- window.__studioLinkSidebarBridge = true;
105
- document.addEventListener('alpine:init', ensureLinkSidebarStore);
106
- document.addEventListener('turbo:load', ensureLinkSidebarStore);
107
- document.addEventListener('turbo:before-cache', closeLinkSidebar);
108
- window.addEventListener('pageshow', function (event) {
109
- if (event.persisted) closeLinkSidebar();
110
- });
111
- document.addEventListener('click', handleLinkSidebarClick, true);
112
- document.addEventListener('keydown', handleLinkSidebarKeydown, true);
113
- }
114
-
115
- ensureLinkSidebarStore();
116
- })();
117
- </script>
19
+ <%# The open flag ($store.sidebars.linkTreeOpen) and the document handlers that
20
+ flip it are studio/link_sidebar, a nonced module the head loads. This anchor
21
+ is the link-sidebar controller's element. %>
22
+ <template data-studio-controller="link-sidebar"></template>
118
23
 
119
24
  <% body_html = capture do %>
120
25
  <nav class="flex-1 min-h-0 overflow-y-auto py-2" aria-label="Studio links">
@@ -1,8 +1,8 @@
1
1
  <%# Link-sidebar toggle button. `class_name` prepends responsive placement
2
2
  classes (the navbar mounts one instance `hidden md:inline-flex` in the
3
3
  desktop icon rail and one in the mobile sub-navbar row). The click is
4
- handled twice on purpose: the Alpine @click for normal operation, and the
5
- document-level capture bridge in _link_sidebar for pre-Alpine clicks. %>
4
+ handled twice on purpose: the Alpine @click for normal operation, and
5
+ studio/link_sidebar's document-level capture handler for pre-Alpine clicks. %>
6
6
  <%# The caller OWNS the display class: a hardcoded inline-flex here beats a
7
7
  caller's `hidden md:inline-flex` in the cascade and un-hides the desktop
8
8
  trigger on mobile. Bare renders default to inline-flex. %>
@@ -89,21 +89,25 @@
89
89
  <% messages = flash.select { |type, _| %w[notice alert].include?(type) } %>
90
90
  <% initial_json = messages.map { |type, msg| { type: type, message: msg } }.to_json %>
91
91
 
92
- <div x-data="toastManager(<%= initial_json %>)"
93
- @toast.window="add($event.detail)">
92
+ <%# The queue is studio/toast ($store.toasts): it listens for the `toast` window
93
+ event and seeds the flash below. The bare x-data is the Alpine scope the
94
+ bindings need; the toast controller binds the queue to this root's lifetime. %>
95
+ <div x-data
96
+ data-studio-controller="toast"
97
+ data-toast-initial-value="<%= initial_json %>">
94
98
 
95
99
  <%# ── Page blur — half-ellipse backdrop blur behind toasts ── %>
96
100
  <div class="toast-page-blur"
97
- :style="'opacity: ' + (hasVisible() ? '1' : '0')"></div>
101
+ :style="'opacity: ' + ($store.toasts.hasVisible() ? '1' : '0')"></div>
98
102
 
99
103
  <div id="toast-container"
100
104
  class="fixed top-0 left-1/2 flex flex-col items-center pointer-events-none"
101
105
  style="transform: translateX(-50%); padding: 1rem; width: 100%; max-width: 30rem;">
102
106
 
103
- <template x-for="(toast, index) in toasts" :key="toast.id">
104
- <div :style="toastStyle(index)"
107
+ <template x-for="(toast, index) in $store.toasts.toasts" :key="toast.id">
108
+ <div :style="$store.toasts.toastStyle(index)"
105
109
  class="toast-wrapper w-full"
106
- @click="handlePeekClick(index)">
110
+ @click="$store.toasts.handlePeekClick(index)">
107
111
 
108
112
  <%# ── Blur glow (optional) — uses filter:blur on a colored div, not backdrop-filter ── %>
109
113
  <template x-if="toast.blurShadow">
@@ -112,7 +116,7 @@
112
116
 
113
117
  <%# ── Toast card — z-index above all blur backdrops ── %>
114
118
  <div class="relative pointer-events-auto bg-surface rounded-lg p-4 w-full flex gap-3 toast-card"
115
- :class="toastShadowClass(index)"
119
+ :class="$store.toasts.toastShadowClass(index)"
116
120
  role="status">
117
121
 
118
122
  <%# ── Image or icon ── %>
@@ -135,7 +139,7 @@
135
139
  <div class="flex items-start justify-between gap-2">
136
140
  <span x-text="toast.title" class="text-heading font-semibold text-sm"></span>
137
141
  <template x-if="toast.dismissible">
138
- <button @click.stop="dismiss(toast.id)"
142
+ <button @click.stop="$store.toasts.dismiss(toast.id)"
139
143
  class="text-muted hover:text-heading shrink-0 cursor-pointer"
140
144
  aria-label="Dismiss">
141
145
  <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
@@ -150,7 +154,7 @@
150
154
  <template x-if="toast.buttons && toast.buttons.length > 0">
151
155
  <div class="flex gap-2" style="margin-top: 0.75rem;">
152
156
  <template x-for="btn in toast.buttons" :key="btn.label">
153
- <button @click.stop="if (btn.onclick) btn.onclick(); dismiss(toast.id)"
157
+ <button @click.stop="if (btn.onclick) btn.onclick(); $store.toasts.dismiss(toast.id)"
154
158
  class="btn btn-sm"
155
159
  :class="btn.style === 'primary' ? 'btn-primary' : btn.style === 'danger' ? 'btn-danger' : 'btn-outline'">
156
160
  <span x-text="btn.label"></span>
@@ -165,112 +169,3 @@
165
169
  </div>
166
170
  </div>
167
171
 
168
- <script>
169
- if (!window.toastManager) {
170
- window.toastManager = function(initial) {
171
- return {
172
- toasts: [],
173
- _nextId: 0,
174
- expanded: false,
175
-
176
- init() {
177
- if (initial && initial.length) {
178
- initial.forEach((msg, i) => {
179
- setTimeout(() => this.add(msg), i * 100 + 50);
180
- });
181
- }
182
- },
183
-
184
- add(detail) {
185
- var id = ++this._nextId;
186
- var type = detail.type || 'notice';
187
- var buttons = detail.buttons || [];
188
- this.toasts.unshift({
189
- id: id,
190
- type: type,
191
- title: detail.title || (type === 'notice' ? 'Success' : 'Error'),
192
- message: detail.message || '',
193
- image: detail.image || null,
194
- dismissible: detail.dismissible !== false,
195
- blurShadow: detail.blurShadow || false,
196
- buttons: buttons,
197
- visible: false
198
- });
199
- // Cap at 5 toasts — dismiss oldest when exceeded
200
- while (this.toasts.length > 5) {
201
- this.toasts.pop();
202
- }
203
- this.expanded = false;
204
- this.$nextTick(() => {
205
- var t = this.toasts.find(function(t) { return t.id === id; });
206
- if (t) t.visible = true;
207
- });
208
- var duration = detail.duration != null ? detail.duration : (buttons.length ? 0 : 4000);
209
- if (duration > 0) {
210
- var self = this;
211
- setTimeout(function() { self.dismiss(id); }, duration);
212
- }
213
- },
214
-
215
- dismiss(id) {
216
- var toast = this.toasts.find(function(t) { return t.id === id; });
217
- if (toast && toast.visible) {
218
- toast.visible = false;
219
- var self = this;
220
- setTimeout(function() {
221
- self.toasts = self.toasts.filter(function(t) { return t.id !== id; });
222
- // Reset expanded when down to 1 or fewer toasts
223
- if (self.toasts.filter(function(t) { return t.visible; }).length <= 1) {
224
- self.expanded = false;
225
- }
226
- }, 400);
227
- }
228
- },
229
-
230
- toastShadowClass(index) {
231
- var visibleCount = this.toasts.filter(function(t) { return t.visible; }).length;
232
- if (visibleCount <= 1) return 'toast-shadow-all';
233
- if (index === 0) return 'toast-shadow-top';
234
- return 'toast-shadow-bottom';
235
- },
236
-
237
- hasVisible() {
238
- return this.toasts.some(function(t) { return t.visible; });
239
- },
240
-
241
- handlePeekClick(index) {
242
- if (!this.expanded && index > 0 && this.toasts.length > 1) {
243
- this.expanded = true;
244
- }
245
- },
246
-
247
- toastStyle(index) {
248
- var toast = this.toasts[index];
249
-
250
- // Leaving — gentle shrink and fade
251
- if (!toast.visible) {
252
- return 'opacity: 0; transform: scale(0.7); max-height: 0; margin-top: 0; padding: 0; overflow: hidden;';
253
- }
254
-
255
- var visibleCount = this.toasts.filter(function(t) { return t.visible; }).length;
256
- var isCollapsed = !this.expanded && visibleCount > 1;
257
-
258
- // Full view — latest toast or expanded mode
259
- if (!isCollapsed || index === 0) {
260
- return 'opacity: 1; transform: scale(1) translateY(0); margin-top: ' + (index > 0 ? '0.35rem' : '0') + '; max-height: 20rem;';
261
- }
262
-
263
- // Collapsed peek — thin strip showing card edge
264
- if (index <= 2) {
265
- var scale = 1 - (index * 0.03);
266
- var opacity = 0.7 - ((index - 1) * 0.15);
267
- return 'display: flex; flex-direction: column; justify-content: flex-end; transform: scale(' + scale + ') translateY(0); max-height: 0.5rem; overflow: hidden; opacity: ' + opacity + '; margin-top: 0.15rem; pointer-events: auto; cursor: pointer;';
268
- }
269
-
270
- // Hidden — beyond visible stack
271
- return 'max-height: 0; overflow: hidden; opacity: 0; margin-top: 0; pointer-events: none;';
272
- }
273
- };
274
- };
275
- }
276
- </script>
@@ -1,9 +1,9 @@
1
- <%# The request's CSP nonce, for the one inline script left in this head and
1
+ <%# The request's CSP nonce, for the two inline scripts left in this head and
2
2
  for javascript_import_module_tag below (importmap-rails nonces its own tags).
3
3
  nil when the host sets no nonce generator, and then no attribute renders. %>
4
4
  <% studio_script_nonce = (content_security_policy_nonce if respond_to?(:content_security_policy_nonce)) %>
5
- <%# PRE-PAINT THEME. Inline, and the only inline script this head keeps: it adds
6
- `dark` before the first paint, which no deferred module can do. The toggle
5
+ <%# PRE-PAINT THEME. Inline: it adds `dark` before the first paint, which no
6
+ deferred module can do. The toggle
7
7
  lives in studio/alpine_stores. %>
8
8
  <%= tag.script(nonce: studio_script_nonce) do %>
9
9
  (function(){
@@ -12,6 +12,10 @@
12
12
  }
13
13
  })();
14
14
  <% end %>
15
+ <%# THE HOLD BUTTON'S GUARD. Inline, and the other inline script this head keeps:
16
+ it reports a hold button whose modules failed to load, so it can share no
17
+ request with them (studio/_hold_button_guard). %>
18
+ <%= render "studio/hold_button_guard", nonce: studio_script_nonce %>
15
19
 
16
20
  <meta name="viewport" content="width=device-width,initial-scale=1">
17
21
  <%# Studio.nav_spinner_min_ms, read by the nav spinner in studio/head_chrome. %>
@@ -37,7 +41,7 @@
37
41
 
38
42
  <%# Montserrat is VENDORED into the engine (studio/montserrat-latin.woff2 and
39
43
  studio/montserrat-latin-ext.woff2) and shipped through the asset pipeline, no CDN,
40
- CSP-safe (same-origin :self), mirroring alpine, canvas_confetti and sortable below.
44
+ CSP-safe (same-origin :self), mirroring alpine and canvas_confetti below.
41
45
  PINNED AT Google Fonts v31.
42
46
 
43
47
  WHAT THIS REPLACED, and why it was never merely a third-party tidy-up:
@@ -113,12 +117,6 @@
113
117
  (studio/head_chrome) rides the same global. %>
114
118
  <%= javascript_include_tag "studio/canvas_confetti", "data-turbo-track": "reload" %>
115
119
  <%= javascript_include_tag "studio/studio_confetti", "data-turbo-track": "reload" %>
116
- <%# SortableJS is VENDORED into the engine (studio/sortable.js) and shipped through
117
- the asset pipeline — no CDN, CSP-safe (same-origin :self). Defines the global
118
- `Sortable`; the studio/board primitive (studio/_board_assets -> window.studioBoard)
119
- drives it for drag-reorder kanban AND depth-chart boards. Loaded here (deferred)
120
- so any board consumer has it with zero per-app wiring, mirroring canvas_confetti. %>
121
- <%= javascript_include_tag "studio/sortable", defer: true, "data-turbo-track": "reload" %>
122
120
  <%# The theme and devMode stores are studio/alpine_stores (its module tag is below).
123
121
  showNavSpinner / hideNavSpinner and fireSuccessConfetti are studio/head_chrome,
124
122
  installed by studio/alpine_shims. %>
@@ -164,9 +162,16 @@
164
162
  <%= stylesheet_link_tag "studio/sticky_table_header", "data-turbo-track": "reload" %>
165
163
  <% end %>
166
164
  <%= javascript_importmap_tags %>
167
- <%# THE ENGINE'S BROWSER BOOT (studio/application): its Stimulus application,
168
- the pinned stack, and the Alpine shims. A module tag, so it carries the nonce. %>
165
+ <%# THE ENGINE'S BROWSER BOOT (studio/application): its every-page Stimulus
166
+ controllers, the pinned stack, and the Alpine shims. A module tag, so it
167
+ carries the nonce. %>
169
168
  <%= javascript_import_module_tag "studio/application" %>
169
+ <%# THE STIMULUS APPLICATION, BY ITS OWN DOOR. studio/stimulus starts the engine's
170
+ Stimulus application and registers the hold button on it. Its only imports
171
+ are Stimulus, studio/lazy_controllers and the hold button's controller, so
172
+ a failure elsewhere in the boot's graph leaves a page its hold button. The
173
+ boot imports it, so this resolves to the same preloaded module. %>
174
+ <%= javascript_import_module_tag "studio/stimulus" %>
170
175
  <%# THE STORES, BY THEIR OWN DOOR. Every host's <body> binds $store.devMode and the
171
176
  theme toggle binds $store.theme, so a boot that fails to load (a missing digest,
172
177
  a network drop, a throw anywhere in its graph) must not take the stores with it.
@@ -182,6 +187,17 @@
182
187
  it. It imports nothing, and the modal-host controller imports it, so this
183
188
  resolves to the same preloaded module. %>
184
189
  <%= javascript_import_module_tag "studio/modal_host" %>
190
+ <%# THE TOAST QUEUE, BY ITS OWN DOOR, for the same reason. studio/toast registers
191
+ $store.toasts on alpine:init, listens for the `toast` window event and seeds
192
+ the flash layouts/studio/_flash renders, so a failed boot leaves a page its
193
+ flash and its toasts. It imports nothing, and the toast controller imports it. %>
194
+ <%= javascript_import_module_tag "studio/toast" %>
195
+ <%# THE LINK SIDEBAR'S FLAG, BY ITS OWN DOOR, for the same reason.
196
+ components/_link_sidebar binds $store.sidebars.linkTreeOpen; studio/link_sidebar
197
+ registers it on alpine:init for a page that renders the panel, and installs
198
+ the document click and Escape handlers. It imports nothing, and the
199
+ link-sidebar controller imports it. %>
200
+ <%= javascript_import_module_tag "studio/link_sidebar" %>
185
201
  <%# ALPINE LOADS AFTER THE MODULE TAGS, and the order is the contract. Deferred
186
202
  classic scripts and module scripts execute in document order, so by the time
187
203
  Alpine starts, studio/application has defined the shims it evaluates
@@ -190,8 +206,8 @@
190
206
  studio/modal_host the one that installs each modal host's store. Moved
191
207
  above the module tags, Alpine would evaluate those before they exist (e2e/stimulus_boot.spec.js). %>
192
208
  <%# Alpine is VENDORED into the engine (studio/alpine.js) and shipped through the
193
- asset pipeline — no CDN, CSP-safe (same-origin :self), mirroring canvas_confetti
194
- and sortable below. PINNED AT 3.16.1.
209
+ asset pipeline — no CDN, CSP-safe (same-origin :self), mirroring canvas_confetti.
210
+ PINNED AT 3.16.1.
195
211
 
196
212
  WHAT THIS REPLACED, and why it is worth the 47KB in the repo: the tag here was
197
213