Skip to content

Phases 3-9: import, inventory, 3D, openings, clearance, floors, portability - #1

Merged
chntnm merged 19 commits into
mainfrom
release/1.0.0
Sep 3, 2026
Merged

Phases 3-9: import, inventory, 3D, openings, clearance, floors, portability#1
chntnm merged 19 commits into
mainfrom
release/1.0.0

Conversation

@chntnm

@chntnm chntnm commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Brings main up to the end of PLAN.md's phasing table: 19 commits covering phases 3
through 9, plus the review passes each one ended with.

What lands

Phase Deliverable
3 Import and the calibration gate — PDF via pdfjs, image import, the blocking gate, background transform and lock
4 Inventory — catalog/placement split, presets, wall and surface snap, 3D overlap warnings
5 The space view — extrusion, orbit and walk modes, arrow-key traversal with collision, mounts, saved views
6 Openings, complete — swing arcs, hinged leaves, sliding and pocket variants, swing-vs-object clearance
7 Clearance and circulation — clearance zones, the walkway width probe, the consolidated validation panel
8 Multi-room and multi-floor — room detection from the wall graph, floor stacking, ghost underlay, 3D floor toggles
9 Polish and portability — File System Access save-in-place, IndexedDB autosave and recovery, thumbnails, drag-and-drop, product URL lookup

Decisions worth knowing about

Collision always comes from the active floor, whatever the 3D display toggle says.
Feeding the whole stack to the walker would make traversal depend on a view setting,
and since floors default to elevationMm: 0 a second floor added before its elevation
is set would put you inside the walls of a storey you were only looking at.

Room detection keeps planar-graph faces by sign, not by size. A courtyard's outer
face is smaller than the room around it, so "discard the biggest" gets it backwards.
Detection adds and updates but never deletes — an Area-tool room has no walls by
design, and removing what detection cannot see would delete a legitimate room on every
run.

supportsSaveInPlace() reads window at call time. Headless Chromium has the File
System Access API, so a module-load snapshot would leave the download path with no
end-to-end coverage and make the in-place path undrivable from a stub.

Autosave keeps assets in their own IndexedDB store. Document-only would recover a
space whose background is gone; rewriting the raster every tick is absurd. An asset is
immutable once stored, so it is written by id once.

Scraped dimensions do not go through parseLength. It reads a bare number in the
document's display unit, which is right for a field with the unit beside it and exactly
wrong for a spec table — 84 x 38 x 32 in a millimetre document is an 84mm sofa.

Deferred and stated rather than claimed

  • Room-boundary dragging is dropped for v1 in favour of move-the-walls-and-re-detect.
  • Floors can only be added at the ends of the stack; a detected room is a simple ring.
  • The walkway probe reports the narrowest gap at a sample, not the true infimum — the
    medial-axis navmesh is out of scope.
  • Windows do not open; §10.4's 500-objects-at-60fps target is unmeasured.
  • Product parser fixtures are synthetic, not saved retailer pages, and §13 says so.
  • DNS rebinding between the lookup endpoint's resolve and its connect is open, because
    fetch will not pin a socket.

Verification

765 unit tests, 124 Playwright e2e, typecheck, lint and production build all green.
Every defect fixed along the way has a test that was watched failing against the code
it replaced — including the worst of them, an item form that turned a 2'7" armchair
into a 67'6" one when opened and saved with nothing changed.

Directory, package name, app title, and the `app` field written into every
.space manifest. Readers never checked that field, so files written by the old
name still open.
PDF and image import, the blocking calibration gate, background
transform/opacity/lock, and the runtime asset store that finally makes a
.space file carry what it references.

Three defects found along the way, two of them predating this phase:

- `openDocumentFile` returned `.document` and dropped `.assets`, so a space
  with a background opened, saved and reopened with nothing behind the walls.
- Konva refreshes hit geometry on the next draw, not on assignment, so a layer
  switched on and clicked in the same frame is still deaf: pick Select, click a
  wall fast enough, nothing happens. The tool is now checked inside the shape
  handlers, and the mode switch rebuilds the hit graph in a layout effect.
