diff --git a/demo/smoke_align_guides.mjs b/demo/smoke_align_guides.mjs index 84aefc62..b68f04c3 100644 --- a/demo/smoke_align_guides.mjs +++ b/demo/smoke_align_guides.mjs @@ -41,8 +41,27 @@ const res = await page.evaluate(async () => { before: { s: b.space, x: pb.x / 1000, y: pb.y / 1000 }, start: c._devicePlacementForCanvas(b, pb.x, pb.y), }; + const keptDrag = c._deviceDrag; c.requestUpdate(); await c.updateComplete; out.devGuide = guides() >= 1; + // #400: направляющая обязана идти ОТ ДРУГОГО значка, а не от самого себя. + // Прежняя проверка `guides() >= 1` этого не различала: точка всегда + // совпадает сама с собой в пределах допуска, поэтому исключение + // перетаскиваемого маркера могло не работать вовсе — и не работало, + // потому что читало `_drag`, который в этом режиме всегда null. + out.devGuideComesFromAnotherMarker = (() => { + const key = (list) => list.map((point) => point.join(',')); + const withDrag = key(c._editorRuntime._alignCandidates()); + c._deviceDrag = null; + const withoutDrag = key(c._editorRuntime._alignCandidates()); + c._deviceDrag = keptDrag; + // Исключение снимает РОВНО один кандидат — перетаскиваемый маркер. + // Сравнение по разнице списков, а не по заранее посчитанной точке: + // позиция значка живёт вместе с перетаскиванием и к моменту проверки + // уже другая. + const removed = withoutDrag.filter((point) => !withDrag.includes(point)); + return withDrag.length === withoutDrag.length - 1 && removed.length === 1; + })(); c._deviceDrag = null; c.requestUpdate(); await c.updateComplete; out.devGuideGone = guides() === 0; // 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры diff --git a/demo/smoke_furniture_polish.mjs b/demo/smoke_furniture_polish.mjs index 8475aa3c..1ed3aa0e 100644 --- a/demo/smoke_furniture_polish.mjs +++ b/demo/smoke_furniture_polish.mjs @@ -7,6 +7,7 @@ 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) => { @@ -107,6 +108,67 @@ const out = await page.evaluate(async () => { 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; // прибрать поставленный диван @@ -116,5 +178,6 @@ const out = await page.evaluate(async () => { c.requestUpdate(); await c.updateComplete; return out; }); -checkAll(out); +const { handleProbeDiagnostics, ...checks } = out; +checkAll(checks); await finish(browser, out); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2aaba150..4e1790e7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1557,6 +1557,26 @@ diffs the real `seedHiddenBindings`/`buildDevices` outputs — there is no second copy of the filter logic to drift. The field registry (#33) carries their passports; `scripts/config-audit.mjs` treats both as `current`. +## The initial bundle carries English and Russian whole (#400, 2026-08-31) + +`en` and `ru` are synchronous dictionaries in the initial chunk; `de` and `fr` +load lazily (`src/i18n/registry.ts`). That includes strings only ever shown in +the editor — the 38 settings-help entries of #86 among them — and the question +of splitting them out was raised by the v1.70.0-beta.1 audit. + +Measured before deciding: those 38 entries are 11 818 B of raw text but +**2 654 B gzip** inside a bundle of 47 754 B of dictionaries — 0.9 % of the +300 000 B ceiling. Splitting them would mean cutting a synchronous dictionary +in two, merging the halves at runtime, a second network request the first time +a hint is opened, and a second source of truth for the key type derived from +`en.json` (#391). That is a lot of new machinery, in the area that #352–#355 +had to stabilise, for 2.6 KB. + +So the decision is deliberate, not an oversight: **English and Russian ship +whole**. Budget planning assumes it. If the editor's text ever grows by tens of +kilobytes, revisit this — the number above is what makes it worth revisiting, +not the feeling that "editor text should be lazy". + ## Backend quality gates (#42, 2026-08-30) - `tests_backend/requirements.txt` is the single source of backend CI diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ba789f0b..e9616980 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Small furniture is easier to resize proportionally: the corner handle now + wins the tap over the side handle when the two overlap, which they do on + anything narrower than about 80 cm + ([#400](https://github.com/Matysh/houseplan-card/issues/400)). + - Undo/Redo of marker positions no longer goes dark on its own: the card now keeps exactly what it sent to the server, so a reconnect or a second tab stops looking like someone else's edit diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 93eb5347..3d14a5e4 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,10 @@ ## Не выпущено +- Мелкую мебель проще тянуть за угол: угловая ручка теперь выигрывает нажатие + у боковой там, где они перекрываются, — а перекрываются они на всём уже + примерно 80 см ([#400](https://github.com/Matysh/houseplan-card/issues/400)). + - История Undo/Redo позиций маркеров больше не гаснет сама: карточка хранит ровно то, что отправила серверу, поэтому переподключение или вторая вкладка перестали выглядеть чужой правкой diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 0b7f7ad3..4f46c527 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -746,6 +746,30 @@ const MUTANT_DEFINITIONS = [ replace: " this._persistDecorStyle();\n }, 0);", }], }, + { + id: 'furniture-edge-handles-steal-the-corner', + guard: 'node demo/smoke_furniture_polish.mjs', + because: 'both handles share one hit radius, so on furniture narrower than ' + + '4·hr whichever is painted last takes the corner — and the corner is ' + + '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.', + }], + }, + { + id: 'align-guides-exclude-dead-source', + guard: 'node demo/smoke_align_guides.mjs', + because: 'device dragging lives in _deviceDrag since #74; excluding by ' + + '_drag excludes nothing, and a guide drawn from a marker to itself ' + + 'looks exactly like an honest one (#400 AC4/AC5)', + patches: [{ + file: 'src/houseplan-editor-runtime.ts', + find: ' const draggedId = this.host._deviceDrag?.id ?? this.host._drag?.id;', + replace: ' const draggedId = this.host._drag?.id;', + }], + }, { id: 'workflow-scan-hardcodes-the-list', guard: 'node --test test/validate-workflow.test.mjs', diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 1f7a52a2..0e066f1c 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -8464,10 +8464,6 @@ export class HouseplanCard extends LitElement { this._dtStart(e, 'rotate')}> - ${corners.map(([sx, sy, cur]) => svg` this._dtStart(e, 'scale', [sx, sy])}>`)} ${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; @@ -8476,6 +8472,17 @@ export class HouseplanCard extends LitElement { 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])}>`)} ` as unknown as TemplateResult; } diff --git a/src/houseplan-editor-runtime.ts b/src/houseplan-editor-runtime.ts index 9b0eab2f..387aa24e 100644 --- a/src/houseplan-editor-runtime.ts +++ b/src/houseplan-editor-runtime.ts @@ -910,6 +910,10 @@ export interface HouseplanEditorHostPort { _display: (url: string | null | undefined) => string; _draftSegmentCms: number[]; _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean; } | null; + /** #400: device dragging has its own state since #74; `_drag` is null in + * the devices mode, so anything excluding "the thing being dragged" there + * must read this instead. Only the id is needed by the editor runtime. */ + _deviceDrag: { id: string } | null; _drawWallCm: number | null; _drawWallField: string | null; _drawWallFieldValue: string; @@ -10966,8 +10970,16 @@ public _alignCandidates(): number[][] { } if (this.host._mode === 'devices') { // other icons of this space only (owner's decision) + // + // #400: the dragged marker is excluded by `_deviceDrag`, not `_drag`. + // Device dragging moved into its own state with #74, and `_drag` is + // null in this mode — so the marker being moved was listed among its + // own alignment candidates. Nothing looked wrong because a point always + // matches itself within tolerance: the guide was drawn from the marker + // to itself and is indistinguishable from an honest one. + const draggedId = this.host._deviceDrag?.id ?? this.host._drag?.id; for (const d of this.host._devices) { - if (d.space !== this.host._space || d.id === this.host._drag?.id + if (d.space !== this.host._space || d.id === draggedId || d.bindingStatus?.kind === 'ha_disabled') continue; const p = this.host._pos(d); out.push([p.x, p.y]);