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
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -438,7 +438,7 @@ Undo оптимизации.
|
||||
|---|---|
|
||||
| Объединить | Склеивает соседние комнаты; в диалоге выбирается сохраняемая идентичность, имя и HA-зона |
|
||||
| Split | Делит комнату путём от одной стены до другой; большая часть сохраняет исходную комнату |
|
||||
| Resize | Двигает одну допустимую горизонтальную/вертикальную стену без изменения топологии комнат. Подписи показывают **внутренние** размеры — расстояние между гранями стен, которое даёт рулетка, — и площадь каждой затронутой комнаты |
|
||||
| Resize | Двигает одну допустимую горизонтальную/вертикальную стену без изменения топологии комнат. Подписи показывают два меняющихся **внутренних** размера боковых стен, подсвечивают эти стены и ставят площадь каждой затронутой комнаты с её стороны перемещаемой стены |
|
||||
| Толщина | Задаёт толщину выбранному участку или всем стенам комнаты |
|
||||
| Удалить комнату | Открывает выбор: удалить комнату и оставить её физические стены либо удалить комнату вместе с ними; общие стены остаются всегда |
|
||||
|
||||
@@ -490,6 +490,11 @@ Resize показывает живые длины и чистую площадь
|
||||
Resize не пересчитывает их пропорционально новой длине. Неоднозначный результат
|
||||
отклоняется целиком, а не повреждает стену в другом месте плана.
|
||||
|
||||
У самой перемещаемой стены больше нет дублирующей подписи длины. У внешней
|
||||
стены показывается одна площадь, у общей — две на противоположных сторонах, с
|
||||
короткими выносными линиями. В узкой комнате площадь остаётся видимой и может
|
||||
выйти за контур, но не перекрывает вторую площадь или кнопку настроек комнаты.
|
||||
|
||||
Прямая стена, нарисованная в несколько кликов, сохраняется одной перегородкой:
|
||||
соседние отрезки одинаковой толщины и направления срастаются сразу после
|
||||
завершения цепочки. Узел остаётся там, где в него приходит третья стена, стена
|
||||
|
||||
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 340 KiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 134 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 → нет
|
||||
@@ -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.
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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); }}>
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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) */
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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",
|
||||
|
||||