- The gate grew taller on mousedown, shifting the stage under the pointer, so a
  reference drawn as 3000mm committed as 3048mm and every dimension traced
  afterwards inherited the error.

pdfjs is dynamically imported: 437kB of PDF renderer no longer loads for people
who draw by hand. Main bundle back to 599kB from 1,036kB.
Both canvas specs redeclared SCALE and ORIGIN with their own copy of the
reasoning; when DEFAULT_VIEWPORT moves, one would go stale and fail like a
product bug rather than a broken assumption.

The history coalescing keys on the label alone, so the case that would break it
is a different edit landing between two slider frames. Asserted directly:
opacity, rotate, opacity leaves three entries and each undoes separately.
Catalog and placements are separate entities, so "I own 6, 4 are placed, 2 to
go" stays expressible. Manual entry in any unit, 28 presets at real published
sizes, click-to-place, drag, rotate, wall snap, surface snap, and a validation
panel that warns and never blocks.

The calibration gate stops being decorative: addPlacement throws
PlacementBlockedError carrying the same sentence the panel shows, and the Place
button is disabled rather than offered-and-refused.

Wall snap seats the footprint's back edge on the wall's near face, not its
centre on the centreline — a centred 2000x900 sofa is half inside the wall.
Tested as a round trip at eight angles including three off-axis.

Two defects fixed in existing code:

- PlacementLayer cancelled the event bubble unconditionally, the same pattern
  fixed in StructureLayer last phase. Unreached only because nothing created
  placements.
- The hit graph also goes stale when a shape first appears, not only when
  `listening` flips: place an item, click it immediately, and it does not
  select. drawHit now runs on a change in shape count too.

deleteSelection re-seats anything surface-mounted on a deleted placement, so
the document never references a host that is gone.
…l path

The item form let you choose Wall or Ceiling, and addPlacement then dropped the
item on the floor without saying so — the same offered-and-refused pattern the
Place button was just fixed for. Both options are disabled until phase 5 gives
them somewhere to attach.

beginPlacementTransform now refuses a placement whose item is missing. Its snap
context would be null, which silently ignores Alt for the whole drag; it was
already unreachable because such a placement renders nothing to grab, and this
makes that a rule rather than a coincidence.

Every placement e2e dropped items into an empty document, so the composition of
a real snap context with a drag that re-solves each frame had only synthetic
unit coverage. Added a spec that drags a sofa across a room into a wall.

The unreadable-file spec now asserts the message rather than a count that was
already zero — the only way that assertion could fail was a blocked page, which
reads as a mystery timeout rather than a bug.
An opening cuts a wall in elevation, not in plan, so `ExtrudeGeometry` holes
were never going to work. `wallSegments` splits a wall into the solid boxes
that remain instead — flank, sill wall, lintel, flank — which needs no CSG and
gives the 3D view, the walker and the validation panel one geometry to share.
A doorway becomes passable because the only solid above it starts at 2032mm,
with no special case anywhere in traversal.

Phase 5 opens three new ways for a reference to dangle, and each is closed
here rather than tolerated downstream:

  - a wall-mounted placement keeps its stored elevation whether or not its wall
    exists, so deleting a wall now re-seats it instead of leaving a shelf in
    mid-air;
  - dragging a wall shorter leaves its openings hanging past the end. That is
    reported, not clamped — silently sliding somebody's front door along the
    wall to make it fit hides the mistake;
  - a ceiling mount resolves to `ceiling − drop − height`, which goes negative
    for anything tall enough and sinks the item through the floor. `solidSpan`
    will not object, so validation does.

Opening sizes are the real published ones — a 32" x 80" door is 813 x 2032,
not "about 800 x 2000" — for the same reason the furniture presets are.

`LengthField` moves out of `ItemForm` so the properties panel can edit an
opening in any unit, committing on blur rather than per keystroke.

