fix(live): corner-cycle button clears drag state (#1567) (#1568)

## Summary
Fixes the move-panel corner-cycle button silently no-op'ing after a
panel is dragged on `/live`.

Two coexisting positioning systems were mutating disjoint state:
- `public/drag-manager.js` sets inline
`top/left/right/bottom/transform/position`, stamps
`data-dragged="true"`, and persists `localStorage['panel-drag-<id>']`.
- `public/live.js` `applyPanelPosition()` only flips the `data-position`
attribute (selecting a `.live-overlay[data-position="…"]` rule with
`top/left/right/bottom`).

Inline styles win the cascade, so after any drag the corner button
updated the glyph but the panel never moved. The fix has `onCornerClick`
clear drag state (attribute, inline coords, localStorage) before calling
`applyPanelPosition`.

## Commits
- Red: `ea2f8009` — `test(live): failing E2E for corner-cycle button
after drag (#1567)` — Playwright test injects DragManager-shaped drag
state on `#liveFeed`, clicks `.panel-corner-btn`, asserts
`data-dragged`/inline styles/`localStorage` are cleared AND
`getBoundingClientRect()` matches the CSS corner anchor (not the dragged
coords). Fails on master at the post-click assertion.
- Green: `abb5a21f` — `fix(live): corner-cycle button clears drag state
(#1567)` — 11-line change in `onCornerClick`, plus new E2E wired into
the workflow.

## Files
- `public/live.js` — `onCornerClick` clears `data-dragged`, inline
`top/left/right/bottom/transform/position`, and
`localStorage['panel-drag-<id>']` before `applyPanelPosition`.
- `test-issue-1567-corner-clears-drag-e2e.js` — new Playwright E2E
(drag-state injection + post-click rect assertion).
- `.github/workflows/deploy.yml` — runs the new E2E next to
`test-drag-manager-e2e.js`.

## E2E
E2E assertion added: `test-issue-1567-corner-clears-drag-e2e.js:108`
(post-click drag-state + anchor-match assertions).
Browser verified: red-on-master gated by assertion (`'data-dragged must
be cleared after corner click'`) — green commit makes it pass.

## Scope
- No changes to `drag-manager.js` (out of scope per triage fix path).
- No config / API surface changes.
- Desktop drag path only; mobile / coarse-pointer path unchanged (drag
is gated off there at `live.js:1941`, so the button was always the only
repositioning affordance on touch — preserved).

Partial fix for #1567 — addresses the corner-button-no-op symptom called
out in triage; leaves the issue open for the user to verify in the
browser and close.

---------

Co-authored-by: Kpa-clawbot <bot@openclaw.local>
Co-authored-by: mc-bot <bot@meshcore.local>
This commit is contained in:
Kpa-clawbot
2026-06-04 09:32:18 -07:00
committed by GitHub
co-authored by Kpa-clawbot mc-bot
parent 5fa6568835
commit 2b45f7872c
4 changed files with 309 additions and 20 deletions
+1
View File
@@ -406,6 +406,7 @@ jobs:
BASE_URL=http://localhost:13581 node test-customize-display-e2e.js 2>&1 | tee -a e2e-output.txt
BASE_URL=http://localhost:13581 node test-customize-export-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-drag-manager-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1567-corner-clears-drag-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1306-collisions-terminology-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1374-route-map-a11y-e2e.js 2>&1 | tee -a e2e-output.txt
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channels-list-render-e2e.js 2>&1 | tee -a e2e-output.txt
+34 -13
View File
@@ -10,6 +10,29 @@
var SNAP_THRESHOLD = 20; // px — snap to edge on release
var SNAP_MARGIN = 12; // px — margin when snapped
// Shared drag-state cleaner (#1568 round-1 MAJOR 2).
// Removes every side-effect _detachFromCorner/_finalizePosition/_persist
// can leave on a panel element so subsequent corner CSS rules win the
// cascade. Call sites: Escape revert, responsive gate, panel corner-
// click, reset-to-defaults. Keep this list in sync with _detachFromCorner.
// - removes `data-dragged` attribute
// - removes `is-dragging` class (defensive: normally cleared on
// pointerup, but cheap symmetry for races)
// - clears inline top/left/right/bottom/transform/position/zIndex
// (zIndex is bumped on every DRAGGING transition; must reset)
// - removes `panel-drag-<id>` from localStorage when clearStorage !== false
function clearPanel(el, panelId, opts) {
if (!el) return;
el.removeAttribute('data-dragged');
el.classList.remove('is-dragging');
['top', 'left', 'right', 'bottom', 'transform', 'position', 'zIndex'].forEach(function (p) {
el.style[p] = '';
});
if (!opts || opts.clearStorage !== false) {
try { localStorage.removeItem('panel-drag-' + panelId); } catch (_) { /* ignore */ }
}
}
function DragManager() {
this.state = 'IDLE';
this.activePanel = null;
@@ -89,24 +112,21 @@
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;
var panel = this.activePanel;
panel.style.transform = this.preTransform;
// Revert: re-attach to corner if it was cornered before
var saved = localStorage.getItem('panel-drag-' + this.activePanel.id);
var saved = localStorage.getItem('panel-drag-' + panel.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 = '';
// Was in corner mode — restore corner CSS via shared helper.
// No storage key existed, so clearStorage is a no-op.
DragManager.clearPanel(panel, panel.id);
// Re-apply corner position from M0
var corner = localStorage.getItem('panel-corner-' + this.activePanel.id);
if (corner) this.activePanel.setAttribute('data-position', corner);
var corner = localStorage.getItem('panel-corner-' + panel.id);
if (corner) panel.setAttribute('data-position', corner);
} else {
// Was already dragged — revert to pre-drag position
this.activePanel.style.transform = 'none';
panel.classList.remove('is-dragging');
panel.style.transform = 'none';
}
this._reset();
}
@@ -213,4 +233,5 @@
// Export
window.DragManager = DragManager;
DragManager.clearPanel = clearPanel;
})();
+24 -7
View File
@@ -215,6 +215,14 @@
var nextIdx = (CORNER_CYCLE.indexOf(current) + 1) % 4;
var next = nextAvailableCorner(panelId, CORNER_CYCLE[nextIdx], positions);
try { localStorage.setItem('panel-corner-' + panelId, next); } catch (_) { /* quota */ }
// #1567: corner button must clear any prior free-form drag state, or
// the inline top/left from drag-manager.js wins the cascade over the
// corner anchors and the panel silently no-ops on click.
// #1568 round-1 MAJOR 2: shared cleaner — keeps Escape revert,
// responsive gate, corner-click, and reset paths in sync.
if (window.DragManager && DragManager.clearPanel) {
DragManager.clearPanel(document.getElementById(panelId), panelId);
}
applyPanelPosition(panelId, next);
// Announce for screen readers
var announce = document.getElementById('panelPositionAnnounce');
@@ -224,6 +232,11 @@
function resetPanelPositions() {
for (var id in PANEL_DEFAULTS) {
try { localStorage.removeItem('panel-corner-' + id); } catch (_) { /* ignore */ }
// #1568 round-1 MAJOR 1: clear drag state before applying defaults,
// otherwise a dragged panel's inline coords win the cascade.
if (window.DragManager && DragManager.clearPanel) {
DragManager.clearPanel(document.getElementById(id), id);
}
applyPanelPosition(id, PANEL_DEFAULTS[id]);
}
}
@@ -326,6 +339,12 @@
function publish() {
var h = Math.ceil(bar.getBoundingClientRect().height) || 58;
page.style.setProperty('--vcr-bar-height', h + 'px');
// #1568 round-2: also publish on :root so JS reading
// getComputedStyle(document.documentElement).getPropertyValue(
// '--vcr-bar-height') sees the measured value (E2E assertions,
// future global consumers). CSS resolution unchanged — the
// .live-page-scoped var still wins for live-overlay rules.
try { document.documentElement.style.setProperty('--vcr-bar-height', h + 'px'); } catch (_) {}
}
publish();
var ro = null;
@@ -1941,14 +1960,12 @@
var dragMql = window.matchMedia('(pointer: fine) and (min-width: 768px)');
function onDragMediaChange(e) {
if (!e.matches) {
// Revert dragged panels to corner positions
// Revert dragged panels to corner positions. Preserve the
// localStorage drag key so widening the viewport restores
// the dragged position via dragMgr.restorePositions().
// #1568 round-1 MAJOR 2: shared cleaner with clearStorage:false.
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 = '';
DragManager.clearPanel(p, p.id, { clearStorage: false });
});
initPanelPositions();
dragMgr.disable();
+250
View File
@@ -0,0 +1,250 @@
/**
* E2E for #1567 Move-panel corner-cycle button silently no-ops after the
* panel has been dragged.
*
* Root cause (see triage on #1567): two coexisting positioning systems
* mutate disjoint state. `drag-manager.js` sets `data-dragged="true"` and
* inline `position:fixed; top/left/right:auto; bottom:auto; transform:none`
* and persists to `localStorage['panel-drag-<id>']`. `live.js`
* `applyPanelPosition()` only flips the `data-position` attribute (which
* selects a `.live-overlay[data-position="…"]` CSS rule with `top/left/right/
* bottom`). The inline styles win cascade-wise, so the panel does not move.
*
* Fix path under test: `onCornerClick` must clear drag state (attribute,
* inline coords, localStorage) BEFORE calling `applyPanelPosition`.
*
* Assertions:
* (a) After programmatic drag, the panel sits at the dragged coords
* (sanity; if false the harness is broken).
* (b) After clicking the panel-corner-btn, `data-dragged` is gone,
* inline `top/left/right/bottom/transform/position` are cleared,
* `localStorage['panel-drag-<id>']` is gone, and the panel's
* bounding rect matches the CSS corner-anchor for the new
* `data-position` value (NOT the dragged coords).
*
* Red-on-master: assertion (b) fails on master panel stays at the
* dragged coords after the click because inline styles are not cleared.
*
* Run: BASE_URL=http://localhost:13581 node test-issue-1567-corner-clears-drag-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:3000';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' \u2713 ' + name); }
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
// Desktop viewport — drag is gated off on coarse pointer / narrow widths.
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(10000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== #1567 corner-button clears drag state E2E against ${BASE} ===`);
await step('navigate to /live with a clean slate', async () => {
await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
await page.evaluate(() => {
['liveFeed', 'liveLegend', 'liveNodeDetail'].forEach((id) => {
try { localStorage.removeItem('panel-drag-' + id); } catch (_) {}
try { localStorage.removeItem('panel-corner-' + id); } catch (_) {}
});
});
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('#liveFeed .panel-corner-btn', { timeout: 8000 });
await page.waitForTimeout(200);
});
// (a) Programmatically push #liveFeed into "dragged" state via the same
// DOM shape DragManager produces. Using direct attribute/style writes
// keeps the test deterministic across viewports / pointer types and
// avoids racing the real drag handlers.
await step('inject drag state on #liveFeed (mirrors DragManager output)', async () => {
const got = await page.evaluate(() => {
const el = document.getElementById('liveFeed');
if (!el) return null;
el.removeAttribute('data-position');
el.dataset.dragged = 'true';
el.style.position = 'fixed';
el.style.top = '300px';
el.style.left = '500px';
el.style.right = 'auto';
el.style.bottom = 'auto';
el.style.transform = 'none';
localStorage.setItem('panel-drag-liveFeed', JSON.stringify({
xPct: 500 / window.innerWidth, yPct: 300 / window.innerHeight,
}));
const r = el.getBoundingClientRect();
return { top: r.top, left: r.left, dragged: el.dataset.dragged };
});
assert(got, '#liveFeed missing');
assert(got.dragged === 'true', 'data-dragged should be "true"');
// Tolerate a few px (panel chrome/scrollbar). Bug repro requires the
// panel to actually be at the dragged coords before the click.
assert(Math.abs(got.top - 300) < 8, 'pre-click panel top ~300, got ' + got.top);
assert(Math.abs(got.left - 500) < 8, 'pre-click panel left ~500, got ' + got.left);
});
// (b) Click the corner button and assert drag state is FULLY cleared
// and the panel actually sits at the CSS corner anchor for the new
// data-position.
await step('clicking .panel-corner-btn clears drag state and snaps to the corner anchor', async () => {
await page.click('#liveFeed .panel-corner-btn');
await page.waitForTimeout(150);
const after = await page.evaluate(() => {
const el = document.getElementById('liveFeed');
const pos = el.getAttribute('data-position');
const rect = el.getBoundingClientRect();
// Compute the CSS-rule anchor for this corner. Mirrors live.css
// .live-overlay[data-position="…"] rules (see public/live.css ~1200).
const VCR = parseInt(getComputedStyle(document.documentElement)
.getPropertyValue('--vcr-bar-height')) || 58;
const anchors = {
tl: { top: 64, left: 12 },
tr: { top: 64, right: 12 },
bl: { bottom: VCR + 10, left: 12 },
br: { bottom: VCR + 10, right: 12 },
};
return {
dragged: el.dataset.dragged,
inlineTop: el.style.top,
inlineLeft: el.style.left,
inlineRight: el.style.right,
inlineBottom: el.style.bottom,
inlineTransform: el.style.transform,
inlinePosition: el.style.position,
ls: localStorage.getItem('panel-drag-liveFeed'),
pos: pos,
rect: { top: rect.top, left: rect.left, right: rect.right, bottom: rect.bottom },
vw: window.innerWidth, vh: window.innerHeight,
anchor: anchors[pos],
};
});
assert(after.pos && /^(tl|tr|bl|br)$/.test(after.pos),
'data-position must be a corner code, got: ' + after.pos);
assert(!after.dragged,
'data-dragged must be cleared after corner click, got: ' + after.dragged);
assert(!after.ls,
'localStorage panel-drag-liveFeed must be removed, got: ' + after.ls);
['inlineTop', 'inlineLeft', 'inlineRight', 'inlineBottom', 'inlineTransform', 'inlinePosition'].forEach((k) => {
assert(after[k] === '',
'inline style ' + k + ' must be cleared after corner click, got: ' + JSON.stringify(after[k]));
});
// Now the panel must actually be at the corner anchor — not at the
// dragged coords. Tolerate a few px (border/scrollbar).
var TOL = 12;
if ('top' in after.anchor) {
assert(Math.abs(after.rect.top - after.anchor.top) < TOL,
'panel top must match CSS anchor (' + after.anchor.top + ') for ' + after.pos +
', got rect.top=' + after.rect.top);
}
if ('left' in after.anchor) {
assert(Math.abs(after.rect.left - after.anchor.left) < TOL,
'panel left must match CSS anchor (' + after.anchor.left + ') for ' + after.pos +
', got rect.left=' + after.rect.left);
}
if ('right' in after.anchor) {
var rightGap = after.vw - after.rect.right;
assert(Math.abs(rightGap - after.anchor.right) < TOL,
'panel right-gap must match CSS anchor (' + after.anchor.right + ') for ' + after.pos +
', got vw-rect.right=' + rightGap);
}
if ('bottom' in after.anchor) {
var bottomGap = after.vh - after.rect.bottom;
assert(Math.abs(bottomGap - after.anchor.bottom) < TOL,
'panel bottom-gap must match CSS anchor (' + after.anchor.bottom + ') for ' + after.pos +
', got vh-rect.bottom=' + bottomGap);
}
// Sanity: the panel must have actually moved away from the dragged coords.
assert(Math.abs(after.rect.top - 300) > TOL || Math.abs(after.rect.left - 500) > TOL,
'panel must move away from dragged coords (300,500); rect=' + JSON.stringify(after.rect));
});
// (c) Same bug applies to `resetPanelPositions` — it calls
// applyPanelPosition without clearing drag state, so a dragged panel
// won't return to its default corner. Re-inject drag state, invoke
// window._panelCorner.resetPanelPositions(), assert full reset.
await step('resetPanelPositions clears drag state and snaps to default corner', async () => {
const after = await page.evaluate(() => {
const el = document.getElementById('liveFeed');
// Re-inject drag state (mirrors DragManager output).
el.removeAttribute('data-position');
el.dataset.dragged = 'true';
el.classList.add('is-dragging');
el.style.position = 'fixed';
el.style.top = '320px';
el.style.left = '520px';
el.style.right = 'auto';
el.style.bottom = 'auto';
el.style.transform = 'translate(5px,5px)';
el.style.zIndex = '1500';
localStorage.setItem('panel-drag-liveFeed', JSON.stringify({
xPct: 520 / window.innerWidth, yPct: 320 / window.innerHeight,
}));
// Now reset.
window._panelCorner.resetPanelPositions();
const r = el.getBoundingClientRect();
const VCR = parseInt(getComputedStyle(document.documentElement)
.getPropertyValue('--vcr-bar-height')) || 58;
// PANEL_DEFAULTS.liveFeed = 'bl' → anchor bottom: VCR+10, left: 12
return {
pos: el.getAttribute('data-position'),
dragged: el.dataset.dragged,
isDragging: el.classList.contains('is-dragging'),
inlineTop: el.style.top, inlineLeft: el.style.left,
inlineRight: el.style.right, inlineBottom: el.style.bottom,
inlineTransform: el.style.transform, inlinePosition: el.style.position,
inlineZIndex: el.style.zIndex,
ls: localStorage.getItem('panel-drag-liveFeed'),
rect: { top: r.top, left: r.left, right: r.right, bottom: r.bottom },
vw: window.innerWidth, vh: window.innerHeight,
anchor: { bottom: VCR + 10, left: 12 },
};
});
assert(after.pos === 'bl',
'data-position must reset to default "bl" for liveFeed, got: ' + after.pos);
assert(!after.dragged,
'data-dragged must be cleared after reset, got: ' + after.dragged);
assert(!after.isDragging,
'is-dragging class must be cleared after reset');
assert(!after.ls,
'localStorage panel-drag-liveFeed must be removed after reset, got: ' + after.ls);
assert(after.inlineZIndex === '',
'inline zIndex must be cleared after reset, got: ' + JSON.stringify(after.inlineZIndex));
['inlineTop', 'inlineLeft', 'inlineRight', 'inlineBottom', 'inlineTransform', 'inlinePosition'].forEach(function (k) {
assert(after[k] === '',
'inline style ' + k + ' must be cleared after reset, got: ' + JSON.stringify(after[k]));
});
var TOL = 12;
var bottomGap = after.vh - after.rect.bottom;
assert(Math.abs(bottomGap - after.anchor.bottom) < TOL,
'panel bottom-gap must match default-corner anchor (' + after.anchor.bottom +
'), got vh-rect.bottom=' + bottomGap);
assert(Math.abs(after.rect.left - after.anchor.left) < TOL,
'panel left must match default-corner anchor (' + after.anchor.left +
'), got rect.left=' + after.rect.left);
// Sanity: panel must have moved off the injected dragged coords.
assert(Math.abs(after.rect.top - 320) > TOL || Math.abs(after.rect.left - 520) > TOL,
'panel must move away from injected dragged coords (320,520); rect=' + JSON.stringify(after.rect));
});
await ctx.close();
await browser.close();
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
process.exit(failed > 0 ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });