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]);