393 unit tests, 50 e2e.
The document has been three-dimensional since phase 1; this is where that
stops being a claim. Walls, floors, ceilings and placements extrude from the
same geometry the plan view draws and the collision engine tests — there is no
second scene graph to keep in sync — and you can walk through the result with
the arrow keys.

Two decisions carried the phase.

**The walk simulation does not live inside three.js.** `useFrame` is the
obvious home and the wrong one: it makes walking something that only happens
when a WebGL context exists, and makes traversal impossible to test without a
GPU. It is a plain rAF loop over pure functions instead, so the camera is a
consumer of the walker rather than its owner, the position readout keeps
working when the canvas does not, and 31 unit tests walk through doorways with
no renderer in sight. The canvas sits behind an error boundary; the HUD and the
loop sit outside it.

**Step-up is not a key — it is the bottom of the body interval.** A body of
[0, 1800] collides with a 5mm rug, because [0,5] and [0,1800] genuinely
overlap, and the walker is stopped dead by a carpet. Starting the interval a
stride above the floor gives [200, 1800]: the rug passes underneath, the
dresser at [0,810] still blocks, the bed frame at [250,600] still blocks, and
a doorway lintel at [2032,2438] still lets you through. One number, and every
case falls out of it — the same shape of fix as `voidBelowMm`. Space raises the
clearance for a deliberate step; C lowers the top so you can duck under a shelf.

Arrow keys **turn** rather than strafe, departing from PLAN.md §10.2. The
requirement is arrow-key traversal, and with turning bound only to Q/E someone
using the arrows alone can never change direction. A/D strafe instead.

Wall and ceiling mounts become reachable, which is what the item form was
holding those options back for. An item that says it is wall-mounted and is
dropped where there is no wall lands on the floor **and says so** — never on a
wall the app guessed, because moving that wall would then move the item.

Also here: a walker who has been built around can walk back out (every
candidate move is blocked, and being frozen is worse than being briefly inside
a wall); a ceiling drop that resolves below the floor is clamped to it rather
than sunk out of sight; and saved views encode through a pinned key set with a
total decode, so a record this version did not write cannot put a NaN into a
matrix and blank the screen.

three and drei are code-split like pdfjs — 888kB that most sessions never
load. Main chunk 661kB / 209kB gzip, up 14kB from phase 4.

Deferred and stated rather than claimed: instancing (§10.4's 500-at-60fps is
unmeasured; one mesh per solid today) and a contact-normal collision resolver
(moves retry per axis, so diagonal walls slide stickily).

463 unit tests, 64 e2e.
Two defects the phase-5 tests could not see.

**A drag was silently re-seating mounted items on the floor.** The snap only
ever reports a surface mount or a floor mount — a wall snap seats the footprint
against the wall but never claims a wall mount — so the floor result means "no
host here", not "put this on the floor". Passing it through dropped a
wall-mounted TV to the ground the moment it was nudged 5mm along its own wall,
with no message. This is the same shadowing bug as the drop path, one layer
down; `PlacementTransform.origin` now carries the mount the drag began with, so
a host wins, a surface mount that has left its host falls, and a wall or
ceiling mount is left alone. Moving a thing is not detaching it.

**The 3D view ignored the edit mode.** Clicking a wall in furnish mode selected
it and the properties panel offered to delete it — structure the plan view is
refusing to let you touch. The layer toggle is a property of the document, not
of the renderer. Every space spec entered from plan mode, which is why nothing
caught it; the new pair asserts both the refusal and that the click really does
reach the scene, so the first is not passing on a raycast that missed.

Also: `notice` is cleared by undo, redo and a mode switch, rather than
outliving the thing it describes. And the ceiling behaviour is written down —
a global toggle rather than camera-aware hiding, because deciding "is the
camera inside this room" per frame flickers on every threshold — since the
other two departures from PLAN.md were documented and this one's silence read
as an oversight.

