From 55b4a5e6ff9c8935afcfe505d7427a0a4f331493 Mon Sep 17 00:00:00 2001 From: nubs Date: Mon, 15 Jun 2026 10:36:34 +0000 Subject: [PATCH] fix(ui): restore all-edge modal snap zones (#2260) --- static/js/tileManager.js | 34 ++++--- tests/test_tile_manager_snap_zones_js.py | 117 +++++++++++++++++++++++ 2 files changed, 138 insertions(+), 13 deletions(-) create mode 100644 tests/test_tile_manager_snap_zones_js.py diff --git a/static/js/tileManager.js b/static/js/tileManager.js index e70e13e80..3ce1b1238 100644 --- a/static/js/tileManager.js +++ b/static/js/tileManager.js @@ -6,16 +6,13 @@ * when the cursor is near a snap zone. On release, snaps the modal-content * to fill that zone with a springy animation. * - * Snap zones (9): - * - top edge (10% strip) → maximize - * - top-left corner → top-left quarter - * - top-right corner → top-right quarter + * Snap zones: + * - over top edge → fullscreen + * - top strip → maximize + * - top edge → top half * - left edge → left half * - right edge → right half - * - bottom-left corner → bottom-left quarter - * - bottom-right corner → bottom-right quarter * - bottom edge → bottom half - * - sidebar edge (if present) → snap next to the sidebar * * Mobile (≤768px) is excluded — the swipe-dismiss UX takes precedence. * @@ -24,7 +21,6 @@ */ const EDGE_THRESHOLD_PX = 24; // how close to an edge counts as "near" -const CORNER_THRESHOLD_PX = 64; // corner box size const TOP_FULL_STRIP_PX = 8; // top strip → maximize let _ghost = null; @@ -111,9 +107,13 @@ function _zoneForPointer(x, y) { return { name: 'maximize', rect: { left: safe.left, top: safe.top, width: W, height: H } }; } - // Corner quarter-snaps DISABLED (user request) — only the top strip - // (maximize) and the right/bottom half-snaps remain. The LEFT-half snap - // is also disabled (the sidebar lives there; docking over it is awkward). + // Symmetric edge half-snaps. The safe rect already starts to the right of + // the sidebar/rail, so left-half fills the left side of the workspace + // without covering navigation. + if (y <= safe.top + EDGE_THRESHOLD_PX) + return { name: 'top-half', rect: { left: safe.left, top: safe.top, width: W, height: H / 2 } }; + if (x <= safe.left + EDGE_THRESHOLD_PX) + return { name: 'left-half', rect: { left: safe.left, top: safe.top, width: W / 2, height: H } }; if (x >= safe.right - EDGE_THRESHOLD_PX) return { name: 'right-half', rect: { left: safe.left + W / 2, top: safe.top, width: W / 2, height: H } }; if (y >= safe.bottom - EDGE_THRESHOLD_PX) @@ -131,8 +131,7 @@ function _zoneForContent(content, x, y) { // flip to top tabs via CSS when the window gets narrow. if (modal && modal.id === 'settings-modal' && zone.name !== 'right-half') return null; if (modal && (modal.id === 'cookbook-modal' - || modal.id === 'theme-modal' - || modal.id === 'memory-modal') + || modal.id === 'theme-modal') && zone.name !== 'fullscreen') return null; return zone; } @@ -304,6 +303,7 @@ function _reclampAll(animate = false) { switch (name) { case 'fullscreen': r = { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }; break; case 'maximize': r = { left: safe.left, top: safe.top, width: W, height: H }; break; + case 'top-half': r = { left: safe.left, top: safe.top, width: W, height: H/2 }; break; case 'left-half': r = { left: safe.left, top: safe.top, width: W/2, height: H }; break; case 'right-half': r = { left: safe.left + W/2, top: safe.top, width: W/2, height: H }; break; case 'bottom-half': r = { left: safe.left, top: safe.top + H/2, width: W, height: H/2 }; break; @@ -374,6 +374,14 @@ export function clearPreview() { _activeZone = null; } +export function _zoneForPointerForTests(x, y) { + return _zoneForPointer(x, y); +} + +export function _zoneForContentForTests(content, x, y) { + return _zoneForContent(content, x, y); +} + // Snap a modal (its .modal-content) into a previously-detected zone. export function snapModalToZone(modal, zone) { if (!modal || !zone) return; diff --git a/tests/test_tile_manager_snap_zones_js.py b/tests/test_tile_manager_snap_zones_js.py new file mode 100644 index 000000000..2d9b7a8cf --- /dev/null +++ b/tests/test_tile_manager_snap_zones_js.py @@ -0,0 +1,117 @@ +"""Regression coverage for desktop modal tile snap edge zones.""" + +import json +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parent.parent +_HELPER = _REPO / "static" / "js" / "tileManager.js" +_HAS_NODE = shutil.which("node") is not None + + +def _run_tile_case(): + script = textwrap.dedent( + f""" + globalThis.window = {{ + innerWidth: 1200, + innerHeight: 800, + addEventListener() {{}}, + }}; + globalThis.document = {{ + readyState: 'loading', + body: {{ appendChild() {{}} }}, + documentElement: {{ style: {{ setProperty() {{}}, removeProperty() {{}} }} }}, + addEventListener() {{}}, + getElementById() {{ return null; }}, + querySelector() {{ return null; }}, + querySelectorAll() {{ return []; }}, + createElement() {{ + return {{ + style: {{}}, + classList: {{ add() {{}}, remove() {{}} }}, + remove() {{}}, + }}; + }}, + }}; + globalThis.requestAnimationFrame = (fn) => fn(); + globalThis.MutationObserver = class {{ + observe() {{}} + disconnect() {{}} + }}; + + const mod = await import('{_HELPER.as_posix()}'); + const pick = (zone) => zone ? {{ + name: zone.name, + rect: {{ + left: zone.rect.left, + top: zone.rect.top, + width: zone.rect.width, + height: zone.rect.height, + }}, + }} : null; + + const memoryModal = {{ id: 'memory-modal' }}; + const memoryContent = {{ closest() {{ return memoryModal; }} }}; + const settingsModal = {{ id: 'settings-modal' }}; + const settingsContent = {{ closest() {{ return settingsModal; }} }}; + + console.log(JSON.stringify({{ + fullscreen: pick(mod._zoneForPointerForTests(500, 0)), + maximize: pick(mod._zoneForPointerForTests(500, 8)), + top: pick(mod._zoneForPointerForTests(500, 20)), + left: pick(mod._zoneForPointerForTests(20, 300)), + right: pick(mod._zoneForPointerForTests(1190, 300)), + bottom: pick(mod._zoneForPointerForTests(500, 790)), + memoryBottom: pick(mod._zoneForContentForTests(memoryContent, 500, 790)), + settingsTop: pick(mod._zoneForContentForTests(settingsContent, 500, 20)), + settingsRight: pick(mod._zoneForContentForTests(settingsContent, 1190, 300)), + }})); + """ + ) + proc = subprocess.run( + ["node", "--input-type=module"], + input=script, + capture_output=True, + text=True, + cwd=str(_REPO), + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip()) + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_tile_manager_detects_all_four_workspace_edges(): + zones = _run_tile_case() + + assert zones["fullscreen"]["name"] == "fullscreen" + assert zones["maximize"]["name"] == "maximize" + assert zones["top"] == { + "name": "top-half", + "rect": {"left": 4, "top": 4, "width": 1192, "height": 396}, + } + assert zones["left"] == { + "name": "left-half", + "rect": {"left": 4, "top": 4, "width": 596, "height": 792}, + } + assert zones["right"] == { + "name": "right-half", + "rect": {"left": 600, "top": 4, "width": 596, "height": 792}, + } + assert zones["bottom"] == { + "name": "bottom-half", + "rect": {"left": 4, "top": 400, "width": 1192, "height": 396}, + } + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_regular_tool_modals_are_not_limited_to_fullscreen_only(): + zones = _run_tile_case() + + assert zones["memoryBottom"]["name"] == "bottom-half" + assert zones["settingsTop"] is None + assert zones["settingsRight"]["name"] == "right-half"