studio-engine 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15b4c45fed9c5a2fd6056fca154236cbc12b778c483af5922486e47fcec6baac
4
- data.tar.gz: d1efa67d91b858e9ba81f9fea1cb63d4b73c1e76a97c08dd0a8ca2afcd0b4522
3
+ metadata.gz: e37d3038ae5d72bbd0829247f562aa744960458dc9e54faa73c38fc1a2adcb83
4
+ data.tar.gz: a74cb5455f9977009b3499981dec577bb7df9782a5ab2f08a2bda92fe9cbe9c5
5
5
  SHA512:
6
- metadata.gz: ec7d708e3665fbccf82ee641f82110cc8adfda8dd19528b3748807697ae6127db02c9579e1ef414051e541ea28005cc6b525b554e46ab1d1abd11cc959f48e74
7
- data.tar.gz: dbc43339aeaad12be683b260e4cfcac8aa70f9fbf3a23be136b161790ce4468c8711f1e45c2fd608257df4bf867427e7cf3f8b8e52b0100e218c6de6a85dce1c
6
+ metadata.gz: 45ecd0d11aef5443dc5342013e39124b52ecf67b46261b096e45a57b088a22335e5ce7c1b58f894c0878888cd1dc087b886f76e7ed15875ec08759222a52ae74
7
+ data.tar.gz: 2e1b9c6dd268214317af8a93aa27bf49e1aa2ebff4e487010a81fd5f332ee261a0d1011fb636cbd9577dd3300c23ba0b1edc6e40e7b56366d83c7314d40a0636
data/CHANGELOG.md CHANGED
@@ -2,6 +2,65 @@
2
2
 
3
3
  The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package with `gem "studio-engine", "~> 0.6"`; bumping the gem version and updating consumer lockfiles is a release.
4
4
 
5
+ ## 0.23.0 — 2026-07-28
6
+
7
+ Phase D, slice 1 — the **board primitive**. The three near-identical McRitchie
8
+ Studio kanban boards (tasks / news / content) each hand-rolled the SAME SortableJS
9
+ init, the SAME optimistic move → revert → toast, and a BYTE-IDENTICAL `reorder`
10
+ controller action; the depth chart hand-rolled a within-lane variant. This slice
11
+ lifts that convergence into a neutral engine primitive, following the same seam as
12
+ the leveling modals and tricks (documented `local_assigns.fetch` locals → a
13
+ page-level Alpine factory in an `_assets` partial → capability / `fx` / event hooks
14
+ → a `demo:` local for `/admin/style` → vendored JS).
15
+
16
+ **Purely additive — new engine files only, no existing primitive's default
17
+ changes. MS and TM render identically on the bump** (they do not use
18
+ `studio/board/*` yet; the board rebases are separate later deploys).
19
+
20
+ ### Added
21
+
22
+ - **`studio/board/_board`** — the board shell: a row of columns (drop zones), an
23
+ optional live `turbo_stream_from`, and a toast host. Documented `local_assigns`
24
+ locals: `columns:` `card_partial:` `card_as:` `card_locals:` `reorder_url:`
25
+ `reorder_payload:` (`:slugs` | `:ids`) `move_url:` `move_param:` `group:`
26
+ (`false` = within-zone) `id_attr:` `zone_attr:` `handle:` `filter:`
27
+ `zone_selector:` `draggable:` `empty_selector:` `live_channel:` `optimistic:`
28
+ `toasts:` `empty_label:` `header_slot:` `above_board_slot:` `fx:` `on_move_hook:`
29
+ `on_drop_hook:` `demo:`.
30
+ - **`studio/board/_column`** — one column: header + count badge + the
31
+ `.kanban-dropzone` (`id="dropzone-<key>"`, `data-<zone_attr>`) + `.kanban-empty`
32
+ empty state (the ZONE half of the identity contract).
33
+ - **`studio/board/_card_shell`** — OPTIONAL `render layout:` chrome that emits the
34
+ CARD half of the contract (`id="card-<id>"`, `.kanban-card`, `data-<id_attr>`,
35
+ `data-<zone_attr>`). Apps may hand-roll a bespoke card that satisfies the same
36
+ contract instead.
37
+ - **`studioBoard(opts)` factory — `studio/_board_assets`.** The Alpine data behind
38
+ the board, shipped at page level (a `<script>` cloned from a component template
39
+ never runs), like `levelingActionModal`. ONE factory drives BOTH shapes — it
40
+ reads `group` / `handle` / `filter` / `draggable` / `id_attr` / `zone_attr` from
41
+ opts, so the cross-column kanban and the within-lane depth chart are the same
42
+ code. Owns `initSortables`, `handleSortEnd` (cross-column move → PATCH →
43
+ optimistic revert + red-ring + toast; a same-zone drop NEVER PATCHes the zone),
44
+ `saveOrder` (reorder POST), `updateCounts`, `observeLive`,
45
+ `installExitStreamFallback`, `animateIn`, and toasts. Preserves the
46
+ `$nextTick` → `data-alpine-ready` init ordering (a documented flake fix). The
47
+ primary extension seam is the dispatched window events
48
+ `studio:board-moved` / `:board-reordered` / `:card-added` (detail
49
+ `{ record, from, to }`) — apps LISTEN, never patch the factory.
50
+ - **SortableJS v1.15.6 (MIT) VENDORED** — `app/assets/javascripts/studio/sortable.js`,
51
+ added to `config.assets.precompile` and loaded (deferred) by
52
+ `layouts/studio/_head`, mirroring the vendored canvas-confetti. CSP-safe, no CDN;
53
+ any board consumer gets `Sortable` with zero per-app wiring.
54
+ - **`Studio::Board::Rankable`** — the shared 100-gap rank read-model: a
55
+ `board_ordered` scope (`position DESC NULLS LAST, created_at DESC`),
56
+ `set_initial_position` (max + 100 within the zone), and `reposition!(ids, gap:,
57
+ direction: :desc | :asc)`.
58
+ - **`Studio::Board::Reorderable`** — the shared `reorder` controller action,
59
+ neutral param, delegating the 100-gap restamp to `Rankable#reposition!`.
60
+ - **`/admin/style` Board specimen** — the Tasks section now renders the REAL
61
+ `studio/board/board` primitive in `demo: true` mode (drag works, no POST),
62
+ above the static stage-palette reference.
63
+
5
64
  ## 0.22.0 — 2026-07-28
6
65
 
7
66
  Two additive batches for the living style guide, converged under one release.