466 unit tests, 66 e2e.
Five kinds of opening behaved identically once the hole was cut. They behave in
four different ways, and the difference is the reason the kinds exist:

  door     hinged, swings into a room  → the swept sector must stay clear
  sliding  slides across the wall face → the wall it parks over must stay clear
  pocket   slides into the wall        → nothing in the room must stay clear
  cased    no leaf                     → nothing
  window   fixed glazing               → nothing

A pocket door needing no room-side clearance is the entire argument for fitting
one, so a model that drew it like a slider would be answering the question wrong
rather than approximately.

The swept sector is computed once and serves three consumers. Its boundary *is*
the 2D door symbol — the closed leaf, the arc, the open leaf — so the drawing and
the clearance check cannot disagree about where the door goes. It is also the
collision polygon and it positions the 3D panel.

Details that would be wrong if skipped:

- The hinge sits on the *face* the door opens onto, half a wall thickness off the
  centreline. 57mm on a 114 wall: invisible in a drawing, a systematic bias in
  every clearance answer.
- The sector is polygonised at one vertex per 5°, because a chord cuts inside the
  true arc and a narrow object at the outer edge falls into the gap. Both the
  area invariant and the sliver case fail at a coarser step; verified by setting
  it to 45 and watching them go red.
- Clearance is tested against furniture only. A door swinging back against the
  adjacent wall is how doors are hung, not a fault.
- Glazing blocks the walker; an open door does not. Glass is something you cannot
  walk through, and a leaf drawn open would otherwise narrow its own doorway by
  however far it happened to have been swung, with no way to push past it.

One stored field, read according to the kind. `leafOf` returns a shape named for
the leaf: a slider structurally has no angle to read, a pocket door no face. The
field is retained across a kind change — door to cased and back is the door you
had — so a cased opening may carry a swing nothing reads. Losing a choice the
user made is the worse trade.

`updateOpening` accepted a `swing` in its patch type and silently dropped it.

Hanging the leaf uses flip buttons rather than selects: there is no honest label
for the two sides of a wall, and the arc in the drawing is what makes the choice
legible.

Adding `opening` to the 3D scene's refs would have undone 837fd4a — an opening
ref fell through both branches of the layer-toggle guard, so a door leaf could be
selected, and deleted, in furnish mode. The guard is now one rule in `modes.ts`,
written as "placement, or else structure", so a kind added later locks with the
structure it belongs to instead of becoming selectable everywhere.

Not claimed: windows do not open. And the 3D gate on a leaf is covered by an
exhaustive unit test over `refIsEditable` rather than end to end — in orbit a leaf
is a slab a few pixels wide seen edge-on, and walk mode takes no selection clicks
at all, so a grid of clicks would test where the camera happens to sit. The first
version of that e2e passed with the guard reverted, which is how it was caught.

516 unit tests, 73 e2e, typecheck, lint, build.
The swing angle wrote to the document on every keystroke, and the stored value is
clamped to 15°. So typing `135` sent `1`, which was stored as `15`, which redrew
the controlled input as "15" — and the next keystroke landed against that. No
angle whose first digit falls below the floor could be entered at all. Every
keystroke was also its own undo entry.

The fix is the pattern already in this file twice — the room name holds its text
locally "so a rename is one undo step, not one per keystroke", and `LengthInput`
commits on blur or Enter and reverts on Escape. The angle field was the third
instance of the same need and the only one that skipped it. `NumberInput` is that
contract for a plain number.

The e2e could not see it: `fill()` delivers the whole value in a single change
event, which is the one input path that works. Rewritten to type the digits, and
verified against the old control — it ends at 180 rather than 135.

517 unit tests, 74 e2e.
Two checks that sound alike and are not. A clearance zone asks whether a drawer
opens; the walkway probe asks whether a person gets past. They disagree about
walls, and that disagreement is the design.

Zones do not test against walls. Wall snap seats an item's back edge *on* the wall
face, so a `back` zone tested against walls fires on every chair pushed against
one — the default outcome of using the snap, not a corner case. That is the rule
phase 6 settled for door swings, and the probe is what answers the other question,
with walls very much included.

