diff --git a/demo/smoke_furniture.mjs b/demo/smoke_furniture.mjs
index 4fd975ad..6841515a 100644
--- a/demo/smoke_furniture.mjs
+++ b/demo/smoke_furniture.mjs
@@ -357,7 +357,13 @@ const res = await page.evaluate(async () => {
const before = { w: sofaNow().w, h: sofaNow().h };
const b = c._dtBox || { x: 0, y: 0, w: 0, h: 0 };
const handles = [...(frame()?.querySelectorAll('.dthandle') || [])];
- const se = handles[3] || stageEl(); // SE — четвёртый угол
+ // #400: угловая ручка ищется по РОЛИ, а не по индексу в DOM. Индекс держался
+ // на порядке отрисовки, а порядок — это решение о приоритете хита (углы
+ // рисуются последними, чтобы выигрывать нажатие у осевых на мелкой мебели),
+ // и оно имеет право меняться. Юго-восточный угол — второй `nwse`.
+ const cornerHandles = handles.filter((el) => !el.classList.contains('dtrot')
+ && !el.classList.contains('dtedge'));
+ const se = cornerHandles[2] || stageEl(); // SE — [1, 1, 'nwse']
ev('pointerdown', se, b.x + b.w, b.y + b.h);
out.cornerDragStarted = c._dtDrag?.kind === 'scale' && !!c._dtDrag?.orig;
ev('pointermove', stageEl(), b.x + b.w + 100, b.y + b.h + 20, { shiftKey: true });
@@ -405,8 +411,9 @@ const res = await page.evaluate(async () => {
// must restore the exact pre-gesture object and create no persisted flip.
const crossBefore = JSON.parse(JSON.stringify(sofaNow()));
const cb = c._dtBox;
- const crossHandles = [...frame().querySelectorAll('.dthandle')];
- const crossSe = crossHandles[3];
+ const crossHandles = [...frame().querySelectorAll('.dthandle')]
+ .filter((el) => !el.classList.contains('dtrot') && !el.classList.contains('dtedge'));
+ const crossSe = crossHandles[2]; // #400: по роли, не по позиции в DOM
ev('pointerdown', crossSe, cb.x + cb.w, cb.y + cb.h);
ev('pointermove', stageEl(), cb.x - 25, cb.y + cb.h + 10, { shiftKey: true });
await c.updateComplete;
diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs
index 4f46c527..a2a82a9d 100644
--- a/scripts/mutation-gate.mjs
+++ b/scripts/mutation-gate.mjs
@@ -754,8 +754,11 @@ const MUTANT_DEFINITIONS = [
+ 'the one that cannot be reached any other way (#400 AC1)',
patches: [{
file: 'src/houseplan-card.ts',
- find: ' ${/* #400: corners LAST.',
- replace: ' ${/* mutant: corners no longer last.',
+ // Порядок — именованное решение, поэтому мутант меняет ЕГО, а не текст
+ // рядом с ним: прежний патч правил комментарий и регрессию не
+ // воспроизводил (находка CODE-REVIEW-400-r1).
+ find: "const HANDLE_PAINT_ORDER = ['edges', 'corners'] as const;",
+ replace: "const HANDLE_PAINT_ORDER = ['corners', 'edges'] as const;",
}],
},
{
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index 0e066f1c..f61c3448 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -811,6 +811,19 @@ interface DeviceInboxDialogState {
type FixedFloorState = FixedFloorSelection | { kind: 'pending'; value: unknown };
+/**
+ * #400: the paint order of the selection handles IS the hit priority, so it is
+ * a named decision rather than a consequence of where the blocks happen to sit
+ * in the template.
+ *
+ * Corner and edge handles carry the same hit radius (1.8 % of the view). On
+ * furniture narrower than 4·hr — a 40 cm cabinet — their circles overlap, and
+ * whichever is painted last takes the tap. The corner must be last: an edge
+ * handle scales one axis, a corner scales both, and the object is small
+ * exactly when proportional resize matters most.
+ */
+const HANDLE_PAINT_ORDER = ['edges', 'corners'] as const;
+
interface DeviceDragState {
id: string;
spaceId: string;
@@ -8464,25 +8477,19 @@ export class HouseplanCard extends LitElement {
this._dtStart(e, 'rotate')}>
- ${sides.map(([sx, sy, cur]) => {
- const x = sx < 0 ? b.x : sx > 0 ? b.x + b.w : b.x + b.w / 2;
- const y = sy < 0 ? b.y : sy > 0 ? b.y + b.h : b.y + b.h / 2;
- return svg`
- this._dtStart(e, 'scale', [sx, sy])}>
- `;
- })}
- ${/* #400: corners LAST. Both 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 later takes the hit.
- The corner is the one that cannot be reached any other way: an edge
- handle scales one axis, a corner scales both, and the object is small
- precisely when proportional resize matters most. The bead order is
- unchanged visually — the knobs are drawn with their own handles. */
- corners.map(([sx, sy, cur]) => svg` this._dtStart(e, 'scale', [sx, sy])}>`)}
+ ${HANDLE_PAINT_ORDER.map((role) => (role === 'edges'
+ ? sides.map(([sx, sy, cur]) => {
+ const x = sx < 0 ? b.x : sx > 0 ? b.x + b.w : b.x + b.w / 2;
+ const y = sy < 0 ? b.y : sy > 0 ? b.y + b.h : b.y + b.h / 2;
+ return svg`
+ this._dtStart(e, 'scale', [sx, sy])}>
+ `;
+ })
+ : corners.map(([sx, sy, cur]) => svg` this._dtStart(e, 'scale', [sx, sy])}>`)))}
` as unknown as TemplateResult;
}