Snapping is a control affordance, not geometry
Affects: lib/cpq-engine.ts, lib/store/cpq-store.ts, components/cpq/cpq-sandbox.tsx, lib/trade-profiles.ts, fixtures/cut-list-parity.json, scripts/check-cpq-parity.mjs, backend/test/cpq-engine.test.ts, .github/workflows/ci.yml
scripts/check-cutlist-parity.mjs was passing. The two engines declared TUBE_DEDUCTION 1.25 and TUBE_DEDUCTION_INCHES "1.25", and every other allowance agreed too. They still cut the same window to two different sizes.
A 30.1" opening came back from the marketing calculator as a 29.00" tube and from the API as a 28.85" one, because lib/cpq-engine.ts ran its input through normalizeInput() — which snapped every dimension to the nearest quarter inch — before subtracting the allowance, and backend/src/services/cpq-engine.ts subtracted from the number it was given. Identical constants, different answers. The check was reading the right values and asking the wrong question.
The snap was removed from the engine rather than added to the server. The quarter inch is where a slider's detents sit and where a specification sheet rounds. It is not a limit on what a saw can cut, and it was never a limit anybody had written down as one: it entered the geometry because the same helper served the slider and the pricing path. The server is the system of record — it is the thing that issues the quote a customer signs — so where the two disagreed about a physical dimension, the server's behaviour was the one to keep.
The affordance itself was kept where it belongs. snapDimension() still backs the range control, because a range control that emits 30.10000000000001 is worse than one that emits 30.25. clampDimension() backs the text field and changes nothing except a number outside the envelope. Two functions, because a slider and a measured dimension typed into a box are not the same input, and collapsing them into one is how this happened.
The dimension envelope became one pair of numbers. The browser refused anything under 24" and the server accepted down to 6". A 12" opening was therefore a validation error on the marketing page and a priced quote from the API — and worse than the disagreement was what the browser did about it: normalizeInput() clamped the 12 up to 24 and calculateQuote() returned a confident, correct-looking price for a shade twice the size of the one that had been asked for. That number was right for nothing anybody had typed.
6 is the shop's actual floor, where a tube and an end cap stop fitting. 24 was the smallest opening worth putting on a slider, which is a presentation judgement, so it moved to SANDBOX_MIN_WIDTH_INCHES / SANDBOX_MIN_DROP_INCHES and out of the envelope. The sandbox still opens at 24 and the track extends downward to meet a smaller typed value; anything under 24 now draws a warning that says the measurement is unusual, and prices it anyway.
Retail rounds up to the cent. enforceMargin() used round2(), which is half-up and can therefore land a quote a cent below the floor that the margin meter beside it reports as met. The server's roundCentsUp() was already deliberate about this. Rounding is an error in either direction; only one direction of it can breach a floor. The published 96 × 120 reference is unaffected — 642.10 ÷ 0.5 is 1284.20 exactly, with nothing to round.
The guard moved from constants to values. check-cutlist-parity.mjs is kept, because a renamed or neutralised constant is still worth catching, but it is no longer the whole of the check. fixtures/cut-list-parity.json holds six openings and the cut sizes they must produce; scripts/check-cpq-parity.mjs asserts the browser engine reproduces the table and a case in backend/test/cpq-engine.test.ts asserts the server engine reproduces the same file. The fixture also carries the envelope, so widening it on one side alone fails on the other.
Two of the six rows exist specifically to make the failure detectable. 30.1 × 47.6 is off the quarter-inch grid, which is the case snapping used to move. 96.1 × 120.3 sits one tenth off the published reference, so a snap, a clamp or a two-place round anywhere in either engine collapses it onto the reference row — and a check whose rows agree with each other proves nothing. Reintroducing the snap was run against the check before this was committed; it fails eight assertions, including that collapse.
What would make this wrong. If the shop's saws acquire a minimum increment, that increment is a physical fact and belongs in both engines with a constant naming it and check-cutlist-parity.mjs watching it — not in a UI helper that one engine happens to call. And if the browser engine ever needs to price rather than illustrate, the argument in D-009 about rate cards has to be settled first; this decision is only about geometry.