Two more that would have been wrong if skipped:

- **The step-over threshold belongs to the intruder, not the zone.** A rug in front
  of a dresser is not a blocked drawer, but lifting the zone's floor to fix that
  exempts a band of space and hides a 90mm shoe rack sitting in it. What makes the
  rug irrelevant is that you step over it. Same shape as `voidBelowMm`.
- **A zone goes through `toWorld`**, the transform the outline itself uses, so
  rotation and flipping come out right by construction. Flipping really does move
  a left-hand drawer to the other side.

## The probe height in PLAN §9.3 was wrong

It specified a single ray at 900mm — "hip height, where you actually squeeze past
furniture". A standard sofa back is 840mm, so that ray passes straight over the one
piece of furniture the spec names and reports a clear walkway through the middle of
the couch. A dining table at 760 and a dresser at 810 go the same way; any single
height is either low enough to catch table legs or high enough to miss the sofa.

So the probe asks what traversal already asks: is anything solid inside
`[STEP_OVER_MM, STAND_HEIGHT_MM]`, imported from `walk.ts` rather than restated.
The walker's answer about a doorway and the plan's answer about a gap can now never
disagree. Found by a test asserting a sofa was an obstruction and getting 4 walls
back; PLAN.md records the correction rather than quietly matching the code.

The route is editor state, like a measurement — a question asked of the plan, not
part of it. Storing the *path* rather than the number is what makes it worth
having: move the sofa and the answer follows. It survives a tool change, and the
tool stays live in furnish mode, because "can I still get past?" is a question you
ask while pushing furniture around. That last one needed the palette's
`t !== 'select'` gate widened and the placement layer's `selectable` narrowed to
Select, or a click meant for the route selected the sofa instead.

Zones draw on the selected item only. A warning that says "the bookcase blocks the
drawer pull" is an argument and the hatched rectangle is the evidence — but six
dining chairs with pull-out zones would carpet the floor in hatching and say
nothing.

The panel groups by what you would do about a problem rather than by the issue
enum, in one list, keeping `validateFloor`'s blocking-first order rather than
forming a second opinion about severity in the component least qualified to have
one.

Stated rather than claimed: the medial-axis navmesh stays out of scope, so the
probe reports the narrowest gap *at a sample* — vertices plus every 100mm — not the
true infimum.

555 unit tests, 85 e2e, typecheck, lint, build.
`findClearanceViolations` skipped the zone's owner and anything a stride clears,
but not what was stacked *on* the owner. A lamp surface-mounted on a dresser rides
on it and cannot be in the way of its drawers, whatever its footprint does.

It was quiet only by coincidence. A zone runs from the floor to `heightMm`, whose
default is the host's own height — which is also the elevation a surface-mounted
child resolves to. The two spans meet exactly and `spansOverlap` is strict, so
nothing fired. Set a zone's `heightMm` explicitly, which is the documented reason
the field exists ("a drawer pull at 810mm is indifferent to a shelf at 1500"), and
the coincidence goes: the lamp reports that it blocks the dresser.

Nothing in the shipped preset library triggers it, so this is a latent trap rather
than a live bug — confirmed by writing the test first and watching it fail.

The walk is by chain rather than one level, so a tray on a lamp on a dresser is
still on the dresser, and it terminates on a cycle instead of hanging. Paired with
a test that something stacked on a *different* nearby item still reports, so the
exemption is "it rides on the host", not "it is off the floor".

Also: `narrowestGap` now says it assumes pre-filtered obstructions — it consults no
spans, and `walkwayObstructions` is what filters by band. And a test that the
walkway route does not survive `loadDocument`, which it already did not.

558 unit tests, 85 e2e.
Detection walks the planar graph of wall centrelines and keeps the faces
that wind positive. By sign, not by magnitude: a courtyard's outer face is
smaller than the room around it, so "discard the biggest" gets it backwards.

Walls are split at crossings *and* at T-junctions. The T-junction pass is
the one that matters — a partition butting into the middle of a wall has its
endpoint on that wall's interior, and without a node there the graph has no
branch and the walk hands back the single loop around the outside.

