The bug class where a constraint works perfectly in one direction and does nothing in the other
TLDR
A uniform-scale constraint used Math.min(scaleX, scaleY), and on edge handles the untouched axis is always exactly 1. So Shift-resize could shrink but never grow. When a formula mixes real measurements with neutral placeholders, the placeholder is a value with opinions.
The bug class
A constraint combines several inputs into one value, and one of those inputs has a neutral identity like 0 or 1. When that neutral value participates in a min, max, or clamp alongside real values, it can dominate the result in exactly one direction.
The output is never wrong-looking. It is a plausible number. The feature just stops responding on one side, which reads as a hit-testing problem rather than arithmetic.
The instance
Resizing a shape by one of eight handles. A handle drives only the axes it touches: east drives X, south drives Y, corners both. The untouched axis is not zero, it is 1, because scaling by 1 leaves it alone:
const scaleX = handle.includes('e') ? (bb.width + dx) / bb.width
: handle.includes('w') ? (bb.width - dx) / bb.width
: 1; // 'n'/'s' do not drive X
const scaleY = handle.includes('s') ? (bb.height + dy) / bb.height
: handle.includes('n') ? (bb.height - dy) / bb.height
: 1; // 'e'/'w' do not drive Y
const sx = Math.max(0.01, lockAspect ? Math.min(scaleX, scaleY) : scaleX); const sy = Math.max(0.01, lockAspect ? Math.min(scaleX, scaleY) : scaleY);
Drag the east handle outward by 60px on a 100px box. scaleX is 1.6, scaleY is 1, and Math.min returns 1. Nothing moves.
Drag the same handle inward by 40px. scaleX is 0.6, and Math.min returns 0.6. It shrinks correctly.
That asymmetry is the bug. Shrink factors are below the neutral 1 and win the min. Growth factors are above it and always lose.
Why the language encourages it
Math.min over candidate scales is the standard way to write "fit within" or "take the most conservative option." It is the right shape for clamping.
The trap is that it is only conservative if every input is a measurement. Here half of them are placeholders meaning "not measured," and min cannot tell the two apart.
Corner handles worked, because both inputs are real there. That is why it survived: corners are what people reach for when testing aspect lock.
The fix
Choose the factor from the axes the handle actually drives, and keep placeholders out of the comparison entirely:
if (lockAspect) {
const drivesX = /[ew]/.test(handle);
const drivesY = /[ns]/.test(handle);
let s;
if (drivesX && drivesY) {
// Corner: both real. Follow whichever moved furthest from neutral.
s = Math.abs(scaleX - 1) >= Math.abs(scaleY - 1) ? scaleX : scaleY;
} else if (drivesX) { s = scaleX; }
else { s = scaleY; }
sx = sy = Math.max(0.01, s);
}
The key move is that drivesX / drivesY make the placeholder explicit. Instead of encoding "not driven" as 1 and hoping the arithmetic copes, the code asks which axes are real before combining them. The corner branch compares distance from neutral rather than raw scale, which is what "whichever axis the user pushed hardest" means.
Verified across directions on a 100 by 50 box:
| handle | drag | old | new |
|---|---|---|---|
e | +60 | 1.00 (inert) | 1.60 |
e | -40 | 0.60 | 0.60 |
s | +25 | 1.00 (inert) | 1.50 |
se | (5, 40) | 1.05 | 1.80 |
Preventing the class
Never encode "not applicable" as a value that participates in the arithmetic. If an axis was not measured, say so with a boolean, and branch. A neutral identity is only safe when every other input is comparable to it.
Test constraints in all four directions. A resize test that only grows exercises half the number line. Symmetric-looking formulas invite the assumption that one direction implies the other.
Extract the arithmetic and table it. Ten lines reproduced this as a pure function outside the DOM, and the inert entries were obvious with old and new in adjacent columns. Reading the expression does not surface it, because Math.min(scaleX, scaleY) looks exactly like what an aspect lock should say.