fix(tabs): restore captured mouse reordering

Issue: #243
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-22 19:42:15 +03:00
parent 02a523045b
commit e6ac0337d0
16 changed files with 564 additions and 241 deletions
File diff suppressed because one or more lines are too long
+68
View File
@@ -861,6 +861,74 @@ export async function prepareGoldenScenario(page, scenario) {
} } : {}),
};
}, { fixture, scenario });
if (scenario.tabDrag) {
const drag = await page.evaluate((placement) => {
const card = window.__goldenCard;
const tabs = [...card.renderRoot.querySelectorAll('[data-hp="space-tab"]')];
if (tabs.length < 3) throw new Error('golden tab drag requires at least three spaces');
const source = placement === 'before' ? tabs.at(-1) : tabs[0];
const target = placement === 'before' ? tabs[0] : tabs.at(-1);
const point = (element) => {
const rect = element.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
};
window.__goldenTabTrustedMove = false;
window.__goldenTabCapturedTargetId = null;
card.renderRoot.querySelector('.tabs').addEventListener('pointermove', (event) => {
if (!event.buttons || !event.isTrusted) return;
window.__goldenTabTrustedMove = true;
window.__goldenTabCapturedTargetId = event.target
?.closest?.('[data-hp="space-tab"]')?.dataset?.id || null;
}, { capture: true });
return {
sourceId: source.dataset.id,
targetId: target.dataset.id,
source: point(source),
target: point(target),
};
}, scenario.tabDrag);
await page.mouse.move(drag.source.x, drag.source.y);
await page.mouse.down();
await page.mouse.move(drag.target.x, drag.target.y, { steps: 5 });
const state = await page.evaluate(async ({ expected, sourceId, targetId }) => {
const card = window.__goldenCard;
await card.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const source = [...card.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
.find((tab) => tab.dataset.id === sourceId);
const target = [...card.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
.find((tab) => tab.dataset.id === targetId);
const targetRect = target?.getBoundingClientRect();
const shadow = getComputedStyle(target).boxShadow;
const insetX = Number(shadow.match(
/([+-]?\d+(?:\.\d+)?)px\s+0px\s+0px\s+0px\s+inset/,
)?.[1] || 0);
const ok = window.__goldenTabTrustedMove
&& window.__goldenTabCapturedTargetId === sourceId
&& card._tabDrag?.id === sourceId
&& card._tabDrag?.targetId === targetId
&& card._tabDrag?.placement === expected
&& source?.classList.contains('dragging')
&& target?.classList.contains(`drop-${expected}`)
&& !target?.classList.contains(`drop-${expected === 'before' ? 'after' : 'before'}`)
&& targetRect?.width > 0 && targetRect?.height > 0
&& (expected === 'before' ? insetX > 0 : insetX < 0);
return {
ok,
trusted: window.__goldenTabTrustedMove,
capturedTargetId: window.__goldenTabCapturedTargetId,
sourceId,
targetId,
placement: card._tabDrag?.placement || null,
targetSize: targetRect ? [targetRect.width, targetRect.height] : null,
insetX,
};
}, { expected: scenario.tabDrag, sourceId: drag.sourceId, targetId: drag.targetId });
if (!state.ok) {
throw new Error(`semantic golden tab drag failed: ${JSON.stringify(state)}`);
}
result.tabDrag = state;
}
if (scenario.hoverDevice) {
const point = await page.evaluate((id) => {
const marker = window.__goldenCard?.renderRoot?.querySelector(
+7 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 35;
export const GOLDEN_MATRIX_VERSION = 36;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -101,6 +101,12 @@ export const GOLDEN_SCENARIOS = Object.freeze([
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'space-tab-drop-before-light', fixture: 'visual', applianceLifecycle: true,
space: 'golden-geometry', mode: 'plan', tabDrag: 'before',
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'space-tab-drop-after-dark', fixture: 'visual', applianceLifecycle: true,
space: 'golden-geometry', mode: 'plan', tabDrag: 'after',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'plan-snap-endpoint-light', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
planSnap: { tool: 'draw', pointer: [0.48, 0.48], expectedKind: 'endpoint' },
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
+7
View File
@@ -721,6 +721,13 @@ try {
}
} catch (error) {
result.error = error instanceof Error ? error.message : String(error);
} finally {
// Tab-divider goldens deliberately hold the real pointer through the
// screenshot. Always release it before the shared page is reused, even
// when semantic validation or capture failed.
if (scenario.tabDrag) {
try { await page.mouse.up(); } catch { /* the browser may already be closing */ }
}
}
results.push(result);
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
+295 -200
View File
@@ -1,224 +1,319 @@
// Issue #220: the order of the space tabs is changed by dragging one of them.
//
// The demo fixture has two spaces, which is all the panel needs to prove the
// contract. What this smoke protects is not the animation but the three
// promises around it: the
// new order survives a save, an ordinary click still switches the space, and
// nothing of the sort exists in View, where the same tabs are a touch-first
// navigation control.
// Issues #220/#243: space tabs reorder through the browser's real mouse input.
// Positive drag assertions must never dispatch pointermove at a chosen target:
// pointer capture is the mechanism under test and owns event delivery.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1100, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
const out = {};
const settle = async () => page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const tabs = () => [...sr().querySelectorAll('[data-hp="space-tab"]')];
const ids = () => tabs().map((tab) => tab.dataset.id);
const settle = async () => {
const started = performance.now();
do { await new Promise((r) => requestAnimationFrame(r)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
};
const started = performance.now();
do { await new Promise((done) => requestAnimationFrame(done)); }
while (c._modeTransitionBusy && performance.now() - started < 1500);
await c.updateComplete;
});
// A save must reach the server exactly once per drop, and carry the order.
const writes = [];
const realWrite = c._writeConfig.bind(c);
c._writeConfig = () => {
writes.push((c._serverCfg.spaces || []).map((space) => space.id));
return Promise.resolve();
};
const ids = () => page.evaluate(() => [...window.__card.renderRoot
.querySelectorAll('[data-hp="space-tab"]')].map((tab) => tab.dataset.id));
const drag = async (fromId, toId, { travel = 40 } = {}) => {
const from = tabs().find((tab) => tab.dataset.id === fromId);
const to = tabs().find((tab) => tab.dataset.id === toId);
const a = from.getBoundingClientRect();
const b = to.getBoundingClientRect();
const event = (type, x, y, target) => target.dispatchEvent(new PointerEvent(type, {
pointerId: 7, pointerType: 'mouse', clientX: x, clientY: y, bubbles: true, composed: true,
}));
event('pointerdown', a.x + a.width / 2, a.y + a.height / 2, from);
// one intermediate move on the source keeps the gesture honest: the drag
// must begin from travel, not from merely touching a second tab
event('pointermove', a.x + a.width / 2 + travel, a.y + a.height / 2, from);
event('pointermove', b.x + b.width / 2, b.y + b.height / 2, to);
event('pointerup', b.x + b.width / 2, b.y + b.height / 2, to);
await settle();
// The write is debounced (~500 ms). Waiting for it is the point: a smoke
// that checks the panel and leaves proves the DOM, not the save.
const deadline = performance.now() + 1500;
const seen = writes.length;
while (writes.length === seen && performance.now() < deadline) {
await new Promise((r) => setTimeout(r, 25));
}
};
const tabPoint = (id) => page.evaluate((tabId) => {
const tab = [...window.__card.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
.find((item) => item.dataset.id === tabId);
if (!tab) throw new Error(`space tab missing: ${tabId}`);
const rect = tab.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}, id);
const writeCount = () => page.evaluate(() => window.__tabReorderWrites.length);
const insetX = (shadow) => Number(shadow.match(
/([+-]?\d+(?:\.\d+)?)px\s+0px\s+0px\s+0px\s+inset/,
)?.[1] || 0);
const waitForWrites = async (count) => page.waitForFunction(
(expected) => window.__tabReorderWrites.length >= expected, count, { timeout: 1800 },
);
const holdDrag = async (fromId, toId) => {
const from = await tabPoint(fromId);
const to = await tabPoint(toId);
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(to.x, to.y, { steps: 5 });
await settle();
return page.evaluate(({ fromId: sourceId, toId: targetId }) => {
const c = window.__card;
const tabs = [...c.renderRoot.querySelectorAll('[data-hp="space-tab"]')];
const source = tabs.find((tab) => tab.dataset.id === sourceId);
const target = tabs.find((tab) => tab.dataset.id === targetId);
const style = target ? getComputedStyle(target) : null;
return {
drag: c._tabDrag ? { ...c._tabDrag } : null,
sourceClass: source?.className || '',
targetClass: target?.className || '',
targetShadow: style?.boxShadow || '',
trustedMoveSeen: window.__tabReorderTrustedMove === true,
};
}, { fromId, toId });
};
const releaseDrag = async (expectedWrites) => {
await page.mouse.up();
await waitForWrites(expectedWrites);
await settle();
};
const setup = await page.evaluate(async () => {
const c = window.__card;
c._mode = 'plan';
c.requestUpdate();
await settle();
// A marker with neither an explicit space nor an area that names one is the
// whole reason this feature has to be careful: today it renders in whichever
// space sits first, so a reorder would hand it to another one. The fixture
// has no such marker, so the smoke plants it — otherwise the guarantee would
// be tested only as a pure function, never as applied behaviour.
await c.updateComplete;
// The directional contract needs a source that can travel across both
// sides. The demo fixture has two spaces, so add a third in-memory space to
// the card and its write model without changing the shared demo fixture.
if (!c._model.some((space) => space.id === 'smoke-third')) {
const modelSpace = structuredClone(c._model.at(-1));
modelSpace.id = 'smoke-third';
modelSpace.title = 'Smoke third';
const configSpace = structuredClone(c._serverCfg.spaces.at(-1));
configSpace.id = modelSpace.id;
configSpace.title = modelSpace.title;
c._model = [...c._model, modelSpace];
c._serverCfg.spaces = [...c._serverCfg.spaces, configSpace];
}
const firstBefore = c._model[0].id;
c._space = firstBefore;
c._serverCfg.markers = [
...(c._serverCfg.markers || []),
...(c._serverCfg.markers || []).filter((marker) => marker.id !== 'smoke-dangling'),
{ id: 'smoke-dangling', binding: 'virtual', name: 'dangling' },
];
const dangling = () => (c._serverCfg.markers || [])
.find((marker) => marker.id === 'smoke-dangling');
out.plantedMarkerStartsWithoutSpace = !dangling().space;
window.__tabReorderWrites = [];
window.__tabReorderOriginalWrite = c._writeConfig.bind(c);
c._writeConfig = () => {
window.__tabReorderWrites.push((c._serverCfg.spaces || []).map((space) => space.id));
return Promise.resolve();
};
window.__tabReorderTrustedMove = false;
c.renderRoot.querySelector('.tabs')?.addEventListener('pointermove', (event) => {
if (event.buttons && event.isTrusted) window.__tabReorderTrustedMove = true;
}, { capture: true });
c.requestUpdate();
await c.updateComplete;
return {
firstBefore,
markerHasNoSpace: !c._serverCfg.markers.find((marker) => marker.id === 'smoke-dangling')?.space,
reorderable: c.renderRoot.querySelector('[data-hp="space-tab"]')
?.hasAttribute('data-reorderable') === true,
};
});
const before = ids();
out.enoughTabsToReorder = before.length >= 2;
out.reorderableInEditor = tabs()[0].hasAttribute('data-reorderable');
out.plantedMarkerStartsWithoutSpace = setup.markerHasNoSpace;
out.reorderableInEditor = setup.reorderable;
const initial = await ids();
out.enoughTabsToReorder = initial.length >= 3;
// --- AC1: the drop changes the order and asks for a save -------------------
const moved = before[before.length - 1];
const target = before[0];
const active = c._space;
await drag(moved, target);
const after = ids();
out.tabMovedToTheFront = after[0] === moved;
out.otherTabsKeptOrder = JSON.stringify(after.filter((id) => id !== moved))
=== JSON.stringify(before.filter((id) => id !== moved));
out.orderReachedTheServer = writes.length >= 1;
out.savedOrderMatchesPanel = JSON.stringify(writes[writes.length - 1])
=== JSON.stringify(after);
out.activeSpaceUnchanged = c._space === active;
// AC3: the order-dependent marker keeps the space it had, written down.
out.danglingMarkerPinnedToItsOldSpace = dangling().space === firstBefore;
out.danglingMarkerDidNotFollowTheOrder = dangling().space !== ids()[0]
|| firstBefore === ids()[0];
// AC1/AC2: same tab moves left and then right through trusted browser input.
const moved = initial.at(-1);
const leftTarget = initial[0];
const activeBefore = await page.evaluate(() => window.__card._space);
const beforeHold = await holdDrag(moved, leftTarget);
out.realMouseEventsAreTrusted = beforeHold.trustedMoveSeen;
out.captureKeptButTargetResolved = beforeHold.drag?.targetId === leftTarget
&& beforeHold.drag?.id === moved;
out.leftDropUsesBeforeSide = beforeHold.drag?.placement === 'before'
&& beforeHold.targetClass.includes('drop-before')
&& !beforeHold.targetClass.includes('drop-after');
out.heldTabShowsDragging = beforeHold.sourceClass.includes('dragging');
out.beforeDividerHasPositiveInset = insetX(beforeHold.targetShadow) > 0;
await releaseDrag(1);
const afterLeft = await ids();
out.realMouseMovedTabLeft = afterLeft[0] === moved;
out.firstDropWroteOnce = await writeCount() === 1;
out.savedLeftOrderMatchesPanel = await page.evaluate(() => {
const panel = [...window.__card.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
.map((tab) => tab.dataset.id);
return JSON.stringify(window.__tabReorderWrites[0]) === JSON.stringify(panel);
});
out.activeSpaceUnchanged = await page.evaluate((active) => window.__card._space === active, activeBefore);
out.danglingMarkerPinnedToItsOldSpace = await page.evaluate(
(first) => window.__card._serverCfg.markers
.find((marker) => marker.id === 'smoke-dangling')?.space === first,
setup.firstBefore,
);
out.warnedAboutPositionalFloor = await page.evaluate(() => typeof window.__card._toast === 'string'
&& window.__card._toast.length > 0);
// --- AC7: the positional-floor warning is said once ------------------------
out.warnedAboutPositionalFloor = typeof c._toast === 'string' && c._toast.length > 0;
c._toast = '';
const second = ids();
await drag(second[second.length - 1], second[0]);
out.secondDropAlsoReordered = ids()[0] === second[second.length - 1];
out.warningNotRepeated = !c._toast;
await page.evaluate(() => { window.__card._toast = ''; });
const rightTarget = afterLeft.at(-1);
const afterHold = await holdDrag(moved, rightTarget);
out.rightDropUsesAfterSide = afterHold.drag?.placement === 'after'
&& afterHold.targetClass.includes('drop-after')
&& !afterHold.targetClass.includes('drop-before');
out.afterDividerHasNegativeInset = insetX(afterHold.targetShadow) < 0;
await releaseDrag(2);
const afterRight = await ids();
out.sameTabMovedRight = afterRight.at(-1) === moved;
out.secondDropWroteOnce = await writeCount() === 2;
out.warningNotRepeated = await page.evaluate(() => !window.__card._toast);
// --- AC2: a click without travel still switches the space ------------------
const other = ids().find((id) => id !== c._space);
const writesBeforeClick = writes.length;
await new Promise((r) => setTimeout(r, 700)); // let any pending debounce land
const tab = tabs().find((t) => t.dataset.id === other);
const rect = tab.getBoundingClientRect();
const at = (type) => tab.dispatchEvent(new PointerEvent(type, {
pointerId: 8, pointerType: 'mouse', composed: true,
clientX: rect.x + rect.width / 2, clientY: rect.y + rect.height / 2, bubbles: true,
// AC3: below-threshold movement remains a click and cannot save config.
const currentActive = await page.evaluate(() => window.__card._space);
const smallTarget = (await ids()).find((id) => id !== currentActive);
const smallPoint = await tabPoint(smallTarget);
const writesBeforeSmall = await writeCount();
await page.mouse.move(smallPoint.x, smallPoint.y);
await page.mouse.down();
await page.mouse.move(smallPoint.x + 2, smallPoint.y);
const smallState = await page.evaluate(() => window.__card._tabDrag && !window.__card._tabDrag.moved);
await page.mouse.up();
await settle();
await page.waitForTimeout(550);
out.subThresholdStayedClick = smallState;
out.subThresholdDidNotWrite = await writeCount() === writesBeforeSmall;
out.clickStillSwitchesSpace = await page.evaluate((id) => window.__card._space === id, smallTarget);
// AC4: a previously valid target is cleared outside tabs; release cancels.
const outsideIds = await ids();
const outsideFrom = outsideIds.at(-1);
const outsideTarget = outsideIds[0];
const writesBeforeOutside = await writeCount();
await holdDrag(outsideFrom, outsideTarget);
const stagePoint = await page.evaluate(() => {
const rect = window.__card.renderRoot.querySelector('.stage').getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + Math.min(rect.height / 2, 300) };
});
await page.mouse.move(stagePoint.x, stagePoint.y, { steps: 3 });
await settle();
out.outsideClearsDropTarget = await page.evaluate(() => window.__card._tabDrag?.moved === true
&& window.__card._tabDrag.targetId === null
&& window.__card._tabDrag.placement === null
&& !window.__card.renderRoot.querySelector('.drop-before, .drop-after'));
await page.mouse.up();
await settle();
await page.waitForTimeout(550);
out.outsideReleaseEndedDrag = await page.evaluate(() => window.__card._tabDrag === null);
out.outsideReleaseDidNotWrite = await writeCount() === writesBeforeOutside;
const activeBeforeRecovery = await page.evaluate(() => window.__card._space);
const recoveryTarget = (await ids()).find((id) => id !== activeBeforeRecovery);
const recoveryPoint = await tabPoint(recoveryTarget);
await page.mouse.click(recoveryPoint.x, recoveryPoint.y);
await settle();
out.nextClickWorksAfterOutsideRelease = await page.evaluate(
(id) => window.__card._space === id, recoveryTarget,
);
// Navigation cannot leave a window-level drag listener behind.
const modeIds = await ids();
const writesBeforeModeChange = await writeCount();
await holdDrag(modeIds.at(-1), modeIds[0]);
await page.evaluate(() => window.__card._setMode('devices', false));
await settle();
out.modeChangeEndsDrag = await page.evaluate(() => window.__card._tabDrag === null);
await page.mouse.up();
await settle();
out.modeChangeDidNotWrite = await writeCount() === writesBeforeModeChange;
await page.evaluate(() => window.__card._setMode('plan', false));
await settle();
// AC5: pointercancel is an explicit cancellation, never a drop.
const cancelIds = await ids();
const cancelFrom = cancelIds.at(-1);
const cancelTarget = cancelIds[0];
const writesBeforeCancel = await writeCount();
await holdDrag(cancelFrom, cancelTarget);
await page.evaluate(() => {
const c = window.__card;
const drag = c._tabDrag;
const source = [...c.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
.find((tab) => tab.dataset.id === drag?.id);
source?.dispatchEvent(new PointerEvent('pointercancel', {
pointerId: drag?.pointerId || 1, pointerType: 'mouse', bubbles: true, composed: true,
}));
at('pointerdown'); at('pointermove'); at('pointerup');
tab.click();
await settle();
out.clickStillSwitchesSpace = c._space === other;
await new Promise((r) => setTimeout(r, 700));
out.clickDidNotReorder = writes.length === writesBeforeClick;
});
await page.mouse.up();
await settle();
await page.waitForTimeout(550);
out.pointerCancelEndedDrag = await page.evaluate(() => window.__card._tabDrag === null);
out.pointerCancelDidNotWrite = await writeCount() === writesBeforeCancel;
// --- AC5: touch and View never start a drag -------------------------------
const touchOrder = ids();
const src = tabs()[tabs().length - 1];
const dst = tabs()[0];
const ra = src.getBoundingClientRect();
const rb = dst.getBoundingClientRect();
const touch = (type, x, y, target) => target.dispatchEvent(new PointerEvent(type, {
pointerId: 9, pointerType: 'touch', clientX: x, clientY: y, bubbles: true, composed: true,
// Regression from #220 review: detached cards release listeners and never save.
const detachIds = await ids();
const detachFrom = detachIds.at(-1);
const detachTarget = detachIds[0];
const writesBeforeDetach = await writeCount();
await holdDrag(detachFrom, detachTarget);
out.dragWasActiveBeforeDetach = await page.evaluate(() => window.__card._tabDrag?.moved === true);
await page.evaluate(() => {
const c = window.__card;
window.__tabReorderParent = c.parentNode;
window.__tabReorderNext = c.nextSibling;
c.remove();
});
out.detachEndedTheDrag = await page.evaluate(() => window.__card._tabDrag === null);
await page.mouse.up();
await page.waitForTimeout(550);
out.detachedCardDidNotWrite = await writeCount() === writesBeforeDetach;
await page.evaluate(async () => {
window.__tabReorderParent.insertBefore(window.__card, window.__tabReorderNext);
await window.__card.updateComplete;
});
out.orderSurvivedDetach = JSON.stringify(await ids()) === JSON.stringify(detachIds);
// AC6: negative boundaries stay negative. Synthetic touch is intentional here:
// the assertion is the pointerType guard, not browser mouse delivery.
const touchBefore = await ids();
await page.evaluate(() => {
const c = window.__card;
const tabs = [...c.renderRoot.querySelectorAll('[data-hp="space-tab"]')];
const source = tabs.at(-1);
const target = tabs[0];
const a = source.getBoundingClientRect();
const b = target.getBoundingClientRect();
const fire = (type, x, y, node) => node.dispatchEvent(new PointerEvent(type, {
pointerId: 91, pointerType: 'touch', clientX: x, clientY: y, bubbles: true, composed: true,
}));
touch('pointerdown', ra.x + ra.width / 2, ra.y + ra.height / 2, src);
touch('pointermove', rb.x + rb.width / 2, rb.y + rb.height / 2, dst);
touch('pointerup', rb.x + rb.width / 2, rb.y + rb.height / 2, dst);
await settle();
out.touchDidNotReorder = JSON.stringify(ids()) === JSON.stringify(touchOrder);
fire('pointerdown', a.x + a.width / 2, a.y + a.height / 2, source);
fire('pointermove', b.x + b.width / 2, b.y + b.height / 2, target);
fire('pointerup', b.x + b.width / 2, b.y + b.height / 2, target);
});
await settle();
out.touchDidNotReorder = JSON.stringify(await ids()) === JSON.stringify(touchBefore);
await page.evaluate(async () => {
const c = window.__card;
c._mode = 'view';
c.requestUpdate();
await settle();
out.notReorderableInView = !tabs()[0].hasAttribute('data-reorderable');
const viewOrder = ids();
await drag(viewOrder[viewOrder.length - 1], viewOrder[0]);
out.viewDidNotReorder = JSON.stringify(ids()) === JSON.stringify(viewOrder);
// --- review r1 M1: the mouse is released away from the panel ---------------
//
// A horizontal drag that ends a few pixels below the tabs is ordinary hand
// imprecision. Without pointer capture no tab ever sees the release, the
// gesture stays stuck with moved:true, and the next click is swallowed by
// _tabClick — the panel simply stops switching spaces.
c._mode = 'plan';
c.requestUpdate();
await settle();
const strayTabs = tabs();
const strayFrom = strayTabs[strayTabs.length - 1];
const strayRect = strayFrom.getBoundingClientRect();
const stray = (type, x, y) => strayFrom.dispatchEvent(new PointerEvent(type, {
pointerId: 11, pointerType: 'mouse', clientX: x, clientY: y, bubbles: true, composed: true,
}));
stray('pointerdown', strayRect.x + strayRect.width / 2, strayRect.y + strayRect.height / 2);
stray('pointermove', strayRect.x + strayRect.width / 2 + 40, strayRect.y + strayRect.height / 2);
// released far below the panel, where no tab lives
// released on the stage, not on a tab: without pointer capture no tab
// handler ever runs and the gesture stays stuck
(sr().querySelector('.stage') || document.body).dispatchEvent(new PointerEvent('pointerup', {
pointerId: 11, pointerType: 'mouse', composed: true,
clientX: strayRect.x + 400, clientY: strayRect.y + 400, bubbles: true,
}));
await settle();
out.strayReleaseEndedTheDrag = c._tabDrag === null;
const strayTarget = ids().find((id) => id !== c._space);
const strayNext = tabs().find((tab) => tab.dataset.id === strayTarget);
strayNext.dispatchEvent(new PointerEvent('pointerdown', {
pointerId: 12, pointerType: 'mouse', bubbles: true, composed: true,
}));
strayNext.dispatchEvent(new PointerEvent('pointerup', {
pointerId: 12, pointerType: 'mouse', bubbles: true, composed: true,
}));
strayNext.click();
await settle();
out.panelStillSwitchesAfterStrayRelease = c._space === strayTarget;
// --- review r2/r3 F1: the card is destroyed mid-drag -----------------------
//
// Lovelace rebuilds its tree, or the user leaves the view with the button
// still down. The window listeners the gesture installed must not outlive
// the card: they hold the instance alive and would let an invisible card
// write its order on the next pointerup anywhere on the page.
c._mode = 'plan';
c.requestUpdate();
await settle();
const orderBeforeDetach = ids();
const detachTabs = tabs();
const held = detachTabs[detachTabs.length - 1];
const heldRect = held.getBoundingClientRect();
const heldEvent = (type, x, y) => held.dispatchEvent(new PointerEvent(type, {
pointerId: 13, pointerType: 'mouse', clientX: x, clientY: y,
bubbles: true, composed: true,
}));
heldEvent('pointerdown', heldRect.x + heldRect.width / 2, heldRect.y + heldRect.height / 2);
heldEvent('pointermove', heldRect.x + heldRect.width / 2 + 40, heldRect.y + heldRect.height / 2);
out.dragWasActiveBeforeDetach = c._tabDrag !== null && c._tabDrag.moved === true;
const parent = c.parentNode;
const next = c.nextSibling;
const writesBeforeDetach = writes.length;
c.remove();
await new Promise((r) => setTimeout(r, 50));
out.detachEndedTheDrag = c._tabDrag === null;
// the release the detached card must no longer hear
window.dispatchEvent(new PointerEvent('pointerup', {
pointerId: 13, pointerType: 'mouse', bubbles: true, composed: true,
}));
await new Promise((r) => setTimeout(r, 700));
out.detachedCardDidNotWrite = writes.length === writesBeforeDetach;
parent.insertBefore(c, next);
await settle();
out.orderSurvivedDetach = JSON.stringify(ids()) === JSON.stringify(orderBeforeDetach);
c._writeConfig = realWrite;
return out;
await c.updateComplete;
});
checkAll(res);
await finish(browser, res);
out.notReorderableInView = await page.evaluate(() => !window.__card.renderRoot
.querySelector('[data-hp="space-tab"]')?.hasAttribute('data-reorderable'));
const viewBefore = await ids();
const viewFromPoint = await tabPoint(viewBefore.at(-1));
const viewTargetPoint = await tabPoint(viewBefore[0]);
await page.mouse.move(viewFromPoint.x, viewFromPoint.y);
await page.mouse.down();
await page.mouse.move(viewTargetPoint.x, viewTargetPoint.y, { steps: 4 });
await page.mouse.up();
await settle();
out.viewDidNotReorder = JSON.stringify(await ids()) === JSON.stringify(viewBefore);
await page.evaluate(async () => {
const c = window.__card;
c._mode = 'plan';
c._config = { ...c._config, floor: c._model[0].id };
c.requestUpdate();
await c.updateComplete;
});
out.fixedFloorNotReorderable = await page.evaluate(() => !window.__card.renderRoot
.querySelector('[data-hp="space-tab"]')?.hasAttribute('data-reorderable'));
await page.evaluate(async () => {
const c = window.__card;
const { floor: _floor, ...rest } = c._config;
c._config = rest;
c._mode = 'plan';
c._writeConfig = window.__tabReorderOriginalWrite;
c.requestUpdate();
await c.updateComplete;
});
checkAll(out);
await finish(browser, out);
File diff suppressed because one or more lines are too long
+7 -7
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- Space tabs can once again be reordered with a real mouse drag in an editor.
Browser pointer capture no longer traps the drop target on the held tab; a
divider now marks the exact side where the space will be inserted, and
releasing outside the tab strip leaves the saved order unchanged
([#243](https://github.com/Matysh/houseplan-card/issues/243)).
- Decorative lines, shapes, text and furniture are now drawn above opaque room
fills, room hover, doorway fill and the dark Glow base, so the floor no
longer hides them. Live light and sun, walls, opening symbols, devices and
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Вкладки пространств снова можно переставлять реальным перетаскиванием мышью
в редакторе. Захват указателя браузером больше не удерживает цель на исходной
вкладке; разделитель показывает точную сторону вставки, а отпускание мыши вне
панели вкладок не меняет сохранённый порядок
([#243](https://github.com/Matysh/houseplan-card/issues/243)).
- Декоративные линии, фигуры, текст и мебель теперь рисуются поверх непрозрачной
заливки комнат, hover-подсветки, заливки проёмов и тёмной основы Glow, поэтому
пол больше не скрывает декор. Живой свет и солнечные лучи, стены, символы
+13
View File
@@ -556,6 +556,19 @@ separately promised workflows:
- [ ] View header: space tabs + count + zoom + editor tabs, the general-settings
cog and the per-space gears (visible in EVERY mode since v1.30.1/v1.30.3
for users who may edit); no editor toolbars [auto: smoke_modes]
- [ ] Space-tab reorder (#243): in an editor with at least three spaces, use a
real mouse drag while browser pointer capture remains on the held tab;
moving left resolves the tab under the cursor and paints its left divider,
moving right paints the right divider, and each valid drop saves exactly
once [auto: smoke_space_tab_reorder; golden: space-tab-drop-before-light,
space-tab-drop-after-dark]
- [ ] Move a held space from a valid target out over the plan: the divider
clears immediately and release does not save. `pointercancel` and removing
the card mid-drag also end the gesture without changing order
[auto: smoke_space_tab_reorder]
- [ ] A sub-threshold mouse gesture remains a tab click; the next click after an
outside release still works. Touch, View and a card fixed to one `floor`
never expose reordering [auto: smoke_space_tab_reorder]
- [ ] Plan: markup toolbar, space gears, +space, ⚙ palette; device icons hidden,
labels/openings draggable; orange stage frame [manual]
- [ ] Devices: icon drag works, click opens the marker editor directly; +/👁/↺/⬡
+3 -1
View File
@@ -253,7 +253,9 @@ uses the 5 cm compatibility fallback and is not silently migrated.
Space tabs follow the order in which the spaces were created, and that order can
be changed: in any editor mode, grab a tab with the mouse and drag it to a new
position. The new order is saved immediately and applies everywhere — the tabs,
the kiosk swipe between floors and the carousel arrows.
the kiosk swipe between floors and the carousel arrows. While dragging, a thin
divider shows the exact insertion side; release outside the tab strip to keep
the existing order.
Dragging works **with a mouse and in the editors only**. In ordinary View and on
touch screens a tab still does one thing: it switches the space. There it is the
+3 -1
View File
@@ -276,7 +276,9 @@ desktop: для точного рисования, Resize, модификато
Вкладки пространств стоят в том порядке, в котором пространства заведены, и
этот порядок можно изменить: в любом режиме редактора возьмите вкладку мышью и
перетащите на новое место. Порядок сохраняется сразу и действует везде —
вкладки, свайп между этажами в киоске, стрелки карусели.
вкладки, свайп между этажами в киоске, стрелки карусели. Во время перетаскивания
тонкий разделитель показывает точную сторону вставки; отпустите мышь вне панели
вкладок, чтобы оставить прежний порядок.
Перетаскивание работает **только мышью и только в редакторах**. В обычном
просмотре и на сенсорных экранах вкладка по-прежнему только переключает
+46 -2
View File
@@ -1211,6 +1211,49 @@ export const MUTANTS = [
replace: ' cfg.spaces = applySpaceOrder(cfg.spaces || [], order);',
}],
},
{
id: 'tab-drag-target-follows-captured-source',
guard: 'node demo/smoke_space_tab_reorder.mjs',
because: 'pointer capture keeps pointermove targeted at the held tab; resolving the drop '
+ 'from that event target reproduces #243, where a real mouse can never reach another tab',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const target = this._tabDropTargetAt(event.clientX, event.clientY, drag.id);',
replace: ' const target = this._tabDropTargetAt(drag.x, drag.y, drag.id);',
}],
},
{
id: 'tab-drop-indicator-always-before',
guard: 'node demo/smoke_space_tab_reorder.mjs',
because: 'a single undirected marker cannot tell whether the held space will land before '
+ 'or after the target; #243 requires the divider on the actual insertion side',
patches: [{
file: 'src/houseplan-card.ts',
find: " return { targetId, placement: targetIndex < sourceIndex ? 'before' : 'after' };",
replace: " return { targetId, placement: 'before' };",
}],
},
{
id: 'tab-drop-outside-commits-last-target',
guard: 'node demo/smoke_space_tab_reorder.mjs',
because: 'leaving the tab strip must clear the preview and make release a no-op; retaining '
+ 'the last target makes an outside drop reorder spaces unexpectedly',
patches: [{
file: 'src/houseplan-card.ts',
find: ` targetId: target?.targetId || null,
placement: target?.placement || null,`,
replace: ` targetId: target?.targetId || drag.targetId,
placement: target?.placement || drag.placement,`,
}, {
file: 'src/houseplan-card.ts',
find: ` const target = event.type === 'pointerup' && drag?.moved
? this._tabDropTargetAt(event.clientX, event.clientY, drag.id)
: null;`,
replace: ` const target = event.type === 'pointerup' && drag?.moved
? drag.targetId ? { targetId: drag.targetId } : null
: null;`,
}],
},
{
id: 'reorder-skips-materialization',
guard: 'node demo/smoke_space_tab_reorder.mjs',
@@ -1253,8 +1296,9 @@ export const MUTANTS = [
+ '(ревью r2/r3, F1)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' this._endTabDrag();\n clearInterval(this._cycleTimer);',
replace: ' clearInterval(this._cycleTimer);',
find: ` this._endTabDrag();
clearTimeout(this._tabSuppressClickTimer);`,
replace: ' clearTimeout(this._tabSuppressClickTimer);',
}],
},
{
+66 -12
View File
@@ -1324,27 +1324,59 @@ class HouseplanCard extends LitElement {
window.addEventListener('pointercancel', this._tabDragRelease);
this._tabDrag = {
id, pointerId: event.pointerId, x: event.clientX, y: event.clientY,
moved: false, overId: id,
moved: false, targetId: null, placement: null,
};
}
private _tabPointerMove(event: PointerEvent, overId: string): void {
/** Resolve a drop from screen coordinates, independent of captured event.target. */
private _tabDropTargetAt(clientX: number, clientY: number, sourceId: string): {
targetId: string; placement: 'before' | 'after';
} | null {
const ids = this._model.map((space) => space.id);
const sourceIndex = ids.indexOf(sourceId);
if (sourceIndex < 0) return null;
const tabs = this.renderRoot.querySelectorAll<HTMLElement>('[data-hp="space-tab"]');
for (const tab of tabs) {
const targetId = tab.dataset.id || '';
if (!targetId || targetId === sourceId) continue;
const rect = tab.getBoundingClientRect();
if (clientX < rect.left || clientX > rect.right
|| clientY < rect.top || clientY > rect.bottom) continue;
const targetIndex = ids.indexOf(targetId);
if (targetIndex < 0) return null;
return { targetId, placement: targetIndex < sourceIndex ? 'before' : 'after' };
}
return null;
}
private _tabPointerMove(event: PointerEvent): void {
const drag = this._tabDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
if (!drag.moved
&& !passedDragThreshold(event.clientX - drag.x, event.clientY - drag.y)) return;
// Past the threshold the gesture is a drag: the click that would otherwise
// follow is suppressed in _tabClick, and the panel shows where it lands.
if (drag.moved && drag.overId === overId) return;
this._tabDrag = { ...drag, moved: true, overId };
const target = this._tabDropTargetAt(event.clientX, event.clientY, drag.id);
if (drag.moved && drag.targetId === target?.targetId
&& drag.placement === target?.placement) return;
this._tabDrag = {
...drag,
moved: true,
targetId: target?.targetId || null,
placement: target?.placement || null,
};
}
private _tabPointerUp(event: PointerEvent): void {
const drag = this._tabDrag;
if (drag && drag.pointerId !== event.pointerId) return;
const target = event.type === 'pointerup' && drag?.moved
? this._tabDropTargetAt(event.clientX, event.clientY, drag.id)
: null;
if (event.type === 'pointerup' && drag?.moved) this._suppressNextTabClick();
this._endTabDrag();
if (!drag || !drag.moved) return;
this._commitTabOrder(drag.id, drag.overId);
if (!drag?.moved || !target) return;
this._commitTabOrder(drag.id, target.targetId);
}
/** Drop the gesture and its window listeners, wherever the release happened. */
@@ -1358,10 +1390,25 @@ class HouseplanCard extends LitElement {
/** A click that followed a real drag must not also switch the space. */
private _tabClick(id: string): void {
if (this._tabDrag?.moved) return;
if (this._tabSuppressClick) {
this._tabSuppressClick = false;
clearTimeout(this._tabSuppressClickTimer);
this._tabSuppressClickTimer = undefined;
return;
}
this._pickSpace(id);
}
/** Browser click follows pointerup in the same task; clear if none arrived. */
private _suppressNextTabClick(): void {
this._tabSuppressClick = true;
clearTimeout(this._tabSuppressClickTimer);
this._tabSuppressClickTimer = window.setTimeout(() => {
this._tabSuppressClick = false;
this._tabSuppressClickTimer = undefined;
}, 0);
}
/**
* Write the new order — and, in the same write, the placement that used to
* depend on it.
@@ -1407,6 +1454,7 @@ class HouseplanCard extends LitElement {
}
private _pickSpace(id: string): void {
this._endTabDrag();
if (id === this._space) return;
const ids = this._model.map((sp) => sp.id);
const from = ids.indexOf(this._space);
@@ -2010,11 +2058,14 @@ class HouseplanCard extends LitElement {
/** Live tab reorder: which tab is held, where it started, where it would land. */
private _tabDrag: {
id: string; pointerId: number; x: number; y: number; moved: boolean; overId: string;
id: string; pointerId: number; x: number; y: number; moved: boolean;
targetId: string | null; placement: 'before' | 'after' | null;
} | null = null;
/** Window-level release handler while a tab is held; see _tabPointerDown. */
private _tabDragRelease: ((event: PointerEvent) => void) | null = null;
private _tabSuppressClick = false;
private _tabSuppressClickTimer?: number;
/** The positional-`floor` warning is worth saying once, not on every drop. */
private _tabOrderWarned = false;
@@ -2296,6 +2347,9 @@ class HouseplanCard extends LitElement {
// pointerup anywhere on the page would make an invisible card write its
// order (review CODE-REVIEW-220-r2/r3, F1).
this._endTabDrag();
clearTimeout(this._tabSuppressClickTimer);
this._tabSuppressClickTimer = undefined;
this._tabSuppressClick = false;
clearInterval(this._cycleTimer);
clearTimeout(this._kioskDotsTimer);
clearTimeout(this._kioskHoldTimer);
@@ -6351,6 +6405,7 @@ class HouseplanCard extends LitElement {
}
private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor', animate = true): void {
this._endTabDrag();
// A mode command is newer than the editor remembered by a same-route warm
// remount. Clear it before the same-mode early return: while can_write is
// pending, Lit may still be presenting the previous editor DOM even though
@@ -16413,17 +16468,16 @@ class HouseplanCard extends LitElement {
<ha-icon icon="mdi:home-city"></ha-icon>
${this._config.title || this._t('card.title')}
</div>
<div class="tabs">
<div class="tabs" @pointermove=${(e: PointerEvent) => this._tabPointerMove(e)}>
${navigationSpaces.map(
(s) => html`<button
data-hp="space-tab" data-id="${s.id}"
class="tab ${this._space === s.id ? 'active' : ''}${
this._tabDrag?.moved && this._tabDrag.id === s.id ? ' dragging' : ''}${
this._tabDrag?.moved && this._tabDrag.overId === s.id
&& this._tabDrag.id !== s.id ? ' droptarget' : ''}"
this._tabDrag?.moved && this._tabDrag.targetId === s.id
? ` drop-${this._tabDrag.placement}` : ''}"
?data-reorderable=${this._canReorderTabs}
@pointerdown=${(e: PointerEvent) => this._tabPointerDown(e, s.id)}
@pointermove=${(e: PointerEvent) => this._tabPointerMove(e, s.id)}
@pointerup=${(e: PointerEvent) => this._tabPointerUp(e)}
@pointercancel=${() => this._endTabDrag()}
@click=${() => this._tabClick(s.id)}
+2 -1
View File
@@ -1725,7 +1725,8 @@ export const cardStyles = css`
/* issue #220: a tab can be dragged to a new position in the editors */
.tab[data-reorderable] { cursor: grab; }
.tab.dragging { cursor: grabbing; opacity: 0.55; }
.tab.droptarget { box-shadow: inset 2px 0 0 0 var(--primary-color, #03a9f4); }
.tab.drop-before { box-shadow: inset 2px 0 0 0 var(--primary-color, #03a9f4); }
.tab.drop-after { box-shadow: inset -2px 0 0 0 var(--primary-color, #03a9f4); }
.modetab .closex {
--mdc-icon-size: 13px;
box-sizing: border-box;
+21 -2
View File
@@ -104,6 +104,12 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
assert.equal(scenario.capture, 'page', scenario.id);
assert.ok(scenario.wallJunctionPreview.path.length >= 1, scenario.id);
}
if (scenario.tabDrag) {
assert.equal(['before', 'after'].includes(scenario.tabDrag), true, scenario.id);
assert.equal(scenario.applianceLifecycle, true, scenario.id);
assert.equal(scenario.mode, 'plan', scenario.id);
assert.equal(scenario.capture, 'page', scenario.id);
}
}
});
@@ -117,7 +123,8 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
'isometric-large-warm-remount', 'split-corner-wall', 'plan-snap-endpoint',
'plan-snap-line-gaps', 'wall-junctions', 'isometric-wall-junctions',
'washer-active-cycle', 'washer-idle-cycle', 'decor-over-opaque-hover',
'washer-active-cycle', 'washer-idle-cycle', 'space-tab-drop-before',
'space-tab-drop-after', 'decor-over-opaque-hover',
'decor-over-glow-base'])
assert.equal(ids.includes(token), true, token);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('plan'), true);
@@ -145,6 +152,18 @@ test('room-label parity goldens pair View and Plan in light and dark themes', ()
}
});
test('space-tab drop goldens hold both insertion sides in light and dark', () => {
const scenarios = GOLDEN_SCENARIOS.filter((scenario) => scenario.tabDrag);
assert.equal(scenarios.length, 2);
assert.deepEqual(new Set(scenarios.map((scenario) => scenario.tabDrag)),
new Set(['before', 'after']));
assert.deepEqual(new Set(scenarios.map((scenario) => scenario.theme)),
new Set(['light', 'dark']));
for (const scenario of scenarios) {
assert.equal(prepareGoldenFixture(scenario).config.spaces.length >= 3, true, scenario.id);
}
});
test('washer lifecycle goldens pair active and idle snapshots of one composite fixture', () => {
const active = GOLDEN_SCENARIOS.find((scenario) => scenario.id === 'washer-active-cycle-dark');
const idle = GOLDEN_SCENARIOS.find((scenario) => scenario.id === 'washer-idle-cycle-dark');
@@ -282,7 +301,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 35);
assert.equal(GOLDEN_MATRIX_VERSION, 36);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');