Boundaries are centrelines, matching commitRoomRect, so a drawn room and its
detected twin compare equal — asserted, along with the ring canonicalisation
that makes a second run a genuine no-op rather than a rewrite of every
boundary in the document.

Detection adds and updates but never deletes. An Area-tool room has no walls
by design; removing what detection cannot see would delete a legitimate room
on every run. Unmatched rooms are reported and left alone. Matching is by
overlap area, so the larger half of a partitioned room keeps its name.

Ceiling height is now editable, which is what makes it worth having: the
headroom check reads it through ceilingHeightAt, so lowering a room to
1900mm immediately reports the wardrobe that no longer fits.

The three mechanisms were each verified by reverting them and watching the
right tests go red.
index is the stacking order and the array is insertion order. orderedFloors
is the only thing that sorts, because the two disagree permanently the first
time someone adds a basement: it takes index -1 and is appended.

Switching floors is a silent document write. Undo walks back the edits you
made; having it teleport you between storeys would make the stack unusable.
It still dirties the document, because reopening a house on the floor you
left it is why activeFloorId is in the document at all. The switch also
clears selection, draft and route — pruneSelection would keep every one of
them, since a wall on the floor below still exists.

Moving a placement carries everything standing on it, however deep, or a
surface mount is left naming a host on another floor that findPlacement
resolves into an elevation against the wrong datum. A wall mount is reseated
and reported. Floor deletion does the same repair and refuses the last floor.

The ghost underlay participates in nothing: not the hit graph, not the
counts, not floorBounds and so not zoom-to-fit — which is what keeps the
viewport still across a floor change, the only reason the underlay is worth
having. listening={false} is load-bearing rather than tidy: PlanStage reads
empty canvas as e.target === stage, and that is what clears the selection and
starts a pan. My first test for that could not fail; the replacement was
verified against a listening ghost before the guard went back.

Collision stays on the active floor whatever the 3D toggle says. Floors
default to elevationMm 0, so feeding the stack to the walker would have you
inside the walls of a storey you were only looking at.

PLAN row 2 promised phase 8 would relate rooms to their walls. Detection is
that mechanism, so room-boundary dragging is now dropped for v1 rather than
left dangling — recorded in both rows.
The ghost floor ate clicks. A solid on another floor stopped propagation and
then declined to act, so R3F never reached the active-floor wall behind it —
looking at a building with every floor shown, the top storey swallowed
everything. The guard now runs before the stop. The existing 3D selection
tests run at the default single-floor visibility, which is why a green suite
said nothing about it.

deleteFloor never ran the reset it was written to run. Its recipe moves
activeFloorId, which makes the setActiveFloor call afterwards a no-op — so a
walkway route drawn on the deleted floor survived and re-answered against the
remaining floor's geometry. The reset is now its own store action that
whoever changes floors calls.

addFloor left activeFloorId dangling after undo: it switched after its own
mutation, so the inverse patch removed the floor while the id still named it.
activeFloor falls back, which hides it, but the picker matched no option and
a save wrote an id that was not in the document. The switch is now inside the
recipe, so undo restores both — a bare switch stays out of history, a switch
that rides along with an edit does not.

movePlacementToFloor skipped the calibration gate addPlacement applies, and
walked only the source floor for riders — the same cross-floor surface mount
deleteFloor already repairs and tests.

Validation is active-floor scoped. That was a distinction without a
difference with one floor and is now the difference between "no issues" and
"none that I looked for", so the panel names the floor.

Each fix has a test that was watched failing against the code it replaces.
Ctrl+S writes the file it wrote last time. The handle is read from
`window` at call time, never snapshotted at import — headless Chromium
has the API, so a snapshot would leave the download path with no
end-to-end coverage and make the in-place path undrivable from a stub.
Cancelling the picker is a decision, not a failure: nothing is shown and
the document stays dirty.

