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 }); }, { 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) { if (scenario.hoverDevice) {
const point = await page.evaluate((id) => { const point = await page.evaluate((id) => {
const marker = window.__goldenCard?.renderRoot?.querySelector( const marker = window.__goldenCard?.renderRoot?.querySelector(
+7 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs'; import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */ /** 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 stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } }; 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 }, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan', { id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page }, 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', { id: 'plan-snap-endpoint-light', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
planSnap: { tool: 'draw', pointer: [0.48, 0.48], expectedKind: 'endpoint' }, planSnap: { tool: 'draw', pointer: [0.48, 0.48], expectedKind: 'endpoint' },
theme: 'light', viewport: { width: 1180, height: 900 }, ...page }, theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
+7
View File
@@ -721,6 +721,13 @@ try {
} }
} catch (error) { } catch (error) {
result.error = error instanceof Error ? error.message : String(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); results.push(result);
console.log(`${result.status.padEnd(17)} ${scenario.id}`); 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. // Issues #220/#243: space tabs reorder through the browser's real mouse input.
// // Positive drag assertions must never dispatch pointermove at a chosen target:
// The demo fixture has two spaces, which is all the panel needs to prove the // pointer capture is the mechanism under test and owns event delivery.
// 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.
import { launch, checkAll, finish } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1100, height: 900 }, 1); 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 c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot; const started = performance.now();
const tabs = () => [...sr().querySelectorAll('[data-hp="space-tab"]')]; do { await new Promise((done) => requestAnimationFrame(done)); }
const ids = () => tabs().map((tab) => tab.dataset.id); while (c._modeTransitionBusy && performance.now() - started < 1500);
const settle = async () => { await c.updateComplete;
const started = performance.now(); });
do { await new Promise((r) => requestAnimationFrame(r)); }
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 ids = () => page.evaluate(() => [...window.__card.renderRoot
const writes = []; .querySelectorAll('[data-hp="space-tab"]')].map((tab) => tab.dataset.id));
const realWrite = c._writeConfig.bind(c);
c._writeConfig = () => {
writes.push((c._serverCfg.spaces || []).map((space) => space.id));
return Promise.resolve();
};
const drag = async (fromId, toId, { travel = 40 } = {}) => { const tabPoint = (id) => page.evaluate((tabId) => {
const from = tabs().find((tab) => tab.dataset.id === fromId); const tab = [...window.__card.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
const to = tabs().find((tab) => tab.dataset.id === toId); .find((item) => item.dataset.id === tabId);
const a = from.getBoundingClientRect(); if (!tab) throw new Error(`space tab missing: ${tabId}`);
const b = to.getBoundingClientRect(); const rect = tab.getBoundingClientRect();
const event = (type, x, y, target) => target.dispatchEvent(new PointerEvent(type, { return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
pointerId: 7, pointerType: 'mouse', clientX: x, clientY: y, bubbles: true, composed: true, }, id);
}));
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 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(); 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._mode = 'plan';
c.requestUpdate(); c.requestUpdate();
await settle(); await c.updateComplete;
// The directional contract needs a source that can travel across both
// A marker with neither an explicit space nor an area that names one is the // sides. The demo fixture has two spaces, so add a third in-memory space to
// whole reason this feature has to be careful: today it renders in whichever // the card and its write model without changing the shared demo fixture.
// space sits first, so a reorder would hand it to another one. The fixture if (!c._model.some((space) => space.id === 'smoke-third')) {
// has no such marker, so the smoke plants it — otherwise the guarantee would const modelSpace = structuredClone(c._model.at(-1));
// be tested only as a pure function, never as applied behaviour. 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; const firstBefore = c._model[0].id;
c._space = firstBefore;
c._serverCfg.markers = [ c._serverCfg.markers = [
...(c._serverCfg.markers || []), ...(c._serverCfg.markers || []).filter((marker) => marker.id !== 'smoke-dangling'),
{ id: 'smoke-dangling', binding: 'virtual', name: 'dangling' }, { id: 'smoke-dangling', binding: 'virtual', name: 'dangling' },
]; ];
const dangling = () => (c._serverCfg.markers || []) window.__tabReorderWrites = [];
.find((marker) => marker.id === 'smoke-dangling'); window.__tabReorderOriginalWrite = c._writeConfig.bind(c);
out.plantedMarkerStartsWithoutSpace = !dangling().space; 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.plantedMarkerStartsWithoutSpace = setup.markerHasNoSpace;
out.enoughTabsToReorder = before.length >= 2; out.reorderableInEditor = setup.reorderable;
out.reorderableInEditor = tabs()[0].hasAttribute('data-reorderable'); const initial = await ids();
out.enoughTabsToReorder = initial.length >= 3;
// --- AC1: the drop changes the order and asks for a save ------------------- // AC1/AC2: same tab moves left and then right through trusted browser input.
const moved = before[before.length - 1]; const moved = initial.at(-1);
const target = before[0]; const leftTarget = initial[0];
const active = c._space; const activeBefore = await page.evaluate(() => window.__card._space);
await drag(moved, target); const beforeHold = await holdDrag(moved, leftTarget);
const after = ids(); out.realMouseEventsAreTrusted = beforeHold.trustedMoveSeen;
out.tabMovedToTheFront = after[0] === moved; out.captureKeptButTargetResolved = beforeHold.drag?.targetId === leftTarget
out.otherTabsKeptOrder = JSON.stringify(after.filter((id) => id !== moved)) && beforeHold.drag?.id === moved;
=== JSON.stringify(before.filter((id) => id !== moved)); out.leftDropUsesBeforeSide = beforeHold.drag?.placement === 'before'
out.orderReachedTheServer = writes.length >= 1; && beforeHold.targetClass.includes('drop-before')
out.savedOrderMatchesPanel = JSON.stringify(writes[writes.length - 1]) && !beforeHold.targetClass.includes('drop-after');
=== JSON.stringify(after); out.heldTabShowsDragging = beforeHold.sourceClass.includes('dragging');
out.activeSpaceUnchanged = c._space === active; out.beforeDividerHasPositiveInset = insetX(beforeHold.targetShadow) > 0;
// AC3: the order-dependent marker keeps the space it had, written down. await releaseDrag(1);
out.danglingMarkerPinnedToItsOldSpace = dangling().space === firstBefore; const afterLeft = await ids();
out.danglingMarkerDidNotFollowTheOrder = dangling().space !== ids()[0] out.realMouseMovedTabLeft = afterLeft[0] === moved;
|| firstBefore === ids()[0]; 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 ------------------------ await page.evaluate(() => { window.__card._toast = ''; });
out.warnedAboutPositionalFloor = typeof c._toast === 'string' && c._toast.length > 0; const rightTarget = afterLeft.at(-1);
c._toast = ''; const afterHold = await holdDrag(moved, rightTarget);
const second = ids(); out.rightDropUsesAfterSide = afterHold.drag?.placement === 'after'
await drag(second[second.length - 1], second[0]); && afterHold.targetClass.includes('drop-after')
out.secondDropAlsoReordered = ids()[0] === second[second.length - 1]; && !afterHold.targetClass.includes('drop-before');
out.warningNotRepeated = !c._toast; 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 ------------------ // AC3: below-threshold movement remains a click and cannot save config.
const other = ids().find((id) => id !== c._space); const currentActive = await page.evaluate(() => window.__card._space);
const writesBeforeClick = writes.length; const smallTarget = (await ids()).find((id) => id !== currentActive);
await new Promise((r) => setTimeout(r, 700)); // let any pending debounce land const smallPoint = await tabPoint(smallTarget);
const tab = tabs().find((t) => t.dataset.id === other); const writesBeforeSmall = await writeCount();
const rect = tab.getBoundingClientRect(); await page.mouse.move(smallPoint.x, smallPoint.y);
const at = (type) => tab.dispatchEvent(new PointerEvent(type, { await page.mouse.down();
pointerId: 8, pointerType: 'mouse', composed: true, await page.mouse.move(smallPoint.x + 2, smallPoint.y);
clientX: rect.x + rect.width / 2, clientY: rect.y + rect.height / 2, bubbles: true, 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 page.mouse.up();
await settle(); await settle();
out.clickStillSwitchesSpace = c._space === other; await page.waitForTimeout(550);
await new Promise((r) => setTimeout(r, 700)); out.pointerCancelEndedDrag = await page.evaluate(() => window.__card._tabDrag === null);
out.clickDidNotReorder = writes.length === writesBeforeClick; out.pointerCancelDidNotWrite = await writeCount() === writesBeforeCancel;
// --- AC5: touch and View never start a drag ------------------------------- // Regression from #220 review: detached cards release listeners and never save.
const touchOrder = ids(); const detachIds = await ids();
const src = tabs()[tabs().length - 1]; const detachFrom = detachIds.at(-1);
const dst = tabs()[0]; const detachTarget = detachIds[0];
const ra = src.getBoundingClientRect(); const writesBeforeDetach = await writeCount();
const rb = dst.getBoundingClientRect(); await holdDrag(detachFrom, detachTarget);
const touch = (type, x, y, target) => target.dispatchEvent(new PointerEvent(type, { out.dragWasActiveBeforeDetach = await page.evaluate(() => window.__card._tabDrag?.moved === true);
pointerId: 9, pointerType: 'touch', clientX: x, clientY: y, bubbles: true, composed: 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); fire('pointerdown', a.x + a.width / 2, a.y + a.height / 2, source);
touch('pointermove', rb.x + rb.width / 2, rb.y + rb.height / 2, dst); fire('pointermove', b.x + b.width / 2, b.y + b.height / 2, target);
touch('pointerup', rb.x + rb.width / 2, rb.y + rb.height / 2, dst); fire('pointerup', b.x + b.width / 2, b.y + b.height / 2, target);
await settle(); });
out.touchDidNotReorder = JSON.stringify(ids()) === JSON.stringify(touchOrder); await settle();
out.touchDidNotReorder = JSON.stringify(await ids()) === JSON.stringify(touchBefore);
await page.evaluate(async () => {
const c = window.__card;
c._mode = 'view'; c._mode = 'view';
c.requestUpdate(); c.requestUpdate();
await settle(); await c.updateComplete;
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;
}); });
checkAll(res); out.notReorderableInView = await page.evaluate(() => !window.__card.renderRoot
await finish(browser, res); .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 ## 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 - 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 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 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, поэтому заливки комнат, hover-подсветки, заливки проёмов и тёмной основы Glow, поэтому
пол больше не скрывает декор. Живой свет и солнечные лучи, стены, символы пол больше не скрывает декор. Живой свет и солнечные лучи, стены, символы
+13
View File
@@ -556,6 +556,19 @@ separately promised workflows:
- [ ] View header: space tabs + count + zoom + editor tabs, the general-settings - [ ] 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 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] 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, - [ ] Plan: markup toolbar, space gears, +space, ⚙ palette; device icons hidden,
labels/openings draggable; orange stage frame [manual] labels/openings draggable; orange stage frame [manual]
- [ ] Devices: icon drag works, click opens the marker editor directly; +/👁/↺/⬡ - [ ] 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 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 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, 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 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 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);', 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', id: 'reorder-skips-materialization',
guard: 'node demo/smoke_space_tab_reorder.mjs', guard: 'node demo/smoke_space_tab_reorder.mjs',
@@ -1253,8 +1296,9 @@ export const MUTANTS = [
+ '(ревью r2/r3, F1)', + '(ревью r2/r3, F1)',
patches: [{ patches: [{
file: 'src/houseplan-card.ts', file: 'src/houseplan-card.ts',
find: ' this._endTabDrag();\n clearInterval(this._cycleTimer);', find: ` this._endTabDrag();
replace: ' clearInterval(this._cycleTimer);', clearTimeout(this._tabSuppressClickTimer);`,
replace: ' clearTimeout(this._tabSuppressClickTimer);',
}], }],
}, },
{ {
+66 -12
View File
@@ -1324,27 +1324,59 @@ class HouseplanCard extends LitElement {
window.addEventListener('pointercancel', this._tabDragRelease); window.addEventListener('pointercancel', this._tabDragRelease);
this._tabDrag = { this._tabDrag = {
id, pointerId: event.pointerId, x: event.clientX, y: event.clientY, 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; const drag = this._tabDrag;
if (!drag || drag.pointerId !== event.pointerId) return; if (!drag || drag.pointerId !== event.pointerId) return;
if (!drag.moved if (!drag.moved
&& !passedDragThreshold(event.clientX - drag.x, event.clientY - drag.y)) return; && !passedDragThreshold(event.clientX - drag.x, event.clientY - drag.y)) return;
// Past the threshold the gesture is a drag: the click that would otherwise // 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. // follow is suppressed in _tabClick, and the panel shows where it lands.
if (drag.moved && drag.overId === overId) return; const target = this._tabDropTargetAt(event.clientX, event.clientY, drag.id);
this._tabDrag = { ...drag, moved: true, overId }; 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 { private _tabPointerUp(event: PointerEvent): void {
const drag = this._tabDrag; const drag = this._tabDrag;
if (drag && drag.pointerId !== event.pointerId) return; 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(); this._endTabDrag();
if (!drag || !drag.moved) return; if (!drag?.moved || !target) return;
this._commitTabOrder(drag.id, drag.overId); this._commitTabOrder(drag.id, target.targetId);
} }
/** Drop the gesture and its window listeners, wherever the release happened. */ /** 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. */ /** A click that followed a real drag must not also switch the space. */
private _tabClick(id: string): void { 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); 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 * Write the new order — and, in the same write, the placement that used to
* depend on it. * depend on it.
@@ -1407,6 +1454,7 @@ class HouseplanCard extends LitElement {
} }
private _pickSpace(id: string): void { private _pickSpace(id: string): void {
this._endTabDrag();
if (id === this._space) return; if (id === this._space) return;
const ids = this._model.map((sp) => sp.id); const ids = this._model.map((sp) => sp.id);
const from = ids.indexOf(this._space); 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. */ /** Live tab reorder: which tab is held, where it started, where it would land. */
private _tabDrag: { 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; } | null = null;
/** Window-level release handler while a tab is held; see _tabPointerDown. */ /** Window-level release handler while a tab is held; see _tabPointerDown. */
private _tabDragRelease: ((event: PointerEvent) => void) | null = null; 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. */ /** The positional-`floor` warning is worth saying once, not on every drop. */
private _tabOrderWarned = false; private _tabOrderWarned = false;
@@ -2296,6 +2347,9 @@ class HouseplanCard extends LitElement {
// pointerup anywhere on the page would make an invisible card write its // pointerup anywhere on the page would make an invisible card write its
// order (review CODE-REVIEW-220-r2/r3, F1). // order (review CODE-REVIEW-220-r2/r3, F1).
this._endTabDrag(); this._endTabDrag();
clearTimeout(this._tabSuppressClickTimer);
this._tabSuppressClickTimer = undefined;
this._tabSuppressClick = false;
clearInterval(this._cycleTimer); clearInterval(this._cycleTimer);
clearTimeout(this._kioskDotsTimer); clearTimeout(this._kioskDotsTimer);
clearTimeout(this._kioskHoldTimer); clearTimeout(this._kioskHoldTimer);
@@ -6351,6 +6405,7 @@ class HouseplanCard extends LitElement {
} }
private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor', animate = true): void { 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 // 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 // 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 // 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> <ha-icon icon="mdi:home-city"></ha-icon>
${this._config.title || this._t('card.title')} ${this._config.title || this._t('card.title')}
</div> </div>
<div class="tabs"> <div class="tabs" @pointermove=${(e: PointerEvent) => this._tabPointerMove(e)}>
${navigationSpaces.map( ${navigationSpaces.map(
(s) => html`<button (s) => html`<button
data-hp="space-tab" data-id="${s.id}" data-hp="space-tab" data-id="${s.id}"
class="tab ${this._space === s.id ? 'active' : ''}${ class="tab ${this._space === s.id ? 'active' : ''}${
this._tabDrag?.moved && this._tabDrag.id === s.id ? ' dragging' : ''}${ this._tabDrag?.moved && this._tabDrag.id === s.id ? ' dragging' : ''}${
this._tabDrag?.moved && this._tabDrag.overId === s.id this._tabDrag?.moved && this._tabDrag.targetId === s.id
&& this._tabDrag.id !== s.id ? ' droptarget' : ''}" ? ` drop-${this._tabDrag.placement}` : ''}"
?data-reorderable=${this._canReorderTabs} ?data-reorderable=${this._canReorderTabs}
@pointerdown=${(e: PointerEvent) => this._tabPointerDown(e, s.id)} @pointerdown=${(e: PointerEvent) => this._tabPointerDown(e, s.id)}
@pointermove=${(e: PointerEvent) => this._tabPointerMove(e, s.id)}
@pointerup=${(e: PointerEvent) => this._tabPointerUp(e)} @pointerup=${(e: PointerEvent) => this._tabPointerUp(e)}
@pointercancel=${() => this._endTabDrag()} @pointercancel=${() => this._endTabDrag()}
@click=${() => this._tabClick(s.id)} @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 */ /* issue #220: a tab can be dragged to a new position in the editors */
.tab[data-reorderable] { cursor: grab; } .tab[data-reorderable] { cursor: grab; }
.tab.dragging { cursor: grabbing; opacity: 0.55; } .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 { .modetab .closex {
--mdc-icon-size: 13px; --mdc-icon-size: 13px;
box-sizing: border-box; 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.equal(scenario.capture, 'page', scenario.id);
assert.ok(scenario.wallJunctionPreview.path.length >= 1, 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-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
'isometric-large-warm-remount', 'split-corner-wall', 'plan-snap-endpoint', 'isometric-large-warm-remount', 'split-corner-wall', 'plan-snap-endpoint',
'plan-snap-line-gaps', 'wall-junctions', 'isometric-wall-junctions', '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']) 'decor-over-glow-base'])
assert.equal(ids.includes(token), true, token); assert.equal(ids.includes(token), true, token);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('plan'), true); 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', () => { 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 active = GOLDEN_SCENARIOS.find((scenario) => scenario.id === 'washer-active-cycle-dark');
const idle = GOLDEN_SCENARIOS.find((scenario) => scenario.id === 'washer-idle-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); assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario); const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space); 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(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90, assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction'); 'the sign-sensitive golden must keep a non-zero north direction');