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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +183 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +57 -0
- data/app/assets/tailwind/studio_engine/engine.css +8 -0
- data/app/javascript/studio/alpine_shims.js +23 -0
- data/app/javascript/studio/application.js +9 -21
- data/app/javascript/studio/board.js +593 -0
- data/app/javascript/studio/booking.js +176 -0
- data/app/javascript/studio/controllers/board_controller.js +41 -0
- data/app/javascript/studio/controllers/booking_controller.js +24 -0
- data/app/javascript/studio/controllers/email_banner_scale_controller.js +22 -0
- data/app/javascript/studio/controllers/footer_map_controller.js +22 -0
- data/app/javascript/studio/controllers/geo_settings_controller.js +24 -0
- data/app/javascript/studio/controllers/hold_button_controller.js +64 -0
- data/app/javascript/studio/controllers/link_preview_card_controller.js +25 -0
- data/app/javascript/studio/controllers/link_sidebar_controller.js +18 -0
- data/app/javascript/studio/controllers/survey_controller.js +18 -0
- data/app/javascript/studio/controllers/toast_controller.js +26 -0
- data/app/javascript/studio/email_banner.js +277 -0
- data/app/javascript/studio/email_banner_scale.js +60 -0
- data/app/javascript/studio/footer_map.js +143 -0
- data/app/javascript/studio/geo_settings.js +105 -0
- data/app/javascript/studio/hold_button.js +416 -0
- data/app/javascript/studio/hold_button_hooks.js +162 -0
- data/app/javascript/studio/lazy_controllers.js +138 -0
- data/app/javascript/studio/leveling_activity.js +252 -0
- data/app/javascript/studio/link_preview_card.js +34 -0
- data/app/javascript/studio/link_sidebar.js +157 -0
- data/app/javascript/studio/stimulus.js +72 -0
- data/app/javascript/studio/survey.js +316 -0
- data/app/javascript/studio/theme_editor.js +197 -0
- data/app/javascript/studio/toast.js +270 -0
- data/app/views/components/_link_sidebar.html.erb +5 -100
- data/app/views/components/_link_sidebar_trigger.html.erb +2 -2
- data/app/views/layouts/studio/_flash.html.erb +13 -118
- data/app/views/layouts/studio/_head.html.erb +30 -14
- data/app/views/studio/_board_assets.html.erb +9 -452
- data/app/views/studio/_hold_button.html.erb +38 -445
- data/app/views/studio/_hold_button_guard.html.erb +172 -0
- data/app/views/studio/_leveling_activity_assets.html.erb +9 -283
- data/app/views/studio/board/_board.html.erb +16 -5
- data/app/views/studio/booking/_assets.html.erb +5 -139
- data/app/views/studio/booking/_frame.html.erb +3 -3
- data/app/views/studio/booking/_popup.html.erb +4 -4
- data/app/views/studio/emails/_banner_editor.html.erb +4 -152
- data/app/views/studio/emails/_banner_scale.html.erb +5 -56
- data/app/views/studio/emails/index.html.erb +0 -1
- data/app/views/studio/geo_settings/edit.html.erb +1 -97
- data/app/views/studio/site_footer/_map.html.erb +7 -5
- data/app/views/studio/site_footer/_map_assets.html.erb +5 -129
- data/app/views/studio/site_identities/edit.html.erb +1 -24
- data/app/views/studio/surveys/show.html.erb +4 -5
- data/app/views/style/_tasks.html.erb +3 -8
- data/app/views/style/_theme.html.erb +2 -52
- data/app/views/theme_settings/edit.html.erb +3 -82
- data/config/importmap.rb +6 -0
- data/docs/BOOKING.md +6 -5
- data/docs/SITE_FOOTER.md +16 -12
- data/lib/studio/version.rb +1 -1
- metadata +28 -3
- data/app/views/studio/emails/_recipient_repaint.html.erb +0 -122
- 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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
5
|
-
document-level capture
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
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
|
|
6
|
-
|
|
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
|
|
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
|
|
168
|
-
the pinned stack, and the Alpine shims. A module tag, so it
|
|
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
|
-
|
|
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
|
|