Four independent cuts into the 2-4 hour full run, none touching the contract
"a mutant must turn its guard red":
- guardNeedsBundle: rollup runs only for guards that open the built bundle
(demo/ smokes, golden captures, bundle:sync) — 68 of 253 registry entries.
Unit and backend guards never read dist/ as a build artifact (verified
against every test that mentions dist/**: they read the git checkout or
synthetic files), so 185 mutants skip the most expensive step entirely.
- seedTestBuild + incremental tsc: the mutant worktree starts from the main
tree's warm test-build/ and .tsbuildinfo; tsc compares file hashes, not
mtimes, so the fresh checkout stays warm and only the mutated delta is
recompiled. This also speeds up the long guards that run tsc themselves.
- --changed[=range]: run only mutants whose patch files are touched by the
diff (origin/dev..HEAD by default). An empty selection is an honest success
with an explicit message — the full registry remains the pre-release
contract, per the workflow comment.
- --shard=i/n: deterministic interleaved slices; the workflow runs a 4-way
matrix, and a warm test-build step feeds every shard. Interleaving spreads
the expensive browser mutants across shards instead of clumping them.
Measured per mutant on this machine: unit 12-13 s (was ~50-70 s), backend
6 s, browser 32 s (unchanged — the bundle is genuinely needed there). Full
run estimate drops to ~70 sequential minutes, ~20 on four shards.
Unit coverage: guard classification on real registry shapes, a floor on both
classes so the split cannot silently collapse, changed-selection semantics,
and shard completeness/disjointness with an anti-clumping bound.
Issue: #332
User-Visible: no
The owner's five limits as pure functions: minimum 15 degrees between
neighbouring rays of a node (a straight wall through the node is a 180 pair,
not a violation), at most 6 walls per node, a segment at least
max(20 cm, its own thickness), 5 cm clearance between non-incident nodes and
between a node and a foreign wall (a T-joint sitting exactly on that wall is
incidence, not a near miss), and a room interior of at least 25 cm2 after the
masonry is subtracted. Thresholds are absolute and do not scale with cell_cm.
newViolations() implements the spec's inheritance boundary: only violations
introduced by the write are reported.
Issue: #329
User-Visible: no
52 блока host/переменных/кросс-поверхностных групп → src/styles/base.styles.ts;
styles.ts — 19-строчный сборщик [base, plan, devices, chrome, dialogs] с
задокументированным контрактом порядка каскада. Два оставшихся мутантных
якоря (:host-гейт ховера устройств) переадресованы на base.styles.ts.
Юниты инвариантов (test/styles-split.test.mjs): состав и порядок сборщика;
непересечение (scope+селектор) между файлами — единственное именованное
исключение: кросс-поверхностная группа «:host(...) .dev:hover, .dev:focus-
visible» живёт в base по §1.1; выживание медиа-обёрток — 2 forced-colors и
10 prefers-reduced-motion (в ТЗ и ревью фигурировали 8 — фактический счёт по
исходнику 10, юнит держит точное число). fix-test-build научился точке в
имени модуля (styles/base.styles → .js). ARCHITECTURE.md — раздел Styles.
Refactor-proof diff (scope-ключ) пуст; golden 129/129 без переприёмки; смоки
plan_snap_overlay/preloader OK; npm test 1318/0; бандл 1 291 440 → 1 291 458
(+18 байт).
Issue: #266
User-Visible: no
Рисование прямой стены в несколько кликов оставляло по записи на каждый
отрезок. Швы невидимы, пока их не тронешь: выделение хватает кусок,
перетаскивание ломает стену пополам, толщина задаётся пофрагментно. У стен
комнат этого давно нет — `normalizeWallIntervals` схлопывает каждый сплошной
участок одной толщины. Независимые перегородки жили по другому правилу.
Новый чистый модуль `src/wall-merge.ts` даёт им то же правило:
- `mergeCollinearPartitions` сращивает соседей одинаковой толщины и
направления до неподвижной точки, но только там, где узел никому не нужен.
Узел остаётся, если в него приходит третья перегородка, стена комнаты
(стороной, а не только вершиной), колонна или конец сохранённого черновика.
- Направление выжившей записи канонизируется лексикографически: иначе одна и
та же физическая стена выходила то a→b, то b→a в зависимости от порядка
входа, и каждый host.t вдоль неё переворачивался вместе с ней.
- `applyOpeningMoves` переносит проёмы на выжившую запись: и авторитетный
`host`, и legacy-проекцию `x/y/angle`, которую рисует старый читатель
конфига (docs/CONFIG-COMPATIBILITY.md, #132). Проекция здесь не кэш —
канонизация направления разворачивает угол на 180°.
Рисование сращивает только свою цепочку и то, чего она коснулась (§8.6 ТЗ):
молча править чужие швы в стороне оно не вправе — для этого есть
«Оптимизировать планы» с предпросмотром, отчётом и отменой. Оптимизация
проходит по всему пространству без seed-ограничения и отдельной строкой
сообщает, сколько записей исчезло.
Issue: #229
User-Visible: yes
The order of config.spaces used to be whatever order the spaces were created
in, and there was no way back other than deleting a space and drawing it
again.
The gesture is deliberately narrow — mouse, editors only. The same tabs are
the primary way to switch spaces in View, where touch is first class, so a
drag there would compete with the tap that switches. Recorded in the spec as
"Touch editor: not exposed".
The part that needed care is not the drag. Position in the array feeds three
things: the marker placement fallback, the swipe neighbour and a positional
`floor`. So the write that stores the new order also writes down the
placement that used to depend on it: a marker with neither an explicit space
nor an area that names one gets the space it has right now. Both changes go in
one save; splitting them would leave a window in which markers move on their
own. The positional `floor` cannot be fixed from here, so the card says so
once.
Issue: #220
User-Visible: yes
Plan-editor wall thickness (docs/WALL-THICKNESS.md) and keep the white drawing sheet under the grid in editors even when a backdrop image is loaded.
Co-authored-by: Cursor <cursoragent@cursor.com>
Ship the unreleased 1.59 batch on dev: top-view furniture in the decor
layer, space toggles to hide decor/openings, stable card-mod data-*
hooks, HA entity value formatting, and the approved wall-thickness
spec (docs only — not implemented yet).
Co-authored-by: Cursor <cursoragent@cursor.com>
Two owner reports after v1.57.0, both about coordinates.
=== DEV-B58-01: nothing stops at the old canvas border any more ===
The infinite canvas freed the FRAME and the DRAWING; it did not free the
drag handlers, and both the owner and a user hit that within a day:
"названия комнат и устройства не перетаскиваются дальше старых границ
холста".
Two clamps survived v1.57.0, and the second is the worse one:
* `_pointerMove` (device marker) clamped into `_baseVb()` — the CONTENT
FRAME, with a 0.8 % inset. A marker could never be dragged past the
outline of what was already drawn, so a plan could not be extended by
putting a device where the next room was going to be.
* `_labelMove` (room label) clamped into `_spaceModel().vb` — the
space's STORED `view_box`, which is `[0,0,1,1]` for every plan the
card has ever written. Literally the old square: a room drawn at 2.5
had a name that could not reach its own room.
And one asymmetry: `_decorCommitDraft` and the decor text anchor had no
guard at all, while `_decorMoveUpdate` did — a draft could be born
outside the range the mover then refused to leave.
The rule now is one line: an editor gesture has exactly ONE bound,
`+/-CANVAS_LIMIT`, the same number `validation.py` enforces, and it is a
garbage limit rather than a frame. `clampCanvasR` / `clampCanvasN` in
space-geometry.ts are the only two functions allowed to impose it, and
`_snap()` applies it on the way out, so every gesture that goes through
the snap is bounded by construction.
demo/smoke_drag_bounds.mjs starts from an ORDINARY plan (rooms inside
0..1, so the old clamps really were in the way), drags a marker, a room
name, a decor shape and an opening far past the old square, checks each
arrives, is stored, survives a rebuild and takes the frame with it — and
that a wild drag still parks at exactly 5000 rather than 1e12. Seven of
its eleven facts fail by name on 85263d5.
=== DEV-B58-02: everything strictly on the grid ===
The owner's suspicion first, answered honestly in docs/CANVAS.md §9.2:
THE GRID STEP DID NOT CHANGE. `_gridPitch = NORM_W / GRID_N = 1000/240`,
both constants, independent of the frame, the view, the zoom, `view_box`
and `cell_cm`; `git log -S` shows neither touched since v1.4.0. So the
move to the infinite canvas did not put any existing element between the
nodes. `gridLevels()` changes what is DRAWN, never what is SNAPPED TO.
What WAS off the grid, and is now fixed:
* auto placements. `defaultPositions`, the `spaceCenter` fallback and
an undragged room label used centroids, which are not nodes for an
odd-sized or polygonal room. This is the likeliest thing the owner
was actually looking at.
* `_decorMoveUpdate` snapped the DELTA, which preserves whatever
off-grid offset a shape already had for ever, one step at a time. It
snaps the resulting anchor now, so one drag is enough.
* `snapToGrid`/`snapR` returned 500.00000000000006 for an exact 500 —
the round trip through a non-dyadic pitch. They are bit-identical on
a node now, so "is this on the grid?" stops answering no.
Openings and split points on a wall are deliberately NOT rounded to a
node — a door on a node but off its diagonal wall is broken geometry.
They are WALL-bound: projected onto the wall, then the offset ALONG it
quantised to the same step (`snapToWall({step,length})`,
`snapPointAlongPoly`). On the axis-aligned, grid-drawn walls the editor
itself makes, the two rules give the same point. The centre magnet is
consulted FIRST, so a wall whose middle is not a node can still hold a
centred window (this is what smoke_opening_measure caught).
Shift now means one thing everywhere: suspend the snap for this gesture.
It keeps its two older meanings (no centre magnet, coarse 15° compass).
=== And why an ACTION rather than a silent migration ===
Old plans may hold coordinates between the nodes. The card does not
round them on update. General settings grow a Grid group with
«Выровнять всё по сетке», which first states how many elements will
move and by how much at most, warns that there is no undo, and only then
writes — one config/set plus the layout updates, in one go.
1. A migration moves the user's data without asking. A house plan is a
drawing; the card has no mandate to redraw it on a version bump.
2. Some elements are off-grid ON PURPOSE — a small decor label nudged
next to an icon, a window on a diagonal wall, a plan traced over a
photo whose scale was never a whole number of cells.
3. A silent migration is unattributable: when a room looks 3 cm wrong
the owner cannot tell whether the card did it or they did.
4. An update that rewrites stored geometry cannot be undone by
downgrading the card. A button can simply not be pressed.
`alignAllToGrid()` (src/align-grid.ts) is pure — it copies its input and
returns the new spaces, the new layout and the report — so the dialog
measures and commits the SAME object and cannot promise one thing and do
another. test/align-grid.test.mjs pins what moves, what does not (a
stray opening with no wall in reach stays put), that a rect's FAR corner
lands on a node too, and idempotency: a second run reports moved 0,
changed false, and deep-equals the first. demo/smoke_grid_snap.mjs does
the same through the DOM plus every by-hand placement.
docs/CANVAS.md §9 carries the whole contract; docs/TESTING.md gains
three manual items. i18n en/ru. The backend is untouched — same
coordinates, same schema.