diff --git a/public/drag-manager.js b/public/drag-manager.js
new file mode 100644
index 00000000..9842f630
--- /dev/null
+++ b/public/drag-manager.js
@@ -0,0 +1,216 @@
+/* drag-manager.js — Free-form panel dragging (#608 M1)
+ * State machine: IDLE → PENDING → DRAGGING → IDLE
+ * Pointer events on .panel-header, transform: translate() during drag,
+ * snap-to-edge on release, z-index on focus, viewport % persistence.
+ */
+(function () {
+ 'use strict';
+
+ var DEAD_ZONE = 5; // px — disambiguate click vs drag
+ var SNAP_THRESHOLD = 20; // px — snap to edge on release
+ var SNAP_MARGIN = 12; // px — margin when snapped
+
+ function DragManager() {
+ this.state = 'IDLE';
+ this.activePanel = null;
+ this.startX = 0;
+ this.startY = 0;
+ this.panelStartX = 0;
+ this.panelStartY = 0;
+ this.preTransform = '';
+ this.enabled = true;
+ this.zCounter = 1000;
+ this._panels = [];
+ this._onKeyDown = this._handleKeyDown.bind(this);
+ }
+
+ DragManager.prototype.register = function (panel) {
+ if (!panel) return;
+ var header = panel.querySelector('.panel-header');
+ if (!header) return;
+ this._panels.push(panel);
+ var self = this;
+
+ header.addEventListener('pointerdown', function (e) {
+ if (!self.enabled) return;
+ if (e.button !== 0) return;
+ if (e.target.closest('button')) return;
+ e.preventDefault();
+ header.setPointerCapture(e.pointerId);
+
+ self.state = 'PENDING';
+ self.activePanel = panel;
+ self.startX = e.clientX;
+ self.startY = e.clientY;
+
+ var rect = panel.getBoundingClientRect();
+ self.panelStartX = rect.left;
+ self.panelStartY = rect.top;
+ self.preTransform = panel.style.transform || '';
+ document.addEventListener('keydown', self._onKeyDown);
+ });
+
+ header.addEventListener('pointermove', function (e) {
+ if (self.state === 'IDLE') return;
+ if (self.activePanel !== panel) return;
+ var dx = e.clientX - self.startX;
+ var dy = e.clientY - self.startY;
+
+ if (self.state === 'PENDING') {
+ if (Math.hypot(dx, dy) < DEAD_ZONE) return;
+ self.state = 'DRAGGING';
+ panel.classList.add('is-dragging');
+ panel.style.zIndex = ++self.zCounter;
+ self._detachFromCorner(panel);
+ }
+
+ panel.style.transform = 'translate(' + dx + 'px, ' + dy + 'px)';
+ });
+
+ header.addEventListener('pointerup', function (e) {
+ if (self.activePanel !== panel) return;
+ header.releasePointerCapture(e.pointerId);
+ if (self.state === 'DRAGGING') {
+ panel.classList.remove('is-dragging');
+ self._finalizePosition(panel);
+ }
+ self._reset();
+ });
+
+ header.addEventListener('pointercancel', function () {
+ if (self.activePanel !== panel) return;
+ panel.classList.remove('is-dragging');
+ if (self.state === 'DRAGGING') {
+ self._finalizePosition(panel);
+ }
+ self._reset();
+ });
+ };
+
+ DragManager.prototype._handleKeyDown = function (e) {
+ if (e.key === 'Escape' && this.state === 'DRAGGING' && this.activePanel) {
+ this.activePanel.classList.remove('is-dragging');
+ this.activePanel.style.transform = this.preTransform;
+ // Revert: re-attach to corner if it was cornered before
+ var saved = localStorage.getItem('panel-drag-' + this.activePanel.id);
+ if (!saved) {
+ // Was in corner mode — restore corner CSS
+ delete this.activePanel.dataset.dragged;
+ this.activePanel.style.top = '';
+ this.activePanel.style.left = '';
+ this.activePanel.style.right = '';
+ this.activePanel.style.bottom = '';
+ this.activePanel.style.transform = '';
+ // Re-apply corner position from M0
+ var corner = localStorage.getItem('panel-corner-' + this.activePanel.id);
+ if (corner) this.activePanel.setAttribute('data-position', corner);
+ } else {
+ // Was already dragged — revert to pre-drag position
+ this.activePanel.style.transform = 'none';
+ }
+ this._reset();
+ }
+ };
+
+ DragManager.prototype._reset = function () {
+ document.removeEventListener('keydown', this._onKeyDown);
+ this.state = 'IDLE';
+ this.activePanel = null;
+ };
+
+ DragManager.prototype._detachFromCorner = function (panel) {
+ var rect = panel.getBoundingClientRect();
+ panel.removeAttribute('data-position');
+ panel.dataset.dragged = 'true';
+ panel.style.position = 'fixed';
+ panel.style.top = rect.top + 'px';
+ panel.style.left = rect.left + 'px';
+ panel.style.right = 'auto';
+ panel.style.bottom = 'auto';
+ panel.style.transform = 'none';
+ };
+
+ DragManager.prototype._finalizePosition = function (panel) {
+ var rect = panel.getBoundingClientRect();
+ var vw = window.innerWidth;
+ var vh = window.innerHeight;
+
+ var x = Math.max(0, Math.min(rect.left, vw - 40));
+ var y = Math.max(0, Math.min(rect.top, vh - 40));
+
+ // Snap to edge
+ if (x < SNAP_THRESHOLD) x = SNAP_MARGIN;
+ if (y < SNAP_THRESHOLD) y = SNAP_MARGIN;
+ if (x + rect.width > vw - SNAP_THRESHOLD) x = vw - rect.width - SNAP_MARGIN;
+ if (y + rect.height > vh - SNAP_THRESHOLD) y = vh - rect.height - SNAP_MARGIN;
+
+ panel.style.top = y + 'px';
+ panel.style.left = x + 'px';
+ panel.style.transform = 'none';
+
+ this._persist(panel.id, x / vw, y / vh);
+ };
+
+ DragManager.prototype._persist = function (id, xPct, yPct) {
+ try {
+ localStorage.setItem('panel-drag-' + id,
+ JSON.stringify({ xPct: xPct, yPct: yPct }));
+ } catch (_) { /* quota exceeded — silent */ }
+ };
+
+ DragManager.prototype.enable = function () { this.enabled = true; };
+ DragManager.prototype.disable = function () {
+ this.enabled = false;
+ if (this.state !== 'IDLE' && this.activePanel) {
+ this.activePanel.classList.remove('is-dragging');
+ this._reset();
+ }
+ };
+
+ DragManager.prototype.restorePositions = function () {
+ var panels = this._panels;
+ for (var i = 0; i < panels.length; i++) {
+ var panel = panels[i];
+ var raw = localStorage.getItem('panel-drag-' + panel.id);
+ if (!raw) continue;
+ try {
+ var pos = JSON.parse(raw);
+ var x = pos.xPct * window.innerWidth;
+ var y = pos.yPct * window.innerHeight;
+ panel.removeAttribute('data-position');
+ panel.dataset.dragged = 'true';
+ panel.style.position = 'fixed';
+ panel.style.top = y + 'px';
+ panel.style.left = x + 'px';
+ panel.style.right = 'auto';
+ panel.style.bottom = 'auto';
+ panel.style.transform = 'none';
+ } catch (_) {
+ localStorage.removeItem('panel-drag-' + panel.id);
+ }
+ }
+ };
+
+ DragManager.prototype.handleResize = function () {
+ var panels = document.querySelectorAll('.live-overlay[data-dragged="true"]');
+ for (var i = 0; i < panels.length; i++) {
+ var panel = panels[i];
+ var rect = panel.getBoundingClientRect();
+ var vw = window.innerWidth;
+ var vh = window.innerHeight;
+ var x = rect.left, y = rect.top, moved = false;
+ if (rect.right > vw) { x = vw - rect.width - SNAP_MARGIN; moved = true; }
+ if (rect.bottom > vh) { y = vh - rect.height - SNAP_MARGIN; moved = true; }
+ if (x < 0) { x = SNAP_MARGIN; moved = true; }
+ if (y < 0) { y = SNAP_MARGIN; moved = true; }
+ if (moved) {
+ panel.style.left = x + 'px';
+ panel.style.top = y + 'px';
+ this._persist(panel.id, x / vw, y / vh);
+ }
+ }
+ };
+
+ // Export
+ window.DragManager = DragManager;
+})();
diff --git a/public/index.html b/public/index.html
index 381735ce..2debebb9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -109,6 +109,7 @@
+
diff --git a/public/live.css b/public/live.css
index 3b73fcf8..7e570e6e 100644
--- a/public/live.css
+++ b/public/live.css
@@ -849,3 +849,40 @@
}
}
+
+/* ── Drag Manager (#608 M1) ── */
+
+/* Panel header as drag handle — desktop pointer devices only */
+@media (pointer: fine) {
+ .live-overlay .panel-header {
+ cursor: grab;
+ user-select: none;
+ touch-action: none;
+ }
+ .live-overlay.is-dragging .panel-header {
+ cursor: grabbing;
+ }
+ .live-overlay .panel-header:hover {
+ background: var(--bg-hover, rgba(255, 255, 255, 0.03));
+ }
+}
+
+/* Panel during drag */
+.live-overlay.is-dragging {
+ opacity: 0.92;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+ will-change: transform;
+ transition: none !important;
+}
+
+/* Freely placed panel — no corner transition animations */
+.live-overlay[data-dragged="true"] {
+ transition: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .live-overlay.is-dragging,
+ .live-overlay[data-dragged="true"] {
+ transition: none;
+ }
+}
diff --git a/public/live.js b/public/live.js
index 92903ab8..e79a3404 100644
--- a/public/live.js
+++ b/public/live.js
@@ -1161,6 +1161,48 @@
// Populate role legend from shared roles.js
// Initialize panel corner positions (#608 M0)
initPanelPositions();
+
+ // Initialize DragManager for free-form panel dragging (#608 M1)
+ if (window.DragManager) {
+ var dragMgr = new DragManager();
+ var dragPanels = ['liveFeed', 'liveLegend', 'liveNodeDetail'];
+ for (var di = 0; di < dragPanels.length; di++) {
+ dragMgr.register(document.getElementById(dragPanels[di]));
+ }
+ dragMgr.restorePositions();
+
+ // Responsive gate: disable drag below medium breakpoint or on touch
+ var dragMql = window.matchMedia('(pointer: fine) and (min-width: 768px)');
+ function onDragMediaChange(e) {
+ if (!e.matches) {
+ // Revert dragged panels to corner positions
+ document.querySelectorAll('.live-overlay[data-dragged="true"]').forEach(function (p) {
+ delete p.dataset.dragged;
+ p.style.transform = '';
+ p.style.top = '';
+ p.style.left = '';
+ p.style.right = '';
+ p.style.bottom = '';
+ });
+ initPanelPositions();
+ dragMgr.disable();
+ } else {
+ dragMgr.enable();
+ dragMgr.restorePositions();
+ }
+ }
+ dragMql.addEventListener('change', onDragMediaChange);
+ // Initial check
+ if (!dragMql.matches) dragMgr.disable();
+
+ // Resize clamping (debounced)
+ var resizeTimer = null;
+ window.addEventListener('resize', function () {
+ clearTimeout(resizeTimer);
+ resizeTimer = setTimeout(function () { dragMgr.handleResize(); }, 200);
+ });
+ }
+
const roleLegendList = document.getElementById('roleLegendList');
if (roleLegendList) {
for (const role of (window.ROLE_SORT || ['repeater', 'companion', 'room', 'sensor', 'observer'])) {
diff --git a/test-drag-manager.js b/test-drag-manager.js
new file mode 100644
index 00000000..1bec20ba
--- /dev/null
+++ b/test-drag-manager.js
@@ -0,0 +1,454 @@
+/* test-drag-manager.js — Unit tests for DragManager (#608 M1) */
+'use strict';
+
+const vm = require('vm');
+const fs = require('fs');
+const path = require('path');
+const assert = require('assert');
+
+// Minimal DOM shim
+function makePanel(id) {
+ const listeners = {};
+ const style = {};
+ const dataset = {};
+ const classList = {
+ _set: new Set(),
+ add(c) { this._set.add(c); },
+ remove(c) { this._set.delete(c); },
+ contains(c) { return this._set.has(c); }
+ };
+ let attrs = {};
+ const header = {
+ _listeners: {},
+ addEventListener(ev, fn) {
+ if (!this._listeners[ev]) this._listeners[ev] = [];
+ this._listeners[ev].push(fn);
+ },
+ setPointerCapture() {},
+ releasePointerCapture() {},
+ _fire(ev, data) {
+ (this._listeners[ev] || []).forEach(fn => fn(data));
+ }
+ };
+ return {
+ id: id,
+ style: style,
+ dataset: dataset,
+ classList: classList,
+ querySelector(sel) {
+ if (sel === '.panel-header') return header;
+ return null;
+ },
+ getAttribute(k) { return attrs[k] || null; },
+ setAttribute(k, v) { attrs[k] = v; },
+ removeAttribute(k) { delete attrs[k]; },
+ getBoundingClientRect() {
+ return {
+ left: parseFloat(style.left) || 0,
+ top: parseFloat(style.top) || 0,
+ right: (parseFloat(style.left) || 0) + 300,
+ bottom: (parseFloat(style.top) || 0) + 200,
+ width: 300,
+ height: 200
+ };
+ },
+ _header: header
+ };
+}
+
+// Mock globals
+const storage = {};
+const mockWindow = {
+ innerWidth: 1920,
+ innerHeight: 1080,
+ DragManager: null,
+ matchMedia() { return { matches: true, addEventListener() {} }; },
+ addEventListener() {}
+};
+const mockDocument = {
+ addEventListener(ev, fn) {
+ if (!mockDocument._listeners) mockDocument._listeners = {};
+ if (!mockDocument._listeners[ev]) mockDocument._listeners[ev] = [];
+ mockDocument._listeners[ev].push(fn);
+ },
+ removeEventListener(ev, fn) {
+ if (mockDocument._listeners && mockDocument._listeners[ev]) {
+ mockDocument._listeners[ev] = mockDocument._listeners[ev].filter(f => f !== fn);
+ }
+ },
+ querySelectorAll() { return []; }
+};
+const mockLocalStorage = {
+ _data: {},
+ getItem(k) { return this._data[k] || null; },
+ setItem(k, v) { this._data[k] = v; },
+ removeItem(k) { delete this._data[k]; },
+ clear() { this._data = {}; }
+};
+
+// Load DragManager
+const src = fs.readFileSync(path.join(__dirname, 'public', 'drag-manager.js'), 'utf8');
+const ctx = vm.createContext({
+ window: mockWindow,
+ document: mockDocument,
+ localStorage: mockLocalStorage,
+ Math: Math,
+ JSON: JSON,
+ console: console,
+ setTimeout: setTimeout,
+ clearTimeout: clearTimeout,
+ parseFloat: parseFloat
+});
+vm.runInContext(src, ctx);
+const DragManager = ctx.window.DragManager;
+
+let passed = 0;
+let failed = 0;
+
+function test(name, fn) {
+ try {
+ mockLocalStorage.clear();
+ fn();
+ passed++;
+ console.log(' ✓ ' + name);
+ } catch (e) {
+ failed++;
+ console.log(' ✗ ' + name + ': ' + e.message);
+ }
+}
+
+console.log('DragManager tests:');
+
+test('constructor initializes IDLE state', () => {
+ const dm = new DragManager();
+ assert.strictEqual(dm.state, 'IDLE');
+ assert.strictEqual(dm.enabled, true);
+});
+
+test('register adds panel', () => {
+ const dm = new DragManager();
+ const panel = makePanel('testPanel');
+ dm.register(panel);
+ assert.strictEqual(dm._panels.length, 1);
+});
+
+test('register ignores null panel', () => {
+ const dm = new DragManager();
+ dm.register(null);
+ assert.strictEqual(dm._panels.length, 0);
+});
+
+test('pointerdown transitions to PENDING', () => {
+ const dm = new DragManager();
+ const panel = makePanel('p1');
+ dm.register(panel);
+ panel._header._fire('pointerdown', {
+ button: 0, clientX: 100, clientY: 100,
+ preventDefault() {},
+ target: { closest() { return null; } }
+ });
+ assert.strictEqual(dm.state, 'PENDING');
+ assert.strictEqual(dm.activePanel, panel);
+});
+
+test('pointerdown ignores non-left button', () => {
+ const dm = new DragManager();
+ const panel = makePanel('p1');
+ dm.register(panel);
+ panel._header._fire('pointerdown', {
+ button: 2, clientX: 100, clientY: 100,
+ preventDefault() {},
+ target: { closest() { return null; } }
+ });
+ assert.strictEqual(dm.state, 'IDLE');
+});
+
+test('pointerdown ignores button clicks', () => {
+ const dm = new DragManager();
+ const panel = makePanel('p1');
+ dm.register(panel);
+ panel._header._fire('pointerdown', {
+ button: 0, clientX: 100, clientY: 100,
+ preventDefault() {},
+ target: { closest(sel) { return sel === 'button' ? {} : null; } }
+ });
+ assert.strictEqual(dm.state, 'IDLE');
+});
+
+test('pointermove within dead zone stays PENDING', () => {
+ const dm = new DragManager();
+ const panel = makePanel('p1');
+ dm.register(panel);
+ panel._header._fire('pointerdown', {
+ button: 0, clientX: 100, clientY: 100,
+ preventDefault() {},
+ target: { closest() { return null; } }
+ });
+ panel._header._fire('pointermove', { clientX: 103, clientY: 102 });
+ assert.strictEqual(dm.state, 'PENDING');
+ assert.ok(!panel.classList.contains('is-dragging'));
+});
+
+test('pointermove beyond dead zone transitions to DRAGGING', () => {
+ const dm = new DragManager();
+ const panel = makePanel('p1');
+ panel.setAttribute('data-position', 'bl');
+ dm.register(panel);
+ panel._header._fire('pointerdown', {
+ button: 0, clientX: 100, clientY: 100,
+ preventDefault() {},
+ target: { closest() { return null; } }
+ });
+ panel._header._fire('pointermove', { clientX: 110, clientY: 110 });
+ assert.strictEqual(dm.state, 'DRAGGING');
+ assert.ok(panel.classList.contains('is-dragging'));
+ assert.strictEqual(panel.getAttribute('data-position'), null); // removed
+ assert.strictEqual(panel.dataset.dragged, 'true');
+});
+
+test('pointerup after drag finalizes position', () => {
+ const dm = new DragManager();
+ const panel = makePanel('p1');
+ panel.setAttribute('data-position', 'bl');
+ ctx.window.innerWidth = 1920;
+ ctx.window.innerHeight = 1080;
+ dm.register(panel);
+
+ panel._header._fire('pointerdown', {
+ button: 0, clientX: 100, clientY: 100,
+ preventDefault() {},
+ target: { closest() { return null; } }
+ });
+ panel._header._fire('pointermove', { clientX: 200, clientY: 300 });
+ panel._header._fire('pointerup', { pointerId: 1 });
+
+ assert.strictEqual(dm.state, 'IDLE');
+ assert.ok(!panel.classList.contains('is-dragging'));
+ // Should have persisted
+ const saved = JSON.parse(mockLocalStorage.getItem('panel-drag-p1'));
+ assert.ok(saved.xPct >= 0);
+ assert.ok(saved.yPct >= 0);
+});
+
+test('pointerup from PENDING (click) does not finalize', () => {
+ const dm = new DragManager();
+ const panel = makePanel('p1');
+ dm.register(panel);
+ panel._header._fire('pointerdown', {
+ button: 0, clientX: 100, clientY: 100,
+ preventDefault() {},
+ target: { closest() { return null; } }
+ });
+ panel._header._fire('pointerup', { pointerId: 1 });
+ assert.strictEqual(dm.state, 'IDLE');
+ assert.strictEqual(mockLocalStorage.getItem('panel-drag-p1'), null);
+});
+
+test('disable prevents drag', () => {
+ const dm = new DragManager();
+ dm.disable();
+ const panel = makePanel('p1');
+ dm.register(panel);
+ panel._header._fire('pointerdown', {
+ button: 0, clientX: 100, clientY: 100,
+ preventDefault() {},
+ target: { closest() { return null; } }
+ });
+ assert.strictEqual(dm.state, 'IDLE');
+});
+
+test('snap-to-edge works within threshold', () => {
+ const dm = new DragManager();
+ const panel = makePanel('p1');
+ panel.setAttribute('data-position', 'tl');
+ ctx.window.innerWidth = 1920;
+ ctx.window.innerHeight = 1080;
+ dm.register(panel);
+
+ // Simulate drag to near top-left edge
+ panel._header._fire('pointerdown', {
+ button: 0, clientX: 500, clientY: 500,
+ preventDefault() {},
+ target: { closest() { return null; } }
+ });
+ panel._header._fire('pointermove', { clientX: 510, clientY: 510 }); // trigger DRAGGING
+
+ // Panel is now detached; set its position near edge
+ panel.style.left = '5px';
+ panel.style.top = '10px';
+
+ panel._header._fire('pointerup', { pointerId: 1 });
+
+ // Should have snapped to margin (12px)
+ assert.strictEqual(panel.style.left, '12px');
+ assert.strictEqual(panel.style.top, '12px');
+});
+
+test('restorePositions applies saved viewport percentages', () => {
+ const dm = new DragManager();
+ const panel = makePanel('p2');
+ panel.setAttribute('data-position', 'br');
+ ctx.window.innerWidth = 1000;
+ ctx.window.innerHeight = 800;
+ dm.register(panel);
+
+ mockLocalStorage.setItem('panel-drag-p2', JSON.stringify({ xPct: 0.5, yPct: 0.25 }));
+ dm.restorePositions();
+
+ assert.strictEqual(panel.style.left, '500px');
+ assert.strictEqual(panel.style.top, '200px');
+ assert.strictEqual(panel.dataset.dragged, 'true');
+ assert.strictEqual(panel.getAttribute('data-position'), null);
+});
+
+test('handleResize clamps panels inside viewport', () => {
+ const dm = new DragManager();
+ const panel = makePanel('p3');
+ dm.register(panel);
+
+ // Simulate a dragged panel that's now off-screen
+ panel.dataset.dragged = 'true';
+ panel.style.left = '1800px';
+ panel.style.top = '900px';
+ ctx.window.innerWidth = 1000;
+ ctx.window.innerHeight = 600;
+
+ // Need querySelectorAll to return this panel
+ const origQSA = mockDocument.querySelectorAll;
+ mockDocument.querySelectorAll = function (sel) {
+ if (sel === '.live-overlay[data-dragged="true"]') return [panel];
+ return [];
+ };
+
+ dm.handleResize();
+ mockDocument.querySelectorAll = origQSA;
+
+ // Should be clamped
+ const left = parseFloat(panel.style.left);
+ const top = parseFloat(panel.style.top);
+ assert.ok(left + 300 <= 1000, 'left clamped: ' + left);
+ assert.ok(top + 200 <= 600, 'top clamped: ' + top);
+});
+
+test('Escape during drag reverts to corner position', () => {
+ const dm = new DragManager();
+ const panel = makePanel('esc1');
+ panel.setAttribute('data-position', 'bl');
+ dm.register(panel);
+
+ panel._header._fire('pointerdown', {
+ button: 0, clientX: 100, clientY: 100,
+ preventDefault() {},
+ target: { closest() { return null; } }
+ });
+ panel._header._fire('pointermove', { clientX: 120, clientY: 120 }); // trigger DRAGGING
+ assert.strictEqual(dm.state, 'DRAGGING');
+
+ // Simulate Escape
+ dm._handleKeyDown({ key: 'Escape' });
+
+ assert.strictEqual(dm.state, 'IDLE');
+ assert.ok(!panel.classList.contains('is-dragging'));
+ assert.strictEqual(panel.dataset.dragged, undefined);
+ assert.strictEqual(panel.style.transform, '');
+});
+
+test('Escape during drag reverts to saved position', () => {
+ const dm = new DragManager();
+ const panel = makePanel('esc2');
+ ctx.window.innerWidth = 1000;
+ ctx.window.innerHeight = 800;
+ dm.register(panel);
+
+ // Pre-save a dragged position
+ mockLocalStorage.setItem('panel-drag-esc2', JSON.stringify({ xPct: 0.3, yPct: 0.4 }));
+ dm.restorePositions();
+ assert.strictEqual(panel.dataset.dragged, 'true');
+
+ panel._header._fire('pointerdown', {
+ button: 0, clientX: 400, clientY: 400,
+ preventDefault() {},
+ target: { closest() { return null; } }
+ });
+ panel._header._fire('pointermove', { clientX: 500, clientY: 500 });
+ assert.strictEqual(dm.state, 'DRAGGING');
+
+ dm._handleKeyDown({ key: 'Escape' });
+
+ assert.strictEqual(dm.state, 'IDLE');
+ assert.strictEqual(panel.style.transform, 'none');
+});
+
+test('pointercancel during drag finalizes position', () => {
+ const dm = new DragManager();
+ const panel = makePanel('pc1');
+ panel.setAttribute('data-position', 'tl');
+ ctx.window.innerWidth = 1920;
+ ctx.window.innerHeight = 1080;
+ dm.register(panel);
+
+ panel._header._fire('pointerdown', {
+ button: 0, clientX: 100, clientY: 100,
+ preventDefault() {},
+ target: { closest() { return null; } }
+ });
+ panel._header._fire('pointermove', { clientX: 200, clientY: 200 });
+ assert.strictEqual(dm.state, 'DRAGGING');
+
+ panel._header._fire('pointercancel', {});
+ assert.strictEqual(dm.state, 'IDLE');
+ assert.ok(!panel.classList.contains('is-dragging'));
+});
+
+test('z-index increments on drag', () => {
+ const dm = new DragManager();
+ const p1 = makePanel('z1');
+ const p2 = makePanel('z2');
+ dm.register(p1);
+ dm.register(p2);
+
+ // Drag p1
+ p1._header._fire('pointerdown', {
+ button: 0, clientX: 100, clientY: 100,
+ preventDefault() {},
+ target: { closest() { return null; } }
+ });
+ p1._header._fire('pointermove', { clientX: 110, clientY: 110 });
+ const z1 = parseInt(p1.style.zIndex);
+ p1._header._fire('pointerup', { pointerId: 1 });
+
+ // Drag p2
+ p2._header._fire('pointerdown', {
+ button: 0, clientX: 100, clientY: 100,
+ preventDefault() {},
+ target: { closest() { return null; } }
+ });
+ p2._header._fire('pointermove', { clientX: 110, clientY: 110 });
+ const z2 = parseInt(p2.style.zIndex);
+ p2._header._fire('pointerup', { pointerId: 1 });
+
+ assert.ok(z2 > z1, 'z2 (' + z2 + ') should be greater than z1 (' + z1 + ')');
+ assert.ok(z1 >= 1001, 'z1 should be >= 1001');
+});
+
+test('disable mid-drag resets state', () => {
+ const dm = new DragManager();
+ const panel = makePanel('dis1');
+ dm.register(panel);
+
+ panel._header._fire('pointerdown', {
+ button: 0, clientX: 100, clientY: 100,
+ preventDefault() {},
+ target: { closest() { return null; } }
+ });
+ panel._header._fire('pointermove', { clientX: 120, clientY: 120 });
+ assert.strictEqual(dm.state, 'DRAGGING');
+
+ dm.disable();
+ assert.strictEqual(dm.state, 'IDLE');
+ assert.ok(!panel.classList.contains('is-dragging'));
+ assert.strictEqual(dm.enabled, false);
+});
+
+console.log('\n' + passed + ' passed, ' + failed + ' failed');
+if (failed > 0) process.exit(1);