From d153b20a1df5e623c56227d5a00f7b30bb5fa233 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 31 Aug 2026 08:10:22 +0300 Subject: [PATCH] fix: make the handle paint order a named decision and mutate it (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CODE-REVIEW-400-r1 Medium: the registered mutant edited a comment, not the order — it could not reproduce the regression AC1 exists to catch. That is the same defect class this issue is fixing elsewhere, in my own guard. The order is now HANDLE_PAINT_ORDER, a named constant, because it IS the hit priority rather than an accident of where the blocks sit in the template. The mutant flips that constant, so it reproduces exactly the behaviour the audit found. Also: smoke_furniture picked the SE corner as handles[3], an index that silently depended on the old paint order — CI shard 3 went red on four checks. It now selects by role (corner handles, third of four), which is what the test actually means. User-Visible: no Issue: #400 --- demo/smoke_furniture.mjs | 13 ++++++++--- scripts/mutation-gate.mjs | 7 ++++-- src/houseplan-card.ts | 45 ++++++++++++++++++++++----------------- 3 files changed, 41 insertions(+), 24 deletions(-) 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; }