Autosave keeps assets in their own IndexedDB store. Keeping only
document.json recovers a space whose background is gone, which is the
failure assetMapFor throws to prevent reached by another door; rewriting
the raster every 20s is absurd. An asset is immutable once stored, so it
is written by id once and every tick after that writes the record alone.

Recovery has two offers. §5 covers "newer than the opened file"; the case
that matters after a crash is the one it does not describe, where there
is no opened file and the record belongs to an id this session has never
heard of. A record is deleted when its document is saved, so a surviving
one always means unsaved work — without that rule every clean reload
nags and the prompt is trained out of you.

Thumbnails render offscreen from document geometry, not from the Konva
stage, which is not mounted in 3D and would capture the current pan.

Also here: drag-and-drop onto the window, which §5 required and no phase
had built, and the frozen schema-1 container. That fixture is
hand-authored and committed on purpose — one regenerated from current
code asserts only that today's writer agrees with today's reader.
schedule() returned early while a timer was pending, so the debounce was
armed by the first change after a write and never re-armed. The deadline
term in autosaveDelay is lastWriteAt + 20000 - now, and lastWriteAt is
always recent at the moment schedule() runs, so the min always picked the
quiet period: a two-second throttle wearing a debounce's documentation.

Every delay autosaveDelay returned was individually correct, which is why
an exhaustive unit test over it stayed green. The behaviours differ only
in how many writes a burst produces, so the new test drives mutate every
100ms for 20s and counts them — one at the cap, against nine before.

The burst that reaches this is a slider, not a drag: the two-slice store
means a wall drag touches the document once, on release.
Four parser tiers — JSON-LD, microdata, OpenGraph, page text — with the
dimensions tracked apart from the name and image, because they are the
only part that becomes geometry and a page can publish a clean name
while leaving the measurements to a paragraph.

Scraped numbers do not go through parseLength. It reads a bare number in
the document's display unit, which is right for a field with the unit on
screen beside it and exactly wrong for a spec table: 84 x 38 x 32 in a
millimetre document is an 84mm sofa. Every number here needs its own
unit, a QuantitativeValue with no unitCode included.

The client decides the endpoint is absent by content type, never
response.ok. A static host answers an unknown POST with a 200 carrying
index.html; ok is true and the JSON parse then throws inside a catch
written for network failures. vite preview is such a host, which is why
the degradation test runs against a real absence.

The endpoint fetches a URL a stranger supplied from inside the server's
network. It resolves before connecting, revalidates every redirect hop,
and caps the read as it goes rather than trusting content-length.

Fixtures are synthetic and §13 now says so. Building real ones means
fetching third-party pages for this repo's convenience and committing
someone else's markup.

Also fixes an old one this work walked into: the item form prefilled raw
millimetres into fields parsed in the display unit, so opening a 2'7"
armchair and pressing Save with nothing changed made it 67'6" wide. The
existing edit test only renamed, so nothing caught it.
::ffff:7f00:1 is 127.0.0.1 written in hex, and the mapped-address check
only recognised the dotted form — which is not the spelling anyone
probing the guard would use. Addresses are now expanded to their eight
groups and tested, rather than matched by prefix string.

The numeric host forms turn out to be handled a layer down: the WHATWG
URL parser normalises https://2130706433/ and 0x7f.0.0.1 to a dotted
quad before parseTargetUrl sees the hostname. That is load-bearing and
invisible, so it is asserted rather than assumed — the literal guard
would otherwise have a hole covered only by the DNS step, which a
runtime with no resolver skips entirely.

Second gap: handleProductLookup had no coverage at all. Everything else
tests the parser or the guards, and the e2e suite runs against preview,
which has no endpoint by design — so the wrapper both deployments call
had never been executed. Now tested, and the dev middleware was run by
hand: 400 with a JSON message for a bad URL, 400 for a private address,
405 for a GET.
@chntnm
chntnm merged commit 88e3ab1 into main Sep 3, 2026
1 check passed
@chntnm
chntnm deleted the release/1.0.0 branch September 6, 2026 01:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant