From 317c0a7e0c63acf32c6f6717bd9afd05e752cb03 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 14:45:54 +0100 Subject: [PATCH 1/4] feat(ui): add panelResize geometry for edge and corner resizing Pure box arithmetic for resizing a floating panel from any of the eight compass handles: the named edges follow the pointer, the opposite edge stays pinned, and minimum size plus the viewport margin win. Owned by the same boundary groups as PanelPositionControls, which wires it next. Co-Authored-By: Claude Fable 5.1 --- scripts/format-scope.json | 1 + scripts/package-boundaries.json | 3 ++ src/ui/panelResize.js | 65 +++++++++++++++++++++++++++++++++ src/ui/panelResize.test.mjs | 64 ++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 src/ui/panelResize.js create mode 100644 src/ui/panelResize.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index caffd41d2..cc248f38d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -147,6 +147,7 @@ "src/ui/panelRails.js", "src/ui/leftPanelRail.js", "src/ui/rightPanelRail.js", + "src/ui/panelResize.test.mjs", "src/ui/panelMeasurement.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 433f5f36a..8e6cda2cd 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -553,6 +553,7 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", + "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/radio.js", @@ -1670,6 +1671,7 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", + "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/radio.js", @@ -2327,6 +2329,7 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", + "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/rightPanelRail.js", diff --git a/src/ui/panelResize.js b/src/ui/panelResize.js new file mode 100644 index 000000000..4472e7a01 --- /dev/null +++ b/src/ui/panelResize.js @@ -0,0 +1,65 @@ +/** + * Pure geometry for resizing a floating panel from any edge or corner. The + * DOM wiring lives in PanelPositionControls; this module only decides boxes. + */ + +/** Viewport margin a resized panel keeps clear on every side. */ +const EDGE_MARGIN_PX = 6; +/** Resize directions: compass edges first, then corners. */ +export const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se']; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +/** + * Work out the box a resize gesture produces. Edges named in `dir` move with + * the pointer; the opposite edge stays put, so growing from the left keeps + * the right side pinned. Minimum size and the viewport margin win over the + * pointer. + * @param {{left: number, top: number, width: number, height: number}} box Box at gesture start. + * @param {string} dir One of RESIZE_DIRECTIONS. + * @param {number} dx Pointer travel since gesture start. + * @param {number} dy Pointer travel since gesture start. + * @param {object} [limits] + * @param {number} limits.minWidth + * @param {number} limits.minHeight + * @param {number} [limits.viewportWidth=Infinity] + * @param {number} [limits.viewportHeight=Infinity] + * @param {number} [limits.margin=6] + * @returns {{left: number, top: number, width: number, height: number}} + */ +export function resizeBox( + box, + dir, + dx, + dy, + { + minWidth, + minHeight, + viewportWidth = Infinity, + viewportHeight = Infinity, + margin = EDGE_MARGIN_PX, + } = {}, +) { + let { left, top, width, height } = box; + const right = box.left + box.width; + const bottom = box.top + box.height; + if (dir.includes('e')) + width = clamp(box.width + dx, minWidth, viewportWidth - margin - box.left); + if (dir.includes('s')) + height = clamp( + box.height + dy, + minHeight, + viewportHeight - margin - box.top, + ); + if (dir.includes('w')) { + width = clamp(box.width - dx, minWidth, right - margin); + left = right - width; + } + if (dir.includes('n')) { + height = clamp(box.height - dy, minHeight, bottom - margin); + top = bottom - height; + } + return { left, top, width, height }; +} diff --git a/src/ui/panelResize.test.mjs b/src/ui/panelResize.test.mjs new file mode 100644 index 000000000..3e1577665 --- /dev/null +++ b/src/ui/panelResize.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resizeBox } from './panelResize.js'; + +const box = { left: 100, top: 100, width: 400, height: 300 }; +const limits = { + minWidth: 200, + minHeight: 150, + viewportWidth: 1000, + viewportHeight: 800, +}; + +test('east and south edges grow away from the anchored corner', () => { + assert.deepEqual(resizeBox(box, 'se', 50, 20, limits), { + left: 100, + top: 100, + width: 450, + height: 320, + }); + assert.deepEqual(resizeBox(box, 'e', -30, 999, limits), { + left: 100, + top: 100, + width: 370, + height: 300, + }); +}); + +test('west and north edges keep the opposite edge pinned', () => { + assert.deepEqual(resizeBox(box, 'w', -40, 0, limits), { + left: 60, + top: 100, + width: 440, + height: 300, + }); + assert.deepEqual(resizeBox(box, 'nw', 40, 30, limits), { + left: 140, + top: 130, + width: 360, + height: 270, + }); + assert.deepEqual( + resizeBox(box, 'n', -50, 0, limits).top, + 100, + 'horizontal motion does not move a north edge', + ); +}); + +test('minimum size stops the moving edge, not the anchored one', () => { + const small = resizeBox(box, 'w', 350, 0, limits); + assert.deepEqual(small, { left: 300, top: 100, width: 200, height: 300 }); + assert.equal(resizeBox(box, 'ne', 0, 400, limits).height, 150); + assert.equal(resizeBox(box, 'ne', 0, 400, limits).top, 250); +}); + +test('the viewport margin caps growth in every direction', () => { + assert.equal(resizeBox(box, 'e', 2000, 0, limits).width, 1000 - 6 - 100); + assert.equal(resizeBox(box, 's', 0, 2000, limits).height, 800 - 6 - 100); + const west = resizeBox(box, 'w', -2000, 0, limits); + assert.equal(west.left, 6); + assert.equal(west.left + west.width, 500); + const north = resizeBox(box, 'n', 0, -2000, limits); + assert.equal(north.top, 6); + assert.equal(north.top + north.height, 400); +}); From 96a1e710eab7a7bfa6ba26589740cca8c6b6c592 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:06:29 +0100 Subject: [PATCH 2/4] feat(ui): portable, resizable rail panels for CCTV A drag spec can now be `portable`: dragging the panel by its header lifts it out of the right rail into a fixed window, resizable from all four edges and corners, that remembers its place and size under the existing godsEyeView.v8.panelPos. key ({ left, top, width?, height?, floating }) and snaps back on a header double-click. Only CCTV opts in here; the rail skips .panel-floating panels when measuring and allocating, and its placement rules in layers.css are guarded so no !important is needed. PanelPositionControls gains an optional onPanelResized hook, threaded through PanelChrome to a no-op on the application shell. Co-Authored-By: Claude Fable 5.1 --- src/rightRailPolicy.test.mjs | 6 +- src/ui/applicationShell.js | 10 + src/ui/panelChrome.js | 2 + src/ui/panelPositionControls.js | 390 ++++++++++++++++++++++++++++++-- src/ui/panelRails.test.mjs | 5 +- src/ui/rightPanelRail.js | 4 +- src/ui/styles/controls.css | 110 +++++++++ src/ui/styles/layers.css | 20 +- src/ui/styles/responsive.css | 4 +- 9 files changed, 525 insertions(+), 26 deletions(-) diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index 2254ea24e..b727e6d12 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -45,7 +45,11 @@ test('desktop Display participates in Tactical exclusivity without changing mobi /panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/, ); assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); - assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); + // A panel floating out of the rail (portable mode) is exempt from exclusivity. + assert.match( + css, + /#right-context-rail\.layout-exclusive\s*> \[data-panel-id\]\.collapsed:not\(\.panel-floating\) \{/, + ); }); test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => { diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index b22c21c3f..3a01f97fe 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -96,6 +96,7 @@ export class StyleManager extends ShellFacade { _setRadioDisclosure: (...args) => this._setRadioDisclosure(...args), _syncCctvPanelViewport: (...args) => this._syncCctvPanelViewport(...args), + _onPanelResized: (...args) => this._onPanelResized(...args), _syncContextRadioLauncherState: (...args) => this._syncContextRadioLauncherState(...args), _showToast: (...args) => this._showToast(...args), @@ -1409,6 +1410,15 @@ export class StyleManager extends ShellFacade { this._initCockpitDisplayPortal(); } + /** + * A portable panel finished a resize or snapped back to its rail. Nothing + * in the shell depends on it yet; a panel with a viewport of its own can + * refit here. + * @param {string} _panelId + * @returns {void} + */ + _onPanelResized(_panelId) {} + /** * Recalculates the CCTV panel max-height based on its current top position * and the window height, enabling internal scroll without viewport overflow. diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 2562f137b..66e469a45 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -56,6 +56,7 @@ export class PanelChrome { layoutRightPanels: () => this._layoutRightPanels(), syncCctvPanelViewport: () => this._syncCctvPanelViewport(), showToast: (message) => this._showToast(message), + onPanelResized: (panelId) => this._onPanelResized(panelId), }); this._panelLayout = new PanelLayoutController({ readHud: () => ({ @@ -139,6 +140,7 @@ export class PanelChrome { this._initCommandDockPins(); this._initCommandDockTrayMetrics(); this._maybeNotifyLayoutReset(); + this._panelPosition._initPanelDrag(); } _collapsePanelOnEscape(event, panelId) { diff --git a/src/ui/panelPositionControls.js b/src/ui/panelPositionControls.js index 40d1c319f..ca85593a5 100644 --- a/src/ui/panelPositionControls.js +++ b/src/ui/panelPositionControls.js @@ -1,4 +1,6 @@ /** Own panel position preferences, viewport clamping and drag listeners. */ +import { RESIZE_DIRECTIONS, resizeBox } from './panelResize.js'; + /** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; /** @@ -10,40 +12,93 @@ const PANEL_POSITION_STORAGE_VERSION = 'v8'; /** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */ const PANEL_Z_BASE = 100; const PANEL_Z_MAX = 139; +/** Pointer travel before a header press becomes a drag that lifts a portable panel out. */ +const DRAG_THRESHOLD_PX = 4; +/** Viewport margin every positioned panel keeps clear. */ +const VIEWPORT_MARGIN_PX = 6; +/** One-time hint shown the first time a portable panel leaves its rail. */ +const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`; +const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back'; +/** Header children whose own interaction wins over a drag or a snap-back. */ +const INTERACTIVE_SELECTOR = + 'input, select, option, textarea, button, a, [role="button"]'; +/** Inline geometry a snap-back clears so the rail owns placement again. */ +const FLOATING_STYLE_PROPERTIES = [ + 'left', + 'top', + 'right', + 'bottom', + 'width', + 'height', +]; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + export class PanelPositionControls { + /** + * @param {object} options + * @param {Function} options.syncPanelCollapseButton + * @param {Function} options.layoutRightPanels + * @param {Function} options.syncCctvPanelViewport + * @param {Function} options.showToast + * @param {(panelId: string) => void} [options.onPanelResized] Fires after a + * portable panel's resize gesture ends or the panel snaps back to its rail. + */ constructor({ syncPanelCollapseButton, layoutRightPanels, syncCctvPanelViewport, showToast, + onPanelResized = null, }) { this._syncPanelCollapseButton = syncPanelCollapseButton; this._layoutRightPanels = layoutRightPanels; this._syncCctvPanelViewport = syncCctvPanelViewport; this._showToast = showToast; + this._onPanelResized = onPanelResized; this._ppToggles = document.getElementById('pp-toggles'); this._panelZCounter = PANEL_Z_BASE + 10; this._draggableResizeObserver = null; this._cancelDrag = null; + /** Portable panels by id: `{ panel, min: { width, height } }`. */ + this._portablePanels = new Map(); + this._resizeHandles = []; + this._dragInitialized = false; this.removers = []; this.destroyed = false; } - listen(target, type, callback) { + listen(target, type, callback, options) { if (this.destroyed || !target) return; const listener = (event) => { if (!this.destroyed) callback(event); }; - target.addEventListener(type, listener); - this.removers.push(() => target.removeEventListener(type, listener)); + target.addEventListener(type, listener, options); + this.removers.push(() => + target.removeEventListener(type, listener, options), + ); } _reclampDraggablePanels() { if (this.destroyed) return; const el = this._ppToggles; - if (!el || !el.style.top || el.style.top === 'auto') return; - const top = parseInt(el.style.top, 10); - if (!Number.isFinite(top)) return; - el.style.top = `${this._clampToViewport(0, top, el).top}px`; - this._pinPanelToRight(el); + if (el && el.style.top && el.style.top !== 'auto') { + const top = parseInt(el.style.top, 10); + if (Number.isFinite(top)) { + el.style.top = `${this._clampToViewport(0, top, el).top}px`; + this._pinPanelToRight(el); + } + } + // Floating panels only move back on-screen; their size is the user's. + for (const { panel } of this._portablePanels.values()) { + if (!panel.classList.contains('panel-floating')) continue; + const left = parseInt(panel.style.left, 10); + const top = parseInt(panel.style.top, 10); + if (!Number.isFinite(left) || !Number.isFinite(top)) continue; + const next = this._clampToViewport(left, top, panel); + panel.style.left = `${next.left}px`; + panel.style.top = `${next.top}px`; + } } _maybeNotifyLayoutReset() { @@ -65,19 +120,41 @@ export class PanelPositionControls { } _initPanelDrag() { - if (this.destroyed) return; + if (this.destroyed || this._dragInitialized) return; + this._dragInitialized = true; + const cctvPanel = document.getElementById('cctv-panel'); + // A `portable` spec lifts out of its rail on a header drag, resizes from + // every edge and remembers its window; the others only reposition in + // place. Another panel opts in by adding a spec here. const dragSpecs = [ { id: 'pp-toggles', panel: this._ppToggles, handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'), }, + { + id: 'cctv-panel', + panel: cctvPanel, + handle: cctvPanel?.querySelector('.panel-header'), + portable: true, + min: { width: 300, height: 160 }, + }, ].filter(Boolean); for (const spec of dragSpecs) { if (!spec.panel || !spec.handle) continue; + if (spec.portable) { + this._portablePanels.set(spec.id, { + panel: spec.panel, + min: { width: 300, height: 160, ...spec.min }, + }); + spec.panel.classList.add('panel-portable'); + } this._restorePanelPosition(spec.id, spec.panel); this._makePanelDraggable(spec.id, spec.panel, spec.handle); + if (spec.portable) { + this._makePanelResizable(spec.id, spec.panel, spec.min); + } } // Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its // full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and @@ -88,6 +165,9 @@ export class PanelPositionControls { ); this._draggableResizeObserver.observe(this._ppToggles); } + if (this._portablePanels.size) { + this.listen(window, 'resize', () => this._reclampDraggablePanels()); + } } _panelStorageKey(panelId) { @@ -151,6 +231,27 @@ export class PanelPositionControls { const pos = JSON.parse(raw); if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number') return; + // A floating record only means something to a panel that can float; a + // portable panel in turn only ever stores floating records. + const portable = this._portablePanels.get(panelId); + if (Boolean(pos.floating) !== Boolean(portable)) return; + if (portable) { + panelEl.classList.add('panel-floating', 'panel-draggable'); + if (Number.isFinite(pos.width)) { + panelEl.style.width = `${clamp( + Math.round(pos.width), + portable.min.width, + Math.max(portable.min.width, window.innerWidth - 12), + )}px`; + } + if (Number.isFinite(pos.height)) { + panelEl.style.height = `${clamp( + Math.round(pos.height), + portable.min.height, + Math.max(portable.min.height, window.innerHeight - 12), + )}px`; + } + } // Clamp to the viewport: a position saved at one window size would otherwise land off-screen at // another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too. const { left, top } = this._clampToViewport( @@ -182,13 +283,21 @@ export class PanelPositionControls { _savePanelPosition(panelId, panelEl) { const rect = panelEl.getBoundingClientRect(); + const record = { + left: Math.round(rect.left), + top: Math.round(rect.top), + }; + if (this._portablePanels.has(panelId)) { + // Only an explicitly set size is remembered: freezing a collapsed + // panel's measured height would stop it from ever expanding again. + if (panelEl.style.width) record.width = Math.round(rect.width); + if (panelEl.style.height) record.height = Math.round(rect.height); + record.floating = true; + } try { localStorage.setItem( this._panelStorageKey(panelId), - JSON.stringify({ - left: Math.round(rect.left), - top: Math.round(rect.top), - }), + JSON.stringify(record), ); } catch { // storage unavailable @@ -211,11 +320,78 @@ export class PanelPositionControls { panelEl.style.zIndex = String(this._panelZCounter); } + _maybeShowFloatHint() { + try { + if (localStorage.getItem(PANEL_FLOAT_HINT_STORAGE_KEY)) return; + localStorage.setItem(PANEL_FLOAT_HINT_STORAGE_KEY, '1'); + } catch { + // storage unavailable: still worth saying once this session + if (this._floatHintShown) return; + this._floatHintShown = true; + } + this._showToast(PANEL_FLOAT_HINT); + } + + /** + * Switch a portable panel from rail flow to a fixed window at the place it + * currently occupies. Width is frozen so a `width: 100%` rail panel keeps + * its size once fixed-positioned; height stays natural until a resize. + */ + _liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) { + if (panelEl.classList.contains('panel-floating')) return false; + panelEl.style.left = `${Math.round(rect.left)}px`; + panelEl.style.top = `${Math.round(rect.top)}px`; + panelEl.style.right = 'auto'; + panelEl.style.bottom = 'auto'; + if (!panelEl.classList.contains('collapsed')) { + panelEl.style.width = `${Math.round(rect.width)}px`; + } + // Rail bookkeeping no longer applies to a window the rail does not allocate. + panelEl.style.removeProperty('--right-panel-allocated-height'); + panelEl.removeAttribute('aria-hidden'); + panelEl.classList.add('panel-floating', 'panel-draggable'); + this._promotePanelZ(panelEl); + this._layoutRightPanels(); + this._maybeShowFloatHint(); + return true; + } + + /** Snap a floating panel back into its rail and forget its window. */ + _resetPanelPosition(panelId) { + const portable = this._portablePanels.get(panelId); + if (!portable) return; + const panelEl = portable.panel; + this._cancelDrag?.(); + panelEl.classList.remove( + 'panel-floating', + 'panel-draggable', + 'panel-dragging', + 'panel-resizing', + ); + for (const property of FLOATING_STYLE_PROPERTIES) { + panelEl.style.removeProperty(property); + } + try { + localStorage.removeItem(this._panelStorageKey(panelId)); + } catch { + // storage unavailable + } + this._layoutRightPanels(); + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + this._onPanelResized?.(panelId); + } + _makePanelDraggable(panelId, panelEl, handleEl) { // Z-order promotion: bring clicked panel to front of the stacking context this.listen(panelEl, 'pointerdown', () => { this._promotePanelZ(panelEl); }); + if (this._portablePanels.has(panelId)) { + this._makePortablePanelDraggable(panelId, panelEl, handleEl); + return; + } this.listen(handleEl, 'pointerdown', (event) => { if (event.button !== 0) return; @@ -283,11 +459,199 @@ export class PanelPositionControls { window.addEventListener('pointercancel', onUp); }); } + + /** + * Portable drag: a press on the header only becomes a drag after a few + * pixels of travel, at which point the panel lifts out of its rail. The + * collapse button still toggles on a plain click, and the click that ends + * a real drag is swallowed so it cannot toggle by accident. Double-clicking + * the header snaps the panel back. + */ + _makePortablePanelDraggable(panelId, panelEl, handleEl) { + let swallowNextClick = false; + this.listen( + handleEl, + 'click', + (event) => { + if (!swallowNextClick) return; + swallowNextClick = false; + event.stopImmediatePropagation(); + event.preventDefault(); + }, + { capture: true }, + ); + this.listen(handleEl, 'dblclick', (event) => { + if (event.target.closest?.(INTERACTIVE_SELECTOR)) return; + if (!panelEl.classList.contains('panel-floating')) return; + event.preventDefault(); + this._resetPanelPosition(panelId); + }); + this.listen(handleEl, 'pointerdown', (event) => { + if (event.button !== 0) return; + const interactive = event.target.closest?.(INTERACTIVE_SELECTOR); + if (interactive && !interactive.matches('.panel-collapse-btn')) return; + + this._cancelDrag?.(); + // A header press must not start a text selection across the page. + event.preventDefault(); + const startX = event.clientX; + const startY = event.clientY; + let dragging = false; + let offsetX = 0; + let offsetY = 0; + let width = 0; + let height = 0; + + const onMove = (moveEvent) => { + if (!dragging) { + if ( + Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) < + DRAG_THRESHOLD_PX + ) + return; + dragging = true; + const rect = panelEl.getBoundingClientRect(); + offsetX = startX - rect.left; + offsetY = startY - rect.top; + width = rect.width; + height = rect.height; + this._liftPanelOut(panelId, panelEl, rect); + panelEl.classList.add('panel-dragging'); + this._promotePanelZ(panelEl); + } + moveEvent.preventDefault(); + const maxLeft = Math.max( + VIEWPORT_MARGIN_PX, + window.innerWidth - width - VIEWPORT_MARGIN_PX, + ); + const maxTop = Math.max( + VIEWPORT_MARGIN_PX, + window.innerHeight - height - VIEWPORT_MARGIN_PX, + ); + panelEl.style.left = `${clamp(moveEvent.clientX - offsetX, VIEWPORT_MARGIN_PX, maxLeft)}px`; + panelEl.style.top = `${clamp(moveEvent.clientY - offsetY, VIEWPORT_MARGIN_PX, maxTop)}px`; + }; + + const cancel = () => { + panelEl.classList.remove('panel-dragging'); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + this._cancelDrag = null; + }; + const onUp = () => { + cancel(); + if (!dragging) return; + swallowNextClick = true; + setTimeout(() => { + swallowNextClick = false; + }, 0); + this._savePanelPosition(panelId, panelEl); + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + }; + + this._cancelDrag = cancel; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + }); + } + + /** + * Append a resize handle for every edge and corner. Seven are invisible + * strips; the bottom-right corner shows a grip. Resizing a docked panel + * lifts it out first, so the handles work in both states. + */ + _makePanelResizable(panelId, panelEl, min = {}) { + const limits = { width: 300, height: 160, ...min }; + for (const dir of RESIZE_DIRECTIONS) { + const handle = document.createElement('div'); + handle.className = + dir === 'se' ? 'panel-resize-grip' : 'panel-resize-edge'; + handle.dataset.dir = dir; + handle.setAttribute('aria-hidden', 'true'); + if (dir === 'se') { + handle.title = 'Drag to resize · double-click the header to snap back'; + } + panelEl.appendChild(handle); + this._resizeHandles.push(handle); + this.listen(handle, 'pointerdown', (event) => + this._startPanelResize(event, panelId, panelEl, dir, limits), + ); + } + } + + _startPanelResize(event, panelId, panelEl, dir, limits) { + if (event.button !== 0) return; + event.preventDefault(); + this._cancelDrag?.(); + const rect = panelEl.getBoundingClientRect(); + this._liftPanelOut(panelId, panelEl, rect); + const startBox = { + left: Math.round(rect.left), + top: Math.round(rect.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + const startX = event.clientX; + const startY = event.clientY; + const apply = ({ left, top, width, height }) => { + panelEl.style.left = `${left}px`; + panelEl.style.top = `${top}px`; + panelEl.style.width = `${width}px`; + panelEl.style.height = `${height}px`; + }; + panelEl.classList.add('panel-resizing'); + this._promotePanelZ(panelEl); + apply(startBox); + + const onMove = (moveEvent) => { + moveEvent.preventDefault(); + apply( + resizeBox( + startBox, + dir, + moveEvent.clientX - startX, + moveEvent.clientY - startY, + { + minWidth: limits.width, + minHeight: limits.height, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }, + ), + ); + }; + const cancel = () => { + panelEl.classList.remove('panel-resizing'); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + this._cancelDrag = null; + }; + const onUp = () => { + cancel(); + this._savePanelPosition(panelId, panelEl); + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + this._onPanelResized?.(panelId); + }; + + this._cancelDrag = cancel; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + } + destroy() { if (this.destroyed) return; this.destroyed = true; this._cancelDrag?.(); for (const remove of this.removers.splice(0)) remove(); + for (const handle of this._resizeHandles.splice(0)) handle.remove(); this._draggableResizeObserver?.disconnect(); this._draggableResizeObserver = null; } diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 46fbebc5f..2b03c1406 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -59,7 +59,10 @@ function element( getBoundingClientRect() { return this.rect; }, - matches: (selector) => selector === '[data-panel-id]', + matches: (selector) => + selector === '[data-panel-id]' || + (selector === '[data-panel-id]:not(.panel-floating)' && + !classes.has('panel-floating')), contains(target) { return ( target === this || this.children.some((child) => child.contains(target)) diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 3dd513d9f..1c324a523 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -41,8 +41,10 @@ export function layoutRightPanelRail({ }) { if (!stack) return; + // A panel lifted out of the rail (PanelPositionControls' portable mode) + // is a fixed window the rail neither measures nor allocates. const panels = [...stack.children].filter((panel) => - panel.matches('[data-panel-id]'), + panel.matches('[data-panel-id]:not(.panel-floating)'), ); if (!hud.visible || hud.variant !== 'tactical') { for (const panel of panels.filter((item) => diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index c86be98f1..d2df76850 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -103,6 +103,116 @@ opacity: 0.96; } +/* ── Portable rail panels ──────────────────── + A header drag lifts a portable panel (PanelPositionControls) out of its + rail into a fixed window that remembers its place and size. The rail's + own placement rules in layers.css are guarded with :not(.panel-floating), + so these only have to beat a panel's base placement (one id). */ +.panel-portable .panel-header { + cursor: grab; +} +.panel-portable.panel-dragging .panel-header { + cursor: grabbing; +} +#right-context-rail > .panel-floating { + position: fixed; + inset: auto; + max-height: calc(100vh - 12px); + margin: 0; + flex: none; + pointer-events: auto; +} +/* A collapsed window keeps its stored height for later but shows only its + header strip, so the resize handles have nothing to attach to. */ +#right-context-rail > .panel-floating.collapsed { + max-height: max-content; +} +.panel-floating[style*='height'] > .cctv-panel-inner { + box-sizing: border-box; + height: 100%; + max-height: none; +} +.panel-resize-edge, +.panel-resize-grip { + position: absolute; + z-index: 3; + touch-action: none; +} +.panel-resize-edge[data-dir='n'], +.panel-resize-edge[data-dir='s'] { + left: 10px; + right: 10px; + height: 7px; + cursor: ns-resize; +} +.panel-resize-edge[data-dir='n'] { + top: -3px; +} +.panel-resize-edge[data-dir='s'] { + bottom: -3px; +} +.panel-resize-edge[data-dir='e'], +.panel-resize-edge[data-dir='w'] { + top: 10px; + bottom: 10px; + width: 7px; + cursor: ew-resize; +} +.panel-resize-edge[data-dir='e'] { + right: -3px; +} +.panel-resize-edge[data-dir='w'] { + left: -3px; +} +.panel-resize-edge[data-dir='ne'], +.panel-resize-edge[data-dir='nw'], +.panel-resize-edge[data-dir='sw'] { + width: 14px; + height: 14px; +} +.panel-resize-edge[data-dir='ne'] { + top: -3px; + right: -3px; + cursor: nesw-resize; +} +.panel-resize-edge[data-dir='nw'] { + top: -3px; + left: -3px; + cursor: nwse-resize; +} +.panel-resize-edge[data-dir='sw'] { + bottom: -3px; + left: -3px; + cursor: nesw-resize; +} +.panel-resize-grip { + right: 3px; + bottom: 3px; + width: 14px; + height: 14px; + cursor: nwse-resize; + opacity: 0.55; + clip-path: polygon(100% 0, 100% 100%, 0 100%); + background: repeating-linear-gradient( + 135deg, + transparent 0 3px, + rgba(0, 212, 255, 0.6) 3px 4px + ); + transition: opacity var(--transition-fast); +} +.panel-portable:hover .panel-resize-grip, +.panel-resizing .panel-resize-grip { + opacity: 1; +} +.panel-collapsible.collapsed > .panel-resize-edge, +.panel-collapsible.collapsed > .panel-resize-grip { + display: none; +} +.panel-resizing, +.panel-resizing * { + user-select: none; +} + .panel-title { font-family: var(--font-mono); font-size: 9px; diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 72207fa52..79ad8c268 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -161,7 +161,7 @@ max-height var(--transition-fast); } #right-context-rail > #pp-toggles, -#right-context-rail > #cctv-panel, +#right-context-rail > #cctv-panel:not(.panel-floating), #right-context-rail > #global-context-panel { position: relative; inset: auto; @@ -173,12 +173,12 @@ max-height: 100%; pointer-events: auto; } -#right-context-rail #cctv-panel { +#right-context-rail #cctv-panel:not(.panel-floating) { width: 100%; max-height: 100%; pointer-events: auto; } -#right-context-rail #cctv-panel.collapsed { +#right-context-rail #cctv-panel.collapsed:not(.panel-floating) { width: var(--right-display-collapsed-width); margin-left: auto; } @@ -234,18 +234,22 @@ #right-context-rail.layout-focus { height: var(--right-stack-max-height); } -#right-context-rail.layout-exclusive > [data-panel-id].collapsed { +#right-context-rail.layout-exclusive + > [data-panel-id].collapsed:not(.panel-floating) { display: none; } -#right-context-rail.layout-focus > [data-panel-id].collapsed { +#right-context-rail.layout-focus + > [data-panel-id].collapsed:not(.panel-floating) { display: block; flex: 0 0 auto; } -#right-context-rail.layout-focus.layout-exclusive > [data-panel-id].collapsed { +#right-context-rail.layout-focus.layout-exclusive + > [data-panel-id].collapsed:not(.panel-floating) { display: none; } #right-context-rail.layout-focus > #pp-toggles:not(.collapsed), -#right-context-rail.layout-focus > #cctv-panel:not(.collapsed), +#right-context-rail.layout-focus + > #cctv-panel:not(.collapsed):not(.panel-floating), #right-context-rail.layout-focus > #global-context-panel:not(.collapsed) { flex: 0 1 var(--right-panel-allocated-height); height: var(--right-panel-allocated-height); @@ -259,7 +263,7 @@ scrollbar-color: rgba(0, 212, 255, 0.4) transparent; } #right-context-rail.layout-focus - > #cctv-panel:not(.collapsed) + > #cctv-panel:not(.collapsed):not(.panel-floating) .cctv-panel-inner, #right-context-rail.layout-focus > #global-context-panel:not(.collapsed) diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index 7ebdaadae..38fe8efb3 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -202,8 +202,8 @@ } #right-context-rail #pp-toggles.collapsed .pp-header-row, - #right-context-rail #cctv-panel, - #right-context-rail #cctv-panel.collapsed, + #right-context-rail #cctv-panel:not(.panel-floating), + #right-context-rail #cctv-panel.collapsed:not(.panel-floating), #right-context-rail #radio-panel, #right-context-rail #radio-panel.collapsed, #right-context-rail #global-context-panel, From 335b4b08a2b9816d616ed079a065babe45a3ab39 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:18:21 +0100 Subject: [PATCH 3/4] test(ui): cover rail exclusion and add the panel-resize QA harness The right-rail test now proves a .panel-floating panel is neither measured, allocated nor auto-collapsed while its docked sibling still is. scripts/qa-panel-resize.mjs drives the CCTV panel in a real browser: lift-out by header drag, all eight handles with the opposite edge pinned, minimum size, reload restore and double-click snap-back. Its pure helpers sit behind a main guard so src/tooling/panelResizeQa.test.mjs can check them without a browser. Run it with `npm run qa:panel-resize`. Co-Authored-By: Claude Fable 5.1 --- package.json | 3 +- scripts/format-scope.json | 2 + scripts/qa-panel-resize.mjs | 285 +++++++++++++++++++++++++++++ src/tooling/panelResizeQa.test.mjs | 57 ++++++ src/ui/panelRails.test.mjs | 22 +++ 5 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 scripts/qa-panel-resize.mjs create mode 100644 src/tooling/panelResizeQa.test.mjs diff --git a/package.json b/package.json index 809841ba2..baeb1ba4f 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "test:track": "node scripts/track-regression.mjs", "qa:map-source-tray": "node scripts/qa-map-source-tray.mjs", "check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs", - "qa:transit": "node scripts/qa-transit.mjs" + "qa:transit": "node scripts/qa-transit.mjs", + "qa:panel-resize": "node scripts/qa-panel-resize.mjs" }, "dependencies": { "@mapbox/vector-tile": "^3.0.0", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index cc248f38d..b0dfd39a0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -26,6 +26,8 @@ "src/main.js", "docs/APPLICATION.md", "scripts/qa-application.mjs", + "scripts/qa-panel-resize.mjs", + "src/tooling/panelResizeQa.test.mjs", "src/app/application.js", "src/app/application.test.mjs", "src/app/mapLifecycle.test.mjs", diff --git a/scripts/qa-panel-resize.mjs b/scripts/qa-panel-resize.mjs new file mode 100644 index 000000000..fc97614e9 --- /dev/null +++ b/scripts/qa-panel-resize.mjs @@ -0,0 +1,285 @@ +#!/usr/bin/env node +/** + * Prove the portable CCTV panel in a real browser: a header drag lifts it out + * of the right rail, every resize handle moves only its own edges, minimum + * sizes hold, the window survives a reload and a header double-click snaps + * it back and forgets the stored position. + */ +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; +import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js'; + +export const PANEL_ID = 'cctv-panel'; +export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`; +export const MIN_SIZE = { width: 300, height: 160 }; +const VIEWPORT = { width: 1400, height: 900 }; +const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' }; + +/** Box edges a resize in `dir` must leave exactly where they were. */ +export function pinnedEdges(dir) { + return Object.entries(EDGE_BY_SIDE) + .filter(([side]) => !dir.includes(side)) + .map(([, edge]) => edge); +} + +/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */ +export function growthDelta(dir, amount) { + return { + dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0, + dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0, + }; +} + +/** + * Viewport point inside the handle for `dir`, given the panel's client rect. + * Edge strips straddle the border by 3px; corners and the grip sit inside. + */ +export function handlePoint(rect, dir) { + const inset = dir.length === 2 ? 4 : 1; + const x = dir.includes('w') + ? rect.left + inset + : dir.includes('e') + ? rect.right - inset + : rect.left + rect.width / 2; + const y = dir.includes('n') + ? rect.top + inset + : dir.includes('s') + ? rect.bottom - inset + : rect.top + rect.height / 2; + return { x, y }; +} + +/** Edges of `after` that moved although a resize in `dir` should have pinned them. */ +export function driftedEdges(before, after, dir, tolerance = 1.5) { + const edges = (box) => ({ + left: box.left, + top: box.top, + right: box.left + box.width, + bottom: box.top + box.height, + }); + const a = edges(before); + const b = edges(after); + return pinnedEdges(dir).filter( + (edge) => Math.abs(a[edge] - b[edge]) > tolerance, + ); +} + +function near(actual, expected, tolerance, label) { + assert.ok( + Math.abs(actual - expected) <= tolerance, + `${label}: expected ${expected} ± ${tolerance}, got ${actual}`, + ); +} + +async function main() { + const { default: puppeteer } = await import('puppeteer'); + const args = process.argv.slice(2); + const urlIndex = args.indexOf('--url'); + const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; + const browser = await puppeteer.launch({ + headless: true, + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || + (await puppeteer.executablePath()), + args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], + }); + try { + const page = await browser.newPage(); + await page.setViewport(VIEWPORT); + const errors = []; + page.on('pageerror', (error) => errors.push(error.stack || error.message)); + + const boot = async () => { + await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.viewer, { + timeout: 90_000, + }); + await page.waitForSelector(`#${PANEL_ID} .panel-header`); + // The first-run dialog sits over the globe until a choice is made. + await page.evaluate(() => { + document.querySelector('.first-run-explore')?.click(); + }); + await page.keyboard.press('Escape'); + await page.evaluate(() => { + document.getElementById('first-run-launcher')?.remove(); + for (const node of document.querySelectorAll('[class*=first-run]')) + node.remove(); + }); + await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2); + await new Promise((resolve) => setTimeout(resolve, 400)); + }; + const readPanel = () => + page.evaluate((id) => { + const panel = document.getElementById(id); + const rect = panel.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + floating: panel.classList.contains('panel-floating'), + collapsed: panel.classList.contains('collapsed'), + inRail: panel.parentElement?.id === 'right-context-rail', + allocated: panel.style.getPropertyValue( + '--right-panel-allocated-height', + ), + inlineWidth: panel.style.width, + inlineHeight: panel.style.height, + stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`), + }; + }, PANEL_ID); + const headerPoint = async () => { + const rect = await page.evaluate((id) => { + const title = document.querySelector( + `#${id} .panel-header .panel-title`, + ); + const r = title.getBoundingClientRect(); + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + }, PANEL_ID); + return rect; + }; + const drag = async (from, dx, dy) => { + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps: 6 }); + await page.mouse.move(from.x + dx, from.y + dy, { steps: 6 }); + await page.mouse.up(); + await new Promise((resolve) => setTimeout(resolve, 120)); + }; + + await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate((key) => { + localStorage.removeItem(key); + localStorage.removeItem('godsEyeView.v8.panelFloatHintShown'); + }, STORAGE_KEY); + await boot(); + + // Expand CCTV so the panel has a body worth resizing. + await page.evaluate((id) => { + const panel = document.getElementById(id); + if (panel.classList.contains('collapsed')) + document + .querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`) + .click(); + }, PANEL_ID); + await page.waitForFunction( + (id) => !document.getElementById(id).classList.contains('collapsed'), + {}, + PANEL_ID, + ); + await new Promise((resolve) => setTimeout(resolve, 600)); + const docked = await readPanel(); + assert.equal(docked.floating, false); + assert.equal(docked.inRail, true); + + // Lift out: drag the header 200px left and 100px up. + await drag(await headerPoint(), -200, -100); + const lifted = await readPanel(); + assert.equal(lifted.floating, true, 'a header drag lifts the panel out'); + assert.equal( + lifted.collapsed, + false, + 'the drag-ending click must not collapse', + ); + near(lifted.left, Math.max(6, docked.left - 200), 2, 'lifted left'); + near(lifted.top, Math.max(6, docked.top - 100), 2, 'lifted top'); + near(lifted.width, docked.width, 2, 'lifted width'); + await new Promise((resolve) => setTimeout(resolve, 700)); + const afterLayout = await readPanel(); + assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it'); + const railExcludes = await page.evaluate((id) => { + const rail = document.getElementById('right-context-rail'); + const counted = [...rail.children].filter((panel) => + panel.matches('[data-panel-id]:not(.panel-floating):not(.collapsed)'), + ); + return { + expandedCount: rail.dataset.expandedCount, + counted: counted.length, + includesPanel: counted.some((panel) => panel.id === id), + }; + }, PANEL_ID); + assert.equal(railExcludes.includesPanel, false); + if (railExcludes.expandedCount !== undefined) + assert.equal(Number(railExcludes.expandedCount), railExcludes.counted); + const storedAfterLift = JSON.parse(afterLayout.stored); + assert.equal(storedAfterLift.floating, true); + assert.equal( + 'height' in storedAfterLift, + false, + 'a drag alone must not freeze the measured height', + ); + console.log('PASS: header drag lifts CCTV out of the rail'); + + // Every handle moves only its own edges. + const limits = { + minWidth: MIN_SIZE.width, + minHeight: MIN_SIZE.height, + viewportWidth: VIEWPORT.width, + viewportHeight: VIEWPORT.height, + }; + for (const dir of RESIZE_DIRECTIONS) { + const before = await readPanel(); + const { dx, dy } = growthDelta(dir, 40); + await drag(handlePoint(before, dir), dx, dy); + const after = await readPanel(); + const expected = resizeBox(before, dir, dx, dy, limits); + for (const key of ['left', 'top', 'width', 'height']) + near(after[key], expected[key], 2, `${dir} ${key}`); + assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`); + } + console.log('PASS: eight resize handles keep the opposite edge pinned'); + + // Minimum size stops the moving edge. + let before = await readPanel(); + await drag(handlePoint(before, 'se'), -2000, -2000); + let after = await readPanel(); + near(after.width, MIN_SIZE.width, 2, 'min width (se)'); + near(after.height, MIN_SIZE.height, 2, 'min height (se)'); + assert.deepEqual(driftedEdges(before, after, 'se', 2), []); + before = after; + await drag(handlePoint(before, 'nw'), 2000, 2000); + after = await readPanel(); + near(after.width, MIN_SIZE.width, 2, 'min width (nw)'); + near(after.height, MIN_SIZE.height, 2, 'min height (nw)'); + assert.deepEqual(driftedEdges(before, after, 'nw', 2), []); + console.log('PASS: minimum size holds from both corners'); + + // Reload restores the window. + const saved = await readPanel(); + const record = JSON.parse(saved.stored); + assert.equal(record.floating, true); + await boot(); + const restored = await readPanel(); + assert.equal(restored.floating, true, 'floating survives a reload'); + for (const key of ['left', 'top', 'width', 'height']) + near(restored[key], record[key], 2, `restored ${key}`); + console.log('PASS: reload restores position and size'); + + // Double-click the header to snap back. + const point = await headerPoint(); + await page.mouse.click(point.x, point.y, { clickCount: 1 }); + await page.mouse.click(point.x, point.y, { clickCount: 2 }); + await new Promise((resolve) => setTimeout(resolve, 300)); + const snapped = await readPanel(); + assert.equal(snapped.floating, false, 'double-click snaps back'); + assert.equal(snapped.inRail, true); + assert.equal(snapped.inlineWidth, ''); + assert.equal(snapped.inlineHeight, ''); + assert.equal(snapped.stored, null, 'the storage key is gone'); + console.log('PASS: header double-click returns the panel to the rail'); + + assert.deepEqual(errors, []); + } finally { + await browser.close(); + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); + }); +} diff --git a/src/tooling/panelResizeQa.test.mjs b/src/tooling/panelResizeQa.test.mjs new file mode 100644 index 000000000..586f22c08 --- /dev/null +++ b/src/tooling/panelResizeQa.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + driftedEdges, + growthDelta, + handlePoint, + MIN_SIZE, + pinnedEdges, + STORAGE_KEY, +} from '../../scripts/qa-panel-resize.mjs'; +import { RESIZE_DIRECTIONS } from '../ui/panelResize.js'; + +test('the harness targets the key and minimums the controls use', () => { + assert.equal(STORAGE_KEY, 'godsEyeView.v8.panelPos.cctv-panel'); + assert.deepEqual(MIN_SIZE, { width: 300, height: 160 }); +}); + +test('pinned edges are exactly the sides a direction does not name', () => { + assert.deepEqual(pinnedEdges('n'), ['bottom', 'right', 'left']); + assert.deepEqual(pinnedEdges('se'), ['top', 'left']); + assert.deepEqual(pinnedEdges('nw'), ['bottom', 'right']); + for (const dir of RESIZE_DIRECTIONS) + assert.equal(pinnedEdges(dir).length, 4 - dir.length); +}); + +test('growth deltas push each named edge outward', () => { + assert.deepEqual(growthDelta('n', 40), { dx: 0, dy: -40 }); + assert.deepEqual(growthDelta('e', 40), { dx: 40, dy: 0 }); + assert.deepEqual(growthDelta('sw', 40), { dx: -40, dy: 40 }); + assert.deepEqual(growthDelta('se', 40), { dx: 40, dy: 40 }); +}); + +test('handle points land inside the strip or corner for every direction', () => { + const rect = { left: 100, top: 200, width: 400, height: 300 }; + rect.right = rect.left + rect.width; + rect.bottom = rect.top + rect.height; + assert.deepEqual(handlePoint(rect, 'n'), { x: 300, y: 201 }); + assert.deepEqual(handlePoint(rect, 'w'), { x: 101, y: 350 }); + assert.deepEqual(handlePoint(rect, 'se'), { x: 496, y: 496 }); + assert.deepEqual(handlePoint(rect, 'nw'), { x: 104, y: 204 }); + for (const dir of RESIZE_DIRECTIONS) { + const { x, y } = handlePoint(rect, dir); + assert.ok(x >= rect.left && x <= rect.right, `${dir} x inside`); + assert.ok(y >= rect.top && y <= rect.bottom, `${dir} y inside`); + } +}); + +test('drift detection reports only pinned edges that moved', () => { + const before = { left: 100, top: 100, width: 400, height: 300 }; + const grewEast = { left: 100, top: 100, width: 440, height: 300 }; + assert.deepEqual(driftedEdges(before, grewEast, 'e'), []); + const slidWest = { left: 60, top: 100, width: 440, height: 300 }; + assert.deepEqual(driftedEdges(before, slidWest, 'e'), ['left']); + assert.deepEqual(driftedEdges(before, slidWest, 'w'), []); + const wobble = { left: 101, top: 100, width: 439, height: 300 }; + assert.deepEqual(driftedEdges(before, wobble, 'e'), []); +}); diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 2b03c1406..448479169 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -243,6 +243,28 @@ test('right layout retains Display allocation during measurement and caps restor ); }); +test('right rail neither measures nor allocates a panel floating out of it', () => { + const f = fixture('right'); + f.expand(f.first, 900); + f.expand(f.second, 300); + f.first.classList.add('panel-floating'); + f.first.style.setProperty('--right-panel-allocated-height', '500px'); + f.first.writes.length = 0; + f.run(); + assert.equal(f.stack.dataset.expandedCount, '1'); + assert.deepEqual( + f.first.writes, + [], + 'a floating panel keeps whatever inline style it left the rail with', + ); + assert.equal(f.first.getAttribute('aria-hidden'), undefined); + assert.equal(f.first.classList.contains('layout-auto-collapsed'), false); + assert.ok( + f.second.style.getPropertyValue('--right-panel-allocated-height'), + 'the docked panel still receives its allocation', + ); +}); + test('right rail aligns to the current left rail and excludes hidden obstacles', () => { const f = fixture('right'); f.options.leftStack.rect.top = 200; From d7d45b48905e3ac317e46f9af0eb567d723998f7 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:18:49 +0100 Subject: [PATCH 4/4] docs(ui): describe portable panel position storage CURRENT-STATE and KNOWN-ISSUES said the panel position key is never written; a portable panel now stores its floating window there, so both documents describe the record and how a header double-click clears it. CHANGELOG notes the CCTV panel's portable mode. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 +++++ docs/CURRENT-STATE.md | 4 ++-- docs/KNOWN-ISSUES.md | 7 ++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f3fd7822..f037c3e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Rail panels can opt into a portable mode: dragging the CCTV panel by its + header lifts it out of the right rail into a floating window that resizes + from any edge or corner, remembers its position and size, and snaps back on + a header double-click. + - Distinguish PARTIAL vessel snapshots from STALE data in the layer panel, with accepted-record counts and unchanged retention, freshness and outage safeguards. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b5727f353..033330624 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3779,8 +3779,8 @@ easier to meet (detection is now on more often), but does not create it. samples and constant elevation during E/N drag; one shared-floor resolution on release; late one-shot shared-cell work is permitted during viewshed idle. The A+B harness intentionally excludes citywide LOD assertions. -- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.`, but the current rails lay panels out adaptively and write none. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.` (`'0'` open, `'1'` closed, absent means the panel's own default). -- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them; collapsed states still persist at `godsEyeView.v6.panelCollapsed.`. +- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.`. The rails lay docked panels out adaptively and write nothing for them; a portable panel (currently CCTV) persists `{ left, top, width, height, floating: true }` under that key once a header drag lifts it out of the rail, and a header double-click removes the record again. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.` (`'0'` open, `'1'` closed, absent means the panel's own default). +- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them unless they describe a portable panel's floating window; collapsed states still persist at `godsEyeView.v6.panelCollapsed.`. - Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker. - Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`. - Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring. diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index 92c92c1f9..9857052c7 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -57,9 +57,10 @@ Related keys (current versions): - Panel collapsed state: `godsEyeView.v6.panelCollapsed.` — `'0'` open, `'1'` closed, absent means the panel's own default. A view opened from a share link ignores the stored value entirely. -- Panel positions: `godsEyeView.v8.panelPos.` — the versioned name for a - stored position. The current layout writes none, so deleting one changes - nothing. +- Panel positions: `godsEyeView.v8.panelPos.` — written only for a + portable panel (currently CCTV) lifted out of its rail, as + `{ left, top, width, height, floating: true }`. Deleting it, or double-clicking + the panel header, returns that panel to the rail; docked panels store nothing. - CCTV calibration: `godsEyeView.cctv.calibration.v2` ---