Merge issue #300 into dev

Owner-approved review exception: the external reviewer is unavailable. The exact branch SHA passed its recorded gates; the integration keeps #296 diagnostic geometry above masonry and #300 resize measurements above wall bodies. Generated bundles were rebuilt from the combined sources.

Issue: #300
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-24 23:14:34 +03:00
35 changed files with 2091 additions and 313 deletions
File diff suppressed because one or more lines are too long
+10
View File
@@ -937,6 +937,16 @@ export async function prepareGoldenScenario(page, scenario) {
if (!card._rszPreview || !card._rszLive?.length || !(delta > 0 && delta < 250)) {
throw new Error(`golden safe Resize opening clamp did not render: ${scenario.id}`);
}
const resizeLengths = card._rszLive.filter((label) => label.kind === 'length');
const resizeAreas = card._rszLive.filter((label) => label.kind === 'area');
const measuredEdges = card.renderRoot.querySelectorAll('[data-hp="resize-measured-edge"]');
const areaLabels = card.renderRoot.querySelectorAll('[data-hp="resize-area-label"]');
const leaders = card.renderRoot.querySelectorAll('[data-hp="resize-area-leader"]');
if (resizeLengths.length !== 2 || measuredEdges.length !== 2
|| resizeAreas.length !== card._rszDrag.plan.roomIds.length
|| areaLabels.length !== resizeAreas.length || leaders.length !== resizeAreas.length) {
throw new Error(`golden Resize measurement contract is incomplete: ${scenario.id}`);
}
}
if (scenario.openingPreview) {
const { type, pointer } = scenario.openingPreview;
+8 -5
View File
@@ -70,16 +70,19 @@ const res = await page.evaluate(async () => {
out.noCentrelineSpan = !!spans
&& !spans.some((s) => Math.abs(s - 300) < 0.5 || Math.abs(s - 400) < 0.5);
// The dragged-edge bubble, called the way `_rszLive` calls it.
// The two useful side-wall bubbles, called the way `_rszLive` calls them.
c._rszSel = 'r233';
c._rszDrag = { rooms: c._spaceModel().rooms.map((r) => ({ id: r.id, poly: r.poly })) };
await upd();
const drag = c._rszEdgeLabels({ polys: { r233: render } }, { roomId: 'r233', edge: 0 });
const dragLens = drag.filter((l) => !l.area).map((l) => num(l.text));
out.dragLabelsThreeEdges = dragLens.length === 3;
const drag = c._rszEdgeLabels(
{ polys: { r233: render } },
{ roomId: 'r233', edge: 0, roomIds: ['r233'], edgeByRoom: { r233: 0 } },
);
const dragLens = drag.filter((l) => l.kind === 'length').map((l) => num(l.text));
out.dragLabelsTwoSideEdges = dragLens.length === 2;
out.dragLabelsInner = dragLens.every((v) => Math.abs(v - 2.85) < 0.02 || Math.abs(v - 3.85) < 0.02);
out.dragLabelsNotCentreline = !dragLens.some((v) => Math.abs(v - 3) < 0.005 || Math.abs(v - 4) < 0.005);
const dragArea = drag.find((l) => l.area);
const dragArea = drag.find((l) => l.kind === 'area');
out.dragAreaStillInner = !!dragArea && Math.abs(num(dragArea.text) - 11) < 0.06;
out.legacyScaleLabelsAbsent = typeof c._rszScaleLabels === 'undefined';
+146
View File
@@ -0,0 +1,146 @@
// #300: the production Resize gesture renders two side-wall measurements,
// matching highlights and one area badge per owner beside the moving wall.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const result = await page.evaluate(async () => {
const card = window.__card;
const out = {};
const update = async () => {
card._cfgEpoch++;
card.requestUpdate();
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const rectRoom = (id, x0, y0, x1, y1) => ({
id, name: id, area: null,
poly: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
.map(([x, y]) => [x / 1000, y / 1000]),
});
card._setMode('plan');
card._tool = 'resize';
const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
space.rooms = [
rectRoom('label-left', 200, 100, 300, 500),
rectRoom('label-right', 300, 100, 400, 500),
];
space.openings = [];
delete space.walls;
delete space.open_spans;
delete space.partitions;
delete space.room_drafts;
delete space.wall_columns;
card._rszEligibilityCache = null;
await update();
const handle = [...card.renderRoot.querySelectorAll('.rszhandle')]
.find((node) => Math.abs(Number(node.getAttribute('cx')) - 300) < 1
&& Math.abs(Number(node.getAttribute('cy')) - 300) < 1
&& node.getAttribute('aria-disabled') === 'false');
const stage = card.renderRoot.querySelector('.stage');
const stageRect = stage.getBoundingClientRect();
const svg = stage.querySelector('svg');
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
const screen = (x, y) => [
stageRect.left + ((x - vx) / vw) * stageRect.width,
stageRect.top + ((y - vy) / vh) * stageRect.height,
];
const [sx, sy] = screen(300, 300);
const [tx] = screen(325, 300);
const dispatch = (type, x, y) => handle?.dispatchEvent(new PointerEvent(type, {
bubbles: true, cancelable: true, pointerId: 300, pointerType: 'mouse',
clientX: x, clientY: y, buttons: type === 'pointerup' ? 0 : 1,
}));
dispatch('pointerdown', sx, sy);
dispatch('pointermove', tx, sy);
await update();
const live = card._rszLive || [];
const lengths = live.filter((label) => label.kind === 'length');
const areas = live.filter((label) => label.kind === 'area');
out.dragStarted = !!card._rszDrag;
out.twoLengths = lengths.length === 2;
out.movingWallLengthAbsent = lengths.every((label) =>
Math.abs(label.edge.a[0] - label.edge.b[0]) > 1);
out.twoMeasuredEdges = card.renderRoot.querySelectorAll(
'[data-hp="resize-measured-edge"]',
).length === 2;
out.twoAreas = card.renderRoot.querySelectorAll('[data-hp="resize-area-label"]').length === 2;
out.twoLeaders = card.renderRoot.querySelectorAll('[data-hp="resize-area-leader"]').length === 2;
out.oppositeSides = new Set(areas.map((label) => label.placement.side)).size === 2;
out.gearVisible = card.renderRoot.querySelectorAll('[data-hp="room-settings"]').length === 2;
const areaRects = [...card.renderRoot.querySelectorAll('[data-hp="resize-area-label"]')]
.map((label) => label.getBoundingClientRect());
out.areasDoNotOverlap = areaRects.length === 2 && (
areaRects[0].right <= areaRects[1].left || areaRects[0].left >= areaRects[1].right
|| areaRects[0].bottom <= areaRects[1].top || areaRects[0].top >= areaRects[1].bottom
);
out.gearAvoided = areas.every((area) => {
const label = card.renderRoot.querySelector(
`[data-hp="resize-area-label"][data-room="${area.roomId}"]`,
);
const gear = card.renderRoot.querySelector(
`[data-hp="room-settings"][data-room="${area.roomId}"]`,
);
if (!label || !gear) return false;
const a = label.getBoundingClientRect();
const b = gear.getBoundingClientRect();
return a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom;
});
out.tangentAvoidanceUsed = areas.some((label) => Math.abs(label.placement.tangentOffsetPx) > 0);
dispatch('pointerup', tx, sy);
await update();
out.cleanedAfterCommit = card._rszLive === null
&& card.renderRoot.querySelectorAll('[data-hp^="resize-"]').length === 0;
// Repeat the actual browser gesture at a non-default zoom. The footprint is
// recomputed from current view.w/iconCqw rather than reusing the first frame.
space.rooms = [
rectRoom('label-left', 200, 100, 300, 500),
rectRoom('label-right', 300, 100, 400, 500),
];
card._rszEligibilityCache = null;
card._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 2);
await update();
const zoomHandle = [...card.renderRoot.querySelectorAll('.rszhandle')]
.find((node) => Math.abs(Number(node.getAttribute('cx')) - 300) < 1
&& Math.abs(Number(node.getAttribute('cy')) - 300) < 1
&& node.getAttribute('aria-disabled') === 'false');
const zoomStageRect = stage.getBoundingClientRect();
const zoomSvg = stage.querySelector('svg');
const [zvx, zvy, zvw, zvh] = zoomSvg.getAttribute('viewBox').split(' ').map(Number);
const zoomScreen = (x, y) => [
zoomStageRect.left + ((x - zvx) / zvw) * zoomStageRect.width,
zoomStageRect.top + ((y - zvy) / zvh) * zoomStageRect.height,
];
const [zsx, zsy] = zoomScreen(300, 300);
const [ztx] = zoomScreen(325, 300);
const zoomDispatch = (type, x, y) => zoomHandle?.dispatchEvent(new PointerEvent(type, {
bubbles: true, cancelable: true, pointerId: 301, pointerType: 'mouse',
clientX: x, clientY: y, buttons: type === 'pointerup' ? 0 : 1,
}));
zoomDispatch('pointerdown', zsx, zsy);
zoomDispatch('pointermove', ztx, zsy);
await update();
const zoomAreas = [...card.renderRoot.querySelectorAll('[data-hp="resize-area-label"]')];
out.nonDefaultZoomActive = card._zoom > 1 && zoomAreas.length === 2;
out.gearAvoidedAtNonDefaultZoom = zoomAreas.every((label) => {
const gear = card.renderRoot.querySelector(
`[data-hp="room-settings"][data-room="${label.getAttribute('data-room')}"]`,
);
if (!gear) return false;
const a = label.getBoundingClientRect();
const b = gear.getBoundingClientRect();
return a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom;
});
zoomDispatch('pointercancel', ztx, zsy);
await update();
out.cleanedAfterZoomCancel = card._rszLive === null;
return out;
});
checkAll(result);
await finish(browser);
+179 -135
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -554,6 +554,13 @@ scale helpers remain pure-test history only and are tree-shaken from the
production interaction path. Exact `a/b` wall endpoints remain identity and
the quantised midpoint/direction `key` remains only a compatibility index.
Live measurement layout is isolated in pure `src/resize-labels.ts` (#300).
The controller supplies the accepted candidate, current view, cached stage
size and the room gear's `iconCqw()`-derived footprint. It produces exactly two
side-wall highlights/lengths plus one area/leader per affected room. The SVG
ink sits above wall bodies and below openings/handles; HTML labels are
pointer-inert. No `getBoundingClientRect()` enters the pointer path.
Near-axis geometry has one shared classifier in `src/near-axis.ts` (#290).
Walls applies it after architectural/grid resolution and before hover/commit,
moving only the free endpoint. Resize validates that its fixed-topology output
+6
View File
@@ -16,6 +16,12 @@
keep new endpoints on the plan grid and their real carriers, and reject an
ambiguous candidate before it can damage a different wall
([#298](https://github.com/Matysh/houseplan-card/issues/298)).
- Resize now labels and highlights only the two side walls being measured,
removes the redundant length from the moving wall, and shows each affected
room's live area beside its own side of that wall. Shared and narrow rooms
keep both areas visible with leader lines, without covering each other or the
room-settings button
([#300](https://github.com/Matysh/houseplan-card/issues/300)).
- While drawing Walls, `Esc` now finishes all accepted segments as independent
walls and releases the last point without deleting geometry or leaving the
tool. The next click starts a new chain; `Ctrl/Cmd+Z` remains the shortcut
+5
View File
@@ -22,6 +22,11 @@
концы на сетке и реальных границах стены, а неоднозначный результат отклоняет
до того, как он повредит другую стену
([#298](https://github.com/Matysh/houseplan-card/issues/298)).
- Resize теперь подписывает и подсвечивает только две измеряемые боковые стены,
не показывает лишнюю длину на перемещаемой стене и ставит живую площадь каждой
затронутой комнаты с её стороны. Для общих и узких комнат обе площади остаются
видимыми с выносными линиями и не перекрывают друг друга или кнопку настроек
комнаты ([#300](https://github.com/Matysh/houseplan-card/issues/300)).
- При рисовании стен `Esc` теперь завершает все принятые отрезки как
независимые стены и отцепляется от последней точки, не удаляя геометрию и не
покидая инструмент. Следующий клик начинает новую цепочку, а `Ctrl/Cmd+Z`
+18
View File
@@ -136,6 +136,24 @@ wall degradation or second geometry reconstruction. Failure, Esc,
`pointercancel`, `lostpointercapture`, pinch and tool exit discard the overlay
with zero Undo entries and zero writes.
## Live measurements
During an accepted drag, Resize labels only the two side walls whose clear
lengths change. Each length has a matching accent highlight on that exact wall;
the moving wall no longer repeats its own length under the pointer.
The clean-floor area of every affected room is anchored beside its side of the
moving wall. An outer wall therefore shows one area and an exactly shared wall
shows two, on opposite sides. A short leader keeps ownership explicit. A label
is never hidden or clipped merely because the room is narrow: it may leave the
room outline, but two labels may not overlap.
`src/resize-labels.ts` performs placement from the accepted preview, current
view and cached stage size. It accounts for the zoom-dependent room-settings
button and shifts a conflicting area label along the wall. The production
pointer path performs no DOM measurement; browser smoke compares the actual
post-render rectangles at default and non-default zoom.
## Thickness, virtual spans and openings
`rekeyWallsAfterMoveChecked()` and `rekeyOpenSpansAfterMove()` map the immutable
+6 -1
View File
@@ -330,7 +330,7 @@ Other operations edit existing geometry:
|---|---|
| Merge | Joins adjacent rooms; a dialog chooses the surviving identity, name and area |
| Split | Cuts a room from one wall to another; the larger part keeps the original room |
| Resize | Moves one eligible horizontal/vertical wall without changing room topology. Live labels report **inner** dimensions — the clear distance between wall faces, the number a tape measure gives — and the area of each affected room |
| Resize | Moves one eligible horizontal/vertical wall without changing room topology. Live labels report the two changing **inner** side-wall dimensions, highlight those walls, and place each affected room's area beside its side of the moving wall |
| Thickness | Changes one physical span or every wall of a room |
| Delete room | Deletes only the selected room after confirmation |
@@ -369,6 +369,11 @@ opening on the moving wall follows it once; a side-wall opening stops the
moving masonry at its physical jamb. Release creates one Undo step, while Esc
or an interrupted pointer writes nothing.
During the drag, the moving wall itself has no redundant length badge. An outer
wall shows one area badge; a shared wall shows two on opposite sides, each with
a short leader. In a narrow room the area stays visible and may extend outside
the room rather than overlap another area or the room-settings button.
### HA area binding
One HA area may be bound to one room. The binding drives automatic device
+6 -1
View File
@@ -438,7 +438,7 @@ Undo оптимизации.
|---|---|
| Объединить | Склеивает соседние комнаты; в диалоге выбирается сохраняемая идентичность, имя и HA-зона |
| Split | Делит комнату путём от одной стены до другой; большая часть сохраняет исходную комнату |
| Resize | Двигает одну допустимую горизонтальную/вертикальную стену без изменения топологии комнат. Подписи показывают **внутренние** размеры — расстояние между гранями стен, которое даёт рулетка, — и площадь каждой затронутой комнаты |
| Resize | Двигает одну допустимую горизонтальную/вертикальную стену без изменения топологии комнат. Подписи показывают два меняющихся **внутренних** размера боковых стен, подсвечивают эти стены и ставят площадь каждой затронутой комнаты с её стороны перемещаемой стены |
| Толщина | Задаёт толщину выбранному участку или всем стенам комнаты |
| Удалить комнату | Открывает выбор: удалить комнату и оставить её физические стены либо удалить комнату вместе с ними; общие стены остаются всегда |
@@ -490,6 +490,11 @@ Resize показывает живые длины и чистую площадь
Resize не пересчитывает их пропорционально новой длине. Неоднозначный результат
отклоняется целиком, а не повреждает стену в другом месте плана.
У самой перемещаемой стены больше нет дублирующей подписи длины. У внешней
стены показывается одна площадь, у общей — две на противоположных сторонах, с
короткими выносными линиями. В узкой комнате площадь остаётся видимой и может
выйти за контур, но не перекрывает вторую площадь или кнопку настроек комнаты.
Прямая стена, нарисованная в несколько кликов, сохраняется одной перегородкой:
соседние отрезки одинаковой толщины и направления срастаются сразу после
завершения цепочки. Узел остаётся там, где в него приходит третья стена, стена
Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 134 KiB

+21 -21
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "6ce217965efcdb0018ce5e95706555a68765aa20e064822e6f2c74be7044cd3a",
"sourceFingerprint": "22202699fb80e82aa4f9c3e3e994bd56e98148528de87b1b4656b1b7bd53825c",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,8 +14,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6ce217965efcdb0018ce5e95706555a68765aa20e064822e6f2c74be7044cd3a",
"imageSha256": "c9e2203fe2df5c2627c861f92e9c4dfad585efb101df19e4b6388a6fbc27d01a"
"sourceSha256": "22202699fb80e82aa4f9c3e3e994bd56e98148528de87b1b4656b1b7bd53825c",
"imageSha256": "5ba555c1f46416ce17e40ef2b6ecd9eb6e1d0ce6b8d467acd0682af95bd36307"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -25,8 +25,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6ce217965efcdb0018ce5e95706555a68765aa20e064822e6f2c74be7044cd3a",
"imageSha256": "a544bd821460e5b9b7a83b1636ce6b6a3fc50be68fe4f74779d23ec9e12abe6a"
"sourceSha256": "22202699fb80e82aa4f9c3e3e994bd56e98148528de87b1b4656b1b7bd53825c",
"imageSha256": "deaae14893e66337b42e45ccfe87cf16f571cfc10f98da4250a6a6589a092a43"
},
"space-create": {
"file": "03-space-create.png",
@@ -36,8 +36,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6ce217965efcdb0018ce5e95706555a68765aa20e064822e6f2c74be7044cd3a",
"imageSha256": "6df7753f00b7d87cd35fd18fb8fbac0a2a7fa2e549902b3afee38f38c93a2ac0"
"sourceSha256": "22202699fb80e82aa4f9c3e3e994bd56e98148528de87b1b4656b1b7bd53825c",
"imageSha256": "c33a7279165a4cec6fa6fadb6fd08cd967e082a17fe101ef442d27d36ae59b6b"
},
"room-contour-close": {
"file": "04-room-contour-close.png",
@@ -47,8 +47,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6ce217965efcdb0018ce5e95706555a68765aa20e064822e6f2c74be7044cd3a",
"imageSha256": "0129b6a5f1f97fa5d29db0a30bfd899bcfe53308a8ba3872d2a794132fccc9cc"
"sourceSha256": "22202699fb80e82aa4f9c3e3e994bd56e98148528de87b1b4656b1b7bd53825c",
"imageSha256": "29b57b4e38cc332849bf7198f709b9af4f6f4a3044fb042308d181ffe2ae1eda"
},
"plan-context-tray": {
"file": "05-plan-context-tray.png",
@@ -58,8 +58,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6ce217965efcdb0018ce5e95706555a68765aa20e064822e6f2c74be7044cd3a",
"imageSha256": "7f243f578d2d286ae7911363571762e3910fe2117abc5253292d9ef2d578c8dc"
"sourceSha256": "22202699fb80e82aa4f9c3e3e994bd56e98148528de87b1b4656b1b7bd53825c",
"imageSha256": "b0398943d8a7c8215159d2d54b895c28feb1be8e303e65ca106ea5185c34644e"
},
"device-editor": {
"file": "06-device-editor.png",
@@ -69,8 +69,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6ce217965efcdb0018ce5e95706555a68765aa20e064822e6f2c74be7044cd3a",
"imageSha256": "24710a46be38418a09c4bdb3db8750611affea593d80c91cfe74c105ef8ed201"
"sourceSha256": "22202699fb80e82aa4f9c3e3e994bd56e98148528de87b1b4656b1b7bd53825c",
"imageSha256": "c3704489b36fd4177e761124fac032d8d09d6a94e3110f9a77d40ec110c1590b"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -80,8 +80,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6ce217965efcdb0018ce5e95706555a68765aa20e064822e6f2c74be7044cd3a",
"imageSha256": "1eaedd95180175f3db2c2e052bad70db63f6c70b296ec19c3d152ccbb81520ed"
"sourceSha256": "22202699fb80e82aa4f9c3e3e994bd56e98148528de87b1b4656b1b7bd53825c",
"imageSha256": "7241478b3fae2a5b1895f241bc50a65f1aaacce907d311cc87796a251c389a8b"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -91,8 +91,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6ce217965efcdb0018ce5e95706555a68765aa20e064822e6f2c74be7044cd3a",
"imageSha256": "460f098f7761a41c6b30e42940cb5c9d6f96966ba34e4df04ea273575c293e93"
"sourceSha256": "22202699fb80e82aa4f9c3e3e994bd56e98148528de87b1b4656b1b7bd53825c",
"imageSha256": "8241b09b0d821247a86eaadd6043a37dde29623c870d8a142bcd381c638a306d"
},
"room-card": {
"file": "08-room-card.png",
@@ -102,8 +102,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6ce217965efcdb0018ce5e95706555a68765aa20e064822e6f2c74be7044cd3a",
"imageSha256": "66d8b0a509909822ff6b891a483bad9d2c72b20f08e287355883aa5205c6cee0"
"sourceSha256": "22202699fb80e82aa4f9c3e3e994bd56e98148528de87b1b4656b1b7bd53825c",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
"file": "09-device-info.png",
@@ -113,8 +113,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "6ce217965efcdb0018ce5e95706555a68765aa20e064822e6f2c74be7044cd3a",
"imageSha256": "5ae15d51f3d75e8a0f05bb19998cf477e6f793d2ed7afd207cf2c25e5a6e886f"
"sourceSha256": "22202699fb80e82aa4f9c3e3e994bd56e98148528de87b1b4656b1b7bd53825c",
"imageSha256": "8e65ff2191ad1f1172848418c8381967ecff2cc831653a8f9c3ae11352a0e84b"
}
}
}
+196
View File
@@ -0,0 +1,196 @@
# SPEC-REVIEW-300-r1
- Issue: [#300](https://github.com/Matysh/houseplan-card/issues/300) — Подписи при ресайзе: подсвечивать измеряемые стены, убрать размер перетаскиваемой, площадь показывать по бокам от неё
- Этап: spec (PROCESS.md §2.4)
- ТЗ: `docs/specs/300-resize-measurement-layout.md`, коммит `ab9a83e755211a599ba8d73be0a3716696a5168b` на ветке `issue/300-resize-labels`
- Заход: r1 · блокирующих циклов израсходовано (до этого раунда) 0/4
- Трек: обычный (аналитика прямо называет `трек: обычный`; меток `small`/`trivial` на issue нет)
- Ревьюер: Claude (роль «ревьюер ТЗ», не автор)
## Скоуп разбора
Первый раунд — разбор полный, по PROCESS.md §2.10 сокращение объёма не
применяется. Прочитаны в порядке из инструкции: `docs/SCOPE.md`, `AGENTS.md`,
`PROCESS.md`, тело issue #300 и все 5 комментариев (аналитика, занятие, вопрос
Q1, решение владельца по Q1, публикация ТЗ), `docs/USER-GUIDE.ru.md` (раздел
Resize), канонические `docs/RESIZE.md`, `docs/UX-MODES.md`, а также связанные
специи `docs/specs/233-resize-inner-dimensions.md` и
`docs/specs/277-safe-resize.md`.
Диагноз ТЗ по коду проверен построчно, а не принят на слово: прочитан
`_rszEdgeLabels()`, `_rszMove()`, `_rszEdgeDown()`, `_renderRoomGear()` и тип
`SafeResizePlan` в `src/houseplan-card.ts` / `src/resize.ts`, а также
существующие golden-сцены `safe-resize-handles-clamp-{light,dark}` в
`demo/golden/matrix.mjs` и бенчмарк `demo/benchmark_safe_resize_render.mjs`.
Ветка на момент ревью содержит ровно один коммит поверх `origin/dev`
(`git diff origin/dev..HEAD` = только новый файл ТЗ и правка
`docs/specs/README.md`) — ребейз не требовался, `dev` не ушёл вперёд.
## Как проверялось
Гейты кода в этом раунде неприменимы: класс изменений — только C
(документация), продуктовый код не тронут. Прогон `typecheck`/`test`/`build`
не требуется — ветка не содержит правок `src/**`/`test/**`. Единственная
проверка этапа — соответствие ТЗ формату §7.1 и фактическому состоянию кода,
на которое ссылается диагноз.
Проверено чтением (не исполнением):
| Утверждение ТЗ | Где проверено |
|---|---|
| `_rszEdgeLabels()` кладёт три длины: previous/moving/next | `src/houseplan-card.ts:8739-8784`, цикл `for (const edge of [(i-1+n)%n, i, j])` |
| Площадь ставится в `poleOfInaccessibility(floor)` | `src/houseplan-card.ts:8776` |
| `.roomgear`-кнопка тоже стоит в `poleOfInaccessibility(r.poly)` — заявленное перекрытие реально | `src/houseplan-card.ts:18688-18711` (`_renderRoomGear`) |
| `SafeResizePlan` содержит `roomIds`/`edgeByRoom` | `src/resize.ts:65-72` |
| Существующий 12px-сдвиг у подписи размера проёма (принятое предположение §18.2) | `src/styles.ts:1227-1233`, `.opdimension` использует `-12px` |
| golden-сцена `safe-resize-handles-clamp-{light,dark}` уже симулирует активный preview (`safeResizePreview: true`) | `demo/golden/matrix.mjs:246-250` |
| `demo/benchmark_safe_resize_render.mjs` меряет только абсолютный потолок `RENDER_P95_MS=25`, без сравнения с историческим baseline | `demo/benchmark_safe_resize_render.mjs:8-9,58-64` |
| Ни один существующий путь рендера не скрывает `.roomgear` или другую интерактивную кнопку во время активного жеста (drag/draw/move) | `grep` по `_roomDrag`/`_moveDrag`/`_drawDrag`/`hide.*drag` в `src/houseplan-card.ts` — 0 совпадений |
| `docs/UX-MODES.md` и `docs/USER-GUIDE.ru.md` не описывают исчезновение кнопки настроек комнаты во время Resize | текстовый поиск по обоим файлам |
## Находки
### Medium — M1: временное скрытие кнопки настроек комнаты — недекларированная догадка, выданная за принятое решение
**Файл:** `docs/specs/300-resize-measurement-layout.md`, раздел 4 п.6 и раздел 18
(последний абзац).
**В чём проблема.** П.6 раздела 4 «Зафиксированные продуктовые решения»
гласит: «Во время активного Resize кнопки настроек комнат временно скрыты».
Раздел 18 явно исключает этот пункт из списка предположений: «Не являются
предположениями: … и **временное отсутствие room gear** — это acceptance
contract». То есть автор фиксирует новое видимое поведение — интерактивная
кнопка пропадает с экрана во время жеста — как решённый факт, не как
предположение и не как продуктовый вопрос владельцу.
Между тем:
- в теле issue и во всех пяти комментариях (включая единственный заданный
владельцу вопрос Q1 и его решение) кнопка настроек комнаты не упоминается
вовсе — только требование «плашка площади не пересекается с кнопкой» (AC4
issue, AC7 ТЗ);
- ни `docs/RESIZE.md`, ни `docs/UX-MODES.md`, ни `docs/USER-GUIDE.ru.md` не
фиксируют исчезновение `.roomgear` во время какого-либо жеста;
- в коде нет прецедента: ни один существующий drag (move комнаты, draw стены,
furniture-drag) не скрывает интерактивные кнопки на время жеста — это будет
первый такой случай.
Согласие/несогласие «не пересекается» можно было бы решить и иначе (например,
подвинуть саму плашку так, чтобы она физически не доставала до центра комнаты
в обычных пропорциях, либо явно спросить владельца форматом Q1 — «скрывать
кнопку на время жеста или…», с default). Автор выбрал одно конкретное решение
и записал его как неоспоримый контракт, а не как оспариваемое предположение
(§7.1 ТЗ прямо предусматривает для этого блок «принято предположительно,
поменять свободно» — сюда это решение не попало).
Это ровно тот класс дефекта, о котором прямо предупреждает PROCESS.md §7.1:
«Догадка, записанная как факт, — худший вид дефекта: она проходит ревью, потому
что выглядит решением» — здесь она к тому же явно помечена как «не
предположение», то есть застрахована от последующего оспаривания на код-ревью.
**Сценарий проявления.** Администратор дома тянет стену комнаты; в этот момент
кнопка «⚙ Настройки» соседней (или той же) комнаты пропадает с экрана без
предупреждения и без документированного контракта — если владелец на самом
деле ожидал что-то другое (например, кнопку, отодвинутую в сторону, а не
скрытую), это выяснится только после того, как код и AC7/mutation-guard
`resize-labels-gear-during-drag` уже реализованы вокруг скрытия.
**Почему не High.** Правится на этом же этапе без переписывания остального
ТЗ: либо явное подтверждение владельца батч-вопросом с default (по образцу
Q1), либо перенос пункта в раздел 18 как явно оспариваемое техническое
предположение с обоснованием, почему скрытие — единственный практичный вариант
и почему это не поменяет продуктовый контракт.
### Medium — M2: AC11 требует относительный регресс-бюджет, которого не существует у названного инструмента
**Файл:** `docs/specs/300-resize-measurement-layout.md`, раздел 11 (AC11) и
раздел 10 (список файлов).
**В чём проблема.** AC11 требует: «`benchmark_safe_resize_render` не
регрессирует больше чем на 10% либо 1 ms p95 (берётся больший допуск)»,
доказательство — «benchmark + code review». Прочитанный
`demo/benchmark_safe_resize_render.mjs` не считает такую величину: он меряет
20 warm-сэмплов и сравнивает p95 с единственным **абсолютным** потолком
`RENDER_P95_MS = 25` (`Object.assign`-объект `budgets: { renderP95Ms:
RENDER_P95_MS, … }`). Никакого сохранённого исторического baseline или
same-run сравнения «до/после» в этом файле нет — в отличие от соседнего
`demo/benchmark_safe_resize.mjs`, который действительно считает `baseline`
и `relativeLimit` для *другого* сценария (курсор pointer, не render layer).
Раздел 10 «Изменяемые файлы и модули» при этом не называет
`demo/benchmark_safe_resize_render.mjs` в списке ожидаемых правок — то есть
ТЗ не проговаривает, что этот файл придётся переписывать, чтобы у AC11 вообще
появился механизм сравнения «регресс не больше 10%». Как AC сформулирован
сейчас, его не с чем сверить: либо метрика придумана без учёта реального
инструмента, либо реализация должна тихо добавить в бенчмарк baseline-логику,
которую ТЗ не анонсирует.
**Сценарий проявления.** На код-ревью разработчик и ревьюер по-разному прочитают
AC11: один добавит baseline-сравнение в бенчмарк (незапланированная работа вне
раздела 10), другой просто проверит, что p95 остаётся в старых 25 ms, и
формально АC11 «не регрессирует более чем на 10%/1мс» не проверен никем,
потому что делать не с чем сравнивать.
**Почему не High.** Легко устраняется формулировкой на этом же этапе: либо
«остаётся в пределах существующего абсолютного бюджета 25 ms p95»
(соответствует реальному инструменту), либо явно добавить
`demo/benchmark_safe_resize_render.mjs` в раздел 10 с описанием, что baseline
записывается тем же способом, что в `benchmark_safe_resize.mjs`.
## Что проверено и признано корректным
- Обязательные разделы §7.1 (сценарий, что видит человек, проблема, скоуп/не-
скоуп, контракт, UX, модель данных/миграция/i18n, AC с доказательством, план
автотестов, риски, откат, release-артефакты) присутствуют и не перепутаны
местами; сценарий и «что человек увидит» — первые два раздела, как требует
процесс.
- Диагноз текущего кода (три длины, `poleOfInaccessibility` для площади и
gear, отсутствие отдельной подсветки измеряемого ребра) подтверждён чтением
кода — не догадка.
- Технический контракт §6 согласован с реальной формой `SafeResizePlan`
(`roomIds`, `edgeByRoom` существуют в `src/resize.ts`).
- Продуктовое решение по Q1 (narrow-room fallback — площадь всегда видна,
выносится за контур с leader-линией) верно перенесено из решения владельца
дословно, без искажения; помечено, что заменяет исходный AC6 issue, с
ссылкой на комментарий.
- Не входит математика длины/площади (#233) и eligibility/commit/Undo (#277) —
граница со смежными контрактами проведена верно и не переоткрывает их.
- Модель данных/миграция/i18n корректно поданы как «без изменений»: новых
config-полей, WebSocket-вызовов и i18n-ключей нет, что соответствует объёму
задачи (только layout существующего оверлея).
- AC1–AC10 однозначны, у каждого указан способ доказательства (unit/smoke/
golden/code review), и golden-сцена `safe-resize-handles-clamp-{light,dark}`
действительно уже эмулирует активный preview (`safeResizePreview: true`),
так что план «переиспользовать существующую сцену» для AC9 реалистичен.
- Раздел 18 «Принятые предположения» корректно оформлен как оспариваемый и
содержит только действительно техническую разметку (расположение файла,
величина 12px, порядок отрисовки leader) — кроме пункта, вынесенного в M1,
который туда не попал, хотя по характеру должен был.
- Откат описан верно (одна frontend-ревизия, без миграции).
- Не входит в скоуп задачи и не относится к #300 никаких признаков смешения
с чужими issue — упомянутые #233/#277/#238/#52 корректно отнесены к «не
входит».
## Чего не проверял
- Полные гейты (`typecheck`/`test`/`build`, `check-docs`, `smoke-select`,
golden, invariants, backend) — не запускались, так как класса A/B изменений
в ветке нет: диапазон `origin/dev..HEAD` содержит только markdown. Проверка
гейтов на этом этапе относится к будущему код-ревью того же issue.
- Реализацию проекции (`src/resize-labels.ts` и т.д.) — она ещё не написана,
это предмет `S6-in-progress`.
- Численную величину 25ms/10%/1ms как перф-бюджет по существу (сколько
реально стоит рендер двух highlight-полосок и двух area-плашек) — на этом
этапе это не наблюдаемо, будет видно в реализации; отмечен только
методологический разрыв AC11 (M2).
- Доступность (`aria-hidden`, focus-order) заявленного measurement-layer — не
верифицируема без DOM; раздел 8 её обещает, содержательных противоречий не
найдено при чтении.
## Вердикт
Оба blocking-замечания в скоупе задачи и устранимы без пересмотра остального
документа. High нет.
Вердикт: жёлтый · заход r1 · блокирующих циклов 1/4 · High: 0 · Medium: 2 → в задаче · Документ: docs/reviews/SPEC-REVIEW-300-r1.md
+196
View File
@@ -0,0 +1,196 @@
# SPEC-REVIEW-300-r2
- Issue: [#300](https://github.com/Matysh/houseplan-card/issues/300) — Подписи при ресайзе: подсвечивать измеряемые стены, убрать размер перетаскиваемой, площадь показывать по бокам от неё
- Этап: spec (PROCESS.md §2.4)
- ТЗ: `docs/specs/300-resize-measurement-layout.md`, коммит `2b7c98e6bcc4a7fed920c05f2298a06a138683fd` на ветке `issue/300-resize-labels`
- Заход: r2 · блокирующих циклов израсходовано (до этого раунда) 1/4
- Ревьюер: Claude (роль «ревьюер ТЗ», не автор)
## Скоуп разбора
Раунд не первый → разбор по дельте (PROCESS.md §2.9, issue #214), не заново.
1. Вердикт r1 найден в комментарии issue #300 (2026-08-24T19:14:59Z): жёлтый,
заход r1, блокирующих циклов 1/4, High 0 / Medium 2 → в задаче. SHA, на
котором получен вердикт r1, в самом комментарии не назван — это находка,
отдельно не заводится (не Medium, не блокирует), восстановлен из
`docs/reviews/SPEC-REVIEW-300-r1.md` (шапка документа) и из тела следующего
коммита: **`ab9a83e755211a599ba8d73be0a3716696a5168b`**.
2. Автор ответил комментарием 2026-08-24T19:19:16Z: правки внесены и запушены
в **`2b7c98e6bcc4a7fed920c05f2298a06a138683fd`**, явно указана дельта для
повторного ревью: `git diff ab9a83e..2b7c98e -- docs/specs/300-resize-measurement-layout.md`.
3. Дельта проверена: `git diff ab9a83e7..2b7c98e6 -- docs/specs/300-resize-measurement-layout.md`
— правка ограничена файлом спецификации, 40 добавленных / 18 удалённых
строк. Дополнительно `git diff ab9a83e7..2b7c98e6 --stat` показывает, что
между раундами появился только сам документ `SPEC-REVIEW-300-r1.md`
(публикация предыдущего ревью) — это не продуктовый код и не меняет предмет
разбора.
4. Дельта локальна: тот же файл, тот же раздел (правки только в §3, §4 п.6,
§5, §6.3, §7, §10, §11 (AC7/AC11), §12, §13, §14, §18, заключительный
абзац §18) — не ребейз на ушедший вперёд `dev` (весь `origin/dev..HEAD`
всё ещё только markdown), не смена контракта поведения целиком, не задета
новая подсистема. Полный повторный разбор не требуется; проверяются:
закрытие M1/M2, и все AC/разделы, чьё доказательство эта дельта задевает
(AC6, AC7, AC11 и связанные с ними §6.3, §7, §12, §13, §14, §18).
5. Гейты кода неприменимы, как и в r1: класс изменений — C (документация),
диапазон `origin/dev..HEAD` не содержит `src/**`/`test/**`.
## Как проверялось
Проверено чтением (не исполнением), только по дельте:
| Утверждение правки | Где проверено |
|---|---|
| Класс `.rlgearbtn` — реальное имя кнопки настроек комнаты (не `.roomgear`, как было в r1) | `src/styles.ts:1000`, `src/houseplan-card.ts:18705` (`_renderRoomGear`) |
| `demo/benchmark_safe_resize_render.mjs` действительно использует абсолютный потолок `RENDER_P95_MS = 25`, без relative-сравнения | `demo/benchmark_safe_resize_render.mjs:9,64,75` — совпадает с формулировкой нового AC11 |
| Утверждение нового §6.3/§18.4 «screen-fixed footprint `.rlgearbtn`» проверено против реального CSS кнопки | `src/styles.ts:996-1008` — `--gear-h: calc(var(--icon-size, 2.5cqw) * 0.77)`; `--icon-size` вычисляется `iconCqw()` |
| `iconCqw()` явно и намеренно **зависит от текущего zoom/viewBox**, а не постоянна в screen px | `src/space-geometry.ts:463-489` — докстринг: «An icon is a percentage of the PLAN, not of the viewport: it scales with the plan as you zoom… (owner, 2026-08-03)»; формула `(iconPct * iconUnit(space) * k) / w`, где `w = viewW` |
| Тот же вывод подтверждён комментарием в стилях | `src/styles.ts:997-998`: «icon-size already rescales with the view… so the button zooms WITH the plan instead of keeping a constant screen size (owner's spec)» |
| `_renderRoomGear` позиционирует кнопку в процентах контейнера (`left/top: %`), что синхронно с zoom — сама позиция не проблема | `src/houseplan-card.ts:18701-18703` |
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **M1** — временное скрытие `.roomgear` во время Resize объявлено «acceptance contract» без подтверждения владельца и без прецедента в коде | Автор не стал спрашивать владельца и не оставил догадку — убрал сам guess: кнопка теперь **всегда видима** (не меняется относительно текущего поведения, значит не требует продуктового решения), а перекрытие снимается сдвигом area-плашки вдоль стены. То, что реально новое и видимо пользователю («площадь не перекрывает видимую кнопку»), явно оставлено в контракте; конкретный алгоритм сдвига явно вынесен в §18 п.4 как оспариваемое техническое предположение («допустима другая pure screen-space стратегия») | §4 п.6, §6.3 (новый абзац «Для collision check…»), §7 («Room settings buttons продолжают рендериться и не получают нового состояния»), §18 п.4, заключительный абзац §18 («…отсутствие overlap с видимой room settings button — это acceptance contract») |
| **M2** — AC11 требовал относительный регресс-бюджет (10%/1ms), которого не считает `demo/benchmark_safe_resize_render.mjs` | AC11 переформулирован под реальный механизм инструмента — абсолютный потолок `RENDER_P95_MS = 25`; §10 теперь явно называет этот файл в списке участвующих файлов с пояснением «исходник менять не требуется»; §13 п.5 явно требует прогонять его перед код-ревью | AC11 (раздел 11), §10 («`demo/benchmark_safe_resize_render.mjs` — существующий real-render gate с абсолютным `RENDER_P95_MS = 25`»), §13 п.5 |
Оба закрытия проверены не на слово: `.rlgearbtn` и `RENDER_P95_MS = 25`
сверены с реальным кодом (таблица выше). Оба Medium из r1 закрыты корректно.
## Находки
### Medium — M3: новый текст утверждает, что footprint `.rlgearbtn` «screen-fixed», хотя размер кнопки явно и намеренно зависит от zoom
**Файл:** `docs/specs/300-resize-measurement-layout.md`, §6.3 (новый абзац «Для
collision check…») и §18 п.4 — обе фразы введены именно этой правкой (в r1
их не было вовсе, тема отсутствовала, так как r1 просто прятал кнопку).
**В чём проблема.** Новый механизм collision-avoidance описан так:
«консервативный **screen-space footprint** плашки, вычисленный из
форматированного текста, font/padding tokens и **screen-fixed footprint**
`.rlgearbtn`» (§6.3), и повторно в §18 п.4: «screen-fixed footprint room
gear». В документе термин «screen-fixed» уже занят и используется
последовательно в другом, точном смысле — «не меняется при zoom SVG»: то же
§6.3 про leader-линию («Перевод screen px в render units использует текущий
viewBox/stage size; stroke остаётся **screen-fixed**») и §6.2 про подсветку
(`vector-effect="non-scaling-stroke"`, «одинаково читается при zoom»).
Реальный `.rlgearbtn` этому определению не соответствует.
`--gear-h: calc(var(--icon-size, 2.5cqw) * 0.77)`, а `--icon-size`
вычисляется `iconCqw()`, чей докстринг прямо говорит: «An icon is a
percentage of the PLAN, not of the viewport: it scales with the plan as you
zoom… (owner, 2026-08-03)» — то есть размер кнопки в CSS px на экране
**растёт и уменьшается вместе с zoom плана**, а не остаётся константой. Это
подтверждено ещё и соседним комментарием в `styles.ts`: «icon-size already
rescales with the view… so the button zooms WITH the plan instead of keeping
a constant screen size **(owner's spec)**» — то есть зависимость от zoom не
случайность реализации, а явное продуктовое решение того же владельца.
Если реализация буквально возьмёт формулировку ТЗ («screen-fixed footprint
`.rlgearbtn`») и один раз посчитает/захардкодит размер кнопки без учёта
текущего zoom (что и подсказывает слово «screen-fixed» рядом с «не читает
layout в pointermove»), консервативный footprint будет верным только на том
zoom, для которого его посчитали, и разойдётся с реальным на любом другом —
ровно то, от чего должен защищать сам механизм (AC7: «её фактический DOM
rectangle не пересекает area-плашку»). Существующий риск-пункт 4 в §15
(«Консервативный footprint разойдётся с фактическим CSS… smoke сравнивает
реальные `getBoundingClientRect()`») называет только font/padding-погрешность,
а не zoom-масштабирование, и план тестов (§13 п.2) не называет конкретный
нестандартный zoom как fixture — то есть смок на дефолтном zoom формально
пройдёт даже с неверной («screen-fixed») трактовкой формулы.
**Сценарий проявления.** Администратор дома открывает Resize на плане,
предварительно отдалённом (zoomed out) от дефолтного масштаба — сценарий
никак не запрещён и не редок при работе с большими планами. `.rlgearbtn`
на экране меньше, чем при дефолтном zoom (или больше — при приближении).
Реализация, посчитавшая «screen-fixed» footprint по дефолтному размеру,
либо пропускает реальное пересечение (кнопка настроек оказывается частично
под площадью — ровно проблема 3 из тела issue, которую задача должна решить),
либо наоборot излишне отодвигает плашку туда, где реального пересечения нет.
Оба исхода — нарушение AC7 на zoom, не покрытом smoke-фикстурой.
**Почему не High.** Правится на этом же этапе без пересмотра остального
документа: либо явно указать, что footprint `.rlgearbtn` должен пересчитываться
по той же zoom-зависимой формуле, что и `--icon-size`/`iconCqw()` (то есть
заменить «screen-fixed» на «screen-space, пересчитываемый от текущего
view.w» в двух местах — §6.3 и §18 п.4), либо явно добавить в §13 п.2 fixture
с нестандартным zoom для доказательства, что консервативный footprint
остаётся консервативным не только на дефолтном масштабе. Технический
алгоритм и так помечен в §18 п.4 как свободно оспариваемое предположение —
менять нужно только формулировку факта о самой кнопке, не продуктовый
контракт AC7.
## Что проверено и признано корректным (в рамках дельты)
- M1 и M2 закрыты корректно и проверены по коду, не на слово (таблица выше).
- Новое решение по M1 не создаёт нового недекларированного guess: сохранение
видимости кнопки — это отсутствие изменения существующего поведения, а не
новое решение, требующее подтверждения владельца; конкретный алгоритм сдвига
корректно оформлен как оспариваемое предположение в §18 п.4, а сам факт
«кнопка видима и не перекрыта» верно зафиксирован как acceptance contract.
- AC7 переформулирован непротиворечиво: «unit + smoke» вместо старого
«smoke», согласуется с тем, что появился чистый collision-helper, который
можно проверить unit-тестом отдельно от production DOM-пути.
- Переименование mutation guard `resize-labels-gear-during-drag` →
`resize-labels-ignore-gear-collision` (§12) соответствует новому механизму
(сдвиг вместо скрытия) и продолжает указывать на AC7.
- §13 п.5 корректно добавляет прогон `demo/benchmark_safe_resize_render.mjs`
как обязательное доказательство AC11 перед код-ревью — согласуется с новой
формулировкой AC11.
- §7 («Area projection использует тот же вычисленный visual centre, что
`_renderRoomGear()`») — корректное техническое требование по *позиции*
(совпадает с `poleOfInaccessibility(r.poly)` в `_renderRoomGear`); в отличие
от заявления о *размере* (M3), про позицию текст не грешит против кода.
- Риск-пункт 4 в §15 добавлен обоснованно (несовпадение расчётного и
фактического CSS — реальный риск), но не покрывает конкретно
zoom-масштабирование — см. M3.
## Чего не проверял
- Полные гейты (`typecheck`/`test`/`build`, `check-docs`, `smoke-select`,
golden, invariants, backend, `python -m pytest`) — не запускались: диапазон
`origin/dev..HEAD` содержит только markdown, класса A/B изменений нет,
как и в r1. Эта проверка относится к будущему код-ревью того же issue.
- Реализацию проекции (`src/resize-labels.ts` и т.д.) — она ещё не написана.
- Прочие AC (AC1–AC5, AC8–AC10) и разделы §1, §2, §6.1, §6.2, §8, §9, §16,
§17, §18 п.1–3,5 — не затронуты дельтой, повторно не проверялись, см.
«Унаследовано из r1».
- Численно, действительно ли `--icon-size`/`iconCqw()` в реализации даёт
«консервативный» (заведомо не меньше фактического) footprint при любом
zoom, если формула будет исправлена по M3 — это вопрос реализации, не
наблюдаем на этапе ТЗ.
## Унаследовано из r1
Всё, что не затронуто дельтой между `ab9a83e7` и `2b7c98e6`, принято без
повторной проверки на основании `docs/reviews/SPEC-REVIEW-300-r1.md`
(SHA `ab9a83e755211a599ba8d73be0a3716696a5168b`), раздел «Что проверено и
признано корректным»:
- обязательные разделы §7.1 присутствуют и не перепутаны местами; сценарий
и «что человек увидит» — первые два раздела;
- диагноз текущего кода (три длины, `poleOfInaccessibility` для площади,
отсутствие отдельной подсветки измеряемого ребра) подтверждён чтением кода;
- технический контракт §6 согласован с реальной формой `SafeResizePlan`
(`roomIds`, `edgeByRoom` в `src/resize.ts`);
- продуктовое решение по Q1 (narrow-room fallback — площадь всегда видна,
выносится за контур с leader-линией) верно перенесено из решения владельца
дословно;
- граница с #233 (математика длины/площади) и #277 (eligibility/commit/Undo)
проведена верно и не переоткрывает их;
- модель данных/миграция/i18n корректно поданы как «без изменений»;
- AC1–AC5, AC8–AC10 однозначны, у каждого указан способ доказательства;
golden-сцена `safe-resize-handles-clamp-{light,dark}` действительно
эмулирует активный preview (`safeResizePreview: true`);
- откат описан верно (одна frontend-ревизия, без миграции);
- смежные issue (#233/#277/#238/#52) корректно отнесены к «не входит».
## Вердикт
Одно новое Medium (M3) в скоупе задачи, устранимо без пересмотра остального
документа — правкой формулировки в двух местах (§6.3, §18 п.4) либо
добавлением non-default-zoom fixture в §13 п.2. High нет. Оба Medium
предыдущего раунда (M1, M2) закрыты корректно и проверены по коду.
Вердикт: жёлтый · заход r2 · блокирующих циклов 2/4 · High: 0 · Medium: 1 → в задаче · Документ: docs/reviews/SPEC-REVIEW-300-r2.md
+195
View File
@@ -0,0 +1,195 @@
# SPEC-REVIEW-300-r3
- Issue: [#300](https://github.com/Matysh/houseplan-card/issues/300) — Подписи при ресайзе: подсвечивать измеряемые стены, убрать размер перетаскиваемой, площадь показывать по бокам от неё
- Этап: spec (PROCESS.md §2.4)
- ТЗ: `docs/specs/300-resize-measurement-layout.md`, коммит `4969f46e0e5d1e6440e53fd133f957c0103cf44e` на ветке `issue/300-resize-labels`
- Заход: r3 · блокирующих циклов израсходовано (до этого раунда) 2/4
- Ревьюер: Claude (роль «ревьюер ТЗ», не автор)
## Скоуп разбора
Раунд не первый → разбор по дельте (PROCESS.md §2.9, issue #214), не заново.
1. Вердикт r2 найден в комментарии issue #300 (2026-08-24T19:25:15Z): жёлтый,
заход r2, блокирующих циклов 2/4, High 0 / Medium 1 (M3) → в задаче. SHA
документа r2 — `docs/reviews/SPEC-REVIEW-300-r2.md`, шапка называет ТЗ-коммит
`2b7c98e6bcc4a7fed920c05f2298a06a138683fd`.
2. Автор ответил комментарием 2026-08-24T19:26:21Z: правка внесена и запушена в
**`4969f46e0e5d1e6440e53fd133f957c0103cf44e`**, явно указана дельта для
повторного ревью: `git diff 2b7c98e..4969f46 -- docs/specs/300-resize-measurement-layout.md`.
3. Дельта проверена: `git diff 2b7c98e6..4969f46e -- docs/specs/300-resize-measurement-layout.md`
— 12 добавленных / 9 удалённых строк, ограничена тремя местами одного файла:
§6.3 (абзац про collision check), §13 п.2 (добавлен zoom-fixture в план
смоков) и §18 п.4 (то же техническое предположение). `git diff 2b7c98e6..4969f46e --stat`
дополнительно показывает только `docs/reviews/SPEC-REVIEW-300-r2.md`
(публикация предыдущего ревью) — не продмет разбора.
4. Дельта локальна: один файл, один смысловой узел (формулировка footprint
`.rlgearbtn` и его тестовое покрытие) — не ребейз на ушедший вперёд `dev`
(`origin/dev...HEAD` по-прежнему только `docs/**`, см. ниже), не смена
контракта поведения, не задета новая подсистема. Полный повторный разбор не
требуется по формальному признаку; однако закрытие M3 проверялось не только
по двум местам, названным в r2, а по всем вхождениям того же термина в
документе — см. «Находки»: это и обнаружило неполноту закрытия.
5. Гейты кода неприменимы, как и в r1/r2: `git diff origin/dev...HEAD --name-only`
даёт только `docs/reviews/SPEC-REVIEW-300-r1.md`, `docs/reviews/SPEC-REVIEW-300-r2.md`,
`docs/specs/300-resize-measurement-layout.md`, `docs/specs/README.md` — класс
изменений C, `src/**`/`test/**` не тронуты.
## Как проверялось
Проверено чтением (не исполнением).
| Утверждение правки | Где проверено |
|---|---|
| `.rlgearbtn` действительно наследует `--icon-size` от родительского `.devlayer`, а не имеет собственного независимого от zoom размера | `src/houseplan-card.ts:17398` — `<div class="devlayer" style="--icon-size:${iconCqw(iconPct, space, view.w, ...)}cqw...">`; `_renderRoomGear` (`src/houseplan-card.ts:18688-18711`) рендерит `<button class="rlgearbtn">` **внутри** этого `.devlayer` (вызов на `houseplan-card.ts:18705`, разметка `.devlayer` открывается на 17398 и рендерит `_renderRoomGear` на 17406) |
| `--icon-size`, наследуемый `.rlgearbtn` через `--gear-h: calc(var(--icon-size, 2.5cqw) * 0.77)`, вычислен именно от `view.w`, а не от какой-то другой опорной ширины | `src/houseplan-card.ts:17398` — первый аргумент `iconCqw(iconPct, space, **view.w**, kiosk)` для `--icon-size` (в отличие от соседнего `--rl-icon-size`, который намеренно использует `this._roomLabelReferenceViewWidth(view)` — другую опорную ширину для шрифта названия комнаты, не для кнопки) |
| Формулировка «zoom-dependent footprint `.rlgearbtn`, вычисленный для текущего `view.w` по той же `iconCqw()`-семантике» (новый текст §6.3/§18.4) технически точна | Подтверждено двумя пунктами выше — новая формулировка исправлена корректно, в отличие от прежней «screen-fixed» |
| Полный перечень вхождений термина, из-за которого возникла M3, в текущей редакции документа | `grep -n "screen-fixed\|no-fly" docs/specs/300-resize-measurement-layout.md` — 3 совпадения на «screen-fixed» (строки 71, 197, 394) и 1 на «no-fly» (строка 71) |
| Диапазон `origin/dev...HEAD` не содержит `src/**`/`test/**` | `git diff origin/dev...HEAD --name-only` |
## Закрытие раунда r2
| Находка r2 | Чем закрыта | Где это видно |
|---|---|---|
| **M3** — §6.3 и §18 п.4 называли footprint `.rlgearbtn` «screen-fixed», хотя размер кнопки зависит от zoom (`iconCqw()`) | В обоих названных местах формулировка заменена на «zoom-dependent footprint `.rlgearbtn`, вычисленный для текущего `view.w` по той же `iconCqw()`-семантике»; дополнительно в план смоков (§13 п.2) добавлен fixture «default и non-default zoom» с проверкой фактического `getBoundingClientRect()`. Оба изменения проверены против кода (таблица выше): `.rlgearbtn` действительно наследует `--icon-size`, вычисленный от `view.w` через `iconCqw()`, то есть новая формулировка точна | `docs/specs/300-resize-measurement-layout.md:182-187` (§6.3), `:389-393` (§18 п.4), `:312-313` (§13 п.2); код — `src/houseplan-card.ts:17398`, `:18688-18711`, `src/styles.ts:996-1001` |
Закрытие в двух названных местах — корректное и проверенное по коду, не на
слово. Но то же самое неверное представление о кнопке («её footprint не
зависит от zoom») осталось нетронутым в третьем месте того же документа,
которое ни r2, ни правка автора не назвали — см. находку ниже. Поэтому M3
закрыта частично: сам механизм и тестовый план исправлены верно, но
«Зафиксированные продуктовые решения» (§4) документу самому себе противоречат.
## Находки
### Medium — M4: §4 п.6 («Зафиксированные продуктовые решения») по-прежнему называет footprint кнопки «screen-fixed no-fly zone», противореча только что исправленным §6.3/§18 п.4
**Файл:** `docs/specs/300-resize-measurement-layout.md:70-72`.
**В чём проблема.** Формулировка введена ещё в правке r1→r2 (M1-фикс,
`git diff ab9a83e7..2b7c98e6`, тот же коммит, где появились и обе фразы,
которые r2 позже поймал как M3) и с тех пор не менялась:
> 6. Кнопка настроек комнаты остаётся видимой. Если nominal area-плашка
> попадает в её **screen-fixed no-fly zone**, плашка сдвигается вдоль
> перемещаемой стены до первого свободного положения; leader сохраняет связь
> с исходным midpoint.
Это тот же самый факт о той же самой кнопке, что и M3 (footprint
`.rlgearbtn`, используемый для collision-avoidance), выраженный другими
словами — «no-fly zone» вместо «footprint». Ревью r2 сформулировало проблему
абстрактно правильно («термин screen-fixed уже занят и означает "не меняется
при zoom"»), но искало и нашло только два конкретных вхождения (§6.3, §18
п.4); §4 п.6 использует не слово «screen-fixed footprint», а «screen-fixed
no-fly zone» — тот же по сути неверный факт, но не совпавший ни с одним из
названных мест, поэтому фикс автора (правка именно двух названных мест) его
не затронул. `grep -n "screen-fixed\|no-fly" docs/specs/300-resize-measurement-layout.md`
подтверждает: строка 71 — единственное оставшееся вхождение с этим смыслом,
строки 197 и 394 относятся к leader/highlight strokes и используют
«screen-fixed» корректно (там это действительно так: non-scaling-stroke).
Раздел 4 в этом документе — не техническое обсуждение, а «Зафиксированные
продуктовые решения»: раздел, который реализация обязана читать как источник
истины наравне с acceptance criteria. Он прямо утверждает то, что r2 уже
опроверг чтением кода: `--gear-h` наследует `--icon-size`, вычисленный
`iconCqw(iconPct, space, view.w, kiosk)` — то есть растёт и уменьшается вместе
с zoom плана (owner's spec, `src/space-geometry.ts:463-489`,
`src/styles.ts:997-998`). Реализация, которая по недосмотру откроет §4 п.6, а
не §6.3, и буквально прочитает «screen-fixed no-fly zone», один раз
посчитает/захардкодит размер зоны без учёта `view.w` — то же нарушение AC7 на
нестандартном zoom, которое M3 уже описывало для двух других мест документа.
**Сценарий проявления.** Идентичен M3: администратор дома открывает Resize на
плане, отдалённом (или приближённом) от дефолтного zoom; кнопка настроек на
экране меньше или больше, чем при дефолтном масштабе. Реализация, взявшая
за основу §4 (первый раздел с продуктовым решением по этому вопросу, а не
§6.3 — технический контракт дальше по документу), не пересчитывает no-fly
zone от текущего `view.w` и либо пропускает реальное пересечение (площадь
частично под кнопкой — проблема 3 из тела issue), либо излишне сдвигает
плашку без реальной причины.
**Почему не High.** Правится без пересмотра остального документа: одна
строка, замена «screen-fixed no-fly zone» на формулировку, согласованную с
уже исправленными §6.3/§18 п.4 (например «zoom-dependent no-fly zone,
пересчитываемую от текущего `view.w`»). Технический механизм не меняется,
новых продуктовых вопросов не возникает — это тот же самый M3, просто в
третьей копии текста, которую предыдущий раунд не нашёл.
## Что проверено и признано корректным (в рамках дельты)
- Обе формулировки, названные в M3 (§6.3, §18 п.4), исправлены корректно и
проверены по коду: `.rlgearbtn` наследует `--icon-size` от `.devlayer`,
вычисленный `iconCqw(iconPct, space, view.w, kiosk)` — то есть именно от
`view.w`, а не от независимой опорной ширины (в отличие от `--rl-icon-size`,
который намеренно использует другую опорную ширину для шрифта названия
комнаты — не спутаны).
- §13 п.2 корректно добавляет fixture «default и non-default zoom» с
фактической проверкой `getBoundingClientRect()` — согласуется с новой
формулировкой и закрывает часть риска M3 независимо от текста (тест поймает
неверную реализацию даже при нечитаемой документации).
- Термин «screen-fixed» в оставшихся двух местах документа (leader stroke,
§6.3 второй абзац и §18 п.5, highlight strokes) использован корректно —
там `vector-effect="non-scaling-stroke"` действительно даёт независимый от
zoom экранный размер, путаницы между «не масштабируется при zoom» (leader,
highlight) и «зависит от zoom плана» (кнопка) в этих местах нет.
- Продуктовый контракт и §18 (принятые технические предположения) в остальном
не менялись этой правкой; новых недекларированных guess не добавлено.
## Чего не проверял
- Полные гейты (`typecheck`/`test`/`build`, `check-docs`, `smoke-select`,
golden, invariants, backend, `python -m pytest`) — не запускались: диапазон
`origin/dev...HEAD` содержит только `docs/**`, класса A/B изменений нет, как
и в r1/r2. Относится к будущему код-ревью того же issue.
- Реализацию проекции (`src/resize-labels.ts` и т.д.) — она ещё не написана.
- Прочие AC (AC1–AC10 кроме затронутых M3/M4 частей AC7) и разделы §1, §2,
§3, §5, §6.1, §6.2, §7 (кроме упомянутого выше), §8, §9, §10, §11 (кроме
AC7/AC11), §12, §14, §15, §16, §17, §18 п.1–3,5 — не затронуты дельтой r2→r3
и не переоткрывались; наследуются из r2 (которая сама унаследовала их из
r1) — см. ниже.
- Влияние M4 на численную точность collision-avoidance для конкретных значений
zoom (`iconCqw()` даёт действительно консервативную оценку на любом `view.w`)
— вопрос реализации, не наблюдаем на этапе ТЗ; тот же вывод, что и r2 сделала
для M3.
## Унаследовано из r2 (и, транзитивно, из r1)
Всё, что не затронуто дельтой между `2b7c98e6` и `4969f46e` и не относится к
находке M4, принято без повторной проверки на основании
`docs/reviews/SPEC-REVIEW-300-r2.md` (SHA `2b7c98e6bcc4a7fed920c05f2298a06a138683fd`),
разделы «Закрытие раунда r1» и «Что проверено и признано корректным»:
- обязательные разделы §7.1 присутствуют и не перепутаны местами;
- диагноз текущего кода (три длины, `poleOfInaccessibility` для площади,
отсутствие отдельной подсветки измеряемого ребра) подтверждён чтением кода;
- технический контракт §6 согласован с реальной формой `SafeResizePlan`
(`roomIds`, `edgeByRoom` в `src/resize.ts`);
- продуктовое решение по Q1 (narrow-room fallback — площадь всегда видна,
выносится за контур с leader-линией) верно перенесено из решения владельца
дословно;
- M1 (недекларированное скрытие `.roomgear`) закрыта корректно: кнопка
`.rlgearbtn` всегда видима, конкретный алгоритм сдвига оформлен как
оспариваемое техническое предположение (§18 п.4), сам факт «кнопка видима и
не перекрыта» — как acceptance contract;
- M2 (AC11 требовал несуществующий относительный regression-бюджет) закрыта:
AC11 переформулирован под реальный абсолютный `RENDER_P95_MS = 25`;
- граница с #233 (математика длины/площади) и #277 (eligibility/commit/Undo)
проведена верно и не переоткрывает их;
- модель данных/миграция/i18n корректно поданы как «без изменений»;
- AC1–AC5, AC8–AC10 однозначны, у каждого указан способ доказательства;
golden-сцена `safe-resize-handles-clamp-{light,dark}` действительно
эмулирует активный preview (`safeResizePreview: true`);
- откат описан верно (одна frontend-ревизия, без миграции);
- смежные issue (#233/#277/#238/#52) корректно отнесены к «не входит».
## Вердикт
Одно новое Medium (M4) в скоупе задачи — прямое продолжение уже пойманной в
r2 находки M3: та же неверная формулировка («screen-fixed» footprint кнопки
настроек комнаты вместо zoom-dependent) осталась в третьем месте документа
(§4 п.6), которое ни r2, ни правка автора не затронули. Устранимо без
пересмотра остального документа — одной строкой, по образцу уже сделанного
исправления в §6.3/§18 п.4. High нет. Оба места, названные в M3, закрыты
корректно и проверены по коду.
Вердикт: жёлтый · заход r3 · блокирующих циклов 3/4 · High: 0 · Medium: 1 → в задаче · Документ: docs/reviews/SPEC-REVIEW-300-r3.md
+107
View File
@@ -0,0 +1,107 @@
# SPEC-REVIEW-300-r4
- Issue: [#300](https://github.com/Matysh/houseplan-card/issues/300) — Подписи при ресайзе: подсвечивать измеряемые стены, убрать размер перетаскиваемой, площадь показывать по бокам от неё
- Этап: spec (PROCESS.md §2.4)
- ТЗ: `docs/specs/300-resize-measurement-layout.md`, коммит `23d208f30cad290c8430098c0446a6804baa8278` на ветке `issue/300-resize-labels`
- Заход: r4 · блокирующих циклов израсходовано (до этого раунда) 3/4
- Ревьюер: Claude (роль «ревьюер ТЗ», не автор)
## Скоуп разбора
Повторный раунд (PROCESS.md §2.10, §2.9). Предыдущий вердикт — r3, жёлтый,
Medium M4, документ `docs/reviews/SPEC-REVIEW-300-r3.md`, получен на SHA
`4969f46e0e5d1e6440e53fd133f957c0103cf44e` (подтверждено заголовком того
документа — SHA в нём назван явно, дефекта r2/r3 «SHA не назван» здесь нет).
Дельта — `git diff 4969f46e..23d208f3 -- docs/specs/300-resize-measurement-layout.md`,
один коммит `23d208f3` («docs: согласовать footprint кнопки с zoom»), 3
вставки / 2 удаления, единственный файл. Правка локальна: одна формулировка в
§4 п.6. Контракт поведения не меняется, новая подсистема не затронута, объём
дельты кратно меньше исходной задачи → полный разбор всего документа не
требуется, разбор по дельте (§2.10 п.4) обоснован.
Так как правка — правка текста ТЗ (класс C), а не продуктового кода, гейты
§8/§10 к этому раунду неприменимы: этап всё ещё `S4-spec-review`, кода нет.
## Закрытие раунда r3
| Находка | Чем закрыта | Где это видно |
|---|---|---|
| **M4** — третья копия устаревшей формулировки «screen-fixed no-fly zone» кнопки настроек комнаты осталась в §4 п.6, хотя r2/r3 уже поправили §6.3 и §18 п.4 на «zoom-dependent footprint, вычисленный для текущего `view.w` по `iconCqw()`» | Строка §4 п.6 заменена на ту же формулировку, слово-в-слово согласованную с §6.3/§18 п.4 | `docs/specs/300-resize-measurement-layout.md:70-73` (коммит `23d208f3`): «...в zoom-dependent footprint кнопки для текущего `view.w`, вычисленный по `iconCqw()`, плашка сдвигается...» |
Проверено не на слово: полнотекстовый поиск по всему файлу
(`rg -n "screen-fixed|no-fly|footprint|rlgearbtn"`) даёт три места с
`footprint` (§4 п.6, §6.3, §18 п.4) — все три теперь идентичны по смыслу
(«zoom-dependent … `view.w` … `iconCqw()`»). Оставшиеся два вхождения
`screen-fixed` (§6.3 про leader-stroke, §18 п.5 про highlight-strokes)
относятся к другим элементам — 12 px leader и halo/accent-обводке подсветки,
— которые по конструкции действительно не масштабируются (`vector-effect:
non-scaling-stroke`), то есть термин там употреблён верно и не подлежит
правке. `no-fly` в файле не встречается вовсе. Автор сообщил о том же
результате локальной проверки в комментарии — совпало с независимым
повторением здесь.
## Унаследовано из r3
Всё, что дельта не затрагивает, принимается без повторной проверки — со
ссылкой на `docs/reviews/SPEC-REVIEW-300-r3.md` (SHA `4969f46e`), который сам
унаследовал закрытия r1/r2 (M1, M2, M3) тем же порядком:
- обязательные разделы §7.1 (сценарий, что человек увидит, скоуп/не-скоуп,
контракт поведения, UX, модель данных, i18n, AC1…AC11 с доказательством,
план автотестов, риски, откат, release-артефакты) — присутствуют и
однозначны;
- диагноз текущего кода (`_rszEdgeLabels()`, `poleOfInaccessibility()`,
`_rszInnerSpanCms()`) сверен построчно с `src/houseplan-card.ts` в r1;
- решение владельца по Q1 (narrow room: площадь всегда видна, leader-линия)
перенесено в ТЗ верно — проверено в r1;
- M1 (недекларированное скрытие `.roomgear`) закрыта в r2: кнопка
`.rlgearbtn` остаётся всегда видимой, конфликт снимается сдвигом
area-плашки, алгоритм сдвига оформлен как оспариваемое предположение
(§18 п.4), сам факт видимости — как acceptance contract (§4 п.6, §11 AC7);
- M2 (AC11 требовал нереализуемый относительный regression-бюджет) закрыта в
r2: AC11 ссылается на реальный абсолютный потолок `RENDER_P95_MS = 25` в
`demo/benchmark_safe_resize_render.mjs`, файл поимённо в §10 и §13 п.5;
- M3 (то же «screen-fixed» вместо zoom-dependent) закрыта в r3 для §6.3 и
§18 п.4, с добавлением non-default-zoom fixture в §13 п.2 — проверено по
коду `iconCqw()`/`view.w` в `src/space-geometry.ts` и
`src/houseplan-card.ts` в r2/r3;
- границы со смежными контрактами (#233 внутренние размеры, #277
fixed-topology Resize, #238 подписи проёмов, #52 View) и структура
разделов 5, 7–10, 14–17 — не тронуты дельтой r4, наследуются как есть.
## Находки этого раунда
Нет. Правка текстовая, консервативная и приводит формулировку в соответствие
с уже дважды проверенным техническим фактом (zoom-зависимость размера
`.rlgearbtn` через `iconCqw(iconPct, space, view.w, kiosk)`); нового
контракта, новой догадки или новой неоднозначности не вносит.
## Что проверено и корректно
- Дельта `4969f46e..23d208f3` ограничена одной строкой ТЗ, ничего другого в
диапазоне коммитов класса A/B/C не изменилось (`git show --stat 23d208f3`
— один файл, `docs/specs/300-resize-measurement-layout.md`);
- закрытие M4 полное и не оставляет четвёртой копии устаревшей формулировки
где-либо в документе (полный `rg` по файлу, а не по изменённым строкам);
- коммит несёт корректные трейлеры `Issue: #300` и `User-Visible: no` —
ожидаемо для правки, ещё не касающейся продукта (`git log -1 --format=%B
23d208f3`);
- открытых продуктовых вопросов не осталось (уже зафиксировано автором в
комментарии к r0/ТЗ, повторной эскалации дельта не создаёт).
## Чего не проверял
- Полный повторный разбор AC1–AC11, разделов 5, 7–10, 14–17 — не входил в
делту, унаследован из r3 согласно списку выше;
- продуктовый код `src/**` — на этапе `S4-spec-review` кода ещё нет, гейты
§8/§10 (typecheck/test/build/check-docs/smoke/golden) к этому раунду
неприменимы и не запускались;
- канонические документы `docs/RESIZE.md`, `docs/CANVAS.md`,
`docs/USER-GUIDE.ru.md` на предмет соответствия терминологии — не входили
в делту r4, их сверка входит в объём r1 (сценарий/термины) и была там
выполнена; здесь не повторялась.
## Вердикт
Вердикт: зелёный · заход r4 · блокирующих циклов 3/4 · High: 0 · Medium: 0 → нет
+401
View File
@@ -0,0 +1,401 @@
# Issue #300 — Понятные подписи и измеряемые стены во время Resize
- Дата: 2026-08-24
- Тип: feature / polish · приоритет P2
- Ценность пользователю: 7/10 · ценность разработке: 4/10
- Сложность: 5/10 · риск: 5/10
- Issue: [#300](https://github.com/Matysh/houseplan-card/issues/300)
- Ветка: `issue/300-resize-labels`
Канонические документы: `docs/SCOPE.md`, `docs/RESIZE.md`,
`docs/CANVAS.md`, `docs/USER-GUIDE.ru.md`, `docs/USER-GUIDE.md`,
`docs/ARCHITECTURE.md`, `docs/TOUCH-SUPPORT.md`.
Связанные контракты: [#233](233-resize-inner-dimensions.md) — внутренние
размеры, [#277](277-safe-resize.md) — fixed-topology Resize.
## 1. Сценарий и персона
Администратор дома на компьютере открывает **Редактор плана**, выбирает
**Resize** и тянет горизонтальную или вертикальную стену. Во время жеста он
смотрит, как меняются две соседние длины и чистая площадь одной либо двух
комнат.
Это улучшение J6 из `docs/SCOPE.md`: редактор помогает поддерживать план в
актуальном состоянии. View, киоск, редактор устройств, редактор подложки и
статическая карточка не затронуты.
## 2. Что человек увидит до и после
**До:** рядом со стенами висят три длины без визуальной связи с измеряемыми
отрезками; одна из них относится к перемещаемой стене и не меняется. Площадь
находится в середине комнаты и может оказаться под кнопкой её настроек.
**После:** видны только две меняющиеся длины, а соответствующие соседние стены
подсвечены. Площадь каждой затронутой комнаты находится со своей стороны
перемещаемой стены и связана с ней короткой выносной линией.
## 3. Подтверждённая проблема
Текущий `_rszEdgeLabels()` в `src/houseplan-card.ts`:
1. добавляет длины предыдущего, перемещаемого и следующего ребра основной
комнаты;
2. не передаёт в render model идентичность измеряемого ребра, поэтому отдельной
подсветки нет;
3. ставит площадь каждой изменяемой комнаты в `poleOfInaccessibility(floor)` —
туда же, где в Plan editor находится `.rlgearbtn`.
Числа уже корректны по #233: `_rszInnerSpanCms()` измеряет между внутренними
гранями, а площадь считается по чистому полу через `innerContourForRoom()` и
`floorMinusBodies()`. Задача не меняет математическую конвенцию чисел — только
отбор, связь и расположение.
## 4. Зафиксированные продуктовые решения
1. Длина перемещаемой стены не показывается: движение параллельно самой себе не
меняет её длину.
2. Показываются две длины соседних рёбер **основной комнаты**, которые уже
показывались по обе стороны от перемещаемого ребра. На общей стене не
добавляются ещё две дублирующие длины соседней комнаты.
3. Оба ребра с показанными длинами подсвечиваются всё время активного жеста.
4. Площадь располагается по нормали от середины перемещаемой стены в сторону
соответствующей комнаты. Для общей стены одновременно видны две площади по
разные стороны.
5. Решение владельца по Q1 от 2026-08-24: принят **не default, а альтернатива**.
Площадь остаётся видимой всегда. Если плашка не помещается внутри узкой
комнаты, ей разрешено выйти за границу комнаты; выносная линия продолжает
однозначно связывать её с нужной стороной стены. Площади не перекрывают друг
друга.
6. Кнопка настроек комнаты остаётся видимой. Если nominal area-плашка попадает
в zoom-dependent footprint кнопки для текущего `view.w`, вычисленный по
`iconCqw()`, плашка сдвигается вдоль перемещаемой стены до первого свободного
положения; leader сохраняет связь с исходным midpoint.
Пункт 5 заменяет первоначальный AC6 в теле issue, где требовалось удерживать
плашку внутри комнаты: владелец явно выбрал альтернативу в комментарии.
## 5. Скоуп
### Входит
- render model двух изменяемых длин и двух соответствующих подсвеченных рёбер;
- pointer-transparent SVG-подсветка поверх кладки;
- размещение одной/двух площадей у перемещаемой стены;
- короткие выносные линии для площадей;
- collision-free раскладка area-плашки относительно room settings button;
- horizontal/vertical, outer/shared и оба направления drag;
- unit, browser smoke, golden, mutation guards и RU/EN документация.
### Не входит
- математика длины и площади #233;
- eligibility, clamp, preview, commit, Undo и opening movement #277;
- новые виды Resize, диагональные стены и изменение топологии;
- постоянные размеры в View (#52) и подписи размещения проёмов (#238);
- изменение Room card или сохранённой позиции её подписи;
- config/backend, storage, схема, миграция и compatibility-поля.
## 6. Контракт проекции подписей
Из `_rszEdgeLabels()` выделяется renderer-independent проекция (рабочее место —
новый `src/resize-labels.ts`). Вход:
- immutable `SafeResizePlan`;
- candidate `res.polys` после `clampSafeResize()`;
- два уже вычисленных текста внутренних длин основной комнаты;
- тексты площадей комнат из `plan.roomIds`;
- текущий SVG viewBox.
Выход не содержит Lit/DOM и различает три сущности:
```ts
type ResizeLengthLabel = {
kind: 'length'; roomId: string; edge: number;
x: number; y: number; text: string;
};
type ResizeMeasuredEdge = {
roomId: string; edge: number; a: Pt; b: Pt;
};
type ResizeAreaLabel = {
kind: 'area'; roomId: string; x: number; y: number; text: string;
side: 'left' | 'right' | 'above' | 'below';
tangentOffsetPx: number;
leader: { a: Pt; b: Pt };
};
```
Имена типов технические и могут измениться без продуктового решения.
### 6.1 Две длины
Для `plan.roomId` и `plan.edge = i` возвращаются только рёбра:
- `(i - 1 + n) % n`;
- `(i + 1) % n`.
Само ребро `i` отсутствует и в label model, и в DOM. Текст продолжает брать
число из `_rszInnerSpanCms()` и форматировать через существующий
`formatLength()`. Позиция остаётся в середине соответствующего ребра candidate,
чтобы не менять привычную связь подписи со стеной.
### 6.2 Подсветка измеряемых стен
Каждая из двух длин имеет ровно один `ResizeMeasuredEdge` с теми же room/edge и
candidate endpoints. Подсветка:
- повторяет сохранённую ось стены от endpoint до endpoint; это идентификатор
стены, а не новая размерная линия;
- рисуется сплошным accent-штрихом с theme-aware halo;
- имеет `vector-effect="non-scaling-stroke"`, одинаково читается при zoom и
`cell_cm` 1/5 см;
- находится после физических wall bodies, но до символов проёмов и Resize
handles;
- `pointer-events:none`, `aria-hidden=true`, без анимации.
Подсветка появляется только после фактического ненулевого preview move и
исчезает синхронно с `_rszLive` на pointerup, Esc, pointercancel,
lostpointercapture, pinch, смене инструмента/пространства/режима.
### 6.3 Площадь по сторонам стены
Для каждого `roomId` из `plan.roomIds` берётся его moving edge из
`plan.edgeByRoom[roomId]` и candidate poly. Внутренняя сторона определяется по
самому candidate polygon, а не по направлению записи endpoint:
- вертикальная стена даёт `left` или `right`;
- горизонтальная — `above` или `below`.
HTML-плашка якорится на midpoint moving edge. CSS-смещение использует **полный
собственный размер плашки**, поэтому не требует синхронного DOM measurement:
- `left`: плашка заканчивается за 12 CSS px до midpoint;
- `right`: начинается через 12 CSS px после midpoint;
- `above`: нижняя грань за 12 CSS px до midpoint;
- `below`: верхняя грань через 12 CSS px после midpoint.
Таким образом две плашки общей стены занимают противоположные полуплоскости и
не могут перекрыться независимо от длины локализованного текста. На наружной
стене строится одна плашка на стороне комнаты.
Для collision check используется консервативный screen-space footprint
плашки, вычисленный из форматированного текста и font/padding tokens, а также
zoom-dependent footprint `.rlgearbtn`, вычисленный для текущего `view.w` по той
же `iconCqw()`-семантике, что использует CSS кнопки. Nominal position сначала
проверяется против gear той же комнаты. При пересечении helper выбирает
минимальный сдвиг по касательной к moving wall; при равных вариантах стабильный
порядок — к меньшей screen-coordinate. Выход за room polygon разрешён решением
владельца, поэтому такой сдвиг не скрывает число и не требует менять сторону
стены.
Production gesture path не вызывает `getBoundingClientRect()` и не читает
layout; фактические DOM rectangles проверяются браузерным smoke как post-render
доказательство консервативности footprint.
Короткая выносная линия идёт от midpoint стены на 12 CSS px в сторону плашки.
Перевод screen px в render units использует текущий viewBox/stage size; stroke
остаётся screen-fixed. Линия присутствует у каждой area-плашки: в обычной
комнате это стабильная визуальная связь, а в узкой сохраняет принадлежность,
когда дальний край плашки выходит за room polygon. Плашку не прячут, не
обрезают, не переносят на противоположную сторону и не уменьшают.
## 7. Слои, жизненный цикл и безопасность жеста
Новый SVG measurement layer рисуется в существующем Plan SVG. HTML labels
остаются в `.measurelayer`. Оба слоя получают данные из одного projection
object на том же accepted preview, поэтому линия, подсветка и число не могут
относиться к разным candidate frames.
Room settings buttons продолжают рендериться и не получают нового состояния.
Area projection использует тот же вычисленный visual centre, что
`_renderRoomGear()`, поэтому collision avoidance не создаёт вторую модель
позиции кнопки.
Новые элементы pointer-inert и не меняют capture/hit priority. Существующие
гарантии `docs/RESIZE.md` обязательны без изменений:
- preview строится из immutable snapshot;
- config не меняется до pointerup;
- cancel/pinch/pointercancel/lost capture дают ноль Undo и ноль writes;
- commit сохраняет ровно существующий fixed-topology candidate.
## 8. UX и доступность
- Новых кнопок, полей, сообщений и tooltip нет.
- Цвет подсветки — существующий `--hp-accent`; halo использует фон темы.
- Area-плашка сохраняет существующий `formatArea()` и оформление `.rszarea`.
- Measurement overlay transient, non-focusable и `aria-hidden`; экранный
диктор не получает поток значений на каждом pointermove.
- `prefers-reduced-motion` ничего не меняет: новой анимации нет.
- Light/dark темы обязаны давать одинаковую структуру и читаемый контраст.
`Touch editor: best effort / intentionally unchanged.` Resize остаётся
desktop-reference. Если существующий touch drag сработал, он получает те же
подписи; новой hover-зависимости нет. Safety floor touch сохраняется и входит в
smoke отмены.
## 9. Модель данных, миграция, i18n
**Модель данных:** не меняется. Новых config/layout полей и WebSocket calls нет.
**Миграция/compatibility:** отсутствуют. Старые планы не переписываются; обычный
Open/Save ничего не материализует.
**i18n:** новых ключей нет. Используются существующие `formatLength()` и
`formatArea()`. Переводы `src/i18n/en.json` и `src/i18n/ru.json` не должны
измениться.
## 10. Изменяемые файлы и модули
Ожидаемый набор:
- `src/resize-labels.ts` — чистая проекция двух длин, сторон площадей и leader;
- `src/houseplan-card.ts` — сбор фактических значений и render/lifecycle;
- `src/styles.ts` — measured-edge, leader и side transforms;
- `test/resize-labels.test.mjs` — pure contract;
- `test/resize-production-path.test.mjs` — production ownership/lifecycle;
- `demo/smoke_resize_labels.mjs` — outer/shared/narrow/horizontal/vertical;
- `demo/benchmark_safe_resize_render.mjs` — существующий real-render gate с
абсолютным `RENDER_P95_MS = 25`; исходник benchmark менять не требуется;
- `demo/golden/harness.mjs`, при необходимости `demo/golden/matrix.mjs` —
semantic checks active Resize scene;
- `scripts/mutation-gate.mjs` — guards §12;
- `docs/RESIZE.md`, `docs/ARCHITECTURE.md`, `docs/USER-GUIDE.ru.md`,
`docs/USER-GUIDE.md`;
- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`.
Точный diff определяется реализацией; class A/B файлы вне этих подсистем —
находка вне скоупа.
## 11. Acceptance criteria
| AC | Требование | Доказательство |
|---|---|---|
| AC1 | После ненулевого preview move `_rszLive`/projection содержит ровно две length-подписи основной комнаты: previous и next edge; moving edge отсутствует | unit + smoke |
| AC2 | Тексты обеих длин равны текущему внутреннему контракту #233; площадь равна прежнему clean-floor значению | existing #233 smoke + smoke |
| AC3 | Ровно две подсветки повторяют candidate endpoints тех же room/edge, лежат поверх wall body и не принимают pointer events | unit + smoke + code review |
| AC4 | Наружная горизонтальная и вертикальная стена показывают одну area-плашку со стороны комнаты и одну выносную линию | unit + smoke |
| AC5 | Общая горизонтальная и вертикальная стена показывают две area-плашки с разными `roomId` по противоположным сторонам и две выносные линии | unit + smoke |
| AC6 | В narrow-room fixture обе площади остаются в DOM, могут выйти за room polygon по решению владельца, но их фактические DOM rectangles не пересекаются; leader ownership остаётся однозначным | smoke |
| AC7 | При horizontal/vertical и обоих направлениях drag room settings button остаётся видимой, а её фактический DOM rectangle не пересекает area-плашку той же комнаты | unit + smoke |
| AC8 | Pointerup очищает measurement overlay; Esc, pointercancel, lost capture и pinch также очищают его и создают 0 Undo/0 writes | smoke |
| AC9 | Light/dark active-Resize golden показывает две подсвеченные стены, две площади shared wall и читаемые leaders; принятие baseline только из полного reviewed Linux CI artifact | golden review |
| AC10 | User-visible docs и оба changelog обновлены в том же коммите; i18n/schema/backend отсутствуют в diff | code review |
| AC11 | Existing `demo/benchmark_safe_resize_render.mjs` остаётся внутри реального абсолютного потолка `RENDER_P95_MS = 25`; `_rszMove` не делает forced layout read | benchmark + code review |
## 12. Mutation guards
| id | Поломка | Краснеет |
|---|---|---|
| `resize-labels-restores-moving-length` | возвращает третью неизменяемую длину | AC1 |
| `resize-labels-drops-measured-edge` | одна длина остаётся без соответствующей подсветки | AC3 |
| `resize-labels-same-side-areas` | обе площади общей стены ставятся с одной стороны | AC5/AC6 |
| `resize-labels-hide-narrow-area` | narrow fallback скрывает одну площадь вопреки решению владельца | AC6 |
| `resize-labels-ignore-gear-collision` | nominal area-плашка не сдвигается от room settings button | AC7 |
| `resize-labels-cancel-leak` | measurement overlay переживает abort | AC8 |
## 13. План автотестов
1. `test/resize-labels.test.mjs`:
- previous/next edge modulo polygon length;
- moving edge отсутствует;
- horizontal/vertical room-side projection независимо от winding и
endpoint direction;
- shared owners получают противоположные стороны;
- leader переводит 12 CSS px в правильное число render units.
2. `demo/smoke_resize_labels.mjs` запускает production
`_rszEdgeDown → _rszMove → _rszUp` реальными browser pointer events:
- outer horizontal и vertical;
- shared wall с двумя rooms;
- positive/negative drag;
- narrow room с фактической проверкой `getBoundingClientRect()` двух badges;
- default и non-default zoom, чтобы фактический zoom-dependent rectangle
`.rlgearbtn` оставался вне area-плашки;
- фактическое отсутствие overlap с видимой room settings button и все abort
paths.
3. `demo/smoke_resize_inner_dimensions.mjs` остаётся зелёным и доказывает, что
числа #233 не изменены.
4. Existing `safe-resize-handles-clamp-light/dark` golden используется как
визуальная сцена; harness дополнительно fail-closed проверяет semantic DOM до
screenshot.
5. Перед `S7-code-review`: `npm run typecheck`, `npm test`, `npm run build`,
`npm run bundle:sync`, `node scripts/check-docs.mjs`, вывод
`node scripts/smoke-select.mjs --base origin/dev --head HEAD` и все выбранные
target smokes, включая `node demo/benchmark_safe_resize_render.mjs` как
доказательство абсолютного p95-бюджета 25 ms.
## 14. Производительность, security, touch
Проекция O(1) по числу затронутых комнат (максимум две) и создаёт фиксированные
две measured edges, 1–2 area labels и 1–2 leaders. CSS placement использует
собственный размер элемента и не читает layout синхронно в pointermove. Тест
может читать DOM rectangles после settled frame; production — нет.
Security и HA actions не затронуты: элементы pointer-inert, новых строк/URL/
HTML-ввода нет. Текст проходит существующие formatter/Lit boundaries.
Touch — best effort, без изменения существующей поддержки; cancellation safety
остаётся блокирующей.
## 15. Риски и митигации
1. **Плашка узкой комнаты визуально окажется в соседнем помещении.** Это
сознательно принято владельцем. Короткая leader line и сторона moving wall
являются обязательной связью; скрытие/обрезка запрещены.
2. **Winding и обратное направление shared edge перепутают стороны.** Сторона
вычисляется по candidate polygon interior и покрывается зеркальными unit
fixtures.
3. **Highlight перехватит жест или перекроет проём.** Слой pointer-transparent и
расположен ниже opening symbols/handles.
4. **Консервативный footprint разойдётся с фактическим CSS.** Размеры выводятся
из общих tokens, а smoke сравнивает реальные `getBoundingClientRect()` в
horizontal/vertical и narrow fixtures.
5. **Forced reflow на каждом pointermove.** Production placement не измеряет
DOM; mutation/code review стережёт отсутствие `getBoundingClientRect()` в
gesture path.
## 16. Откат
Одна frontend-ревизия: вернуть старую форму `_rszLive` и прежний render без
measured-edge/leader layers. Данных и миграции нет, backend откатывать не нужно.
Откат возвращает прежние три длины и площадь в центре комнаты; это допустимая
техническая деградация, а не повреждение плана.
## 17. Release-артефакты
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #300;
- `docs/USER-GUIDE.ru.md` и `docs/USER-GUIDE.md`: Resize показывает две
меняющиеся длины у подсвеченных стен и площадь у moving wall;
- `docs/RESIZE.md` и `docs/ARCHITECTURE.md`: projection/layer/lifecycle;
- reviewed light/dark active-Resize golden. Baselines принимаются только
`npm run golden:accept -- --reviewed` из полного Linux CI artifact с
обязательными `Release:` и `Baseline-Reviewed:` trailers;
- любая правка `src/**` обновляет screenshot fingerprint. Если
`node scripts/check-docs.mjs` требует пересъёмку, запускается `Docs
screenshots`, а полный artifact принимается через
`npm run docs:accept -- --reviewed --from=<artifact>`;
- pre-beta: полный golden/smoke/performance по общему процессу.
## 18. Принятые предположения (техническое, менять свободно)
1. Новый pure helper живёт в `src/resize-labels.ts`; допустимо оставить его в
существующем Resize module, если импортный граф и тестируемость лучше.
2. CSS gap равен 12 px — существующий размер opening-dimension labels и
достаточно короткая визуальная связь. Reviewer может скорректировать число
без продуктового вопроса, сохранив противоположные полуплоскости.
3. Leader рисуется всегда, а не только после определения выхода за polygon.
Это избегает forced layout read и сохраняет стабильную ownership-связь без
визуального переключения режима во время drag.
4. Collision helper использует консервативную расчётную ширину текста и
zoom-dependent footprint room gear для текущего `view.w`, согласованный с
`iconCqw()`. Допустима другая pure screen-space стратегия, если кнопка
остаётся видимой, фактические rectangles не пересекаются на default и
non-default zoom и pointermove не читает layout.
5. Highlight использует два screen-fixed strokes (halo + accent); точные
ширины являются темизацией, не продуктовым решением.
Не являются предположениями: отсутствие moving-wall length, две подсвеченные
соседние стены, area по обе стороны shared wall, всегда видимая narrow-room
площадь с leader и отсутствие overlap с видимой room settings button — это
acceptance contract.
+1
View File
@@ -132,6 +132,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#186](https://github.com/Matysh/houseplan-card/issues/186) Безопасный остаток стены у торцов партиционного проёма | [186-partition-opening-jamb-margin.md](186-partition-opening-jamb-margin.md) |
| [#234](https://github.com/Matysh/houseplan-card/issues/234) Толщина отрезка цепочки не расходится между превью и записью | [234-chain-segment-thickness.md](234-chain-segment-thickness.md) |
| [#233](https://github.com/Matysh/houseplan-card/issues/233) Ресайз показывает внутренние размеры, а не осевые | [233-resize-inner-dimensions.md](233-resize-inner-dimensions.md) |
| [#300](https://github.com/Matysh/houseplan-card/issues/300) Понятные подписи и измеряемые стены во время Resize | [300-resize-measurement-layout.md](300-resize-measurement-layout.md) |
| [#238](https://github.com/Matysh/houseplan-card/issues/238) Размеры проёма до внутренних физических границ | [238-opening-inner-distances.md](238-opening-inner-distances.md) |
| [#242](https://github.com/Matysh/houseplan-card/issues/242) Символ проёма по центру толщины стены | [242-opening-symbol-center.md](242-opening-symbol-center.md) |
| [#244](https://github.com/Matysh/houseplan-card/issues/244) Восстановление маркеров с мёртвой ссылкой на пространство | [244-orphan-space-references.md](244-orphan-space-references.md) |
+47
View File
@@ -412,6 +412,53 @@ export const MUTANTS = [
replace: ' const span = centre;',
}],
},
{
id: 'resize-labels-drops-measured-edge',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/resize-labels.test.mjs',
because: 'each of the two remaining length badges must retain a matching side-wall '
+ 'highlight; duplicating one edge silently drops the other measurement (#300)',
patches: [{
file: 'src/resize-labels.ts',
find: ' return [(movingEdge - 1 + n) % n, (movingEdge + 1) % n];',
replace: ' return [(movingEdge - 1 + n) % n, (movingEdge - 1 + n) % n];',
}],
},
{
id: 'resize-labels-same-side-areas',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/resize-labels.test.mjs',
because: 'the inward side must follow polygon winding; a shared wall needs its two room '
+ 'areas on opposite sides instead of stacked together (#300)',
patches: [{
file: 'src/resize-labels.ts',
find: ' return signedArea(poly) >= 0\n',
replace: ' return true\n',
}],
},
{
id: 'resize-labels-ignore-gear-collision',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test test/resize-labels.test.mjs',
because: 'a visible room-settings button must move the area badge tangentially at current '
+ 'zoom; keeping the nominal position recreates the overlap (#300)',
patches: [{
file: 'src/resize-labels.ts',
find: ' if (collides(0)) {',
replace: ' if (false && collides(0)) {',
}],
},
{
id: 'resize-labels-hide-narrow-area',
guard: 'node demo/smoke_resize_labels.mjs',
because: 'the owner chose always-visible area labels; keeping only one owner in a narrow '
+ 'shared fixture loses the second room instead of using its leader (#300)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' for (const id of ids) {\n const poly = res.polys[id]',
replace: ' for (const id of ids.slice(0, 1)) {\n const poly = res.polys[id]',
}],
},
{
id: 'resize-wall-partial-overlap-not-split',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+9
View File
@@ -27,6 +27,15 @@
/** @type {SmokeLink[]} */
export const SMOKE_LINKS = [
{
symbols: [
'placeResizeAreaLabel', 'resizeInwardNormal', 'resizeMeasuredEdges',
'ResizeAreaPlacement', 'ResizeAreaPlacementInput', 'ResizeLabelView',
],
smokes: ['smoke_resize_labels.mjs'],
because: 'the #300 production gesture observes the pure projection only through the '
+ 'rendered side-wall highlights, area/leader DOM and actual gear rectangles at current zoom',
},
{
symbols: [
'classifyNearAxisSegment', 'snapNearAxisEndpoint', 'repairNearAxisRoomWalls',
+99 -13
View File
@@ -47,6 +47,10 @@ import {
type SafeOpeningIn, type SafeResizeObstacle, type SafeResizeOptions,
type SafeResizePlan, type SafeResizeReason, type SafeResizeResolution,
} from './resize';
import {
placeResizeAreaLabel, resizeMeasuredEdges,
type ResizeAreaPlacement,
} from './resize-labels';
import {
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn,
sunStateOf, rayPeakAlpha, raysVisible, rayColor, RAY_FADE_MS, type SunRay,
@@ -308,6 +312,21 @@ import {
import { applyOpeningMoves, mergeCollinearPartitions, spaceMergeGeometry } from './wall-merge';
const CARD_VERSION = '1.67.0-rc.1';
type ResizeLiveLabel = {
kind: 'length';
x: number;
y: number;
text: string;
edge: { a: [number, number]; b: [number, number] };
} | {
kind: 'area';
roomId: string;
x: number;
y: number;
text: string;
placement: ResizeAreaPlacement;
};
const DISPLAY_LABEL_KEYS: Record<DeviceDisplayMode, I18nKey> = {
badge: 'display.badge',
icon_ripple: 'display.icon_ripple',
@@ -1634,7 +1653,7 @@ class HouseplanCard extends LitElement {
} | null = null;
/** HP-1550-01: the live resize preview, kept OUT of _serverCfg (see _rszApplyPreview). */
private _rszPreview: { space: string; sp: any } | null = null;
private _rszLive: { x: number; y: number; text: string; area?: boolean }[] | null = null;
private _rszLive: ResizeLiveLabel[] | null = null;
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid)
private _cursorPt: number[] | null = null;
private _planSnapHover: {
@@ -8806,33 +8825,61 @@ class HouseplanCard extends LitElement {
private _rszEdgeLabels(
res: { polys: Record<string, number[][]> }, plan: SafeResizePlan,
): { x: number; y: number; text: string; area?: boolean }[] {
): ResizeLiveLabel[] {
const g = this._rszDrag!;
const labels: { x: number; y: number; text: string; area?: boolean }[] = [];
const labels: ResizeLiveLabel[] = [];
const own = res.polys[plan.roomId] || g.rooms.find((r) => r.id === plan.roomId)!.poly;
const n = own.length;
const i = plan.edge, j = (i + 1) % n;
// Длины — между внутренними гранями, как и площадь ниже (#233). Раньше
// здесь считалась осевая длина, и одно облачко подписей несло две разные
// конвенции: «3.00 × 4.00» по центрам стен рядом с площадью по полу.
const spanCms = this._rszInnerSpanCms(plan.roomId, own, res.polys);
// the dragged wall and its two adjacent walls
for (const edge of [(i - 1 + n) % n, i, j]) {
// The moving wall is obvious under the pointer. Only its two side walls
// remain useful measurements, and each one owns the matching highlight.
for (const edge of resizeMeasuredEdges(own, plan.edge)) {
const a = own[edge], b = own[(edge + 1) % n];
const cm = spanCms?.[edge];
labels.push({
kind: 'length',
x: (a[0] + b[0]) / 2,
y: (a[1] + b[1]) / 2,
text: cm == null ? this._fmtLen(a, b)
: formatLength(cm, this.hass?.config?.unit_system?.length === 'mi'),
edge: { a: [a[0], a[1]], b: [b[0], b[1]] },
});
}
// live areas of EVERY room the drag reshapes (both sides of a shared wall)
// Live areas sit beside the moving wall, one on each owner side. Placement
// is pure screen-space math fed by the stage size cached by ResizeObserver;
// no pointermove DOM measurement is allowed.
const imperial = this.hass?.config?.unit_system?.length === 'mi';
const ids = Object.keys(res.polys).length ? Object.keys(res.polys) : [plan.roomId];
const ids = plan.roomIds;
const walls = this._spaceWalls;
const openCuts = this._openPairs().flatMap((p) => p.segs);
const physical = this._physicalBodiesR();
const base = this._baseVb();
const currentView = this._view && this._view.w > 0 && this._view.h > 0
? this._view
: { x: base[0], y: base[1], w: base[2], h: base[3] };
const [cachedW, cachedH] = this._lastValidStageSize || [currentView.w, currentView.h];
const view = {
...currentView,
stageWidth: Math.max(1, cachedW),
stageHeight: Math.max(1, cachedH),
};
const space = this._spaceModel();
const cfgSize = this._config?.icon_size ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
const iconPx = space
? iconCqw(iconPct, space, currentView.w, this._kiosk ? this._kioskScale.icon : 1)
* view.stageWidth / 100
: 24;
const gearHeightPx = Math.max(10, iconPx * 0.77);
const gearText = this._t('room.settings_short');
// Mirrors the CSS proportions conservatively: icon + gap + two paddings +
// localized text at the button's 0.42*height font size.
const gearWidthPx = gearHeightPx * (
0.55 + 0.35 * 0.42 + 0.76 + Math.max(1, gearText.length) * 0.66 * 0.42
);
for (const id of ids) {
const poly = res.polys[id] || g.rooms.find((r) => r.id === id)!.poly;
const floor = walls.length
@@ -8841,12 +8888,24 @@ class HouseplanCard extends LitElement {
id, walls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
) || poly)
: poly;
const c = poleOfInaccessibility(floor);
const m2 = physical.length
? geometryArea(floorMinusBodies(floor, physical))
* Math.pow(this._cellCm / this._gridPitch, 2) / 1e4
: areaM2(floor, this._gridPitch, this._cellCm);
labels.push({ x: c[0], y: c[1], text: formatArea(m2, imperial), area: true });
const text = formatArea(m2, imperial);
const placement = placeResizeAreaLabel({
poly,
edge: plan.edgeByRoom[id],
text,
view,
gearCenter: poleOfInaccessibility(poly),
gearWidthPx,
gearHeightPx,
});
labels.push({
kind: 'area', roomId: id,
x: placement.anchor[0], y: placement.anchor[1], text, placement,
});
}
return labels;
}
@@ -8878,6 +8937,26 @@ class HouseplanCard extends LitElement {
return own.map((_, edge) => innerEdgeSpan(own, edge, offsets) * perUnitCm);
}
/** Pointer-inert measurement ink above masonry and below openings/handles. */
private _renderResizeMeasurements(): TemplateResult | typeof nothing {
if (!this._rszLive?.length) return nothing;
const lengths = this._rszLive.filter((label) => label.kind === 'length');
const areas = this._rszLive.filter((label) => label.kind === 'area');
return svg`<g class="rszmeasurelayer" aria-hidden="true" pointer-events="none">
${lengths.map((label, index) => svg`<g class="rszmeasuredge"
data-hp="resize-measured-edge" data-edge-index=${index}>
<line class="rszmeasurehalo" x1=${label.edge.a[0]} y1=${label.edge.a[1]}
x2=${label.edge.b[0]} y2=${label.edge.b[1]}></line>
<line class="rszmeasureink" x1=${label.edge.a[0]} y1=${label.edge.a[1]}
x2=${label.edge.b[0]} y2=${label.edge.b[1]}></line>
</g>`)}
${areas.map((label) => svg`<line class="rszleader" data-hp="resize-area-leader"
data-room=${label.roomId}
x1=${label.placement.leader.a[0]} y1=${label.placement.leader.a[1]}
x2=${label.placement.leader.b[0]} y2=${label.placement.leader.b[1]}></line>`)}
</g>`;
}
/** Fixed-topology wall handles. Disabled geometry remains visible and
* explains why it cannot start a gesture; the old corner scale is gone. */
private _renderResizeLayer(view: { x: number; y: number; w: number; h: number }): TemplateResult {
@@ -17422,6 +17501,7 @@ class HouseplanCard extends LitElement {
inside thick jambs without changing the stored span. */}
${!this._editing ? this._renderOpenWalls(disp) : nothing}
${this._renderWallBodies(disp)}
${this._markup && this._tool === 'resize' ? this._renderResizeMeasurements() : nothing}
${this._renderRoomHoverOutline(roomHover)}
${''/* Editors: saved virtual boundaries and the live two-click
preview deliberately paint AFTER real wall bodies. Their
@@ -17485,8 +17565,13 @@ class HouseplanCard extends LitElement {
: nothing}
${this._rszLive
? html`<div class="measurelayer">${this._rszLive.map((l) => html`<div
class="measurelabel ${l.area ? 'rszarea' : ''}"
style="left:${(((l.x - view.x) / view.w) * 100).toFixed(2)}%;top:${(((l.y - view.y) / view.h) * 100).toFixed(2)}%">${l.text}</div>`)}</div>`
class="measurelabel ${l.kind === 'area' ? 'rszarea' : 'rszlength'}"
data-hp=${l.kind === 'area' ? 'resize-area-label' : 'resize-length-label'}
data-room=${l.kind === 'area' ? l.roomId : nothing}
data-side=${l.kind === 'area' ? l.placement.side : nothing}
style="left:${(((l.x - view.x) / view.w) * 100).toFixed(2)}%;top:${(((l.y - view.y) / view.h) * 100).toFixed(2)}%;${l.kind === 'area'
? `--rsz-label-x:${l.placement.offsetXPx.toFixed(2)}px;--rsz-label-y:${l.placement.offsetYPx.toFixed(2)}px;--rsz-label-tangent:${l.placement.tangentOffsetPx.toFixed(2)}px`
: ''}">${l.text}</div>`)}</div>`
: nothing}
${opMeasure
? html`<div class="measurelayer">${opMeasure.labels.map((l) => html`<div
@@ -18777,7 +18862,8 @@ class HouseplanCard extends LitElement {
if (!c) return nothing;
const left = ((c[0] - view.x) / view.w) * 100;
const top = ((c[1] - view.y) / view.h) * 100;
return html`<button class="rlgearbtn" style="left:${left}%;top:${top}%"
return html`<button class="rlgearbtn" data-hp="room-settings" data-room=${r.id}
style="left:${left}%;top:${top}%"
title=${this._t('room.settings_title')}
@pointerdown=${(e: Event) => e.stopPropagation()}
@click=${(e: Event) => { e.stopPropagation(); this._openRoomEdit(r); }}>
+155
View File
@@ -0,0 +1,155 @@
/**
* Screen-space projection for the live Resize measurements (#300).
*
* The pointer path must stay free of DOM measurements. Callers pass the
* viewport size cached by the stage ResizeObserver; this module then keeps the
* area badge clear of the zoom-dependent room-settings button with pure math.
*/
export type ResizePoint = [number, number];
export interface ResizeLabelView {
x: number;
y: number;
w: number;
h: number;
stageWidth: number;
stageHeight: number;
}
export interface ResizeAreaPlacementInput {
poly: number[][];
edge: number;
text: string;
view: ResizeLabelView;
gearCenter: number[];
gearWidthPx: number;
gearHeightPx: number;
}
export interface ResizeAreaPlacement {
anchor: ResizePoint;
offsetXPx: number;
offsetYPx: number;
tangentOffsetPx: number;
side: 'left' | 'right' | 'above' | 'below';
leader: { a: ResizePoint; b: ResizePoint };
}
const EPS = 1e-9;
const AREA_NORMAL_PX = 28;
const LEADER_PX = 12;
const COLLISION_GAP_PX = 4;
const signedArea = (poly: number[][]): number => {
let sum = 0;
for (let i = 0; i < poly.length; i++) {
const a = poly[i];
const b = poly[(i + 1) % poly.length];
sum += a[0] * b[1] - b[0] * a[1];
}
return sum / 2;
};
/** Unit normal pointing into the room, independent of polygon winding. */
export function resizeInwardNormal(poly: number[][], edge: number): ResizePoint {
const a = poly[edge];
const b = poly[(edge + 1) % poly.length];
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const length = Math.hypot(dx, dy) || 1;
return signedArea(poly) >= 0
? [-dy / length, dx / length]
: [dy / length, -dx / length];
}
/** The two side walls whose clear lengths remain useful during the drag. */
export function resizeMeasuredEdges(poly: number[][], movingEdge: number): [number, number] {
const n = poly.length;
return [(movingEdge - 1 + n) % n, (movingEdge + 1) % n];
}
const toScreen = (p: number[], view: ResizeLabelView): ResizePoint => [
((p[0] - view.x) / Math.max(EPS, view.w)) * view.stageWidth,
((p[1] - view.y) / Math.max(EPS, view.h)) * view.stageHeight,
];
const overlaps = (
ax: number, ay: number, aw: number, ah: number,
bx: number, by: number, bw: number, bh: number,
): boolean => Math.abs(ax - bx) * 2 < aw + bw + COLLISION_GAP_PX * 2
&& Math.abs(ay - by) * 2 < ah + bh + COLLISION_GAP_PX * 2;
/**
* Place one area badge on the room side of the moving wall. If the nominal
* position hits the room-settings button, search outwards along the wall in
* stable 4 px steps. The smaller screen coordinate wins equal distances.
*/
export function placeResizeAreaLabel(input: ResizeAreaPlacementInput): ResizeAreaPlacement {
const { poly, edge, text, view } = input;
const a = poly[edge];
const b = poly[(edge + 1) % poly.length];
const anchor: ResizePoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const normal = resizeInwardNormal(poly, edge);
const length = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
const tangent: ResizePoint = [(b[0] - a[0]) / length, (b[1] - a[1]) / length];
const anchorScreen = toScreen(anchor, view);
const gearScreen = toScreen(input.gearCenter, view);
const labelWidth = Math.max(34, text.length * 7.2 + 12);
const labelHeight = 18;
// Opposite-owner badges must clear each other even before gear avoidance.
// On a vertical wall their text width is the limiting dimension; on a
// horizontal wall it is the fixed badge height.
const normalDistancePx = Math.abs(normal[0]) >= Math.abs(normal[1])
? Math.max(AREA_NORMAL_PX, labelWidth / 2 + COLLISION_GAP_PX)
: Math.max(AREA_NORMAL_PX, labelHeight / 2 + COLLISION_GAP_PX);
const nominalX = anchorScreen[0] + normal[0] * normalDistancePx;
const nominalY = anchorScreen[1] + normal[1] * normalDistancePx;
const collides = (shift: number): boolean => overlaps(
nominalX + tangent[0] * shift,
nominalY + tangent[1] * shift,
labelWidth,
labelHeight,
gearScreen[0],
gearScreen[1],
input.gearWidthPx,
input.gearHeightPx,
);
let tangentOffsetPx = 0;
if (collides(0)) {
// Pick the sign that moves the dominant screen coordinate towards the
// smaller value first. This makes equal-distance choices deterministic.
const dominant = Math.abs(tangent[0]) >= Math.abs(tangent[1]) ? tangent[0] : tangent[1];
const firstSign = dominant > 0 ? -1 : 1;
const limit = Math.max(view.stageWidth, view.stageHeight, 64);
for (let distance = 4; distance <= limit; distance += 4) {
const first = distance * firstSign;
if (!collides(first)) { tangentOffsetPx = first; break; }
const second = -first;
if (!collides(second)) { tangentOffsetPx = second; break; }
}
}
const offsetXPx = normal[0] * normalDistancePx + tangent[0] * tangentOffsetPx;
const offsetYPx = normal[1] * normalDistancePx + tangent[1] * tangentOffsetPx;
const screenLength = Math.hypot(offsetXPx, offsetYPx) || 1;
const leaderDxPx = (offsetXPx / screenLength) * LEADER_PX;
const leaderDyPx = (offsetYPx / screenLength) * LEADER_PX;
const leaderB: ResizePoint = [
anchor[0] + leaderDxPx * view.w / Math.max(1, view.stageWidth),
anchor[1] + leaderDyPx * view.h / Math.max(1, view.stageHeight),
];
const side = Math.abs(normal[0]) >= Math.abs(normal[1])
? (normal[0] < 0 ? 'left' : 'right')
: (normal[1] < 0 ? 'above' : 'below');
return {
anchor,
offsetXPx,
offsetYPx,
tangentOffsetPx,
side,
leader: { a: anchor, b: leaderB },
};
}
+28 -1
View File
@@ -2876,6 +2876,29 @@ export const cardStyles = css`
:host([data-pointer-hover]) .rszhandle:hover + .rszicon .rszink { stroke-width: 3; }
.rszicon.disabled { opacity: 0.38; }
:host([data-pointer-hover]) .rszhandle.disabled:hover + .rszicon .rszink { stroke-width: 2; }
.rszmeasurelayer,
.rszmeasurelayer * { pointer-events: none; }
.rszmeasurehalo,
.rszmeasureink,
.rszleader {
fill: none;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.rszmeasurehalo {
stroke: var(--hp-bg);
stroke-width: 7px;
opacity: 0.9;
}
.rszmeasureink {
stroke: var(--hp-accent);
stroke-width: 3px;
}
.rszleader {
stroke: var(--hp-accent);
stroke-width: 2px;
opacity: 0.95;
}
/* the decor draft badge rides the MIDDLE of the shape, so it is centred
horizontally and lifted clear of the line instead of trailing the
cursor the way a wall badge does (owner 2026-08-04) */
@@ -2883,8 +2906,12 @@ export const cardStyles = css`
transform: translate(-50%, -160%);
}
.measurelabel.rszarea {
transform: translate(-50%, -50%);
transform: translate(
calc(-50% + var(--rsz-label-x, 0px)),
calc(-50% + var(--rsz-label-y, 0px))
);
background: rgba(0, 0, 0, 0.6);
border: 1px solid var(--hp-accent);
}
/* width and depth of a piece of furniture while its corner is dragged —
centred on the edge they measure (docs/FURNITURE.md §6) */
+65
View File
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
placeResizeAreaLabel, resizeInwardNormal, resizeMeasuredEdges,
} from '../test-build/resize-labels.js';
const VIEW = { x: 0, y: 0, w: 1000, h: 1000, stageWidth: 1000, stageHeight: 1000 };
const ROOM = [[100, 100], [500, 100], [500, 500], [100, 500]];
test('#300 keeps only the two side-wall measurements', () => {
assert.deepEqual(resizeMeasuredEdges(ROOM, 1), [0, 2]);
assert.deepEqual(resizeMeasuredEdges(ROOM, 0), [3, 1]);
});
test('#300 inward side is stable for both polygon windings', () => {
const clean = (normal) => normal.map((value) => Math.abs(value) < 1e-9 ? 0 : value);
assert.deepEqual(clean(resizeInwardNormal(ROOM, 1)), [-1, 0]);
const reversed = [...ROOM].reverse();
const edge = reversed.findIndex((a, index) => {
const b = reversed[(index + 1) % reversed.length];
return a[0] === 500 && a[1] === 500 && b[0] === 500 && b[1] === 100;
});
assert.deepEqual(clean(resizeInwardNormal(reversed, edge)), [-1, 0]);
});
test('#300 shared-wall areas land on opposite sides', () => {
const left = placeResizeAreaLabel({
poly: ROOM, edge: 1, text: '16.0 m²', view: VIEW,
gearCenter: [300, 300], gearWidthPx: 20, gearHeightPx: 20,
});
const rightRoom = [[500, 100], [900, 100], [900, 500], [500, 500]];
const right = placeResizeAreaLabel({
poly: rightRoom, edge: 3, text: '16.0 m²', view: VIEW,
gearCenter: [700, 300], gearWidthPx: 20, gearHeightPx: 20,
});
assert.equal(left.side, 'left');
assert.equal(right.side, 'right');
assert.ok(left.offsetXPx < 0);
assert.ok(right.offsetXPx > 0);
});
test('#300 room gear collision shifts area along the wall without hiding it', () => {
const placed = placeResizeAreaLabel({
poly: ROOM, edge: 1, text: '16.0 m²', view: VIEW,
// Nominal label centre is x=472,y=300; put a wide zoomed gear there.
gearCenter: [472, 300], gearWidthPx: 120, gearHeightPx: 28,
});
assert.notEqual(placed.tangentOffsetPx, 0);
assert.equal(placed.anchor[0], 500);
assert.equal(placed.anchor[1], 300);
const dxPx = (placed.leader.b[0] - placed.leader.a[0]) * VIEW.stageWidth / VIEW.w;
const dyPx = (placed.leader.b[1] - placed.leader.a[1]) * VIEW.stageHeight / VIEW.h;
assert.ok(Math.abs(Math.hypot(dxPx, dyPx) - 12) < 1e-9);
});
test('#300 non-default zoom keeps gear avoidance in current screen pixels', () => {
const zoomedView = { ...VIEW, x: 250, y: 250, w: 500, h: 500 };
const placed = placeResizeAreaLabel({
poly: ROOM, edge: 1, text: '16.0 m²', view: zoomedView,
gearCenter: [486, 300], gearWidthPx: 160, gearHeightPx: 36,
});
assert.notEqual(placed.tangentOffsetPx, 0);
assert.ok(Number.isFinite(placed.offsetXPx));
assert.ok(Number.isFinite(placed.offsetYPx));
});
+1 -1
View File
@@ -14,7 +14,7 @@
"src/integration-provider.ts", "src/vacuum.ts",
"src/sun.ts",
"src/light-visibility.ts",
"src/resize.ts",
"src/resize.ts", "src/resize-labels.ts",
"src/rules.ts",
"src/devices.ts",
"src/virtual-light-state.ts",