studio-engine 0.96.2 → 0.97.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.
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,593 @@
1
+ // studio/board: the board primitive (studio/board/_board): a kanban whose cards
2
+ // move between columns, and a depth chart whose cards rank inside one lane. One
3
+ // module drives both; a board reads its selectors and behaviour from its opts.
4
+ //
5
+ // <section x-data="studioBoard({...})" data-studio-controller="board">
6
+ //
7
+ // THREE PARTS, AND WHO LOADS EACH.
8
+ // - studioBoard(opts) is the Alpine scope: the toast list, the chrome state
9
+ // bag, the move and reorder requests, the counts. Alpine evaluates the
10
+ // x-data before any lazy module can arrive, so studio/alpine_shims imports
11
+ // this module and publishes the factory as window.studioBoard.
12
+ // - The board controller (studio/controllers/board_controller) is registered
13
+ // lazily, on a page that renders a board. It loads SortableJS and hands it
14
+ // to wire(), which makes the zones draggable and marks the board ready.
15
+ // - loadSortable() fetches the vendored SortableJS through the "sortablejs"
16
+ // pin, once, on the first board or the first Sortable.create a page makes.
17
+ //
18
+ // UI ONLY. A board PATCHes an app-supplied move URL, POSTs an app-supplied
19
+ // reorder URL and reads a neutral JSON contract; it holds no domain logic. An
20
+ // app extends it by listening for the window events it dispatches
21
+ // (studio:board-moved, studio:board-reordered, studio:card-added), by naming a
22
+ // window[fx] animation module, and by naming window[hook] functions. The
23
+ // engine inspects none of them.
24
+ //
25
+ // It imports nothing; test/javascript/board.test.mjs loads it as a data: module.
26
+
27
+ const CONTROLLER = "board"
28
+ const CONTROLLER_ATTRIBUTE = "data-studio-controller"
29
+
30
+ // ---- the opts a board is rendered with ------------------------------------
31
+
32
+ // The board's settings, from the opts studio/board/_board serialises.
33
+ // group false keeps a drag inside its zone (the depth chart).
34
+ // archiveZone the column a data-exit-action="archive" remove re-parents
35
+ // its card into; null removes the card.
36
+ // lockedSelector pinned cards: they do not drag and no drag crosses one.
37
+ export function boardConfig(opts) {
38
+ opts = opts || {}
39
+ return {
40
+ zoneSelector: opts.zoneSelector || ".kanban-dropzone",
41
+ cardSelector: opts.draggable || ".kanban-card",
42
+ emptySelector: opts.emptySelector || null,
43
+ groupName: opts.group === false ? null : (opts.group || "studio-board"),
44
+ handle: opts.handle || null,
45
+ sortFilter: opts.filter || ".kanban-empty",
46
+ idAttr: opts.idAttr || "slug",
47
+ zoneAttr: opts.zoneAttr || "stage",
48
+ domIdPrefix: opts.domIdPrefix || "card-",
49
+ moveUrl: opts.moveUrl || null,
50
+ moveParam: opts.moveParam || null,
51
+ reorderUrl: opts.reorderUrl || "",
52
+ reorderPayload: opts.reorderPayload || "slugs",
53
+ optimistic: opts.optimistic !== false,
54
+ toastsEnabled: opts.toasts !== false,
55
+ live: !!opts.live,
56
+ demo: !!opts.demo,
57
+ fxName: opts.fx || null,
58
+ onMoveHook: opts.onMoveHook || null,
59
+ onDropHook: opts.onDropHook || null,
60
+ labels: opts.labels || {},
61
+ archiveZone: opts.archiveZone || null,
62
+ lockedSelector: opts.lockedSelector || null
63
+ }
64
+ }
65
+
66
+ // ---- the drop: a lane move, or a rank inside a lane ------------------------
67
+
68
+ // A drop moves a card only when it lands in another zone. The zones decide,
69
+ // never the card: a card dropped back into its own zone is a reorder, and a
70
+ // reorder never PATCHes the zone.
71
+ export function isLaneMove(fromZone, toZone) {
72
+ return fromZone !== toZone
73
+ }
74
+
75
+ // The ids of a zone's cards in DOM order: the rank a reorder saves.
76
+ export function rankedIds(zone, cardSelector, idAttr) {
77
+ return Array.prototype.slice
78
+ .call(zone.querySelectorAll(cardSelector))
79
+ .map((card) => card.getAttribute("data-" + idAttr))
80
+ }
81
+
82
+ // The reorder POST body: the ranked ids under the app's key (slugs or ids),
83
+ // with the zone they were ranked in.
84
+ export function reorderBody(payloadKey, ids, zone) {
85
+ const body = {}
86
+ body[payloadKey] = ids
87
+ body.zone = zone
88
+ return body
89
+ }
90
+
91
+ // The move PATCH: ":id" in the URL template becomes the card's id, and the
92
+ // body is { resource: { attr: zone } }. Null when the board has no move
93
+ // endpoint, which is how a reorder-only board refuses a move.
94
+ export function moveRequest(moveUrl, moveParam, id, zone) {
95
+ if (!moveUrl || !moveParam) return null
96
+
97
+ const body = {}
98
+ body[moveParam.resource] = {}
99
+ body[moveParam.resource][moveParam.attr] = zone
100
+ return { url: moveUrl.replace(":id", encodeURIComponent(id)), body }
101
+ }
102
+
103
+ // What SortableJS is created with for one zone. No group keeps a drag inside
104
+ // its zone. A locked card joins the undraggable filter, and onMove refuses a
105
+ // drag that would cross one, so a pinned card keeps its slot.
106
+ export function sortableOptions(config, { onStart, onEnd }) {
107
+ const locked = config.lockedSelector
108
+ const options = {
109
+ animation: 150,
110
+ ghostClass: "opacity-30",
111
+ draggable: config.cardSelector,
112
+ filter: locked ? `${config.sortFilter}, ${locked}` : config.sortFilter,
113
+ onStart,
114
+ onEnd
115
+ }
116
+ if (config.groupName) options.group = config.groupName
117
+ if (config.handle) options.handle = config.handle
118
+ if (locked) {
119
+ options.onMove = (event) => !(event.related && event.related.matches && event.related.matches(locked))
120
+ }
121
+ return options
122
+ }
123
+
124
+ // ---- a card leaving on a Turbo `remove` stream -----------------------------
125
+
126
+ // What a `remove` stream does to a card:
127
+ // "ignore" not this board's stream: another action, another dom id
128
+ // prefix, or no data-exit-action. Turbo renders it untouched.
129
+ // "reparent" an archive exit on a board whose archive column is on the
130
+ // page: the card moves into that column and stays.
131
+ // "remove" the card animates out and Turbo removes it.
132
+ export function exitPlan({ action, target, exitAction, domIdPrefix, archiveZone, archiveZonePresent }) {
133
+ if (action !== "remove") return "ignore"
134
+ if (!exitAction || String(target || "").indexOf(domIdPrefix) !== 0) return "ignore"
135
+ if (exitAction === "archive" && archiveZone && archiveZonePresent) return "reparent"
136
+ return "remove"
137
+ }
138
+
139
+ // The exit animation: an archive sinks and shrinks, a delete shrinks.
140
+ export function exitAnimation(kind) {
141
+ const archive = kind === "archive"
142
+ return {
143
+ frames: archive
144
+ ? [{ opacity: 1, transform: "translateY(0) scale(1)" }, { opacity: 0, transform: "translateY(18px) scale(0.86)" }]
145
+ : [{ opacity: 1, transform: "scale(1)" }, { opacity: 0, transform: "scale(0.6)" }],
146
+ timing: { duration: archive ? 500 : 420, easing: "cubic-bezier(.35,0,.2,1)", fill: "forwards" }
147
+ }
148
+ }
149
+
150
+ // ---- SortableJS, fetched once ----------------------------------------------
151
+
152
+ // SortableJS itself is a constructor. window.Sortable can also hold the
153
+ // loading shim below, which is a plain object.
154
+ export function isSortable(candidate) {
155
+ return typeof candidate === "function" && typeof candidate.create === "function"
156
+ }
157
+
158
+ let sortableLoad = null
159
+
160
+ // Resolves with SortableJS. The vendored build is a classic script that assigns
161
+ // window.Sortable; a host that pins its own "sortablejs" may export it instead.
162
+ // A failed load is forgotten, so the next board tries again.
163
+ export function loadSortable({ win = window, importer = () => import("sortablejs") } = {}) {
164
+ if (isSortable(win.Sortable)) return Promise.resolve(win.Sortable)
165
+ if (sortableLoad) return sortableLoad
166
+
167
+ sortableLoad = Promise.resolve()
168
+ .then(importer)
169
+ .then((module) => {
170
+ const loaded = [module && module.default, module && module.Sortable, win.Sortable].find(isSortable)
171
+ if (!loaded) throw new Error("SortableJS loaded without defining Sortable")
172
+ if (!isSortable(win.Sortable)) win.Sortable = loaded
173
+ return loaded
174
+ })
175
+ .catch((error) => {
176
+ sortableLoad = null
177
+ throw error
178
+ })
179
+ return sortableLoad
180
+ }
181
+
182
+ // window.Sortable until SortableJS is on the page: a page script that calls
183
+ // Sortable.create(el, options) loads the library with that call, and the
184
+ // sortable is created when it arrives. SortableJS replaces this object when it
185
+ // loads. create() returns nothing; a caller that needs the instance awaits
186
+ // load(), which resolves with SortableJS.
187
+ export function sortableShim(load, report) {
188
+ const complain = report || ((error) => console.error("[studio] SortableJS failed to load", error))
189
+ return {
190
+ studioShim: true,
191
+ load,
192
+ create(element, options) {
193
+ load().then((Sortable) => Sortable.create(element, options)).catch(complain)
194
+ }
195
+ }
196
+ }
197
+
198
+ // ---- the scope and its controller find each other --------------------------
199
+
200
+ // A data-studio-controller value that names the board controller once.
201
+ export function withBoardController(value) {
202
+ const names = String(value || "").split(/\s+/).filter(Boolean)
203
+ if (names.indexOf(CONTROLLER) === -1) names.push(CONTROLLER)
204
+ return names.join(" ")
205
+ }
206
+
207
+ const scopes = new WeakMap()
208
+ const waiting = new WeakMap()
209
+
210
+ // The factory's init announces its scope for the element it sits on.
211
+ export function announceScope(element, scope) {
212
+ scopes.set(element, scope)
213
+ const resolvers = waiting.get(element) || []
214
+ waiting.delete(element)
215
+ resolvers.forEach((resolve) => resolve(scope))
216
+ }
217
+
218
+ // Resolves with the element's board scope, now or when Alpine initialises it.
219
+ // The controller may connect before or after Alpine reaches the element.
220
+ export function scopeFor(element) {
221
+ if (scopes.has(element)) return Promise.resolve(scopes.get(element))
222
+ return new Promise((resolve) => {
223
+ waiting.set(element, (waiting.get(element) || []).concat(resolve))
224
+ })
225
+ }
226
+
227
+ // ---- the Alpine scope ------------------------------------------------------
228
+
229
+ // x-data="studioBoard({...})". Every name on the returned object is one a
230
+ // consumer's markup or script may read: a board's header slot binds `state`
231
+ // and the three state helpers, and a host reaches toast(), updateCounts() and
232
+ // animateCardExit() through Alpine.$data.
233
+ export function studioBoard(opts, { win = window, doc = document } = {}) {
234
+ opts = opts || {}
235
+ return {
236
+ ...boardConfig(opts),
237
+
238
+ toasts: [],
239
+ // Board-level chrome state: a header slot's controls and a column's
240
+ // show_expr bind to it, so a board with filters needs no wrapper component.
241
+ state: (opts.state && typeof opts.state === "object") ? opts.state : {},
242
+
243
+ // Alpine runs this while it is still walking the subtree. It only makes the
244
+ // scope findable: the board controller wires the zones (wire, below).
245
+ init() {
246
+ const element = this.$el
247
+ const named = withBoardController(element.getAttribute(CONTROLLER_ATTRIBUTE))
248
+ if (element.getAttribute(CONTROLLER_ATTRIBUTE) !== named) element.setAttribute(CONTROLLER_ATTRIBUTE, named)
249
+ announceScope(element, this)
250
+ },
251
+
252
+ // Makes the board interactive: the zones become sortable, a live board
253
+ // starts watching its zones, and the element is marked ready. `Sortable` is
254
+ // null when the library failed to load; the board then still renders, counts
255
+ // and follows live updates.
256
+ //
257
+ // data-alpine-ready is set after the next tick, so it means "the directives
258
+ // are bound and the zones drag". A test waits on it before it drags.
259
+ //
260
+ // Returns the function that undoes it.
261
+ wire(Sortable) {
262
+ const element = this.$el
263
+ const sortables = this.initSortables(Sortable)
264
+ const observers = this.live ? this.observeLive() : []
265
+ if (this.live && !this.fx()) this.installExitStreamFallback()
266
+
267
+ let wired = true
268
+ this.$nextTick(() => { if (wired) element.dataset.alpineReady = "true" })
269
+
270
+ return () => {
271
+ wired = false
272
+ delete element.dataset.alpineReady
273
+ sortables.forEach((sortable) => { if (sortable && sortable.destroy) sortable.destroy() })
274
+ observers.forEach((observer) => observer.disconnect())
275
+ }
276
+ },
277
+
278
+ // This board's own zones. A page may hold several boards, and each wires,
279
+ // watches and counts only the zones inside its element.
280
+ zones() {
281
+ return Array.prototype.slice.call((this.$el || doc).querySelectorAll(this.zoneSelector))
282
+ },
283
+
284
+ fx() { return this.fxName ? win[this.fxName] : null },
285
+ cardId(el) { return el ? el.getAttribute("data-" + this.idAttr) : null },
286
+ zoneKey(el) { return el ? el.getAttribute("data-" + this.zoneAttr) : null },
287
+ label(zone) { return this.labels[zone] || zone },
288
+
289
+ // ---- chrome state helpers --------------------------------------------
290
+ toggleState(key) { this.state[key] = !this.state[key] },
291
+ listHas(key, value) {
292
+ return Array.isArray(this.state[key]) && this.state[key].indexOf(value) !== -1
293
+ },
294
+ toggleInState(key, value) {
295
+ if (!Array.isArray(this.state[key])) this.state[key] = []
296
+ const index = this.state[key].indexOf(value)
297
+ if (index === -1) { this.state[key].push(value) } else { this.state[key].splice(index, 1) }
298
+ },
299
+
300
+ // ---- SortableJS --------------------------------------------------------
301
+ // One sortable per zone. Returns them, so wire() can destroy them.
302
+ initSortables(Sortable) {
303
+ Sortable = Sortable || win.Sortable
304
+ if (!isSortable(Sortable)) return []
305
+
306
+ const options = sortableOptions(this, {
307
+ onStart: () => { win.__studioBoardDragging = true },
308
+ onEnd: (event) => {
309
+ this.handleSortEnd(event)
310
+ win.requestAnimationFrame(() => { win.__studioBoardDragging = false })
311
+ }
312
+ })
313
+ return this.zones().map((zone) => Sortable.create(zone, { ...options }))
314
+ },
315
+
316
+ // A drop in another zone moves the card and then saves that zone's order. A
317
+ // drop in the same zone only saves the order. A refused move is reverted
318
+ // and saves nothing.
319
+ handleSortEnd(event) {
320
+ const card = event.item
321
+ const fromZone = event.from
322
+ const toZone = event.to
323
+ const moved = isLaneMove(fromZone, toZone)
324
+
325
+ const afterMove = (ok) => {
326
+ if (moved && !ok) return
327
+ this.saveOrder(toZone)
328
+ this.updateCounts()
329
+ }
330
+
331
+ if (moved) {
332
+ Promise.resolve(this.applyMove(card, fromZone, toZone, this.cardId(card))).then(afterMove)
333
+ } else {
334
+ afterMove(true)
335
+ }
336
+ },
337
+
338
+ // A move between zones. Resolves true when the card stays in its new zone.
339
+ applyMove(card, fromZone, toZone, id) {
340
+ const newZone = this.zoneKey(toZone)
341
+ const fromKey = this.zoneKey(fromZone)
342
+ const request = moveRequest(this.moveUrl, this.moveParam, id, newZone)
343
+
344
+ // No move endpoint: a demo board still reflects the move, and any other
345
+ // board snaps the card back, so a stray drag cannot mismove it.
346
+ if (!request) {
347
+ if (this.demo) {
348
+ this.setCardZone(card, newZone)
349
+ this.emit("board-moved", id, fromKey, newZone)
350
+ return true
351
+ }
352
+ this.revert(card, fromZone)
353
+ return false
354
+ }
355
+
356
+ const landed = () => {
357
+ this.setCardZone(card, newZone)
358
+ this.toast("Moved to " + this.label(newZone), "success")
359
+ this.emit("board-moved", id, fromKey, newZone)
360
+ this.hook(this.onMoveHook, { record: id, from: fromKey, to: newZone })
361
+ return true
362
+ }
363
+
364
+ if (this.demo) return landed()
365
+
366
+ // request() rejects on a refusal with the server's own message, so the
367
+ // catch is the one failure path.
368
+ return this.request(request.url, "PATCH", request.body).then(landed).catch((error) => {
369
+ if (this.optimistic) this.revert(card, fromZone)
370
+ this.toast((error && error.message) || "Move failed", "error")
371
+ this.updateCounts()
372
+ return false
373
+ })
374
+ },
375
+
376
+ // Saves a zone's order: POSTs its ranked ids under the app's key.
377
+ //
378
+ // A REFUSAL IS SHOWN. request() rejects on a 4xx and the catch toasts what
379
+ // the server said ("That order does not match this week's games — reload
380
+ // and try again").
381
+ //
382
+ // NOTHING IS REVERTED. The whole column re-ranked, and SortableJS hands
383
+ // over the drop with the order before it already gone. A reorder endpoint's
384
+ // refusal tells the operator to reload, which restores the stored order; the
385
+ // board's job is that the instruction is read.
386
+ //
387
+ // RESOLVES true or false and never rejects, so the call in handleSortEnd,
388
+ // which nothing awaits, cannot raise an unhandled rejection.
389
+ saveOrder(toZone) {
390
+ const ids = rankedIds(toZone, this.cardSelector, this.idAttr)
391
+ const zone = this.zoneKey(toZone)
392
+ this.emit("board-reordered", null, zone, zone, { ids, zone })
393
+ this.hook(this.onDropHook, { ids, zone })
394
+ if (this.demo || !this.reorderUrl) return Promise.resolve(true)
395
+
396
+ return this.request(this.reorderUrl, "POST", reorderBody(this.reorderPayload, ids, zone))
397
+ .then(() => true)
398
+ .catch((error) => {
399
+ this.toast((error && error.message) || "Order save failed — reload and try again.", "error")
400
+ return false
401
+ })
402
+ },
403
+
404
+ setCardZone(card, zone) { card.setAttribute("data-" + this.zoneAttr, zone) },
405
+
406
+ // A refused move: the card goes back into the zone it left, above the empty
407
+ // state, and flashes a red ring.
408
+ revert(card, fromZone) {
409
+ const anchor = this.emptySelector ? fromZone.querySelector(this.emptySelector) : null
410
+ fromZone.insertBefore(card, anchor)
411
+ card.classList.add("ring-2", "ring-red-500")
412
+ setTimeout(() => { card.classList.remove("ring-2", "ring-red-500") }, 1500)
413
+ },
414
+
415
+ // A host may wrap this on its own scope (the hub's task board adds the
416
+ // cards a capped column keeps off the page).
417
+ updateCounts() {
418
+ const zones = {}
419
+ this.zones().forEach((zone) => { zones[zone.id] = zone })
420
+
421
+ const badges = (this.$el || doc).querySelectorAll("[data-board-count]")
422
+ badges.forEach((badge) => {
423
+ const zone = zones["dropzone-" + badge.getAttribute("data-board-count")]
424
+ if (!zone) return
425
+ const count = zone.querySelectorAll(this.cardSelector).length
426
+ badge.textContent = count
427
+ if (this.emptySelector) {
428
+ const empty = zone.querySelector(this.emptySelector)
429
+ if (empty) empty.style.display = count === 0 ? "flex" : "none"
430
+ }
431
+ })
432
+ },
433
+
434
+ // ---- live updates (Turbo Streams) --------------------------------------
435
+ // The board subscribes with turbo_stream_from, and Turbo patches the DOM on
436
+ // every broadcast. These observers add what Turbo does not: an entrance on
437
+ // each card it patches in (window[fx].onAdd when the app names one) and a
438
+ // recount after any change to a zone. Returns the observers.
439
+ observeLive() {
440
+ const onChange = (mutations) => {
441
+ mutations.forEach((mutation) => {
442
+ mutation.addedNodes.forEach((node) => {
443
+ if (node.nodeType !== 1 || !node.matches || !node.matches(this.cardSelector)) return
444
+ const fx = this.fx()
445
+ if (fx && fx.onAdd) { fx.onAdd(node) } else { this.animateIn(node) }
446
+ this.emit("card-added", this.cardId(node), null, this.zoneKey(node.parentElement))
447
+ })
448
+ })
449
+ this.updateCounts()
450
+ }
451
+ return this.zones().map((zone) => {
452
+ const observer = new win.MutationObserver(onChange)
453
+ observer.observe(zone, { childList: true })
454
+ return observer
455
+ })
456
+ },
457
+
458
+ animateIn(el) {
459
+ el.style.opacity = "0"
460
+ el.style.transform = "scale(0.96)"
461
+ el.classList.add("ring-2", "ring-primary")
462
+ win.requestAnimationFrame(() => {
463
+ el.style.transition = "opacity 300ms ease, transform 300ms ease"
464
+ el.style.opacity = ""
465
+ el.style.transform = ""
466
+ })
467
+ setTimeout(() => { el.classList.remove("ring-2", "ring-primary"); el.style.transition = "" }, 900)
468
+ },
469
+
470
+ // A Turbo `remove` stream carrying data-exit-action animates its card out
471
+ // (exitPlan says which way). Installed once per page, by the first live
472
+ // board with no fx module; a board with one leaves exits to it.
473
+ installExitStreamFallback() {
474
+ if (win.__studioBoardExitFallback) return
475
+ win.__studioBoardExitFallback = true
476
+
477
+ doc.addEventListener("turbo:before-stream-render", (event) => {
478
+ const stream = event.target
479
+ const target = stream.getAttribute("target") || ""
480
+ const exitAction = stream.dataset.exitAction
481
+ const archive = this.archiveZone ? doc.getElementById("dropzone-" + this.archiveZone) : null
482
+ const plan = exitPlan({
483
+ action: stream.getAttribute("action"),
484
+ target,
485
+ exitAction,
486
+ domIdPrefix: this.domIdPrefix,
487
+ archiveZone: this.archiveZone,
488
+ archiveZonePresent: !!archive
489
+ })
490
+ if (plan === "ignore") return
491
+
492
+ const renderNow = event.detail.render
493
+ event.detail.render = (streamElement) => {
494
+ const card = doc.getElementById(target)
495
+ if (!card) { renderNow(streamElement); return }
496
+
497
+ this.animateCardExit(card, exitAction).then(() => {
498
+ if (plan === "reparent") {
499
+ const anchor = this.emptySelector ? archive.querySelector(this.emptySelector) : null
500
+ archive.insertBefore(card, anchor)
501
+ this.setCardZone(card, this.archiveZone)
502
+ this.resetCardExit(card)
503
+ } else {
504
+ renderNow(streamElement)
505
+ }
506
+ this.updateCounts()
507
+ })
508
+ }
509
+ })
510
+ },
511
+
512
+ // Marks which exit is running (data-exit-action) before anything else, so
513
+ // the marker is there even when reduced motion skips the animation.
514
+ animateCardExit(card, kind) {
515
+ if (!card) return Promise.resolve()
516
+ card.dataset.exitAction = kind
517
+ if (win.matchMedia("(prefers-reduced-motion: reduce)").matches) return Promise.resolve()
518
+
519
+ card.style.pointerEvents = "none"
520
+ const { frames, timing } = exitAnimation(kind)
521
+ return card.animate(frames, timing).finished.catch(() => {})
522
+ },
523
+
524
+ // Clears an exit animation and its marker, so a re-parented card is fully
525
+ // visible in the archive column.
526
+ resetCardExit(card) {
527
+ if (!card) return
528
+ card.getAnimations({ subtree: false }).forEach((animation) => animation.cancel())
529
+ delete card.dataset.exitAction
530
+ card.style.pointerEvents = ""
531
+ card.style.opacity = ""
532
+ card.style.transform = ""
533
+ card.style.filter = ""
534
+ },
535
+
536
+ // ---- toasts ------------------------------------------------------------
537
+ // A board rendered with toasts: false alerts an error instead, so a refusal
538
+ // still reaches the operator.
539
+ toast(message, type) {
540
+ if (!this.toastsEnabled) {
541
+ if (type === "error" && typeof win.alert === "function") win.alert(message)
542
+ return
543
+ }
544
+ const id = Date.now() + Math.random()
545
+ this.toasts.push({ id, message, type, visible: true })
546
+ setTimeout(() => {
547
+ const toast = this.toasts.find((entry) => entry.id === id)
548
+ if (toast) toast.visible = false
549
+ setTimeout(() => { this.toasts = this.toasts.filter((entry) => entry.id !== id) }, 300)
550
+ }, 3000)
551
+ },
552
+
553
+ // ---- the one HTTP seam -------------------------------------------------
554
+ // REJECTS ON A SERVER REFUSAL, which neither fetcher underneath does:
555
+ // - window.fetch resolves on a 4xx or 5xx, so a bare catch sees a network
556
+ // failure and nothing else;
557
+ // - window.authedFetch (turf-monster's, used when the host defines it)
558
+ // resolves with the response for anything but a 401, and with null on
559
+ // an expired session or a rate-limited tier.
560
+ // Both are normalised here, so a caller ignores a refusal only by saying so
561
+ // in its own catch. The message is the server's own { error: "…" }, which
562
+ // every board endpoint writes for the operator to read.
563
+ request(url, method, body) {
564
+ const csrf = (doc.querySelector('meta[name="csrf-token"]') || {}).content || ""
565
+ const fetcher = win.authedFetch || win.fetch
566
+ return fetcher(url, {
567
+ method,
568
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
569
+ body: JSON.stringify(body || {})
570
+ }).then((response) => {
571
+ if (!response) throw new Error("Session expired — please sign in again.")
572
+ if (response.ok) return response
573
+ return response.json().catch(() => ({})).then((refusal) => {
574
+ throw new Error(refusal.error || ("Failed (" + response.status + ")"))
575
+ })
576
+ })
577
+ },
578
+
579
+ // The extension seam: an app listens for these on window.
580
+ emit(name, record, from, to, extra) {
581
+ try {
582
+ const detail = Object.assign({ record, from, to }, extra || {})
583
+ win.dispatchEvent(new win.CustomEvent("studio:" + name, { detail }))
584
+ } catch (error) {}
585
+ },
586
+
587
+ hook(hookName, detail) {
588
+ if (!hookName) return
589
+ const fn = win[hookName]
590
+ if (typeof fn === "function") { try { fn(detail) } catch (error) {} }
591
+ }
592
+ }
593
+ }