Files
houseplan-card/demo/smoke_furniture_polish.mjs
Codex fe3b85c06b fix: the corner handle wins on small furniture, guides exclude the real drag (#400)
(1) Corner and edge handles carry the same hit radius (1.8 % of the
view), so on furniture narrower than 4·hr — a 40 cm cabinet — the two
circles overlap and whichever is painted last takes the tap. Edges were
painted last. Corners are now, because a side handle scales one axis
while a corner scales both, and the object is small exactly when
proportional resize matters most. The visible beads are unchanged.

The audit called this 'proportional resize becomes unavailable'; the
measurement says otherwise and the spec records the correction: the
corner centre lies outside the edge circle, so the corner was reachable
— its area was halved, not lost. A polish, not a bug, and worth fixing
because it is one line of ordering.

(2) Alignment guides in the devices mode excluded the dragged marker by
_drag, which has been null there since #74 moved device dragging into
_deviceDrag. So the marker being moved was among its own candidates.
Nothing looked wrong because a point always matches itself within
tolerance — the guide was drawn from the marker to itself, visually
identical to an honest one, and the smoke asserted only guides() >= 1.
The smoke now compares the candidate lists with and without the drag and
demands exactly one removed entry.

(3) The 38 settings-help strings stay in the initial chunk, and that is
now a recorded decision rather than an oversight: measured 2 654 B gzip,
0.9 % of the ceiling, against splitting a synchronous dictionary in two,
a second request on first hint, and a second source for the key type
derived from en.json (#391). docs/ARCHITECTURE.md says so, with the
number that would justify revisiting it.

Both mutants run by hand: reverting the paint order reddens the 40 cm
probe while the 160 cm one stays green; restoring _drag reddens the
guides smoke.

User-Visible: yes
Issue: #400
2026-08-31 07:56:27 +03:00

184 lines
9.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #369(д)(е): the armed furniture preview follows Shift without mouse motion,
// its window listeners come and go with the palette, and only the primary
// mouse button places objects — a right-click with an armed tool is a no-op.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const stageEl = () => sr().querySelector('.stage');
const toScreen = (x, y) => {
const r = stageEl().getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return [r.left + ((x - v.x) / v.w) * r.width, r.top + ((y - v.y) / v.h) * r.width * (v.h / v.w) * (r.height / (r.width * (v.h / v.w)))];
};
// счётчики слушателей окна
const addReal = window.addEventListener.bind(window);
const removeReal = window.removeEventListener.bind(window);
let shiftAdds = 0; let shiftRemoves = 0;
window.addEventListener = (type, fn, opts) => {
if (type === 'keydown' || type === 'keyup') shiftAdds++;
return addReal(type, fn, opts);
};
window.removeEventListener = (type, fn, opts) => {
if (type === 'keydown' || type === 'keyup') shiftRemoves++;
return removeReal(type, fn, opts);
};
// в декор-режим, инструмент «мебель», выбрать символ
await c._requestMode('decor', false);
await c.updateComplete;
c._editorSecondary.openPalette?.();
c._decorTool = 'furniture';
c._furnPalette = null; c._furnCategory = null;
c.requestUpdate(); await c.updateComplete;
const pal = () => sr().querySelector('.furnpalette');
pal()?.querySelector('.furnitem[data-category="sofa"]')?.click();
await c.updateComplete;
pal()?.querySelector('.furnitem[data-symbol="sofa"]')?.click();
await c.updateComplete;
out.symbolArmed = !!c._furnPalette;
out.listenersAttached = shiftAdds >= 2;
// превью от движения мыши
const stage = stageEl();
const rect = stage.getBoundingClientRect();
const cx = rect.left + rect.width * 0.5;
const cy = rect.top + rect.height * 0.55;
stage.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, composed: true, pointerType: 'mouse', pointerId: 7,
clientX: cx, clientY: cy,
}));
await c.updateComplete;
out.previewVisible = !!c._furnPreviewInput;
out.previewStartsSnapped = c._furnPreviewInput?.free === false;
// (д): Shift БЕЗ движения мыши переводит превью в free и обратно
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift', bubbles: true }));
await c.updateComplete;
out.shiftDownGoesFree = c._furnPreviewInput?.free === true;
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Shift', bubbles: true }));
await c.updateComplete;
out.shiftUpBacksToSnap = c._furnPreviewInput?.free === false;
// (е): правый клик с armed-символом НЕ ставит мебель
const beforeCount = c._decorList.length;
stage.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, pointerType: 'mouse', pointerId: 8,
clientX: cx, clientY: cy, button: 2, buttons: 2,
}));
await c.updateComplete; await sleep(50);
out.rightClickPlacesNothing = c._decorList.length === beforeCount
&& !!c._furnPalette;
// левый клик — ставит (регресс)
stage.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, pointerType: 'mouse', pointerId: 9,
clientX: cx, clientY: cy, button: 0, buttons: 1,
}));
await c.updateComplete; await sleep(50);
out.leftClickPlaces = c._decorList.length === beforeCount + 1;
// (д): после установки палитра погашена — слушатели сняты симметрично
out.paletteDisarmed = !c._furnPalette;
out.listenersDetached = shiftRemoves >= 2 && shiftRemoves === shiftAdds;
// Shift после снятия — не трогает ничего (превью нет)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift', bubbles: true }));
out.shiftAfterDetachIsInert = !c._furnPreviewInput;
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Shift', bubbles: true }));
// r2-H1: Escape с armed-символом тоже снимает слушатели (путь мимо place)
c._editorSecondary.openPalette?.();
c._decorTool = 'furniture';
c._furnPalette = null; c._furnCategory = null;
c.requestUpdate(); await c.updateComplete;
pal()?.querySelector('.furnitem[data-category="sofa"]')?.click();
await c.updateComplete;
pal()?.querySelector('.furnitem[data-symbol="sofa"]')?.click();
await c.updateComplete;
const addsBeforeEscape = shiftAdds;
out.rearmAttachesAgain = shiftAdds === shiftRemoves + 2;
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
c._onKey?.(new KeyboardEvent('keydown', { key: 'Escape' }));
await c.updateComplete;
out.escapeClosesPalette = !c._furnPalette && c._decorTool === 'select';
out.escapeDetachesListeners = shiftRemoves === addsBeforeEscape;
// #400 AC1/AC2: на мелкой мебели угловая и осевая ручки перекрываются —
// радиус хита один и тот же (1.8 % вида), и на объекте уже, чем 4·hr, круги
// пересекаются. Побеждает нарисованный последним. Угол обязан быть сверху:
// боком масштабируется одна ось, углом — обе, и мелкая мебель — как раз тот
// случай, где пропорциональное изменение нужнее всего.
//
// Проверяется настоящим pointerdown в геометрический угол рамки:
// elementFromPoint через shadow root тут бесполезен (возвращает саму
// карточку), а какой обработчик сработал — видно только по вызову.
const handleProbe = async (size) => {
c._setMode('decor'); await c.updateComplete;
c._decorTool = 'select';
c._curSpaceCfg.decor = [{ id: 'probe', kind: 'furniture', symbol: 'fridge',
x: 0.3, y: 0.3, w: size, h: size, angle: 0, color: '#ff00ff', opacity: 1, width_cm: 8 }];
c._cfgEpoch++; c._decorSel = 'probe';
c._editorRuntime?._syncDecorFrame?.();
c.requestUpdate(); await c.updateComplete; await frame();
const box = sr().querySelector('.dtframe');
if (!box) return { frame: false };
const handles = [...box.querySelectorAll('.dthandle')];
const corner = handles.find((el) => !el.classList.contains('dtrot')
&& !el.classList.contains('dtedge'));
if (!corner) return { frame: true, corner: false };
const r = corner.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
// Кто получит событие в этой точке: считаем нажатия на каждой ручке.
let hitEdge = 0; let hitCorner = 0;
const mark = (el) => {
const isEdge = el.classList.contains('dtedge');
const listener = () => { if (isEdge) hitEdge++; else hitCorner++; };
el.addEventListener('pointerdown', listener, true);
return () => el.removeEventListener('pointerdown', listener, true);
};
const off = handles.filter((el) => !el.classList.contains('dtrot')).map(mark);
const top = sr().elementsFromPoint
? sr().elementsFromPoint(cx, cy)
: document.elementsFromPoint(cx, cy);
const target = top.find((el) => el.classList && el.classList.contains('dthandle'));
if (target) {
target.dispatchEvent(new PointerEvent('pointerdown', {
clientX: cx, clientY: cy, pointerId: 91, isPrimary: true, button: 0,
bubbles: true, composed: true, cancelable: true,
}));
}
off.forEach((fn) => fn());
c._dtDrag = null;
return { frame: true, corner: true, hitCorner, hitEdge,
targetIsCorner: !!target && !target.classList.contains('dtedge') };
};
const small = await handleProbe(0.04); // 40 см на 10-метровом плане
const big = await handleProbe(0.16); // 160 см
out.smallFurnitureCornerWinsTheHit = small.targetIsCorner === true && small.hitCorner === 1
&& small.hitEdge === 0;
out.largeFurnitureKeepsBothHandles = big.targetIsCorner === true && big.hitCorner === 1
&& big.hitEdge === 0;
out.handleProbeDiagnostics = { small, big };
c._curSpaceCfg.decor = [];
c._decorSel = null;
c._setMode('background'); await c.updateComplete;
window.addEventListener = addReal;
window.removeEventListener = removeReal;
// прибрать поставленный диван
const sp = c._curSpaceCfg;
sp.decor = sp.decor.slice(0, beforeCount);
c._decorSel = null;
c.requestUpdate(); await c.updateComplete;
return out;
});
const { handleProbeDiagnostics, ...checks } = out;
checkAll(checks);
await finish(browser, out);