@@ -0,0 +1,11 @@
1
+ /*
2
+ SortableJS v1.15.6 — VENDORED into studio-engine (no CDN, CSP-safe).
3
+ Source: https://github.com/SortableJS/Sortable (MIT, (c) All Sortable contributors).
4
+ Minified build: https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js
5
+ Shipped as an engine asset so consumers get zero-per-app drag-and-drop with no
6
+ third-party CDN dependency. Defines the global `Sortable`; the studio/board
7
+ primitive (studio/_board_assets -> window.studioBoard) drives it. To re-vendor on
8
+ a bump: re-fetch the pinned dist file above, keep this header, bump the version.
9
+ */
10
+ /*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */
11
+ !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;t<arguments.length;t++){var i=null!=arguments[t]?arguments[t]:{};t%2?e(Object(i),!0).forEach(function(t){var e,n;e=o,t=i[n=t],n in e?Object.defineProperty(e,n,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[n]=t}):Object.getOwnPropertyDescriptors?Object.defineProperties(o,Object.getOwnPropertyDescriptors(i)):e(Object(i)).forEach(function(t){Object.defineProperty(o,t,Object.getOwnPropertyDescriptor(i,t))})}return o}function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var n,o=arguments[e];for(n in o)Object.prototype.hasOwnProperty.call(o,n)&&(t[n]=o[n])}return t}).apply(this,arguments)}function i(t,e){if(null==t)return{};var n,o=function(t,e){if(null==t)return{};for(var n,o={},i=Object.keys(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||(o[n]=t[n]);return o}(t,e);if(Object.getOwnPropertySymbols)for(var i=Object.getOwnPropertySymbols(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n]);return o}function r(t){return function(t){if(Array.isArray(t))return l(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return l(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return"Map"===(n="Object"===n&&t.constructor?t.constructor.name:n)||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=new Array(e);n<e;n++)o[n]=t[n];return o}function t(t){if("undefined"!=typeof window&&window.navigator)return!!navigator.userAgent.match(t)}var y=t(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),w=t(/Edge/i),s=t(/firefox/i),u=t(/safari/i)&&!t(/chrome/i)&&!t(/android/i),c=t(/iP(ad|od|hone)/i),n=t(/chrome/i)&&t(/android/i),d={capture:!1,passive:!1};function h(t,e,n){t.addEventListener(e,n,!y&&d)}function p(t,e,n){t.removeEventListener(e,n,!y&&d)}function f(t,e){if(e&&(">"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i<r;i++)n(o[i],i);return o}return[]}function O(){var t=document.scrollingElement;return t||document.documentElement}function X(t,e,n,o,i){if(t.getBoundingClientRect||t===window){var r,a,l,s,c,u,d=t!==window&&t.parentNode&&t!==O()?(a=(r=t.getBoundingClientRect()).top,l=r.left,s=r.bottom,c=r.right,u=r.height,r.width):(l=a=0,s=window.innerHeight,c=window.innerWidth,u=window.innerHeight,window.innerWidth);if((e||n)&&t!==window&&(i=i||t.parentNode,!y))do{if(i&&i.getBoundingClientRect&&("none"!==R(i,"transform")||n&&"static"!==R(i,"position"))){var h=i.getBoundingClientRect();a-=h.top+parseInt(R(i,"border-top-width")),l-=h.left+parseInt(R(i,"border-left-width")),s=a+r.height,c=l+r.width;break}}while(i=i.parentNode);return o&&t!==window&&(o=(e=b(i||t))&&e.a,t=e&&e.d,e&&(s=(a/=t)+(u/=t),c=(l/=o)+(d/=o))),{top:a,left:l,bottom:s,right:c,width:d,height:u}}}function Y(t,e,n){for(var o=M(t,!0),i=X(t)[e];o;){var r=X(o)[n];if(!("top"===n||"left"===n?r<=i:i<=r))return o;if(o===O())break;o=M(o,!1)}return!1}function B(t,e,n,o){for(var i=0,r=0,a=t.children;r<a.length;){if("none"!==a[r].style.display&&a[r]!==jt.ghost&&(o||a[r]!==jt.dragged)&&P(a[r],n.draggable,t,!1)){if(i===e)return a[r];i++}r++}return null}function F(t,e){for(var n=t.lastElementChild;n&&(n===jt.ghost||"none"===R(n,"display")||e&&!f(n,e));)n=n.previousElementSibling;return n||null}function j(t,e){var n=0;if(!t||!t.parentNode)return-1;for(;t=t.previousElementSibling;)"TEMPLATE"===t.nodeName.toUpperCase()||t===jt.clone||e&&!f(t,e)||n++;return n}function E(t){var e=0,n=0,o=O();if(t)do{var i=b(t),r=i.a,i=i.d}while(e+=t.scrollLeft*r,n+=t.scrollTop*i,t!==o&&(t=t.parentNode));return[e,n]}function M(t,e){if(!t||!t.getBoundingClientRect)return O();var n=t,o=!1;do{if(n.clientWidth<n.scrollWidth||n.clientHeight<n.scrollHeight){var i=R(n);if(n.clientWidth<n.scrollWidth&&("auto"==i.overflowX||"scroll"==i.overflowX)||n.clientHeight<n.scrollHeight&&("auto"==i.overflowY||"scroll"==i.overflowY)){if(!n.getBoundingClientRect||n===document.body)return O();if(o||e)return n;o=!0}}}while(n=n.parentNode);return O()}function S(t,e){return Math.round(t.top)===Math.round(e.top)&&Math.round(t.left)===Math.round(e.left)&&Math.round(t.height)===Math.round(e.height)&&Math.round(t.width)===Math.round(e.width)}function _(e,n){return function(){var t;m||(1===(t=arguments).length?e.call(this,t[0]):e.apply(this,t),m=setTimeout(function(){m=void 0},n))}}function H(t,e,n){t.scrollLeft+=e,t.scrollTop+=n}function C(t){var e=window.Polymer,n=window.jQuery||window.Zepto;return e&&e.dom?e.dom(t).cloneNode(!0):n?n(t).clone(!0)[0]:t.cloneNode(!0)}function T(t,e){R(t,"position","absolute"),R(t,"top",e.top),R(t,"left",e.left),R(t,"width",e.width),R(t,"height",e.height)}function x(t){R(t,"position",""),R(t,"top",""),R(t,"left",""),R(t,"width",""),R(t,"height","")}function L(n,o,i){var r={};return Array.from(n.children).forEach(function(t){var e;P(t,o.draggable,n,!1)&&!t.animated&&t!==i&&(e=X(t),r.left=Math.min(null!==(t=r.left)&&void 0!==t?t:1/0,e.left),r.top=Math.min(null!==(t=r.top)&&void 0!==t?t:1/0,e.top),r.right=Math.max(null!==(t=r.right)&&void 0!==t?t:-1/0,e.right),r.bottom=Math.max(null!==(t=r.bottom)&&void 0!==t?t:-1/0,e.bottom))}),r.width=r.right-r.left,r.height=r.bottom-r.top,r.x=r.left,r.y=r.top,r}var K="Sortable"+(new Date).getTime();function A(){var e,o=[];return{captureAnimationState:function(){o=[],this.options.animation&&[].slice.call(this.el.children).forEach(function(t){var e,n;"none"!==R(t,"display")&&t!==jt.ghost&&(o.push({target:t,rect:X(t)}),e=I({},o[o.length-1].rect),!t.thisAnimationDuration||(n=b(t,!0))&&(e.top-=n.f,e.left-=n.e),t.fromRect=e)})},addAnimationState:function(t){o.push(t)},removeAnimationState:function(t){o.splice(function(t,e){for(var n in t)if(t.hasOwnProperty(n))for(var o in e)if(e.hasOwnProperty(o)&&e[o]===t[n][o])return Number(n);return-1}(o,{target:t}),1)},animateAll:function(t){var c=this;if(!this.options.animation)return clearTimeout(e),void("function"==typeof t&&t());var u=!1,d=0;o.forEach(function(t){var e=0,n=t.target,o=n.fromRect,i=X(n),r=n.prevFromRect,a=n.prevToRect,l=t.rect,s=b(n,!0);s&&(i.top-=s.f,i.left-=s.e),n.toRect=i,n.thisAnimationDuration&&S(r,i)&&!S(o,i)&&(l.top-i.top)/(l.left-i.left)==(o.top-i.top)/(o.left-i.left)&&(t=l,s=r,r=a,a=c.options,e=Math.sqrt(Math.pow(s.top-t.top,2)+Math.pow(s.left-t.left,2))/Math.sqrt(Math.pow(s.top-r.top,2)+Math.pow(s.left-r.left,2))*a.animation),S(i,o)||(n.prevFromRect=o,n.prevToRect=i,e=e||c.options.animation,c.animate(n,l,i,e)),e&&(u=!0,d=Math.max(d,e),clearTimeout(n.animationResetTimer),n.animationResetTimer=setTimeout(function(){n.animationTime=0,n.prevFromRect=null,n.fromRect=null,n.prevToRect=null,n.thisAnimationDuration=null},e),n.thisAnimationDuration=e)}),clearTimeout(e),u?e=setTimeout(function(){"function"==typeof t&&t()},d):"function"==typeof t&&t(),o=[]},animate:function(t,e,n,o){var i,r;o&&(R(t,"transition",""),R(t,"transform",""),i=(r=b(this.el))&&r.a,r=r&&r.d,i=(e.left-n.left)/(i||1),r=(e.top-n.top)/(r||1),t.animatingX=!!i,t.animatingY=!!r,R(t,"transform","translate3d("+i+"px,"+r+"px,0)"),this.forRepaintDummy=t.offsetWidth,R(t,"transition","transform "+o+"ms"+(this.options.easing?" "+this.options.easing:"")),R(t,"transform","translate3d(0,0,0)"),"number"==typeof t.animated&&clearTimeout(t.animated),t.animated=setTimeout(function(){R(t,"transition",""),R(t,"transform",""),t.animated=!1,t.animatingX=!1,t.animatingY=!1},o))}}}var N=[],W={initializeByDefault:!0},z={mount:function(e){for(var t in W)!W.hasOwnProperty(t)||t in e||(e[t]=W[t]);N.forEach(function(t){if(t.pluginName===e.pluginName)throw"Sortable: Cannot mount plugin ".concat(e.pluginName," more than once")}),N.push(e)},pluginEvent:function(e,n,o){var t=this;this.eventCanceled=!1,o.cancel=function(){t.eventCanceled=!0};var i=e+"Global";N.forEach(function(t){n[t.pluginName]&&(n[t.pluginName][i]&&n[t.pluginName][i](I({sortable:n},o)),n.options[t.pluginName]&&n[t.pluginName][e]&&n[t.pluginName][e](I({sortable:n},o)))})},initializePlugins:function(n,o,i,t){for(var e in N.forEach(function(t){var e=t.pluginName;(n.options[e]||t.initializeByDefault)&&((t=new t(n,o,n.options)).sortable=n,t.options=n.options,n[e]=t,a(i,t.defaults))}),n.options){var r;n.options.hasOwnProperty(e)&&(void 0!==(r=this.modifyOption(n,e,n.options[e]))&&(n.options[e]=r))}},getEventProperties:function(e,n){var o={};return N.forEach(function(t){"function"==typeof t.eventProperties&&a(o,t.eventProperties.call(n[t.pluginName],e))}),o},modifyOption:function(e,n,o){var i;return N.forEach(function(t){e[t.pluginName]&&t.optionListeners&&"function"==typeof t.optionListeners[n]&&(i=t.optionListeners[n].call(e[t.pluginName],o))}),i}};function G(t){var e=t.sortable,n=t.rootEl,o=t.name,i=t.targetEl,r=t.cloneEl,a=t.toEl,l=t.fromEl,s=t.oldIndex,c=t.newIndex,u=t.oldDraggableIndex,d=t.newDraggableIndex,h=t.originalEvent,p=t.putSortable,f=t.extraEventProperties;if(e=e||n&&n[K]){var g,m=e.options,t="on"+o.charAt(0).toUpperCase()+o.substr(1);!window.CustomEvent||y||w?(g=document.createEvent("Event")).initEvent(o,!0,!0):g=new CustomEvent(o,{bubbles:!0,cancelable:!0}),g.to=a||n,g.from=l||n,g.item=i||n,g.clone=r,g.oldIndex=s,g.newIndex=c,g.oldDraggableIndex=u,g.newDraggableIndex=d,g.originalEvent=h,g.pullMode=p?p.lastPutMode:void 0;var v,b=I(I({},f),z.getEventProperties(o,e));for(v in b)g[v]=b[v];n&&n.dispatchEvent(g),m[t]&&m[t].call(e,g)}}function U(t,e){var n=(o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:{}).evt,o=i(o,q);z.pluginEvent.bind(jt)(t,e,I({dragEl:Z,parentEl:$,ghostEl:Q,rootEl:J,nextEl:tt,lastDownEl:et,cloneEl:nt,cloneHidden:ot,dragStarted:mt,putSortable:ct,activeSortable:jt.active,originalEvent:n,oldIndex:it,oldDraggableIndex:at,newIndex:rt,newDraggableIndex:lt,hideGhostForTarget:Xt,unhideGhostForTarget:Yt,cloneNowHidden:function(){ot=!0},cloneNowShown:function(){ot=!1},dispatchSortableEvent:function(t){V({sortable:e,name:t,originalEvent:n})}},o))}var q=["evt"];function V(t){G(I({putSortable:ct,cloneEl:nt,targetEl:Z,rootEl:J,oldIndex:it,oldDraggableIndex:at,newIndex:rt,newDraggableIndex:lt},t))}var Z,$,Q,J,tt,et,nt,ot,it,rt,at,lt,st,ct,ut,dt,ht,pt,ft,gt,mt,vt,bt,yt,wt,Dt=!1,Et=!1,St=[],_t=!1,Ct=!1,Tt=[],xt=!1,Ot=[],Mt="undefined"!=typeof document,At=c,Nt=w||y?"cssFloat":"float",It=Mt&&!n&&!c&&"draggable"in document.createElement("div"),Pt=function(){if(Mt){if(y)return!1;var t=document.createElement("x");return t.style.cssText="pointer-events:auto","auto"===t.style.pointerEvents}}(),kt=function(t,e){var n=R(t),o=parseInt(n.width)-parseInt(n.paddingLeft)-parseInt(n.paddingRight)-parseInt(n.borderLeftWidth)-parseInt(n.borderRightWidth),i=B(t,0,e),r=B(t,1,e),a=i&&R(i),l=r&&R(r),s=a&&parseInt(a.marginLeft)+parseInt(a.marginRight)+X(i).width,t=l&&parseInt(l.marginLeft)+parseInt(l.marginRight)+X(r).width;if("flex"===n.display)return"column"===n.flexDirection||"column-reverse"===n.flexDirection?"vertical":"horizontal";if("grid"===n.display)return n.gridTemplateColumns.split(" ").length<=1?"vertical":"horizontal";if(i&&a.float&&"none"!==a.float){e="left"===a.float?"left":"right";return!r||"both"!==l.clear&&l.clear!==e?"horizontal":"vertical"}return i&&("block"===a.display||"flex"===a.display||"table"===a.display||"grid"===a.display||o<=s&&"none"===n[Nt]||r&&"none"===n[Nt]&&o<s+t)?"vertical":"horizontal"},Rt=function(t){function l(r,a){return function(t,e,n,o){var i=t.options.group.name&&e.options.group.name&&t.options.group.name===e.options.group.name;if(null==r&&(a||i))return!0;if(null==r||!1===r)return!1;if(a&&"clone"===r)return r;if("function"==typeof r)return l(r(t,e,n,o),a)(t,e,n,o);e=(a?t:e).options.group.name;return!0===r||"string"==typeof r&&r===e||r.join&&-1<r.indexOf(e)}}var e={},n=t.group;n&&"object"==o(n)||(n={name:n}),e.name=n.name,e.checkPull=l(n.pull,!0),e.checkPut=l(n.put),e.revertClone=n.revertClone,t.group=e},Xt=function(){!Pt&&Q&&R(Q,"display","none")},Yt=function(){!Pt&&Q&&R(Q,"display","")};Mt&&!n&&document.addEventListener("click",function(t){if(Et)return t.preventDefault(),t.stopPropagation&&t.stopPropagation(),t.stopImmediatePropagation&&t.stopImmediatePropagation(),Et=!1},!0);function Bt(t){if(Z){t=t.touches?t.touches[0]:t;var e=(i=t.clientX,r=t.clientY,St.some(function(t){var e=t[K].options.emptyInsertThreshold;if(e&&!F(t)){var n=X(t),o=i>=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Ft(t){Z&&Z.parentNode[K]._isOutsideThisEl(t.target)}function jt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return kt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==jt.supportPointer&&"PointerEvent"in window&&(!u||c),emptyInsertThreshold:5};for(n in z.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Rt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&It,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),St.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,A())}function Ht(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Lt(t){t.draggable=!1}function Kt(){xt=!1}function Wt(t){return setTimeout(t,0)}function zt(t){return clearTimeout(t)}jt.prototype={constructor:jt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(vt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,Z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Ot.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Ot.push(o)}}(o),!Z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||et===l)){if(it=j(l),at=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return V({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),U("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return V({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),U("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!Z&&n.parentNode===r&&(o=X(n),J=r,$=(Z=n).parentNode,tt=Z.nextSibling,et=n,st=a.group,ut={target:jt.dragged=Z,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ut.clientX-o.left,gt=ut.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,Z.style["will-change"]="all",o=function(){U("delayEnded",i,{evt:t}),jt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(Z.draggable=!0),i._triggerDragStart(t,e),V({sortable:i,name:"choose",originalEvent:t}),k(Z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){D(Z,t.trim(),Lt)}),h(l,"dragover",Bt),h(l,"mousemove",Bt),h(l,"touchmove",Bt),a.supportPointer?(h(l,"pointerup",i._onDrop),this.nativeDraggable||h(l,"pointercancel",i._onDrop)):(h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,Z.draggable=!0),U("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():jt.eventCanceled?this._onDrop():(a.supportPointer?(h(l,"pointerup",i._disableDelayedDrag),h(l,"pointercancel",i._disableDelayedDrag)):(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag)),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){Z&&Lt(Z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(Z,"dragend",this),h(J,"dragstart",this._onDragStart));try{document.selection?Wt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Dt=!1,J&&Z?(U("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Ft),n=this.options,t||k(Z,n.dragClass,!1),k(Z,n.ghostClass,!0),jt.active=this,t&&this._appendGhost(),V({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(dt){this._lastX=dt.clientX,this._lastY=dt.clientY,Xt();for(var t=document.elementFromPoint(dt.clientX,dt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(dt.clientX,dt.clientY))!==e;)e=t;if(Z.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:dt.clientX,clientY:dt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=g(t=e));Yt()}},_onTouchMove:function(t){if(ut){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Q&&b(Q,!0),a=Q&&r&&r.a,l=Q&&r&&r.d,e=At&&wt&&E(wt),a=(i.clientX-ut.clientX+o.x)/(a||1)+(e?e[0]-Tt[0]:0)/(a||1),l=(i.clientY-ut.clientY+o.y)/(l||1)+(e?e[1]-Tt[1]:0)/(l||1);if(!jt.active&&!Dt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))<n)return;this._onDragStart(t,!0)}Q&&(r?(r.e+=a-(ht||0),r.f+=l-(pt||0)):r={a:1,b:0,c:0,d:1,e:a,f:l},r="matrix(".concat(r.a,",").concat(r.b,",").concat(r.c,",").concat(r.d,",").concat(r.e,",").concat(r.f,")"),R(Q,"webkitTransform",r),R(Q,"mozTransform",r),R(Q,"msTransform",r),R(Q,"transform",r),ht=a,pt=l,dt=i),t.cancelable&&t.preventDefault()}},_appendGhost:function(){if(!Q){var t=this.options.fallbackOnBody?document.body:J,e=X(Z,!0,At,!0,t),n=this.options;if(At){for(wt=t;"static"===R(wt,"position")&&"none"===R(wt,"transform")&&wt!==document;)wt=wt.parentNode;wt!==document.body&&wt!==document.documentElement?(wt===document&&(wt=O()),e.top+=wt.scrollTop,e.left+=wt.scrollLeft):wt=O(),Tt=E(wt)}k(Q=Z.cloneNode(!0),n.ghostClass,!1),k(Q,n.fallbackClass,!0),k(Q,n.dragClass,!0),R(Q,"transition",""),R(Q,"transform",""),R(Q,"box-sizing","border-box"),R(Q,"margin",0),R(Q,"top",e.top),R(Q,"left",e.left),R(Q,"width",e.width),R(Q,"height",e.height),R(Q,"opacity","0.8"),R(Q,"position",At?"absolute":"fixed"),R(Q,"zIndex","100000"),R(Q,"pointerEvents","none"),jt.ghost=Q,t.appendChild(Q),R(Q,"transform-origin",ft/parseInt(Q.style.width)*100+"% "+gt/parseInt(Q.style.height)*100+"%")}},_onDragStart:function(t,e){var n=this,o=t.dataTransfer,i=n.options;U("dragStart",this,{evt:t}),jt.eventCanceled?this._onDrop():(U("setupClone",this),jt.eventCanceled||((nt=C(Z)).removeAttribute("id"),nt.draggable=!1,nt.style["will-change"]="",this._hideClone(),k(nt,this.options.chosenClass,!1),jt.clone=nt),n.cloneId=Wt(function(){U("clone",n),jt.eventCanceled||(n.options.removeCloneOnHide||J.insertBefore(nt,Z),n._hideClone(),V({sortable:n,name:"clone"}))}),e||k(Z,i.dragClass,!0),e?(Et=!0,n._loopId=setInterval(n._emulateDragOver,50)):(p(document,"mouseup",n._onDrop),p(document,"touchend",n._onDrop),p(document,"touchcancel",n._onDrop),o&&(o.effectAllowed="move",i.setData&&i.setData.call(n,o,Z)),h(document,"drop",n),R(Z,"transform","translateZ(0)")),Dt=!0,n._dragStartId=Wt(n._dragStarted.bind(n,e,t)),h(document,"selectstart",n),mt=!0,window.getSelection().removeAllRanges(),u&&R(document.body,"user-select","none"))},_onDragOver:function(n){var o,i,r,t,e,a=this.el,l=n.target,s=this.options,c=s.group,u=jt.active,d=st===c,h=s.sort,p=ct||u,f=this,g=!1;if(!xt){if(void 0!==n.preventDefault&&n.cancelable&&n.preventDefault(),l=P(l,s.draggable,a,!0),O("dragOver"),jt.eventCanceled)return g;if(Z.contains(n.target)||l.animated&&l.animatingX&&l.animatingY||f._ignoreWhileAnimating===l)return A(!1);if(Et=!1,u&&!s.disabled&&(d?h||(i=$!==J):ct===this||(this.lastPutMode=st.checkPull(this,u,Z,n))&&c.checkPut(this,u,Z,n))){if(r="vertical"===this._getDirection(n,l),o=X(Z),O("dragOverValid"),jt.eventCanceled)return g;if(i)return $=J,M(),this._hideClone(),O("revert"),jt.eventCanceled||(tt?J.insertBefore(Z,tt):J.appendChild(Z)),A(!0);var m=F(a,s.draggable);if(m&&(S=n,c=r,x=X(F((E=this).el,E.options.draggable)),E=L(E.el,E.options,Q),!(c?S.clientX>E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,Q),e?t.clientX<_.left-10||t.clientY<C.top&&t.clientX<C.right:t.clientY<_.top-10||t.clientY<C.bottom&&t.clientX<C.left)){var v=B(a,0,s,!0);if(v===Z)return A(!1);if(D=X(l=v),!1!==Ht(J,a,Z,o,l,D,n,!1))return M(),a.insertBefore(Z,v),$=a,N(),A(!0)}else if(l.parentNode===a){var b,y,w,D=X(l),E=Z.parentNode!==a,S=(S=Z.animated&&Z.toRect||o,x=l.animated&&l.toRect||D,_=(e=r)?S.left:S.top,t=e?S.right:S.bottom,C=e?S.width:S.height,v=e?x.left:x.top,S=e?x.right:x.bottom,x=e?x.width:x.height,!(_===v||t===S||_+C/2===v+x/2)),_=r?"top":"left",C=Y(l,"top","top")||Y(Z,"top","top"),v=C?C.scrollTop:void 0;if(vt!==l&&(y=D[_],_t=!1,Ct=!S&&s.invertSwap||E),0!==(b=function(t,e,n,o,i,r,a,l){var s=o?t.clientY:t.clientX,c=o?n.height:n.width,t=o?n.top:n.left,o=o?n.bottom:n.right,n=!1;if(!a)if(l&&yt<c*i){if(_t=!_t&&(1===bt?t+c*r/2<s:s<o-c*r/2)?!0:_t)n=!0;else if(1===bt?s<t+yt:o-yt<s)return-bt}else if(t+c*(1-i)/2<s&&s<o-c*(1-i)/2)return function(t){return j(Z)<j(t)?1:-1}(e);if((n=n||a)&&(s<t+c*r/2||o-c*r/2<s))return t+c/2<s?1:-1;return 0}(n,l,D,r,S?1:s.swapThreshold,null==s.invertedSwapThreshold?s.swapThreshold:s.invertedSwapThreshold,Ct,vt===l)))for(var T=j(Z);(w=$.children[T-=b])&&("none"===R(w,"display")||w===Q););if(0===b||w===l)return A(!1);bt=b;var x=(vt=l).nextElementSibling,E=!1,S=Ht(J,a,Z,o,l,D,n,E=1===b);if(!1!==S)return 1!==S&&-1!==S||(E=1===S),xt=!0,setTimeout(Kt,30),M(),E&&!x?a.appendChild(Z):l.parentNode.insertBefore(Z,E?x:l),C&&H(C,0,v-C.scrollTop),$=Z.parentNode,void 0===y||Ct||(yt=Math.abs(y-X(l)[_])),N(),A(!0)}}else{if(m===Z)return A(!1);if((l=m&&a===n.target?m:l)&&(D=X(l)),!1!==Ht(J,a,Z,o,l,D,n,!!l))return M(),m&&m.nextSibling?a.insertBefore(Z,m.nextSibling):a.appendChild(Z),$=a,N(),A(!0)}if(a.contains(Z))return A(!1)}return!1}function O(t,e){U(t,f,I({evt:n,isOwner:d,axis:r?"vertical":"horizontal",revert:i,dragRect:o,targetRect:D,canSort:h,fromSortable:p,target:l,completed:A,onMove:function(t,e){return Ht(J,a,Z,o,t,X(t),n,e)},changed:N},e))}function M(){O("dragOverAnimationCapture"),f.captureAnimationState(),f!==p&&p.captureAnimationState()}function A(t){return O("dragOverCompleted",{insertion:t}),t&&(d?u._hideClone():u._showClone(f),f!==p&&(k(Z,(ct||u).options.ghostClass,!1),k(Z,s.ghostClass,!0)),ct!==f&&f!==jt.active?ct=f:f===jt.active&&ct&&(ct=null),p===f&&(f._ignoreWhileAnimating=l),f.animateAll(function(){O("dragOverAnimationComplete"),f._ignoreWhileAnimating=null}),f!==p&&(p.animateAll(),p._ignoreWhileAnimating=null)),(l===Z&&!Z.animated||l===a&&!l.animated)&&(vt=null),s.dragoverBubble||n.rootEl||l===document||(Z.parentNode[K]._isOutsideThisEl(n.target),t||Bt(n)),!s.dragoverBubble&&n.stopPropagation&&n.stopPropagation(),g=!0}function N(){rt=j(Z),lt=j(Z,s.draggable),V({sortable:f,name:"change",toEl:a,newIndex:rt,newDraggableIndex:lt,originalEvent:n})}},_ignoreWhileAnimating:null,_offMoveEvents:function(){p(document,"mousemove",this._onTouchMove),p(document,"touchmove",this._onTouchMove),p(document,"pointermove",this._onTouchMove),p(document,"dragover",Bt),p(document,"mousemove",Bt),p(document,"touchmove",Bt)},_offUpEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._onDrop),p(t,"touchend",this._onDrop),p(t,"pointerup",this._onDrop),p(t,"pointercancel",this._onDrop),p(t,"touchcancel",this._onDrop),p(document,"selectstart",this)},_onDrop:function(t){var e=this.el,n=this.options;rt=j(Z),lt=j(Z,n.draggable),U("drop",this,{evt:t}),$=Z&&Z.parentNode,rt=j(Z),lt=j(Z,n.draggable),jt.eventCanceled||(_t=Ct=Dt=!1,clearInterval(this._loopId),clearTimeout(this._dragStartTimer),zt(this.cloneId),zt(this._dragStartId),this.nativeDraggable&&(p(document,"drop",this),p(e,"dragstart",this._onDragStart)),this._offMoveEvents(),this._offUpEvents(),u&&R(document.body,"user-select",""),R(Z,"transform",""),t&&(mt&&(t.cancelable&&t.preventDefault(),n.dropBubble||t.stopPropagation()),Q&&Q.parentNode&&Q.parentNode.removeChild(Q),(J===$||ct&&"clone"!==ct.lastPutMode)&&nt&&nt.parentNode&&nt.parentNode.removeChild(nt),Z&&(this.nativeDraggable&&p(Z,"dragend",this),Lt(Z),Z.style["will-change"]="",mt&&!Dt&&k(Z,(ct||this).options.ghostClass,!1),k(Z,this.options.chosenClass,!1),V({sortable:this,name:"unchoose",toEl:$,newIndex:null,newDraggableIndex:null,originalEvent:t}),J!==$?(0<=rt&&(V({rootEl:$,name:"add",toEl:$,fromEl:J,originalEvent:t}),V({sortable:this,name:"remove",toEl:$,originalEvent:t}),V({rootEl:$,name:"sort",toEl:$,fromEl:J,originalEvent:t}),V({sortable:this,name:"sort",toEl:$,originalEvent:t})),ct&&ct.save()):rt!==it&&0<=rt&&(V({sortable:this,name:"update",toEl:$,originalEvent:t}),V({sortable:this,name:"sort",toEl:$,originalEvent:t})),jt.active&&(null!=rt&&-1!==rt||(rt=it,lt=at),V({sortable:this,name:"end",toEl:$,originalEvent:t}),this.save())))),this._nulling()},_nulling:function(){U("nulling",this),J=Z=$=Q=tt=nt=et=ot=ut=dt=mt=rt=lt=it=at=vt=bt=ct=st=jt.dragged=jt.ghost=jt.clone=jt.active=null,Ot.forEach(function(t){t.checked=!0}),Ot.length=ht=pt=0},handleEvent:function(t){switch(t.type){case"drop":case"dragend":this._onDrop(t);break;case"dragenter":case"dragover":Z&&(this._onDragOver(t),function(t){t.dataTransfer&&(t.dataTransfer.dropEffect="move");t.cancelable&&t.preventDefault()}(t));break;case"selectstart":t.preventDefault()}},toArray:function(){for(var t,e=[],n=this.el.children,o=0,i=n.length,r=this.options;o<i;o++)P(t=n[o],r.draggable,this.el,!1)&&e.push(t.getAttribute(r.dataIdAttr)||function(t){var e=t.tagName+t.className+t.src+t.href+t.textContent,n=e.length,o=0;for(;n--;)o+=e.charCodeAt(n);return o.toString(36)}(t));return e},sort:function(t,e){var n={},o=this.el;this.toArray().forEach(function(t,e){e=o.children[e];P(e,this.options.draggable,o,!1)&&(n[t]=e)},this),e&&this.captureAnimationState(),t.forEach(function(t){n[t]&&(o.removeChild(n[t]),o.appendChild(n[t]))}),e&&this.animateAll()},save:function(){var t=this.options.store;t&&t.set&&t.set(this)},closest:function(t,e){return P(t,e||this.options.draggable,this.el,!1)},option:function(t,e){var n=this.options;if(void 0===e)return n[t];var o=z.modifyOption(this,t,e);n[t]=void 0!==o?o:e,"group"===t&&Rt(n)},destroy:function(){U("destroy",this);var t=this.el;t[K]=null,p(t,"mousedown",this._onTapStart),p(t,"touchstart",this._onTapStart),p(t,"pointerdown",this._onTapStart),this.nativeDraggable&&(p(t,"dragover",this),p(t,"dragenter",this)),Array.prototype.forEach.call(t.querySelectorAll("[draggable]"),function(t){t.removeAttribute("draggable")}),this._onDrop(),this._disableDelayedDragEvents(),St.splice(St.indexOf(this.el),1),this.el=t=null},_hideClone:function(){ot||(U("hideClone",this),jt.eventCanceled||(R(nt,"display","none"),this.options.removeCloneOnHide&&nt.parentNode&&nt.parentNode.removeChild(nt),ot=!0))},_showClone:function(t){"clone"===t.lastPutMode?ot&&(U("showClone",this),jt.eventCanceled||(Z.parentNode!=J||this.options.group.revertClone?tt?J.insertBefore(nt,tt):J.appendChild(nt):J.insertBefore(nt,Z),this.options.group.revertClone&&this.animate(Z,nt),R(nt,"display",""),ot=!1)):this._hideClone()}},Mt&&h(document,"touchmove",function(t){(jt.active||Dt)&&t.cancelable&&t.preventDefault()}),jt.utils={on:h,off:p,css:R,find:D,is:function(t,e){return!!P(t,e,t,!1)},extend:function(t,e){if(t&&e)for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n]);return t},throttle:_,closest:P,toggleClass:k,clone:C,index:j,nextTick:Wt,cancelNextTick:zt,detectDirection:kt,getChild:B,expando:K},jt.get=function(t){return t[K]},jt.mount=function(){for(var t=arguments.length,e=new Array(t),n=0;n<t;n++)e[n]=arguments[n];(e=e[0].constructor===Array?e[0]:e).forEach(function(t){if(!t.prototype||!t.prototype.constructor)throw"Sortable: Mounted plugin must be a constructor function, not ".concat({}.toString.call(t));t.utils&&(jt.utils=I(I({},jt.utils),t.utils)),z.mount(t)})},jt.create=function(t,e){return new jt(t,e)};var Gt,Ut,qt,Vt,Zt,$t,Qt=[],Jt=!(jt.version="1.15.6");function te(){Qt.forEach(function(t){clearInterval(t.pid)}),Qt=[]}function ee(){clearInterval($t)}var ne,oe=_(function(n,t,e,o){if(t.scroll){var i,r=(n.touches?n.touches[0]:n).clientX,a=(n.touches?n.touches[0]:n).clientY,l=t.scrollSensitivity,s=t.scrollSpeed,c=O(),u=!1;Ut!==e&&(Ut=e,te(),Gt=t.scroll,i=t.scrollFn,!0===Gt&&(Gt=M(e,!0)));var d=0,h=Gt;do{var p=h,f=X(p),g=f.top,m=f.bottom,v=f.left,b=f.right,y=f.width,w=f.height,D=void 0,E=void 0,S=p.scrollWidth,_=p.scrollHeight,C=R(p),T=p.scrollLeft,f=p.scrollTop,E=p===c?(D=y<S&&("auto"===C.overflowX||"scroll"===C.overflowX||"visible"===C.overflowX),w<_&&("auto"===C.overflowY||"scroll"===C.overflowY||"visible"===C.overflowY)):(D=y<S&&("auto"===C.overflowX||"scroll"===C.overflowX),w<_&&("auto"===C.overflowY||"scroll"===C.overflowY)),T=D&&(Math.abs(b-r)<=l&&T+y<S)-(Math.abs(v-r)<=l&&!!T),f=E&&(Math.abs(m-a)<=l&&f+w<_)-(Math.abs(g-a)<=l&&!!f);if(!Qt[d])for(var x=0;x<=d;x++)Qt[x]||(Qt[x]={});Qt[d].vx==T&&Qt[d].vy==f&&Qt[d].el===p||(Qt[d].el=p,Qt[d].vx=T,Qt[d].vy=f,clearInterval(Qt[d].pid),0==T&&0==f||(u=!0,Qt[d].pid=setInterval(function(){o&&0===this.layer&&jt.active._onTouchMove(Zt);var t=Qt[this.layer].vy?Qt[this.layer].vy*s:0,e=Qt[this.layer].vx?Qt[this.layer].vx*s:0;"function"==typeof i&&"continue"!==i.call(jt.dragged.parentNode[K],e,t,n,Zt,Qt[this.layer].el)||H(Qt[this.layer].el,e,t)}.bind({layer:d}),24))),d++}while(t.bubbleScroll&&h!==c&&(h=M(h,!1)));Jt=u}},30),n=function(t){var e=t.originalEvent,n=t.putSortable,o=t.dragEl,i=t.activeSortable,r=t.dispatchSortableEvent,a=t.hideGhostForTarget,t=t.unhideGhostForTarget;e&&(i=n||i,a(),e=e.changedTouches&&e.changedTouches.length?e.changedTouches[0]:e,e=document.elementFromPoint(e.clientX,e.clientY),t(),i&&!i.el.contains(e)&&(r("spill"),this.onSpill({dragEl:o,putSortable:n})))};function ie(){}function re(){}ie.prototype={startIndex:null,dragStart:function(t){t=t.oldDraggableIndex;this.startIndex=t},onSpill:function(t){var e=t.dragEl,n=t.putSortable;this.sortable.captureAnimationState(),n&&n.captureAnimationState();t=B(this.sortable.el,this.startIndex,this.options);t?this.sortable.el.insertBefore(e,t):this.sortable.el.appendChild(e),this.sortable.animateAll(),n&&n.animateAll()},drop:n},a(ie,{pluginName:"revertOnSpill"}),re.prototype={onSpill:function(t){var e=t.dragEl,t=t.putSortable||this.sortable;t.captureAnimationState(),e.parentNode&&e.parentNode.removeChild(e),t.animateAll()},drop:n},a(re,{pluginName:"removeOnSpill"});var ae,le,se,ce,ue,de=[],he=[],pe=!1,fe=!1,ge=!1;function me(n,o){he.forEach(function(t,e){e=o.children[t.sortableIndex+(n?Number(e):0)];e?o.insertBefore(t,e):o.appendChild(t)})}function ve(){de.forEach(function(t){t!==se&&t.parentNode&&t.parentNode.removeChild(t)})}return jt.mount(new function(){function t(){for(var t in this.defaults={scroll:!0,forceAutoScrollFallback:!1,scrollSensitivity:30,scrollSpeed:10,bubbleScroll:!0},this)"_"===t.charAt(0)&&"function"==typeof this[t]&&(this[t]=this[t].bind(this))}return t.prototype={dragStarted:function(t){t=t.originalEvent;this.sortable.nativeDraggable?h(document,"dragover",this._handleAutoScroll):this.options.supportPointer?h(document,"pointermove",this._handleFallbackAutoScroll):t.touches?h(document,"touchmove",this._handleFallbackAutoScroll):h(document,"mousemove",this._handleFallbackAutoScroll)},dragOverCompleted:function(t){t=t.originalEvent;this.options.dragOverBubble||t.rootEl||this._handleAutoScroll(t)},drop:function(){this.sortable.nativeDraggable?p(document,"dragover",this._handleAutoScroll):(p(document,"pointermove",this._handleFallbackAutoScroll),p(document,"touchmove",this._handleFallbackAutoScroll),p(document,"mousemove",this._handleFallbackAutoScroll)),ee(),te(),clearTimeout(m),m=void 0},nulling:function(){Zt=Ut=Gt=Jt=$t=qt=Vt=null,Qt.length=0},_handleFallbackAutoScroll:function(t){this._handleAutoScroll(t,!0)},_handleAutoScroll:function(e,n){var o,i=this,r=(e.touches?e.touches[0]:e).clientX,a=(e.touches?e.touches[0]:e).clientY,t=document.elementFromPoint(r,a);Zt=e,n||this.options.forceAutoScrollFallback||w||y||u?(oe(e,this.options,t,n),o=M(t,!0),!Jt||$t&&r===qt&&a===Vt||($t&&ee(),$t=setInterval(function(){var t=M(document.elementFromPoint(r,a),!0);t!==o&&(o=t,te()),oe(e,i.options,t,n)},10),qt=r,Vt=a)):this.options.bubbleScroll&&M(t,!0)!==O()?oe(e,this.options,M(t,!1),!1):te()}},a(t,{pluginName:"scroll",initializeByDefault:!0})}),jt.mount(re,ie),jt.mount(new function(){function t(){this.defaults={swapClass:"sortable-swap-highlight"}}return t.prototype={dragStart:function(t){t=t.dragEl;ne=t},dragOverValid:function(t){var e=t.completed,n=t.target,o=t.onMove,i=t.activeSortable,r=t.changed,a=t.cancel;i.options.swap&&(t=this.sortable.el,i=this.options,n&&n!==t&&(t=ne,ne=!1!==o(n)?(k(n,i.swapClass,!0),n):null,t&&t!==ne&&k(t,i.swapClass,!1)),r(),e(!0),a())},drop:function(t){var e,n,o=t.activeSortable,i=t.putSortable,r=t.dragEl,a=i||this.sortable,l=this.options;ne&&k(ne,l.swapClass,!1),ne&&(l.swap||i&&i.options.swap)&&r!==ne&&(a.captureAnimationState(),a!==o&&o.captureAnimationState(),n=ne,t=(e=r).parentNode,l=n.parentNode,t&&l&&!t.isEqualNode(n)&&!l.isEqualNode(e)&&(i=j(e),r=j(n),t.isEqualNode(l)&&i<r&&r++,t.insertBefore(n,t.children[i]),l.insertBefore(e,l.children[r])),a.animateAll(),a!==o&&o.animateAll())},nulling:function(){ne=null}},a(t,{pluginName:"swap",eventProperties:function(){return{swapItem:ne}}})}),jt.mount(new function(){function t(o){for(var t in this)"_"===t.charAt(0)&&"function"==typeof this[t]&&(this[t]=this[t].bind(this));o.options.avoidImplicitDeselect||(o.options.supportPointer?h(document,"pointerup",this._deselectMultiDrag):(h(document,"mouseup",this._deselectMultiDrag),h(document,"touchend",this._deselectMultiDrag))),h(document,"keydown",this._checkKeyDown),h(document,"keyup",this._checkKeyUp),this.defaults={selectedClass:"sortable-selected",multiDragKey:null,avoidImplicitDeselect:!1,setData:function(t,e){var n="";de.length&&le===o?de.forEach(function(t,e){n+=(e?", ":"")+t.textContent}):n=e.textContent,t.setData("Text",n)}}}return t.prototype={multiDragKeyDown:!1,isMultiDrag:!1,delayStartGlobal:function(t){t=t.dragEl;se=t},delayEnded:function(){this.isMultiDrag=~de.indexOf(se)},setupClone:function(t){var e=t.sortable,t=t.cancel;if(this.isMultiDrag){for(var n=0;n<de.length;n++)he.push(C(de[n])),he[n].sortableIndex=de[n].sortableIndex,he[n].draggable=!1,he[n].style["will-change"]="",k(he[n],this.options.selectedClass,!1),de[n]===se&&k(he[n],this.options.chosenClass,!1);e._hideClone(),t()}},clone:function(t){var e=t.sortable,n=t.rootEl,o=t.dispatchSortableEvent,t=t.cancel;this.isMultiDrag&&(this.options.removeCloneOnHide||de.length&&le===e&&(me(!0,n),o("clone"),t()))},showClone:function(t){var e=t.cloneNowShown,n=t.rootEl,t=t.cancel;this.isMultiDrag&&(me(!1,n),he.forEach(function(t){R(t,"display","")}),e(),ue=!1,t())},hideClone:function(t){var e=this,n=(t.sortable,t.cloneNowHidden),t=t.cancel;this.isMultiDrag&&(he.forEach(function(t){R(t,"display","none"),e.options.removeCloneOnHide&&t.parentNode&&t.parentNode.removeChild(t)}),n(),ue=!0,t())},dragStartGlobal:function(t){t.sortable;!this.isMultiDrag&&le&&le.multiDrag._deselectMultiDrag(),de.forEach(function(t){t.sortableIndex=j(t)}),de=de.sort(function(t,e){return t.sortableIndex-e.sortableIndex}),ge=!0},dragStarted:function(t){var e,n=this,t=t.sortable;this.isMultiDrag&&(this.options.sort&&(t.captureAnimationState(),this.options.animation&&(de.forEach(function(t){t!==se&&R(t,"position","absolute")}),e=X(se,!1,!0,!0),de.forEach(function(t){t!==se&&T(t,e)}),pe=fe=!0)),t.animateAll(function(){pe=fe=!1,n.options.animation&&de.forEach(function(t){x(t)}),n.options.sort&&ve()}))},dragOver:function(t){var e=t.target,n=t.completed,t=t.cancel;fe&&~de.indexOf(e)&&(n(!1),t())},revert:function(t){var n,o,e=t.fromSortable,i=t.rootEl,r=t.sortable,a=t.dragRect;1<de.length&&(de.forEach(function(t){r.addAnimationState({target:t,rect:fe?X(t):a}),x(t),t.fromRect=a,e.removeAnimationState(t)}),fe=!1,n=!this.options.removeCloneOnHide,o=i,de.forEach(function(t,e){e=o.children[t.sortableIndex+(n?Number(e):0)];e?o.insertBefore(t,e):o.appendChild(t)}))},dragOverCompleted:function(t){var e,n=t.sortable,o=t.isOwner,i=t.insertion,r=t.activeSortable,a=t.parentEl,l=t.putSortable,t=this.options;i&&(o&&r._hideClone(),pe=!1,t.animation&&1<de.length&&(fe||!o&&!r.options.sort&&!l)&&(e=X(se,!1,!0,!0),de.forEach(function(t){t!==se&&(T(t,e),a.appendChild(t))}),fe=!0),o||(fe||ve(),1<de.length?(o=ue,r._showClone(n),r.options.animation&&!ue&&o&&he.forEach(function(t){r.addAnimationState({target:t,rect:ce}),t.fromRect=ce,t.thisAnimationDuration=null})):r._showClone(n)))},dragOverAnimationCapture:function(t){var e=t.dragRect,n=t.isOwner,t=t.activeSortable;de.forEach(function(t){t.thisAnimationDuration=null}),t.options.animation&&!n&&t.multiDrag.isMultiDrag&&(ce=a({},e),e=b(se,!0),ce.top-=e.f,ce.left-=e.e)},dragOverAnimationComplete:function(){fe&&(fe=!1,ve())},drop:function(t){var o,i,r,a,n,e,l,s=t.originalEvent,c=t.rootEl,u=t.parentEl,d=t.sortable,h=t.dispatchSortableEvent,p=t.oldIndex,t=t.putSortable,f=t||this.sortable;s&&(o=this.options,i=u.children,ge||(o.multiDragKey&&!this.multiDragKeyDown&&this._deselectMultiDrag(),k(se,o.selectedClass,!~de.indexOf(se)),~de.indexOf(se)?(de.splice(de.indexOf(se),1),ae=null,G({sortable:d,rootEl:c,name:"deselect",targetEl:se,originalEvent:s})):(de.push(se),G({sortable:d,rootEl:c,name:"select",targetEl:se,originalEvent:s}),s.shiftKey&&ae&&d.el.contains(ae)?(r=j(ae),a=j(se),~r&&~a&&r!==a&&function(){for(var e,t=r<a?(e=r,a):(e=a,r+1),n=o.filter;e<t;e++)~de.indexOf(i[e])||P(i[e],o.draggable,u,!1)&&(n&&("function"==typeof n?n.call(d,s,i[e],d):n.split(",").some(function(t){return P(i[e],t.trim(),u,!1)}))||(k(i[e],o.selectedClass,!0),de.push(i[e]),G({sortable:d,rootEl:c,name:"select",targetEl:i[e],originalEvent:s})))}()):ae=se,le=f)),ge&&this.isMultiDrag&&(fe=!1,(u[K].options.sort||u!==c)&&1<de.length&&(n=X(se),e=j(se,":not(."+this.options.selectedClass+")"),!pe&&o.animation&&(se.thisAnimationDuration=null),f.captureAnimationState(),pe||(o.animation&&(se.fromRect=n,de.forEach(function(t){var e;t.thisAnimationDuration=null,t!==se&&(e=fe?X(t):n,t.fromRect=e,f.addAnimationState({target:t,rect:e}))})),ve(),de.forEach(function(t){i[e]?u.insertBefore(t,i[e]):u.appendChild(t),e++}),p===j(se)&&(l=!1,de.forEach(function(t){t.sortableIndex!==j(t)&&(l=!0)}),l&&(h("update"),h("sort")))),de.forEach(function(t){x(t)}),f.animateAll()),le=f),(c===u||t&&"clone"!==t.lastPutMode)&&he.forEach(function(t){t.parentNode&&t.parentNode.removeChild(t)}))},nullingGlobal:function(){this.isMultiDrag=ge=!1,he.length=0},destroyGlobal:function(){this._deselectMultiDrag(),p(document,"pointerup",this._deselectMultiDrag),p(document,"mouseup",this._deselectMultiDrag),p(document,"touchend",this._deselectMultiDrag),p(document,"keydown",this._checkKeyDown),p(document,"keyup",this._checkKeyUp)},_deselectMultiDrag:function(t){if(!(void 0!==ge&&ge||le!==this.sortable||t&&P(t.target,this.options.draggable,this.sortable.el,!1)||t&&0!==t.button))for(;de.length;){var e=de[0];k(e,this.options.selectedClass,!1),de.shift(),G({sortable:this.sortable,rootEl:this.sortable.el,name:"deselect",targetEl:e,originalEvent:t})}},_checkKeyDown:function(t){t.key===this.options.multiDragKey&&(this.multiDragKeyDown=!0)},_checkKeyUp:function(t){t.key===this.options.multiDragKey&&(this.multiDragKeyDown=!1)}},a(t,{pluginName:"multiDrag",utils:{select:function(t){var e=t.parentNode[K];e&&e.options.multiDrag&&!~de.indexOf(t)&&(le&&le!==e&&(le.multiDrag._deselectMultiDrag(),le=e),k(t,e.options.selectedClass,!0),de.push(t))},deselect:function(t){var e=t.parentNode[K],n=de.indexOf(t);e&&e.options.multiDrag&&~n&&(k(t,e.options.selectedClass,!1),de.splice(n,1))}},eventProperties:function(){var n=this,o=[],i=[];return de.forEach(function(t){var e;o.push({multiDragElement:t,index:t.sortableIndex}),e=fe&&t!==se?-1:fe?j(t,":not(."+n.options.selectedClass+")"):j(t),i.push({multiDragElement:t,index:e})}),{items:r(de),clones:[].concat(he),oldIndicies:o,newIndicies:i}},optionListeners:{multiDragKey:function(t){return"ctrl"===(t=t.toLowerCase())?t="Control":1<t.length&&(t=t.charAt(0).toUpperCase()+t.substr(1)),t}}})}),jt});
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ module Board
5
+ # Shared `reorder` action for board controllers. The three McRitchie Studio
6
+ # kanban controllers (tasks / news / content) each carried a BYTE-IDENTICAL
7
+ # `reorder` — guard the incoming id array, restamp the column with 100-gaps
8
+ # INSIDE `rescue_and_log` (so a failure lands in ErrorLog — backend write
9
+ # discipline), render `{ success: true }`, and rescue to a 422. This concern is
10
+ # that action, neutral: the including controller declares its model, id column,
11
+ # and the incoming param, and the ranking rule itself is delegated to the model's
12
+ # Studio::Board::Rankable#reposition! (one place owns the 100-gap math). This is
13
+ # THE designated shared write action, so it owns the ErrorLog logging here rather
14
+ # than leaving each host to re-add it.
15
+ #
16
+ # class TasksController < ApplicationController
17
+ # include Studio::Board::Reorderable
18
+ # board_reorderable model: Task, id_attr: :slug, param: :slugs
19
+ # end
20
+ #
21
+ # # Route it however the app names the endpoint:
22
+ # post "tasks/reorder", to: "tasks#reorder"
23
+ #
24
+ # The POST body the studio/board factory sends is `{ <param>: [...ids],
25
+ # zone: "<zone-key>" }`; this action reads only `<param>` (the ordered id list)
26
+ # and ignores the advisory `zone`, so a kanban and a depth-chart board share it.
27
+ module Reorderable
28
+ extend ActiveSupport::Concern
29
+
30
+ included do
31
+ class_attribute :board_reorder_model, instance_accessor: false
32
+ class_attribute :board_reorder_id_attr, instance_accessor: false, default: :slug
33
+ class_attribute :board_reorder_param, instance_accessor: false, default: :slugs
34
+ class_attribute :board_reorder_gap, instance_accessor: false, default: 100
35
+ class_attribute :board_reorder_direction, instance_accessor: false, default: :desc
36
+ end
37
+
38
+ class_methods do
39
+ # Configure the reorder action for this controller's board.
40
+ def board_reorderable(model:, id_attr: :slug, param: :slugs, gap: 100, direction: :desc)
41
+ self.board_reorder_model = model
42
+ self.board_reorder_id_attr = id_attr
43
+ self.board_reorder_param = param
44
+ self.board_reorder_gap = gap
45
+ self.board_reorder_direction = direction
46
+ end
47
+ end
48
+
49
+ # POST — restamp a column from its DOM-ordered id list. Neutral param
50
+ # (`slugs` or `ids`), 100-gap, DESC by default. Mirrors the MS hand-rolled
51
+ # `reorder` exactly, INCLUDING its ErrorLog logging: the restamp runs inside
52
+ # `rescue_and_log(target: nil)` (Studio::ErrorHandling), which captures a
53
+ # failure to ErrorLog and RE-RAISES to the outer 422 net below — the "every
54
+ # write path logs" discipline, owned here since this is the one shared write
55
+ # action. `respond_to?` guards it so a host whose ApplicationController somehow
56
+ # lacks the concern still degrades to a bare restamp + 422 (no NoMethodError).
57
+ def reorder
58
+ model = self.class.board_reorder_model
59
+ raise "board_reorderable model not configured" unless model
60
+
61
+ param = self.class.board_reorder_param
62
+ ids = params[param]
63
+ return render(json: { error: "#{param} required" }, status: :unprocessable_entity) unless ids.is_a?(Array)
64
+
65
+ if respond_to?(:rescue_and_log)
66
+ rescue_and_log(target: nil) { board_reorder_apply(model, ids) }
67
+ else
68
+ board_reorder_apply(model, ids)
69
+ end
70
+ render json: { success: true }
71
+ rescue StandardError => e
72
+ render json: { error: e.message }, status: :unprocessable_entity
73
+ end
74
+
75
+ private
76
+
77
+ # Delegate to the model's Rankable#reposition! when available (the shared
78
+ # 100-gap rule), else fall back to the same per-id update_all loop inline so
79
+ # a model that only carries a `position` column still reorders.
80
+ def board_reorder_apply(model, ids)
81
+ id_attr = self.class.board_reorder_id_attr
82
+ gap = self.class.board_reorder_gap
83
+ direction = self.class.board_reorder_direction
84
+
85
+ if model.respond_to?(:reposition!)
86
+ model.reposition!(ids, gap: gap, direction: direction, id_attr: id_attr)
87
+ else
88
+ length = ids.length
89
+ ids.each_with_index do |id, index|
90
+ rank = direction.to_sym == :asc ? (index + 1) * gap : (length - index) * gap
91
+ model.where(id_attr => id).update_all(position: rank)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ module Board
5
+ # Shared 100-gap rank read-model for board-orderable records — the kanban
6
+ # columns (McRitchie Studio tasks / news / content) and the depth-chart lanes.
7
+ # A record carries an integer `position`; the board renders by `board_ordered`
8
+ # (highest position on top), and a drag-reorder restamps the whole column with
9
+ # 100-gaps via `reposition!`. Ranking is ZONE-SCOPED: a fresh rank is computed
10
+ # within the record's zone (a kanban `stage`, a depth-chart position group), so
11
+ # every column ranks independently and 100-spacing leaves room for the next
12
+ # drag insert without a full re-stamp.
13
+ #
14
+ # This is the read/rank half of the board primitive; the write half is
15
+ # Studio::Board::Reorderable (the controller action that calls reposition!).
16
+ #
17
+ # class Task < ApplicationRecord
18
+ # include Studio::Board::Rankable
19
+ # before_create :set_initial_position # seeds the genesis rank
20
+ # end
21
+ #
22
+ # class DepthChartEntry < ApplicationRecord
23
+ # include Studio::Board::Rankable
24
+ # self.board_zone_attr = :position_group # a lane, not a kanban stage
25
+ # end
26
+ module Rankable
27
+ extend ActiveSupport::Concern
28
+
29
+ included do
30
+ # The column the record ranks WITHIN. Default matches the three MS kanban
31
+ # boards (stage); a within-lane board overrides it, and `nil` ranks globally.
32
+ class_attribute :board_zone_attr, instance_accessor: false, default: :stage
33
+
34
+ # Board order: highest `position` first (freshest/top card wins), NULLS last,
35
+ # then newest by created_at as a stable tiebreak. Arel.sql: the fragment is a
36
+ # fixed literal, not user input.
37
+ scope :board_ordered, -> { order(Arel.sql("position DESC NULLS LAST, created_at DESC")) }
38
+ end
39
+
40
+ # Seed the genesis rank on create: max(position) within this record's zone plus
41
+ # a 100 gap. A no-op when `position` is already set, so an explicit rank is
42
+ # never clobbered. Wire from the host via `before_create :set_initial_position`.
43
+ def set_initial_position
44
+ return if position.present?
45
+
46
+ self.position = self.class.board_next_position(zone_value_for_rank)
47
+ end
48
+
49
+ # This record's zone value (nil when the model ranks globally).
50
+ def zone_value_for_rank
51
+ attr = self.class.board_zone_attr
52
+ attr && respond_to?(attr) ? public_send(attr) : nil
53
+ end
54
+
55
+ class_methods do
56
+ # max(position) + gap within a zone (or globally when the zone value / attr
57
+ # is blank). The genesis seed and the "bump to top on a stage move" both read
58
+ # from here, so the 100-gap rule lives in one place.
59
+ def board_next_position(zone_value = nil, gap: 100)
60
+ scope = board_zone_attr && zone_value ? where(board_zone_attr => zone_value) : all
61
+ (scope.maximum(:position) || 0) + gap
62
+ end
63
+
64
+ # Restamp a column top-to-bottom in ONE pass. `ids` arrive in DOM order (top
65
+ # first). Under the `position DESC` board sort the TOP card must hold the
66
+ # HIGHEST rank, so with `direction: :desc` (the board default) the first id
67
+ # gets the largest value and each next one drops by `gap` — 100-spacing that
68
+ # leaves gaps for the next drag insert. `direction: :asc` ranks the other way
69
+ # (first id smallest) for an ascending board. This is exactly the per-id
70
+ # `update_all(position: ...)` loop the MS tasks/news/content reorder actions
71
+ # each ran by hand, lifted into the model. Returns the ids it stamped.
72
+ def reposition!(ids, gap: 100, direction: :desc, id_attr: primary_key)
73
+ ids = Array(ids)
74
+ length = ids.length
75
+ ids.each_with_index do |id, index|
76
+ rank = direction.to_sym == :asc ? (index + 1) * gap : (length - index) * gap
77
+ where(id_attr => id).update_all(position: rank)
78
+ end
79
+ ids
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -1,4 +1,4 @@
1
- <%# locals: (text:, scheme: "neutral") %>
1
+ <%# locals: (text:, scheme: "neutral", data_board_count: nil) %>
2
2
  <%#
3
3
  Stage roles (stage-fresh ... stage-closed) form a shared pipeline palette
4
4
  used by News and Content (and any future pipelines). Mapping:
@@ -32,4 +32,6 @@
32
32
  else "bg-surface-alt text-body border-subtle"
33
33
  end
34
34
  %>
35
- <span class="badge <%= scheme_classes %>"><%= text %></span>
35
+ <%# data_board_count wires the badge as a studio/board count target (updateCounts sets
36
+ its textContent) — additive + opt-in, so every existing caller renders identically. %>
37
+ <span class="badge <%= scheme_classes %>"<% if local_assigns[:data_board_count] %> data-board-count="<%= data_board_count %>"<% end %>><%= text %></span>
@@ -28,6 +28,12 @@
28
28
  is unchanged and rides the same global. %>
29
29
  <%= javascript_include_tag "studio/canvas_confetti", "data-turbo-track": "reload" %>
30
30
  <%= javascript_include_tag "studio/studio_confetti", "data-turbo-track": "reload" %>
31
+ <%# SortableJS is VENDORED into the engine (studio/sortable.js) and shipped through
32
+ the asset pipeline — no CDN, CSP-safe (same-origin :self). Defines the global
33
+ `Sortable`; the studio/board primitive (studio/_board_assets -> window.studioBoard)
34
+ drives it for drag-reorder kanban AND depth-chart boards. Loaded here (deferred)
35
+ so any board consumer has it with zero per-app wiring, mirroring canvas_confetti. %>
36
+ <%= javascript_include_tag "studio/sortable", defer: true, "data-turbo-track": "reload" %>
31
37
  <script>
32
38
  window.fireSuccessConfetti = function() {
33
39
  if (typeof confetti === 'undefined') return;
@@ -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>
@@ -1,13 +1,52 @@
1
1
  <%# Tasks section of the living style guide (admin/style).
2
2
 
3
- A DEMONSTRATOR of the shared board primitive the columns, cards, stage
4
- badges, and drag/rank affordance that every board (McRitchie Studio's
5
- /deployments is the top-of-line) builds from. It is a static sketch, not a
6
- backend-wired sortable board; the point is to show the standard so custom
7
- boards can adopt it. The stage-* badge palette (components/_badge) is the
8
- spine. %>
3
+ The shared board primitive, LIVE. Phase D lifted the columns / cards / drag +
4
+ optimistic move / reorder that the three near-identical McRitchie Studio boards
5
+ (tasks / news / content) each hand-rolled into one engine primitive
6
+ (studio/board/_board + the window.studioBoard factory + vendored SortableJS).
7
+ The first block below renders that real primitive with `demo: true` — you can
8
+ DRAG a card between columns and the move animates + toasts, but nothing POSTs.
9
+ The blocks under it stay a static reference: the stage-* badge palette
10
+ (components/_badge) that is the pipeline spine, and the standard-vs-custom
11
+ split of what a board inherits vs sets. %>
9
12
  <%
10
- # The shared pipeline palette: each column maps to a stage-* badge scheme.
13
+ # LIVE specimen the real studio/board/_board primitive, demo mode. Plain Hash
14
+ # records (the style guide has no models); each card id becomes id="card-<id>".
15
+ # Each demo card carries its current `zone` (the column it sits in) — in a real
16
+ # board the record knows its own stage; here the Hash does.
17
+ live_board_columns = [
18
+ { key: "designed", label: "Designed", badge_class: "stage-fresh",
19
+ cards: [
20
+ { id: "engine-board-primitive", zone: "designed", title: "Engine board primitive", repo: "studio-engine", who: "SH" },
21
+ { id: "coinflow-buy-rail", zone: "designed", title: "Coinflow buy rail", repo: "turf-monster", who: "JA" }
22
+ ] },
23
+ { key: "building", label: "Building", badge_class: "stage-shaping",
24
+ cards: [{ id: "navbar-rpc-preload", zone: "building", title: "Navbar RPC preload", repo: "turf-monster", who: "CA" }] },
25
+ { key: "submitted", label: "Submitted", badge_class: "stage-structured",
26
+ cards: [{ id: "magic-link-ttl-bump", zone: "submitted", title: "Magic-link TTL bump", repo: "studio-engine", who: "SH" }] },
27
+ { key: "shipped", label: "Shipped", badge_class: "stage-shipped",
28
+ cards: [{ id: "ses-email-backend", zone: "shipped", title: "SES email backend", repo: "mcritchie", who: "ST" }] }
29
+ ]
30
+
31
+ # Copy-paste-for-an-agent usage. Built by array-join, NOT a heredoc: a heredoc as
32
+ # the last expression of an ERB scriptlet trips Erubi, and literal ERB tags in the
33
+ # body would close the tag. Plain render() calls read clearly for an agent.
34
+ board_usage_snippet = [
35
+ "# once at layout level (a <script> in the board template never runs):",
36
+ 'render "studio/board_assets"',
37
+ "",
38
+ "# then the board itself:",
39
+ 'render "studio/board/board",',
40
+ " columns: @columns, # [{ key:, label:, cards:, badge_class: }]",
41
+ ' card_partial: "tasks/task_card",',
42
+ " reorder_url: tasks_reorder_path, # POST { slugs: [...], zone: }",
43
+ ' move_url: "/tasks/:id.json", # PATCH { task: { stage: } }',
44
+ ' move_param: { resource: "task", attr: "stage" },',
45
+ ' live_channel: "deployments"'
46
+ ].join("\n")
47
+
48
+ # The static reference palette below (unchanged): each column maps to a stage-*
49
+ # badge scheme.
11
50
  board_columns = [
12
51
  { key: "stage-fresh", label: "Fresh", count: 2 },
13
52
  { key: "stage-shaping", label: "Shaping", count: 1 },
@@ -32,13 +71,45 @@
32
71
  <h2 class="text-2xl font-bold text-heading">Tasks</h2>
33
72
  <p class="text-muted text-sm max-w-2xl">
34
73
  The shared board primitive that every board builds from &mdash; columns, cards, the
35
- <code class="font-mono text-2xs">stage-*</code> badge palette, and the drag / rank affordance.
36
- McRitchie Studio's <code class="font-mono text-2xs">/deployments</code> board is the top-of-line;
37
- this is a static demonstrator (full extraction lands in a later phase).
74
+ <code class="font-mono text-2xs">stage-*</code> badge palette, and the drag / optimistic
75
+ move + reorder. McRitchie Studio's <code class="font-mono text-2xs">/deployments</code> board is
76
+ the top-of-line; the specimen below is the <strong>real</strong>
77
+ <code class="font-mono text-2xs">studio/board/board</code> primitive in
78
+ <code class="font-mono text-2xs">demo: true</code> mode &mdash; drag a card, no save.
38
79
  </p>
39
80
  </div>
40
81
 
41
- <%# ---- The board ------------------------------------------------------- %>
82
+ <%# ---- The LIVE primitive (demo mode) --------------------------------- %>
83
+ <%# Render the factory once (a <script> in the board template would never run), then
84
+ the board. demo: true → drag animates + toasts locally, nothing POSTs. %>
85
+ <%= render "studio/board_assets" %>
86
+ <div class="space-y-3">
87
+ <div class="flex items-center gap-2">
88
+ <span class="badge" style="color: var(--color-success); border-color: var(--color-success)">Live</span>
89
+ <p class="label-upper"><code class="font-mono text-2xs">studio/board/board</code> &middot; demo &mdash; drag a card between columns</p>
90
+ </div>
91
+ <%= render "studio/board/board",
92
+ demo: true,
93
+ columns: live_board_columns,
94
+ card_partial: "style/board/demo_card",
95
+ card_as: :card,
96
+ reorder_url: "#",
97
+ move_url: "#",
98
+ move_param: { resource: "task", attr: "stage" },
99
+ group: "studio-board-demo",
100
+ empty_label: "Drop tasks here" %>
101
+ <div class="relative" x-data="{ copied: false }">
102
+ <pre class="bg-inset text-body text-2xs rounded-lg px-3 py-2 pr-16 overflow-x-auto"><code x-ref="boardUsage"><%= board_usage_snippet %></code></pre>
103
+ <button type="button"
104
+ class="btn btn-neutral btn-sm absolute top-2 right-2"
105
+ aria-label="Copy usage snippet"
106
+ @click="navigator.clipboard?.writeText($refs.boardUsage.innerText); copied = true; setTimeout(() => copied = false, 1200)">
107
+ <span x-text="copied ? 'Copied' : 'Copy'">Copy</span>
108
+ </button>
109
+ </div>
110
+ </div>
111
+
112
+ <%# ---- The static reference sketch ------------------------------------- %>
42
113
  <div class="fade-edge-x overflow-x-auto pb-2">
43
114
  <div class="flex gap-4 min-w-max">
44
115
  <% board_columns.each do |col| %>
@@ -0,0 +1,22 @@
1
+ <%# locals: (card:, id_attr: "slug", zone_attr: "stage") %>
2
+ <%#
3
+ A demo board card for the /admin/style Board specimen. Renders THROUGH the engine
4
+ card-shell chrome (studio/board/_card_shell) so the specimen exercises the real
5
+ identity contract (id="card-<id>", class .kanban-card, data-<id_attr>/<zone_attr>),
6
+ not a bespoke stand-in. `card` is a plain Hash — the specimen has no models. The
7
+ id_attr / zone_attr flow through from the board so the same partial demonstrates
8
+ the primitive under both the default and a custom attribute naming.
9
+ %>
10
+ <%= render layout: "studio/board/card_shell",
11
+ locals: { id: card[:id], zone: card[:zone], id_attr: id_attr, zone_attr: zone_attr,
12
+ class: "card p-3 space-y-2 cursor-grab active:cursor-grabbing" } do %>
13
+ <div class="flex items-start gap-2">
14
+ <span class="text-muted select-none leading-none" aria-hidden="true">⠿</span>
15
+ <p class="text-sm font-semibold text-heading leading-snug flex-1"><%= card[:title] %></p>
16
+ </div>
17
+ <div class="flex items-center justify-between gap-2">
18
+ <%= render "components/badge", text: card[:repo], scheme: "neutral" %>
19
+ <span class="inline-flex h-6 w-6 items-center justify-center rounded-full text-2xs font-bold text-white shrink-0"
20
+ style="background: var(--color-cta)"><%= card[:who] %></span>
21
+ </div>
22
+ <% end %>
data/lib/studio/engine.rb CHANGED
@@ -6,6 +6,7 @@ module Studio
6
6
  studio/sticky_table_header.js
7
7
  studio/canvas_confetti.js
8
8
  studio/studio_confetti.js
9
+ studio/sortable.js
9
10
  ]
10
11
  end
11
12
 
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.22.0"
2
+ VERSION = "0.23.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.22.0
4
+ version: 0.23.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -171,6 +171,7 @@ files:
171
171
  - app/assets/images/resend-favicon.png
172
172
  - app/assets/images/ses-favicon.png
173
173
  - app/assets/javascripts/studio/canvas_confetti.js
174
+ - app/assets/javascripts/studio/sortable.js
174
175
  - app/assets/javascripts/studio/sticky_table_header.js
175
176
  - app/assets/javascripts/studio/studio_confetti.js
176
177
  - app/assets/stylesheets/studio/sticky_table_header.css
@@ -178,6 +179,7 @@ files:
178
179
  - app/assets/tailwind/studio_engine/engine.css
179
180
  - app/controllers/concerns/solana/session_auth.rb
180
181
  - app/controllers/concerns/studio/admin_models.rb
182
+ - app/controllers/concerns/studio/board/reorderable.rb
181
183
  - app/controllers/concerns/studio/error_handling.rb
182
184
  - app/controllers/concerns/studio/impersonation.rb
183
185
  - app/controllers/concerns/studio/link_consumption.rb
@@ -205,6 +207,7 @@ files:
205
207
  - app/mailers/application_mailer.rb
206
208
  - app/mailers/user_mailer.rb
207
209
  - app/models/concerns/sluggable.rb
210
+ - app/models/concerns/studio/board/rankable.rb
208
211
  - app/models/concerns/studio/broadcastable.rb
209
212
  - app/models/current.rb
210
213
  - app/models/error_log.rb
@@ -246,6 +249,7 @@ files:
246
249
  - app/views/sessions/_sso_continue.html.erb
247
250
  - app/views/sessions/new.html.erb
248
251
  - app/views/studio/_age_verify_assets.html.erb
252
+ - app/views/studio/_board_assets.html.erb
249
253
  - app/views/studio/_confirm_interstitial.html.erb
250
254
  - app/views/studio/_cropper_assets.html.erb
251
255
  - app/views/studio/_leveling_activity_assets.html.erb
@@ -259,6 +263,9 @@ files:
259
263
  - app/views/studio/banners/_email_status_button.html.erb
260
264
  - app/views/studio/banners/_environment.html.erb
261
265
  - app/views/studio/banners/_impersonation.html.erb
266
+ - app/views/studio/board/_board.html.erb
267
+ - app/views/studio/board/_card_shell.html.erb
268
+ - app/views/studio/board/_column.html.erb
262
269
  - app/views/studio/email_images/index.html.erb
263
270
  - app/views/studio/links/confirm.html.erb
264
271
  - app/views/studio/local_emails/index.html.erb
@@ -299,6 +306,7 @@ files:
299
306
  - app/views/style/_tasks.html.erb
300
307
  - app/views/style/_theme.html.erb
301
308
  - app/views/style/_tricks.html.erb
309
+ - app/views/style/board/_demo_card.html.erb
302
310
  - app/views/style/index.html.erb
303
311
  - app/views/style/modals/_age_verify.html.erb
304
312
  - app/views/style/modals/_auth.html.erb