studio-engine 0.22.0 → 0.24.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.
@@ -0,0 +1,15 @@
1
+ <%# Smooth-load convention (opt-in via Studio.smooth_load) — the next page
2
+ materializes behind the current one, then presents itself. Turbo 8 sees the
3
+ view-transition meta and wraps its body swap in document.startViewTransition;
4
+ browsers without support get the instant swap. no-preview stops application
5
+ visits from painting a stale cache snapshot before the fresh response — the
6
+ current page holds until the new one is ready, so navigation renders exactly
7
+ once. Restoration visits (back/forward) still restore instantly from cache.
8
+ Transition styling ships in engine.css ("Smooth-load convention" block);
9
+ pin an app's sticky header with the .vt-pinned-header utility (exactly one
10
+ element per page — a duplicate view-transition-name silently disables the
11
+ whole transition). %>
12
+ <% if Studio.smooth_load %>
13
+ <meta name="view-transition" content="same-origin">
14
+ <meta name="turbo-cache-control" content="no-preview">
15
+ <% end %>
@@ -0,0 +1,341 @@
1
+ <%#
2
+ studioBoard factory — the Alpine data behind the engine board primitive
3
+ (studio/board/_board). Ships here at PAGE level on purpose: a <script> inside the
4
+ board component template would be cloned/re-parsed and never run, so a consumer
5
+ renders THIS once in its layout (like studio/_leveling_activity_assets homes
6
+ levelingActionModal and studio/_age_verify_assets homes ageVerifyModal), and the
7
+ board anywhere below just references window.studioBoard. SortableJS is loaded
8
+ globally by layouts/studio/_head (vendored, no CDN); this factory drives it.
9
+
10
+ ONE factory drives BOTH board shapes — it reads its selectors and behavior from
11
+ opts, so the kanban cross-column board (group + move PATCH + toasts + counts) and
12
+ the within-lane depth chart (group:false, a drag handle, reorder-only) are the
13
+ same code with different opts. See studio/board/_board for the full opts contract.
14
+
15
+ CRITICAL BOUNDARY — this factory is UI ONLY. It PATCHes an app-supplied move_url,
16
+ POSTs an app-supplied reorder_url, and reacts to a neutral JSON contract; it holds
17
+ ZERO domain logic. Custom behavior attaches by LISTENING to the dispatched
18
+ window events (studio:board-moved / :board-reordered / :card-added), and heavier
19
+ animation is delegated OPAQUELY to a window[fx] module + window[hook] fns the
20
+ engine never inspects.
21
+ %>
22
+ <script>
23
+ (function () {
24
+ if (window.studioBoard) return;
25
+
26
+ window.studioBoard = function (opts) {
27
+ opts = opts || {};
28
+ return {
29
+ // --- config (from opts) --------------------------------------------
30
+ zoneSelector: opts.zoneSelector || ".kanban-dropzone",
31
+ cardSelector: opts.draggable || ".kanban-card",
32
+ emptySelector: opts.emptySelector || null,
33
+ groupName: (opts.group === false ? null : (opts.group || "studio-board")),
34
+ handle: opts.handle || null,
35
+ sortFilter: opts.filter || ".kanban-empty",
36
+ idAttr: opts.idAttr || "slug",
37
+ zoneAttr: opts.zoneAttr || "stage",
38
+ domIdPrefix: opts.domIdPrefix || "card-",
39
+ moveUrl: opts.moveUrl || null,
40
+ moveParam: opts.moveParam || null,
41
+ reorderUrl: opts.reorderUrl || "",
42
+ reorderPayload: opts.reorderPayload || "slugs",
43
+ optimistic: opts.optimistic !== false,
44
+ toastsEnabled: opts.toasts !== false,
45
+ live: !!opts.live,
46
+ demo: !!opts.demo,
47
+ fxName: opts.fx || null,
48
+ onMoveHook: opts.onMoveHook || null,
49
+ onDropHook: opts.onDropHook || null,
50
+ labels: opts.labels || {},
51
+
52
+ // --- state ---------------------------------------------------------
53
+ toasts: [],
54
+
55
+ // --- init ----------------------------------------------------------
56
+ init: function () {
57
+ // Set data-alpine-ready in $nextTick, NOT synchronously: x-init runs while
58
+ // Alpine is still walking this subtree, so inner @click handlers are not
59
+ // bound yet. $nextTick fires after the directive walk, so the flag then
60
+ // means "the board is fully wired and interactive" — the documented init
61
+ // flake fix carried over from the MS kanbanBoard.
62
+ var self = this;
63
+ this.$nextTick(function () {
64
+ self.$el.dataset.alpineReady = "true";
65
+ self.initSortables();
66
+ if (self.live) {
67
+ self.observeLive();
68
+ if (!self.fx()) self.installExitStreamFallback();
69
+ }
70
+ });
71
+ },
72
+
73
+ fx: function () { return this.fxName ? window[this.fxName] : null; },
74
+ cardId: function (el) { return el ? el.getAttribute("data-" + this.idAttr) : null; },
75
+ zoneKey: function (el) { return el ? el.getAttribute("data-" + this.zoneAttr) : null; },
76
+ label: function (zone) { return this.labels[zone] || zone; },
77
+
78
+ // --- SortableJS wiring ---------------------------------------------
79
+ initSortables: function () {
80
+ if (typeof Sortable === "undefined") return;
81
+ var self = this;
82
+ document.querySelectorAll(this.zoneSelector).forEach(function (zone) {
83
+ var cfg = {
84
+ animation: 150,
85
+ ghostClass: "opacity-30",
86
+ draggable: self.cardSelector,
87
+ filter: self.sortFilter,
88
+ onStart: function () { window.__studioBoardDragging = true; },
89
+ onEnd: function (evt) {
90
+ self.handleSortEnd(evt);
91
+ requestAnimationFrame(function () { window.__studioBoardDragging = false; });
92
+ }
93
+ };
94
+ if (self.groupName) cfg.group = self.groupName; // omit ⇒ within-zone only
95
+ if (self.handle) cfg.handle = self.handle;
96
+ Sortable.create(zone, cfg);
97
+ });
98
+ },
99
+
100
+ // A cross-column drop moves the card (PATCH); a same-zone drop is a pure
101
+ // reorder and must NEVER PATCH the zone. Source the from/to from the
102
+ // DROPZONES (evt.from / evt.to), never the card, so an in-place reorder
103
+ // can't mismove.
104
+ handleSortEnd: function (evt) {
105
+ var self = this;
106
+ var card = evt.item;
107
+ var fromZone = evt.from;
108
+ var toZone = evt.to;
109
+ var id = this.cardId(card);
110
+ var moved = fromZone !== toZone;
111
+
112
+ var afterMove = function (ok) {
113
+ if (moved && !ok) return; // reverted — leave the order alone
114
+ self.saveOrder(toZone);
115
+ self.updateCounts();
116
+ };
117
+
118
+ if (moved) {
119
+ Promise.resolve(this.applyMove(card, fromZone, toZone, id)).then(afterMove);
120
+ } else {
121
+ afterMove(true);
122
+ }
123
+ },
124
+
125
+ // Cross-column move: PATCH the zone attr; optimistic revert on failure.
126
+ applyMove: function (card, fromZone, toZone, id) {
127
+ var self = this;
128
+ var newZone = this.zoneKey(toZone);
129
+ var fromKey = this.zoneKey(fromZone);
130
+
131
+ // Moves not configured (or a within-zone board): a cross-zone drop is
132
+ // disallowed — snap back so a stray drag can't silently mismove. In demo
133
+ // mode with no move endpoint, still reflect the move locally.
134
+ if (!this.moveUrl || !this.moveParam) {
135
+ if (this.demo) {
136
+ this.setCardZone(card, newZone);
137
+ this.emit("board-moved", id, fromKey, newZone);
138
+ return true;
139
+ }
140
+ this.revert(card, fromZone);
141
+ return false;
142
+ }
143
+
144
+ if (this.demo) {
145
+ this.setCardZone(card, newZone);
146
+ this.toast("Moved to " + this.label(newZone), "success");
147
+ this.emit("board-moved", id, fromKey, newZone);
148
+ this.hook(this.onMoveHook, { record: id, from: fromKey, to: newZone });
149
+ return true;
150
+ }
151
+
152
+ var url = this.moveUrl.replace(":id", encodeURIComponent(id));
153
+ var body = {};
154
+ body[this.moveParam.resource] = {};
155
+ body[this.moveParam.resource][this.moveParam.attr] = newZone;
156
+
157
+ return this.request(url, "PATCH", body).then(function (resp) {
158
+ if (!resp.ok) {
159
+ return resp.json().catch(function () { return {}; }).then(function (err) {
160
+ throw new Error(err.error || ("Failed (" + resp.status + ")"));
161
+ });
162
+ }
163
+ self.setCardZone(card, newZone);
164
+ self.toast("Moved to " + self.label(newZone), "success");
165
+ self.emit("board-moved", id, fromKey, newZone);
166
+ self.hook(self.onMoveHook, { record: id, from: fromKey, to: newZone });
167
+ return true;
168
+ }).catch(function (e) {
169
+ if (self.optimistic) self.revert(card, fromZone);
170
+ self.toast((e && e.message) || "Move failed", "error");
171
+ self.updateCounts();
172
+ return false;
173
+ });
174
+ },
175
+
176
+ // Save the destination column's order. Reads the ordered id list and POSTs
177
+ // it under the neutral payload key (`slugs` | `ids`) plus the advisory zone.
178
+ saveOrder: function (toZone) {
179
+ var self = this;
180
+ var ids = Array.prototype.slice
181
+ .call(toZone.querySelectorAll(this.cardSelector))
182
+ .map(function (c) { return self.cardId(c); });
183
+ var zone = this.zoneKey(toZone);
184
+ this.emit("board-reordered", null, zone, zone, { ids: ids, zone: zone });
185
+ this.hook(this.onDropHook, { ids: ids, zone: zone });
186
+ if (this.demo || !this.reorderUrl) return;
187
+ var payload = {};
188
+ payload[this.reorderPayload] = ids;
189
+ payload.zone = zone;
190
+ this.request(this.reorderUrl, "POST", payload).catch(function () {
191
+ // Order save failed — positions are stale but the board stays functional.
192
+ });
193
+ },
194
+
195
+ setCardZone: function (card, zone) { card.setAttribute("data-" + this.zoneAttr, zone); },
196
+
197
+ // Revert a failed move: drop the card back into its SOURCE zone (which
198
+ // always exists) above the empty state, and flash a red ring.
199
+ revert: function (card, fromZone) {
200
+ var anchor = this.emptySelector ? fromZone.querySelector(this.emptySelector) : null;
201
+ fromZone.insertBefore(card, anchor);
202
+ card.classList.add("ring-2", "ring-red-500");
203
+ setTimeout(function () { card.classList.remove("ring-2", "ring-red-500"); }, 1500);
204
+ },
205
+
206
+ updateCounts: function () {
207
+ var self = this;
208
+ document.querySelectorAll("[data-board-count]").forEach(function (badge) {
209
+ var key = badge.getAttribute("data-board-count");
210
+ var zone = document.getElementById("dropzone-" + key);
211
+ if (!zone) return;
212
+ var count = zone.querySelectorAll(self.cardSelector).length;
213
+ badge.textContent = count;
214
+ if (self.emptySelector) {
215
+ var empty = zone.querySelector(self.emptySelector);
216
+ if (empty) empty.style.display = count === 0 ? "flex" : "none";
217
+ }
218
+ });
219
+ },
220
+
221
+ // --- live updates (Turbo Streams) ----------------------------------
222
+ // The board subscribes via turbo_stream_from(live_channel); Turbo patches the
223
+ // DOM on every broadcast. This observer adds the flourish Turbo doesn't: a
224
+ // fade/scale-in on each patched-in card (deferred to window[fx] when present)
225
+ // and a count refresh after any column change.
226
+ observeLive: function () {
227
+ var self = this;
228
+ var onChange = function (mutations) {
229
+ for (var i = 0; i < mutations.length; i++) {
230
+ mutations[i].addedNodes.forEach(function (node) {
231
+ if (node.nodeType === 1 && node.matches && node.matches(self.cardSelector)) {
232
+ var fx = self.fx();
233
+ if (fx && fx.onAdd) { fx.onAdd(node); } else { self.animateIn(node); }
234
+ self.emit("card-added", self.cardId(node), null, self.zoneKey(node.parentElement));
235
+ }
236
+ });
237
+ }
238
+ self.updateCounts();
239
+ };
240
+ document.querySelectorAll(this.zoneSelector).forEach(function (zone) {
241
+ new MutationObserver(onChange).observe(zone, { childList: true });
242
+ });
243
+ },
244
+
245
+ animateIn: function (el) {
246
+ el.style.opacity = "0";
247
+ el.style.transform = "scale(0.96)";
248
+ el.classList.add("ring-2", "ring-primary");
249
+ requestAnimationFrame(function () {
250
+ el.style.transition = "opacity 300ms ease, transform 300ms ease";
251
+ el.style.opacity = "";
252
+ el.style.transform = "";
253
+ });
254
+ setTimeout(function () { el.classList.remove("ring-2", "ring-primary"); el.style.transition = ""; }, 900);
255
+ },
256
+
257
+ // Archive re-parent / delete exit animation via a Turbo `remove` stream
258
+ // carrying data-exit-action. Installed only when no fx module owns exits.
259
+ installExitStreamFallback: function () {
260
+ if (window.__studioBoardExitFallback) return;
261
+ window.__studioBoardExitFallback = true;
262
+ var self = this;
263
+ document.addEventListener("turbo:before-stream-render", function (event) {
264
+ var stream = event.target;
265
+ if (stream.getAttribute("action") !== "remove") return;
266
+ var target = stream.getAttribute("target") || "";
267
+ var exitAction = stream.dataset.exitAction;
268
+ if (target.indexOf(self.domIdPrefix) !== 0 || !exitAction) return;
269
+ var renderNow = event.detail.render;
270
+ event.detail.render = function (el) {
271
+ var card = document.getElementById(target);
272
+ if (!card) { renderNow(el); return; }
273
+ self.animateCardExit(card, exitAction).then(function () {
274
+ renderNow(el);
275
+ self.updateCounts();
276
+ });
277
+ };
278
+ });
279
+ },
280
+
281
+ animateCardExit: function (card, kind) {
282
+ if (!card || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return Promise.resolve();
283
+ card.style.pointerEvents = "none";
284
+ var frames = kind === "archive"
285
+ ? [{ opacity: 1, transform: "translateY(0) scale(1)" }, { opacity: 0, transform: "translateY(18px) scale(0.86)" }]
286
+ : [{ opacity: 1, transform: "scale(1)" }, { opacity: 0, transform: "scale(0.6)" }];
287
+ var anim = card.animate(frames, {
288
+ duration: kind === "archive" ? 500 : 420,
289
+ easing: "cubic-bezier(.35,0,.2,1)",
290
+ fill: "forwards"
291
+ });
292
+ return anim.finished.catch(function () {});
293
+ },
294
+
295
+ // --- toasts --------------------------------------------------------
296
+ toast: function (message, type) {
297
+ if (!this.toastsEnabled) {
298
+ if (type === "error" && typeof window.alert === "function") window.alert(message);
299
+ return;
300
+ }
301
+ var self = this;
302
+ var id = Date.now() + Math.random();
303
+ this.toasts.push({ id: id, message: message, type: type, visible: true });
304
+ setTimeout(function () {
305
+ var t = self.toasts.find(function (x) { return x.id === id; });
306
+ if (t) t.visible = false;
307
+ setTimeout(function () {
308
+ self.toasts = self.toasts.filter(function (x) { return x.id !== id; });
309
+ }, 300);
310
+ }, 3000);
311
+ },
312
+
313
+ // --- helpers -------------------------------------------------------
314
+ request: function (url, method, body) {
315
+ var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
316
+ var fetcher = window.authedFetch || window.fetch;
317
+ return fetcher(url, {
318
+ method: method,
319
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
320
+ body: JSON.stringify(body || {})
321
+ });
322
+ },
323
+
324
+ // The primary extension seam — apps LISTEN for these on window and attach
325
+ // confetti / swipe / cart / analytics, never patching this factory.
326
+ emit: function (name, record, from, to, extra) {
327
+ try {
328
+ var detail = Object.assign({ record: record, from: from, to: to }, extra || {});
329
+ window.dispatchEvent(new CustomEvent("studio:" + name, { detail: detail }));
330
+ } catch (e) {}
331
+ },
332
+
333
+ hook: function (hookName, detail) {
334
+ if (!hookName) return;
335
+ var fn = window[hookName];
336
+ if (typeof fn === "function") { try { fn(detail); } catch (e) {} }
337
+ }
338
+ };
339
+ };
340
+ })();
341
+ </script>
@@ -0,0 +1,151 @@
1
+ <%#
2
+ studio/board — the shared kanban / depth-chart board shell. An ADDITIVE engine
3
+ primitive (Phase D): the SortableJS drag-reorder, optimistic cross-column move →
4
+ revert → toast, and count/live wiring that the three near-identical McRitchie
5
+ Studio boards (tasks / news / content) and the depth chart each hand-rolled,
6
+ lifted into one place. Renders a horizontal row of columns, each a drop zone the
7
+ vendored SortableJS + window.studioBoard factory (studio/_board_assets, rendered
8
+ once at layout level — a <script> inside this component template would never run)
9
+ wires.
10
+
11
+ UI ONLY and NEUTRAL: no model, no routes, no domain. The app supplies its columns,
12
+ its card partial, and its endpoints. Custom behavior attaches by LISTENING to the
13
+ DOM events the factory dispatches on window — `studio:board-moved`,
14
+ `studio:board-reordered`, `studio:card-added` (detail `{ record, from, to }`) —
15
+ never by patching the factory. Heavier per-app animation defers OPAQUELY to a
16
+ `fx:` module (window[<name>]) and to `on_move_hook:` / `on_drop_hook:` fns.
17
+
18
+ The identity contract every card + zone must satisfy is emitted by
19
+ studio/board/_column (the `.kanban-dropzone`) and studio/board/_card_shell (the
20
+ `.kanban-card`) — an app may hand-roll a bespoke card as long as it matches.
21
+
22
+ locals:
23
+ columns: (req) [{ key:, label:, cards:, count:, badge_class:,
24
+ collapsible:, hidden:, header_extra: }]. `cards` are records
25
+ passed to `card_partial`; `count` defaults to cards.size.
26
+ card_partial: (req) partial rendered per card (e.g. "tasks/task_card").
27
+ card_as: local the record binds to in card_partial. Default :card.
28
+ card_locals: per-card extra locals — a Proc ->(record){ Hash } or a Hash.
29
+ reorder_url: POST endpoint the drag-reorder saves the new order to.
30
+ reorder_payload: :slugs | :ids — the key the ordered id list POSTs under.
31
+ Default :slugs.
32
+ move_url: PATCH template for a cross-column move, ":id" → the card's id
33
+ (e.g. "/tasks/:id.json"). nil disallows moves (reorder only).
34
+ move_param: { resource:, attr: } → PATCH body { resource => { attr => zone } }.
35
+ nil disallows moves.
36
+ group: SortableJS group — a shared string allows cross-column drags;
37
+ false keeps drags within a zone (the depth-chart shape).
38
+ Default "studio-board".
39
+ id_attr: card id data-attr + dom-id suffix. Default "slug".
40
+ zone_attr: column/zone data-attr. Default "stage".
41
+ handle: drag-handle selector (nil = whole card draggable).
42
+ filter: SortableJS filter (undraggable els). Default ".kanban-empty".
43
+ zone_selector: the drop-zone selector. Default ".kanban-dropzone".
44
+ draggable: the card selector. Default ".kanban-card".
45
+ empty_selector: the empty-state selector (revert anchor + count toggle).
46
+ Default ".kanban-empty"; nil for a board with no empty state.
47
+ live_channel: Turbo Stream channel for live updates (nil = static board).
48
+ optimistic: revert + red-ring flash a failed move. Default true.
49
+ toasts: show the toast host (false = window.alert on error). Default true.
50
+ empty_label: empty-zone text. Default "Drop here".
51
+ header_slot: raw HTML rendered above the columns (pass html_safe content).
52
+ above_board_slot: raw HTML rendered between the header and the columns.
53
+ fx: window[<name>] module the factory defers add/exit anims to.
54
+ on_move_hook: window[<name>] opaque fn, called with { record, from, to }.
55
+ on_drop_hook: window[<name>] opaque fn, called with { ids, zone }.
56
+ demo: style-guide preview — drag works locally, no network POST.
57
+ %>
58
+ <%
59
+ columns = local_assigns.fetch(:columns)
60
+ card_partial = local_assigns.fetch(:card_partial)
61
+ card_as = local_assigns.fetch(:card_as, :card)
62
+ card_locals = local_assigns.fetch(:card_locals, nil)
63
+ reorder_url = local_assigns.fetch(:reorder_url, "")
64
+ reorder_payload = local_assigns.fetch(:reorder_payload, :slugs).to_s
65
+ move_url = local_assigns.fetch(:move_url, nil)
66
+ move_param = local_assigns.fetch(:move_param, nil)
67
+ group = local_assigns.fetch(:group, "studio-board")
68
+ id_attr = local_assigns.fetch(:id_attr, "slug").to_s
69
+ zone_attr = local_assigns.fetch(:zone_attr, "stage").to_s
70
+ handle = local_assigns.fetch(:handle, nil)
71
+ sort_filter = local_assigns.fetch(:filter, ".kanban-empty")
72
+ zone_selector = local_assigns.fetch(:zone_selector, ".kanban-dropzone")
73
+ draggable_sel = local_assigns.fetch(:draggable, ".kanban-card")
74
+ empty_selector = local_assigns.fetch(:empty_selector, ".kanban-empty")
75
+ dom_id_prefix = local_assigns.fetch(:dom_id_prefix, "card-")
76
+ live_channel = local_assigns.fetch(:live_channel, nil)
77
+ optimistic = local_assigns.fetch(:optimistic, true)
78
+ toasts = local_assigns.fetch(:toasts, true)
79
+ empty_label = local_assigns.fetch(:empty_label, "Drop here")
80
+ header_slot = local_assigns.fetch(:header_slot, nil)
81
+ above_board_slot = local_assigns.fetch(:above_board_slot, nil)
82
+ fx = local_assigns.fetch(:fx, nil)
83
+ on_move_hook = local_assigns.fetch(:on_move_hook, nil)
84
+ on_drop_hook = local_assigns.fetch(:on_drop_hook, nil)
85
+ demo = local_assigns.fetch(:demo, false)
86
+
87
+ board_opts = {
88
+ zoneSelector: zone_selector,
89
+ draggable: draggable_sel,
90
+ emptySelector: empty_selector,
91
+ group: (group == false ? false : group.to_s),
92
+ handle: handle,
93
+ filter: sort_filter,
94
+ idAttr: id_attr,
95
+ zoneAttr: zone_attr,
96
+ domIdPrefix: dom_id_prefix,
97
+ moveUrl: move_url,
98
+ moveParam: (move_param ? { resource: move_param[:resource].to_s, attr: move_param[:attr].to_s } : nil),
99
+ reorderUrl: reorder_url,
100
+ reorderPayload: reorder_payload,
101
+ optimistic: !!optimistic,
102
+ toasts: !!toasts,
103
+ live: live_channel.present?,
104
+ demo: !!demo,
105
+ fx: fx,
106
+ onMoveHook: on_move_hook,
107
+ onDropHook: on_drop_hook,
108
+ labels: columns.each_with_object({}) { |c, h| h[c[:key].to_s] = (c[:label] || c[:key]).to_s if c[:key] }
109
+ }
110
+ %>
111
+ <section class="studio-board" data-test="studio-board" x-data="studioBoard(<%= board_opts.to_json %>)">
112
+ <% if live_channel.present? %><%= turbo_stream_from live_channel %><% end %>
113
+
114
+ <% if header_slot %><div class="studio-board-header mb-4"><%= header_slot %></div><% end %>
115
+ <% if above_board_slot %><%= above_board_slot %><% end %>
116
+
117
+ <div class="flex flex-col gap-4 overflow-x-visible pb-4 sm:flex-row sm:overflow-x-auto">
118
+ <% columns.each do |column| %>
119
+ <%= render "studio/board/column",
120
+ column: column,
121
+ zone_attr: zone_attr,
122
+ dom_id_prefix: dom_id_prefix,
123
+ zone_selector_class: zone_selector.delete_prefix("."),
124
+ empty_selector_class: (empty_selector || "kanban-empty").delete_prefix("."),
125
+ empty_label: empty_label,
126
+ card_partial: card_partial,
127
+ card_as: card_as,
128
+ card_locals: card_locals %>
129
+ <% end %>
130
+ </div>
131
+
132
+ <% if toasts %>
133
+ <div class="studio-board-toasts fixed top-4 right-4 z-50 space-y-2" data-test="studio-board-toasts">
134
+ <template x-for="toast in toasts" :key="toast.id">
135
+ <div x-show="toast.visible"
136
+ x-transition:enter="transition ease-out duration-300"
137
+ x-transition:enter-start="opacity-0 translate-x-8"
138
+ x-transition:enter-end="opacity-100 translate-x-0"
139
+ x-transition:leave="transition ease-in duration-200"
140
+ x-transition:leave-start="opacity-100 translate-x-0"
141
+ x-transition:leave-end="opacity-0 translate-x-8"
142
+ class="bg-surface border rounded-lg px-4 py-2.5 text-sm font-medium shadow-lg backdrop-blur-sm"
143
+ :style="toast.type === 'success'
144
+ ? 'border-color: var(--color-success); color: var(--color-success)'
145
+ : 'border-color: var(--color-danger); color: var(--color-danger)'">
146
+ <span x-text="toast.message"></span>
147
+ </div>
148
+ </template>
149
+ </div>
150
+ <% end %>
151
+ </section>
@@ -0,0 +1,47 @@
1
+ <%#
2
+ studio/board/card_shell — OPTIONAL chrome that emits the board CARD half of the
3
+ identity contract, so a card partial can `render layout: "studio/board/card_shell"`
4
+ around its own body instead of hand-rolling the wrapper. It emits:
5
+
6
+ id="<dom_id_prefix><id>" (default id="card-<id>")
7
+ data-<id_attr>="<id>" (default data-slug — window.studioBoard reads this)
8
+ data-<zone_attr>="<zone>" (default data-stage — the card's current column)
9
+ class="kanban-card <extra>" (the SortableJS draggable + live-observe hook)
10
+
11
+ Apps may SKIP this and hand-roll a bespoke card, as long as it satisfies the same
12
+ contract (that is the point of the primitive — the contract, not this exact
13
+ markup). UI only; no domain, no persistence.
14
+
15
+ Checks local_assigns (never block_given?) for its locals, but always yields the
16
+ caller block — it is only ever used as a `render layout:` wrapper.
17
+
18
+ locals:
19
+ id: (req) the card id value (a slug / record id).
20
+ zone: (req) the card's current column/zone key.
21
+ id_attr: data-attr name for the id. Default "slug".
22
+ zone_attr: data-attr name for the zone. Default "stage".
23
+ dom_id_prefix: dom-id prefix. Default "card-".
24
+ card_class: the required base class. Default "kanban-card".
25
+ class: extra classes appended after card_class.
26
+ style: inline style string.
27
+ attrs: Hash of extra root attributes ({ "x-data" => "…", "data-foo" => 1 }),
28
+ values are HTML-escaped — the rebase seam for a card that carries
29
+ its own Alpine/data-* on the root.
30
+ %>
31
+ <%
32
+ id = local_assigns.fetch(:id)
33
+ zone = local_assigns.fetch(:zone)
34
+ id_attr = local_assigns.fetch(:id_attr, "slug")
35
+ zone_attr = local_assigns.fetch(:zone_attr, "stage")
36
+ dom_id_prefix = local_assigns.fetch(:dom_id_prefix, "card-")
37
+ card_class = local_assigns.fetch(:card_class, "kanban-card")
38
+ extra_class = local_assigns.fetch(:class, nil)
39
+ inline_style = local_assigns.fetch(:style, nil)
40
+ root_attrs = local_assigns.fetch(:attrs, {}) || {}
41
+ %>
42
+ <div id="<%= dom_id_prefix %><%= id %>"
43
+ class="<%= [card_class, extra_class].compact.join(" ") %>"
44
+ data-<%= id_attr %>="<%= id %>"
45
+ data-<%= zone_attr %>="<%= zone %>"
46
+ <% root_attrs.each do |name, value| %><%= name %>="<%= value %>" <% end %>
47
+ <% if inline_style %>style="<%= inline_style %>"<% end %>><%= yield %></div>
@@ -0,0 +1,80 @@
1
+ <%#
2
+ studio/board/column — ONE board column: the header (label + count), the drop
3
+ zone (the SortableJS target), its cards, and the empty state. Emits the ZONE half
4
+ of the board identity contract:
5
+
6
+ id="dropzone-<key>" (window.studioBoard.updateCounts reads this)
7
+ class="kanban-dropzone …" (the SortableJS + live-observe target)
8
+ data-<zone_attr>="<key>" (the column key a dropped card moves TO)
9
+
10
+ Cards emit the other half (see studio/board/_card_shell). Rendered by
11
+ studio/board/_board; not usually rendered directly.
12
+
13
+ locals:
14
+ column: (req) { key:, label:, cards:, count:, badge_class:,
15
+ collapsible:, hidden:, header_extra: }. `cards` are the
16
+ records passed to the card partial; `count` defaults to
17
+ cards.size; `badge_class` picks a components/_badge scheme
18
+ for the count (else a plain neutral count chip).
19
+ zone_attr: data-attr name for the zone. Default "stage".
20
+ dom_id_prefix: card dom-id prefix, forwarded to the cards. Default "card-".
21
+ zone_selector_class: the drop-zone class. Default "kanban-dropzone".
22
+ empty_selector_class: the empty-state class. Default "kanban-empty".
23
+ empty_label: empty-zone text. Default "Drop here".
24
+ card_partial: (req) partial rendered per card.
25
+ card_as: local the record binds to in card_partial. Default :card.
26
+ card_locals: per-card extra locals — a Proc ->(record){ Hash } or Hash.
27
+ %>
28
+ <%
29
+ column = local_assigns.fetch(:column)
30
+ zone_attr = local_assigns.fetch(:zone_attr, "stage")
31
+ dom_id_prefix = local_assigns.fetch(:dom_id_prefix, "card-")
32
+ zone_selector_class = local_assigns.fetch(:zone_selector_class, "kanban-dropzone")
33
+ empty_selector_class = local_assigns.fetch(:empty_selector_class, "kanban-empty")
34
+ empty_label = local_assigns.fetch(:empty_label, "Drop here")
35
+ card_partial = local_assigns.fetch(:card_partial)
36
+ card_as = local_assigns.fetch(:card_as, :card)
37
+ card_locals = local_assigns.fetch(:card_locals, nil)
38
+
39
+ key = column[:key].to_s
40
+ label = column[:label] || key
41
+ cards = column[:cards] || []
42
+ count = column.fetch(:count) { cards.size }
43
+ badge_class = column[:badge_class]
44
+ collapsible = column[:collapsible]
45
+ hidden = column[:hidden]
46
+ header_extra = column[:header_extra]
47
+
48
+ # Merge the per-card hook (Proc or Hash) with the record binding.
49
+ resolve_locals = lambda do |record|
50
+ extra = card_locals.respond_to?(:call) ? card_locals.call(record) : (card_locals || {})
51
+ { card_as => record }.merge(extra || {})
52
+ end
53
+ %>
54
+ <div class="studio-board-column w-full flex-none sm:flex-1 sm:min-w-[190px]"
55
+ data-board-column="<%= key %>"
56
+ <% if collapsible %>data-board-collapsible="true"<% end %>
57
+ <% if hidden %>x-cloak style="display:none"<% end %>>
58
+ <div class="mb-3 px-1 flex items-center gap-2">
59
+ <h3 class="text-sm font-bold text-heading uppercase tracking-wider"><%= label %></h3>
60
+ <% if badge_class %>
61
+ <%= render "components/badge", text: count.to_s, scheme: badge_class, data_board_count: key %>
62
+ <% else %>
63
+ <span class="stage-count inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full text-xs font-bold bg-surface-alt text-secondary border border-subtle"
64
+ data-board-count="<%= key %>"><%= count %></span>
65
+ <% end %>
66
+ <% if header_extra %><%= header_extra %><% end %>
67
+ </div>
68
+
69
+ <div id="dropzone-<%= key %>"
70
+ data-<%= zone_attr %>="<%= key %>"
71
+ class="<%= zone_selector_class %> rounded-xl border-2 border-dashed border-subtle min-h-40 p-2 space-y-2 transition-colors duration-150 sm:min-h-[calc(100vh-220px)]">
72
+ <% cards.each do |record| %>
73
+ <%= render partial: card_partial, locals: resolve_locals.call(record) %>
74
+ <% end %>
75
+ <div class="<%= empty_selector_class %> flex items-center justify-center h-24 text-muted text-sm"
76
+ <% if cards.any? %>style="display: none"<% end %>>
77
+ <%= empty_label %>
78
+ </div>
79
+ </div>
80
+ </div>